fix: deliver RDP bundle via SAC HTTP zip download, not WinRM chunks (0.20.15)

This commit is contained in:
PTah
2026-06-20 19:51:45 +10:00
parent ca90f5c296
commit 6acbfe22de
6 changed files with 153 additions and 121 deletions
+15
View File
@@ -1,6 +1,7 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -59,3 +60,17 @@ def post_agent_command_result(
raise HTTPException(status_code=404, detail="Command not found") raise HTTPException(status_code=404, detail="Command not found")
db.commit() db.commit()
return {"status": cmd.status, "command_uuid": cmd.command_uuid} 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"'},
)
@@ -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
+40 -74
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import base64
import re import re
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,6 +10,7 @@ from pathlib import Path
from app.config import get_settings from app.config import get_settings
from app.models import Host from app.models import Host
from app.services.agent_git_release import _cache_dir, clone_or_update_repo 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( _CLIXML_PROGRESS_NOISE = frozenset(
{ {
@@ -38,7 +38,6 @@ RDP_BUNDLE_REQUIRED = frozenset(
"Deploy-LoginMonitor.ps1", "Deploy-LoginMonitor.ps1",
} }
) )
_WINRM_FILE_CHUNK_BYTES = 20_000
class WinAdminNotConfiguredError(Exception): class WinAdminNotConfiguredError(Exception):
@@ -344,62 +343,25 @@ def _deploy_from_staging_body(staging_path: str) -> str:
) )
def _write_file_chunk_body(remote_path: str, b64_chunk: str, *, append: bool) -> str: def _bundle_download_url(token: str) -> str:
literal = _powershell_literal_path(remote_path) base = get_settings().sac_public_url.rstrip("/")
if append: return f"{base}/api/v1/agent/rdp-bundle/{token}"
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"
)
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"
)
def _winrm_push_file( def _download_bundle_body(staging_path: str, bundle_url: str) -> str:
*, staging = _powershell_literal_path(staging_path)
target: str, url = _powershell_literal_path(bundle_url)
user: str, return (
password: str, f"$staging = '{staging}'\n"
remote_path: str, f"$url = '{url}'\n"
data: bytes, "$zip = Join-Path $staging 'sac-rdp-bundle.zip'\n"
) -> WinRmCmdResult: "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
last: WinRmCmdResult | None = None "Write-Output \"Downloading bundle: $url\"\n"
if not data: "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
body = ( "Expand-Archive -LiteralPath $zip -DestinationPath $staging -Force\n"
f"$path = '{_powershell_literal_path(remote_path)}'\n" "Remove-Item -LiteralPath $zip -Force\n"
"$dir = Split-Path -LiteralPath $path -Parent\n" "Write-Output \"Bundle extracted to $staging\"\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: def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
@@ -460,6 +422,16 @@ def run_winrm_rdp_monitor_update(
) )
staging = RDP_REMOTE_STAGING 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( prep = run_winrm_ps(
target=target, target=target,
user=user, user=user,
@@ -470,31 +442,25 @@ def run_winrm_rdp_monitor_update(
if not prep.ok: if not prep.ok:
return prep return prep
pushed: list[str] = [] download = run_winrm_ps(
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(
target=target, target=target,
user=user, user=user,
password=password, password=password,
remote_path=remote_file, script=_wrap_powershell_body(_download_bundle_body(staging, bundle_url)),
data=local_file.read_bytes(), timeout_sec=300,
) )
log_parts.append(push_result.stdout) if not download.ok:
if not push_result.ok:
return WinRmCmdResult( return WinRmCmdResult(
ok=False, ok=False,
message=f"Failed to push {filename} to client: {push_result.message}", message=(
f"Failed to download RDP bundle on client: {download.message} "
f"(URL: {bundle_url})"
),
target=target, target=target,
stdout="\n".join(part.strip() for part in log_parts if part.strip()), stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
stderr=push_result.stderr, stderr=download.stderr,
exit_code=push_result.exit_code, exit_code=download.exit_code,
) )
pushed.append(filename)
deploy = run_winrm_ps( deploy = run_winrm_ps(
target=target, target=target,
@@ -503,10 +469,10 @@ def run_winrm_rdp_monitor_update(
script=_wrap_powershell_body(_deploy_from_staging_body(staging)), script=_wrap_powershell_body(_deploy_from_staging_body(staging)),
timeout_sec=900, 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( stdout = "\n\n".join(
part.strip() 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() if part.strip()
) )
return WinRmCmdResult( return WinRmCmdResult(
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.14" APP_VERSION = "0.20.15"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+32 -38
View File
@@ -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 pathlib import Path
from unittest.mock import patch 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 ( from app.services.winrm_connect import (
RDP_BUNDLE_REQUIRED, RDP_BUNDLE_REQUIRED,
RDP_REMOTE_STAGING, RDP_REMOTE_STAGING,
@@ -10,9 +11,9 @@ from app.services.winrm_connect import (
_clixml_to_plain, _clixml_to_plain,
_custom_deploy_body, _custom_deploy_body,
_deploy_from_staging_body, _deploy_from_staging_body,
_download_bundle_body,
_prepare_staging_body, _prepare_staging_body,
_winrm_failure_detail, _winrm_failure_detail,
_write_file_chunk_body,
run_winrm_rdp_monitor_update, run_winrm_rdp_monitor_update,
) )
@@ -23,36 +24,42 @@ def test_prepare_staging_recreates_remote_dir():
assert "_sac_staging" in script 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(): def test_deploy_from_staging_runs_local_bundle():
script = _deploy_from_staging_body(RDP_REMOTE_STAGING) script = _deploy_from_staging_body(RDP_REMOTE_STAGING)
assert "-SourceShareRoot" in script assert "-SourceShareRoot" in script
assert "Deploy-LoginMonitor.ps1" 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(): def test_custom_deploy_script_escapes_single_quotes():
script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1") script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1")
assert "O''Brien" in script 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 = tmp_path / "repo"
repo.mkdir() repo.mkdir()
for name in RDP_BUNDLE_REQUIRED: for name in RDP_BUNDLE_REQUIRED:
(repo / name).write_text(f"content-{name}", encoding="utf-8") (repo / name).write_text(f"content-{name}", encoding="utf-8")
(repo / "Sac-Client.ps1").write_text("Sac client", encoding="utf-8")
calls: list[str] = [] 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) calls.append(script)
if "Remove-Item" in script: if "Remove-Item" in script:
return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready") 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( return WinRmCmdResult(
ok=True, ok=True,
message="WinRM OK (pc), exit 0", 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", stdout="deployed 1.2.3",
exit_code=0, 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 ( with (
patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo), 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), patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps),
): ):
result = run_winrm_rdp_monitor_update( 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 result.ok is True
assert "SAC pushed" in result.stdout assert "SAC served bundle" in result.stdout
assert any("WriteAllBytes" in call or "Append" in call for call in calls) assert any("Invoke-WebRequest" in call for call in calls)
assert "git.exe" not in "\n".join(calls) assert not any("FromBase64String" in call for call in calls)
def test_winrm_failure_detail_extracts_message_from_clixml():
clixml = (
"#< CLIXML\n"
'<Objs><Obj><MS><S N="Message">Preparing modules for first use.</S>'
'<S N="Message">Deploy-LoginMonitor.ps1 not found on client PC</S></MS></Obj></Objs>'
)
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"
def test_winrm_failure_detail_never_returns_raw_clixml_progress(): 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) detail = _winrm_failure_detail("", clixml, 1)
assert "#< CLIXML" not in detail assert "#< CLIXML" not in detail
assert "exit code" in detail.lower()
def test_clixml_to_plain_strips_progress_only_blob(): def test_clixml_to_plain_strips_progress_only_blob():
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center"; 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}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;