From 6acbfe22defd7acdbc763192766c9ca4b08d4eb3 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:51:45 +1000 Subject: [PATCH] fix: deliver RDP bundle via SAC HTTP zip download, not WinRM chunks (0.20.15) --- backend/app/api/v1/agent.py | 15 +++ backend/app/services/rdp_bundle_delivery.py | 57 +++++++++ backend/app/services/winrm_connect.py | 128 +++++++------------- backend/app/version.py | 2 +- backend/tests/test_winrm_connect.py | 70 +++++------ frontend/src/version.ts | 2 +- 6 files changed, 153 insertions(+), 121 deletions(-) create mode 100644 backend/app/services/rdp_bundle_delivery.py diff --git a/backend/app/api/v1/agent.py b/backend/app/api/v1/agent.py index ef4b26d..320ac37 100644 --- a/backend/app/api/v1/agent.py +++ b/backend/app/api/v1/agent.py @@ -1,6 +1,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -59,3 +60,17 @@ def post_agent_command_result( raise HTTPException(status_code=404, detail="Command not found") db.commit() return {"status": cmd.status, "command_uuid": cmd.command_uuid} + + +@router.get("/rdp-bundle/{token}") +def download_rdp_bundle(token: str) -> Response: + from app.services.rdp_bundle_delivery import get_rdp_bundle_zip + + data = get_rdp_bundle_zip(token) + if not data: + raise HTTPException(status_code=404, detail="Bundle not found or expired") + return Response( + content=data, + media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="sac-rdp-bundle.zip"'}, + ) diff --git a/backend/app/services/rdp_bundle_delivery.py b/backend/app/services/rdp_bundle_delivery.py new file mode 100644 index 0000000..90c12fd --- /dev/null +++ b/backend/app/services/rdp_bundle_delivery.py @@ -0,0 +1,57 @@ +"""Short-lived in-memory RDP agent bundles for WinRM clients to download.""" + +from __future__ import annotations + +import io +import secrets +import time +import zipfile +from pathlib import Path +from threading import Lock + +TTL_SECONDS = 600 + +_lock = Lock() +_entries: dict[str, tuple[bytes, float]] = {} + + +def _prune_expired(now: float) -> None: + expired = [token for token, (_, expires_at) in _entries.items() if expires_at <= now] + for token in expired: + _entries.pop(token, None) + + +def build_rdp_bundle_zip(repo_dir: Path, filenames: tuple[str, ...]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name in filenames: + path = repo_dir / name + if path.is_file(): + archive.write(path, name) + return buffer.getvalue() + + +def register_rdp_bundle_zip(zip_bytes: bytes) -> str: + token = secrets.token_urlsafe(32) + expires_at = time.time() + TTL_SECONDS + with _lock: + _prune_expired(time.time()) + _entries[token] = (zip_bytes, expires_at) + return token + + +def get_rdp_bundle_zip(token: str) -> bytes | None: + text = (token or "").strip() + if not text: + return None + now = time.time() + with _lock: + _prune_expired(now) + entry = _entries.get(text) + if entry is None: + return None + data, expires_at = entry + if expires_at <= now: + _entries.pop(text, None) + return None + return data diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index 67ebd53..8259e0b 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -2,7 +2,6 @@ from __future__ import annotations -import base64 import re import socket from dataclasses import dataclass @@ -11,6 +10,7 @@ from pathlib import Path from app.config import get_settings from app.models import Host from app.services.agent_git_release import _cache_dir, clone_or_update_repo +from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, register_rdp_bundle_zip _CLIXML_PROGRESS_NOISE = frozenset( { @@ -38,7 +38,6 @@ RDP_BUNDLE_REQUIRED = frozenset( "Deploy-LoginMonitor.ps1", } ) -_WINRM_FILE_CHUNK_BYTES = 20_000 class WinAdminNotConfiguredError(Exception): @@ -344,64 +343,27 @@ def _deploy_from_staging_body(staging_path: str) -> str: ) -def _write_file_chunk_body(remote_path: str, b64_chunk: str, *, append: bool) -> str: - literal = _powershell_literal_path(remote_path) - if append: - return ( - f"$path = '{literal}'\n" - f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n" - "$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Append)\n" - "try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }\n" - ) +def _bundle_download_url(token: str) -> str: + base = get_settings().sac_public_url.rstrip("/") + return f"{base}/api/v1/agent/rdp-bundle/{token}" + + +def _download_bundle_body(staging_path: str, bundle_url: str) -> str: + staging = _powershell_literal_path(staging_path) + url = _powershell_literal_path(bundle_url) return ( - f"$path = '{literal}'\n" - f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n" - "$dir = Split-Path -LiteralPath $path -Parent\n" - "if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n" - "[System.IO.File]::WriteAllBytes($path, $bytes)\n" + f"$staging = '{staging}'\n" + f"$url = '{url}'\n" + "$zip = Join-Path $staging 'sac-rdp-bundle.zip'\n" + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n" + "Write-Output \"Downloading bundle: $url\"\n" + "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n" + "Expand-Archive -LiteralPath $zip -DestinationPath $staging -Force\n" + "Remove-Item -LiteralPath $zip -Force\n" + "Write-Output \"Bundle extracted to $staging\"\n" ) -def _winrm_push_file( - *, - target: str, - user: str, - password: str, - remote_path: str, - data: bytes, -) -> WinRmCmdResult: - last: WinRmCmdResult | None = None - if not data: - body = ( - f"$path = '{_powershell_literal_path(remote_path)}'\n" - "$dir = Split-Path -LiteralPath $path -Parent\n" - "if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n" - "[System.IO.File]::WriteAllBytes($path, [byte[]]@())\n" - ) - return run_winrm_ps( - target=target, - user=user, - password=password, - script=_wrap_powershell_body(body), - timeout_sec=120, - ) - for offset in range(0, len(data), _WINRM_FILE_CHUNK_BYTES): - chunk = data[offset : offset + _WINRM_FILE_CHUNK_BYTES] - b64_chunk = base64.b64encode(chunk).decode("ascii") - body = _write_file_chunk_body(remote_path, b64_chunk, append=offset > 0) - last = run_winrm_ps( - target=target, - user=user, - password=password, - script=_wrap_powershell_body(body), - timeout_sec=120, - ) - if not last.ok: - return last - assert last is not None - return last - - def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path: settings = get_settings() return clone_or_update_repo( @@ -460,6 +422,16 @@ def run_winrm_rdp_monitor_update( ) staging = RDP_REMOTE_STAGING + zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES) + if not zip_bytes: + return WinRmCmdResult( + ok=False, + message="RDP bundle zip is empty on SAC", + target=target, + ) + bundle_token = register_rdp_bundle_zip(zip_bytes) + bundle_url = _bundle_download_url(bundle_token) + prep = run_winrm_ps( target=target, user=user, @@ -470,31 +442,25 @@ def run_winrm_rdp_monitor_update( if not prep.ok: return prep - pushed: list[str] = [] - log_parts = [prep.stdout] - for filename in RDP_BUNDLE_FILES: - local_file = repo_dir / filename - if not local_file.is_file(): - continue - remote_file = f"{staging}\\{filename}" - push_result = _winrm_push_file( + download = run_winrm_ps( + target=target, + user=user, + password=password, + script=_wrap_powershell_body(_download_bundle_body(staging, bundle_url)), + timeout_sec=300, + ) + if not download.ok: + return WinRmCmdResult( + ok=False, + message=( + f"Failed to download RDP bundle on client: {download.message} " + f"(URL: {bundle_url})" + ), target=target, - user=user, - password=password, - remote_path=remote_file, - data=local_file.read_bytes(), + stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()), + stderr=download.stderr, + exit_code=download.exit_code, ) - log_parts.append(push_result.stdout) - if not push_result.ok: - return WinRmCmdResult( - ok=False, - message=f"Failed to push {filename} to client: {push_result.message}", - target=target, - stdout="\n".join(part.strip() for part in log_parts if part.strip()), - stderr=push_result.stderr, - exit_code=push_result.exit_code, - ) - pushed.append(filename) deploy = run_winrm_ps( target=target, @@ -503,10 +469,10 @@ def run_winrm_rdp_monitor_update( script=_wrap_powershell_body(_deploy_from_staging_body(staging)), timeout_sec=900, ) - header = f"SAC pushed {len(pushed)} file(s) from git to {staging}" + header = f"SAC served bundle from git; client downloaded to {staging}" stdout = "\n\n".join( part.strip() - for part in [header, "\n".join(part.strip() for part in log_parts if part.strip()), deploy.stdout] + for part in [header, prep.stdout, download.stdout, deploy.stdout] if part.strip() ) return WinRmCmdResult( diff --git a/backend/app/version.py b/backend/app/version.py index faf19dc..0fbdec4 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.20.14" +APP_VERSION = "0.20.15" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_winrm_connect.py b/backend/tests/test_winrm_connect.py index ab6c543..8dce42e 100644 --- a/backend/tests/test_winrm_connect.py +++ b/backend/tests/test_winrm_connect.py @@ -1,8 +1,9 @@ -"""WinRM command builder, bundle push and CLIXML error parsing tests.""" +"""WinRM bundle delivery and CLIXML error parsing tests.""" from pathlib import Path from unittest.mock import patch +from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, get_rdp_bundle_zip, register_rdp_bundle_zip from app.services.winrm_connect import ( RDP_BUNDLE_REQUIRED, RDP_REMOTE_STAGING, @@ -10,9 +11,9 @@ from app.services.winrm_connect import ( _clixml_to_plain, _custom_deploy_body, _deploy_from_staging_body, + _download_bundle_body, _prepare_staging_body, _winrm_failure_detail, - _write_file_chunk_body, run_winrm_rdp_monitor_update, ) @@ -23,36 +24,42 @@ def test_prepare_staging_recreates_remote_dir(): assert "_sac_staging" in script +def test_download_bundle_uses_invoke_webrequest(): + script = _download_bundle_body( + RDP_REMOTE_STAGING, + "https://sac.example/api/v1/agent/rdp-bundle/token", + ) + assert "Invoke-WebRequest" in script + assert "Expand-Archive" in script + assert "WriteAllBytes" not in script + + def test_deploy_from_staging_runs_local_bundle(): script = _deploy_from_staging_body(RDP_REMOTE_STAGING) assert "-SourceShareRoot" in script assert "Deploy-LoginMonitor.ps1" in script -def test_write_file_chunk_body_supports_append(): - first = _write_file_chunk_body(r"C:\temp\a.ps1", "YQ==", append=False) - second = _write_file_chunk_body(r"C:\temp\a.ps1", "Yg==", append=True) - assert "WriteAllBytes" in first - assert "Append" in second - - -def test_custom_deploy_script_uses_literal_path(): - script = _custom_deploy_body(r"C:\tmp\Deploy-LoginMonitor.ps1") - assert r"C:\tmp\Deploy-LoginMonitor.ps1" in script - assert "Test-Path -LiteralPath" in script - - def test_custom_deploy_script_escapes_single_quotes(): script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1") assert "O''Brien" in script -def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path): +def test_rdp_bundle_zip_roundtrip(tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + for name in RDP_BUNDLE_REQUIRED: + (repo / name).write_text(name, encoding="utf-8") + zip_bytes = build_rdp_bundle_zip(repo, tuple(RDP_BUNDLE_REQUIRED)) + token = register_rdp_bundle_zip(zip_bytes) + assert get_rdp_bundle_zip(token) == zip_bytes + + +def test_run_winrm_rdp_monitor_update_downloads_bundle_from_sac(tmp_path: Path): repo = tmp_path / "repo" repo.mkdir() for name in RDP_BUNDLE_REQUIRED: (repo / name).write_text(f"content-{name}", encoding="utf-8") - (repo / "Sac-Client.ps1").write_text("Sac client", encoding="utf-8") calls: list[str] = [] @@ -60,7 +67,9 @@ def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path) calls.append(script) if "Remove-Item" in script: return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready") - if "Deploy-LoginMonitor.ps1 missing" in script or "& $deploy" in script: + if "Invoke-WebRequest" in script: + return WinRmCmdResult(ok=True, message="download ok", target="pc", stdout="Bundle extracted") + if "& $deploy" in script: return WinRmCmdResult( ok=True, message="WinRM OK (pc), exit 0", @@ -68,10 +77,11 @@ def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path) stdout="deployed 1.2.3", exit_code=0, ) - return WinRmCmdResult(ok=True, message="chunk ok", target="pc", exit_code=0) + return WinRmCmdResult(ok=True, message="ok", target="pc", exit_code=0) with ( patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo), + patch("app.services.winrm_connect._bundle_download_url", return_value="https://sac.test/bundle.zip"), patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps), ): result = run_winrm_rdp_monitor_update( @@ -82,24 +92,9 @@ def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path) ) assert result.ok is True - assert "SAC pushed" in result.stdout - assert any("WriteAllBytes" in call or "Append" in call for call in calls) - assert "git.exe" not in "\n".join(calls) - - -def test_winrm_failure_detail_extracts_message_from_clixml(): - clixml = ( - "#< CLIXML\n" - 'Preparing modules for first use.' - 'Deploy-LoginMonitor.ps1 not found on client PC' - ) - detail = _winrm_failure_detail("", clixml, 1) - assert detail == "Deploy-LoginMonitor.ps1 not found on client PC" - - -def test_winrm_failure_detail_prefers_plain_stderr(): - detail = _winrm_failure_detail("", "Access is denied", 5) - assert detail == "Access is denied" + assert "SAC served bundle" in result.stdout + assert any("Invoke-WebRequest" in call for call in calls) + assert not any("FromBase64String" in call for call in calls) def test_winrm_failure_detail_never_returns_raw_clixml_progress(): @@ -109,7 +104,6 @@ def test_winrm_failure_detail_never_returns_raw_clixml_progress(): ) detail = _winrm_failure_detail("", clixml, 1) assert "#< CLIXML" not in detail - assert "exit code" in detail.lower() def test_clixml_to_plain_strips_progress_only_blob(): diff --git a/frontend/src/version.ts b/frontend/src/version.ts index e5bfafb..ac63016 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.20.14"; +export const APP_VERSION = "0.20.15"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;