From 939fe122c52e87ca7a0c9af5aa5f3810d558a4c3 Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 6 Jul 2026 12:11:54 +1000 Subject: [PATCH] fix: WinRM RDP bundle staging in %TEMP% (0.4.14) Move zip download and extract to user TEMP to avoid ProgramData access denied on DCs. Improve WinRM ERROR line parsing and add optional SAC_AGENT_BUNDLE_BASE_URL for LAN clients. Co-authored-by: Cursor --- backend/app/config.py | 2 + backend/app/services/winrm_connect.py | 111 +++++++++++++++----------- backend/app/version.py | 2 +- backend/tests/test_winrm_connect.py | 23 ++++-- deploy/env.native.example | 2 + 5 files changed, 88 insertions(+), 52 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 94c57d4..9cdcdd4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,8 @@ class Settings(BaseSettings): sac_db_pool_size: int = 15 sac_db_max_overflow: int = 25 sac_public_url: str = "http://localhost:8000" + # URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL. + sac_agent_bundle_base_url: str = "" jwt_secret: str = "change-me-in-production" jwt_algorithm: str = "HS256" jwt_expire_minutes: int = 60 * 24 diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index 80feb68..4205f29 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -20,7 +20,8 @@ _CLIXML_PROGRESS_NOISE = frozenset( ) _CLIXML_MARKER = "#< CLIXML" _CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]") -RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging" +RDP_REMOTE_STAGING_DIRNAME = "sac-rdp-staging" +RDP_LEGACY_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging" RDP_BUNDLE_FILES = ( "Login_Monitor.ps1", "Sac-Client.ps1", @@ -165,12 +166,17 @@ def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str: stdout_plain = _clixml_to_plain(stdout) stderr_plain = _clixml_to_plain(stderr) + for text in (stderr_plain, stdout_plain): + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("ERROR:"): + return stripped[6:].strip() or stripped + for candidate in (stderr_plain, stdout_plain): if candidate and _CLIXML_MARKER not in candidate: - line = candidate.strip().splitlines()[0][:2000] - if line.startswith("ERROR:"): - return line[6:].strip() or line - return line + lines = [line.strip() for line in candidate.splitlines() if line.strip()] + if lines: + return lines[-1][:2000] if stdout_plain: return stdout_plain[:2000] @@ -380,52 +386,67 @@ def _custom_deploy_body(script_path: str) -> str: ) -def _prepare_staging_body(staging_path: str) -> str: - literal = _powershell_literal_path(staging_path) +def _rdp_staging_setup_ps() -> str: + dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME) + legacy = _powershell_literal_path(RDP_LEGACY_STAGING) return ( - f"$staging = '{literal}'\n" - "if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n" - "New-Item -ItemType Directory -Path $staging -Force | Out-Null\n" - "Write-Output \"Staging ready: $staging\"\n" - ) - - -def _deploy_from_staging_body(staging_path: str) -> str: - literal = _powershell_literal_path(staging_path) - return ( - f"$staging = '{literal}'\n" - "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n" - "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n" - "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n" - "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n" - "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n" - "if (Test-Path -LiteralPath $logPath) {\n" - " Write-Output ''\n" - " Write-Output '--- deploy.log ---'\n" - " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n" + f"$staging = Join-Path $env:TEMP '{dirname}'\n" + f"$legacyStaging = '{legacy}'\n" + "if (Test-Path -LiteralPath $legacyStaging) {\n" + " Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\n" "}\n" ) +def _prepare_staging_body() -> str: + return ( + _rdp_staging_setup_ps() + + "if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n" + + "New-Item -ItemType Directory -Path $staging -Force | Out-Null\n" + + "Write-Output \"Staging ready: $staging\"\n" + ) + + +def _deploy_from_staging_body() -> str: + return ( + _rdp_staging_setup_ps() + + "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n" + + "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n" + + "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n" + + "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n" + + "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n" + + "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n" + + "if (Test-Path -LiteralPath $logPath) {\n" + + " Write-Output ''\n" + + " Write-Output '--- deploy.log ---'\n" + + " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n" + + "}\n" + ) + + def _bundle_download_url(token: str) -> str: - base = get_settings().sac_public_url.rstrip("/") + settings = get_settings() + base = (settings.sac_agent_bundle_base_url or 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) +def _download_bundle_body(bundle_url: str) -> str: url = _powershell_literal_path(bundle_url) return ( - 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" - "Add-Type -AssemblyName System.IO.Compression.FileSystem\n" - "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n" - "Remove-Item -LiteralPath $zip -Force\n" - "Write-Output \"Bundle extracted to $staging\"\n" + _rdp_staging_setup_ps() + + "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n" + + f"$url = '{url}'\n" + + "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n" + + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n" + + "Write-Output \"Downloading bundle: $url\"\n" + + "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n" + + "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n" + + "$zipSize = (Get-Item -LiteralPath $zip).Length\n" + + "if ($zipSize -lt 100) { throw \"Bundle too small ($zipSize bytes)\" }\n" + + "Add-Type -AssemblyName System.IO.Compression.FileSystem\n" + + "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n" + + "Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue\n" + + "Write-Output \"Bundle extracted to $staging\"\n" ) @@ -486,7 +507,6 @@ def run_winrm_rdp_monitor_update( target=target, ) - staging = RDP_REMOTE_STAGING zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES) if not zip_bytes: return WinRmCmdResult( @@ -496,12 +516,13 @@ def run_winrm_rdp_monitor_update( ) bundle_token = register_rdp_bundle_zip(zip_bytes) bundle_url = _bundle_download_url(bundle_token) + staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}" prep = run_winrm_ps( target=target, user=user, password=password, - script=_wrap_powershell_body(_prepare_staging_body(staging)), + script=_wrap_powershell_body(_prepare_staging_body()), timeout_sec=120, ) if not prep.ok: @@ -511,7 +532,7 @@ def run_winrm_rdp_monitor_update( target=target, user=user, password=password, - script=_wrap_powershell_body(_download_bundle_body(staging, bundle_url)), + script=_wrap_powershell_body(_download_bundle_body(bundle_url)), timeout_sec=300, ) if not download.ok: @@ -531,10 +552,10 @@ def run_winrm_rdp_monitor_update( target=target, user=user, password=password, - script=_wrap_powershell_body(_deploy_from_staging_body(staging)), + script=_wrap_powershell_body(_deploy_from_staging_body()), timeout_sec=900, ) - header = f"SAC served bundle from git; client downloaded to {staging}" + header = f"SAC served bundle from git; client staging {staging_label}" stdout = "\n\n".join( part.strip() for part in [header, prep.stdout, download.stdout, deploy.stdout] diff --git a/backend/app/version.py b/backend/app/version.py index 4b34410..a3aaee0 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.4.12" +APP_VERSION = "0.4.14" 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 a6ae234..9f0dceb 100644 --- a/backend/tests/test_winrm_connect.py +++ b/backend/tests/test_winrm_connect.py @@ -11,7 +11,8 @@ from app.services.rdp_bundle_delivery import ( ) from app.services.winrm_connect import ( RDP_BUNDLE_REQUIRED, - RDP_REMOTE_STAGING, + RDP_LEGACY_STAGING, + RDP_REMOTE_STAGING_DIRNAME, WinRmCmdResult, _clixml_to_plain, _custom_deploy_body, @@ -37,30 +38,40 @@ def test_decode_winrm_bytes_handles_utf8_multibyte(): def test_deploy_from_staging_includes_deploy_log_tail(): - script = _deploy_from_staging_body(RDP_REMOTE_STAGING) + script = _deploy_from_staging_body() assert "deploy.log" in script assert "Get-Content" in script assert "-Encoding UTF8" in script + assert RDP_REMOTE_STAGING_DIRNAME in script def test_prepare_staging_recreates_remote_dir(): - script = _prepare_staging_body(RDP_REMOTE_STAGING) + script = _prepare_staging_body() assert "Remove-Item" in script - assert "_sac_staging" in script + assert RDP_REMOTE_STAGING_DIRNAME in script + assert RDP_LEGACY_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 "$env:TEMP" in script + assert RDP_REMOTE_STAGING_DIRNAME in script assert "ZipFile]::ExtractToDirectory" in script + assert "_sac_staging" not in script or RDP_LEGACY_STAGING in script assert "Expand-Archive" not in script +def test_winrm_failure_detail_prefers_error_line_over_progress_stdout(): + stdout = "Downloading bundle: https://sac.test/bundle\nERROR: destination not empty" + detail = _winrm_failure_detail(stdout, "", 1) + assert detail == "destination not empty" + + def test_deploy_from_staging_runs_local_bundle(): - script = _deploy_from_staging_body(RDP_REMOTE_STAGING) + script = _deploy_from_staging_body() assert "-SourceShareRoot" in script assert "Deploy-LoginMonitor.ps1" in script diff --git a/deploy/env.native.example b/deploy/env.native.example index af87725..3724e0d 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -9,6 +9,8 @@ SAC_DB_POOL_SIZE=15 SAC_DB_MAX_OVERFLOW=25 SAC_PUBLIC_URL=https://sac.kalinamall.ru +# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS). +# SAC_AGENT_BUNDLE_BASE_URL=https://192.168.x.x JWT_SECRET=CHANGE_ME_openssl_rand_hex_32 # API key для агентов: Authorization: Bearer <ключ>