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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 12:11:54 +10:00
parent c72e510fb0
commit 939fe122c5
5 changed files with 88 additions and 52 deletions
+2
View File
@@ -37,6 +37,8 @@ class Settings(BaseSettings):
sac_db_pool_size: int = 15 sac_db_pool_size: int = 15
sac_db_max_overflow: int = 25 sac_db_max_overflow: int = 25
sac_public_url: str = "http://localhost:8000" 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_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 jwt_expire_minutes: int = 60 * 24
+66 -45
View File
@@ -20,7 +20,8 @@ _CLIXML_PROGRESS_NOISE = frozenset(
) )
_CLIXML_MARKER = "#< CLIXML" _CLIXML_MARKER = "#< CLIXML"
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]") _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 = ( RDP_BUNDLE_FILES = (
"Login_Monitor.ps1", "Login_Monitor.ps1",
"Sac-Client.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) stdout_plain = _clixml_to_plain(stdout)
stderr_plain = _clixml_to_plain(stderr) 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): for candidate in (stderr_plain, stdout_plain):
if candidate and _CLIXML_MARKER not in candidate: if candidate and _CLIXML_MARKER not in candidate:
line = candidate.strip().splitlines()[0][:2000] lines = [line.strip() for line in candidate.splitlines() if line.strip()]
if line.startswith("ERROR:"): if lines:
return line[6:].strip() or line return lines[-1][:2000]
return line
if stdout_plain: if stdout_plain:
return stdout_plain[:2000] return stdout_plain[:2000]
@@ -380,52 +386,67 @@ def _custom_deploy_body(script_path: str) -> str:
) )
def _prepare_staging_body(staging_path: str) -> str: def _rdp_staging_setup_ps() -> str:
literal = _powershell_literal_path(staging_path) dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME)
legacy = _powershell_literal_path(RDP_LEGACY_STAGING)
return ( return (
f"$staging = '{literal}'\n" f"$staging = Join-Path $env:TEMP '{dirname}'\n"
"if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n" f"$legacyStaging = '{legacy}'\n"
"New-Item -ItemType Directory -Path $staging -Force | Out-Null\n" "if (Test-Path -LiteralPath $legacyStaging) {\n"
"Write-Output \"Staging ready: $staging\"\n" " Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\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"
"}\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: 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}" return f"{base}/api/v1/agent/rdp-bundle/{token}"
def _download_bundle_body(staging_path: str, bundle_url: str) -> str: def _download_bundle_body(bundle_url: str) -> str:
staging = _powershell_literal_path(staging_path)
url = _powershell_literal_path(bundle_url) url = _powershell_literal_path(bundle_url)
return ( return (
f"$staging = '{staging}'\n" _rdp_staging_setup_ps()
f"$url = '{url}'\n" + "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
"$zip = Join-Path $staging 'sac-rdp-bundle.zip'\n" + f"$url = '{url}'\n"
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n" + "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n"
"Write-Output \"Downloading bundle: $url\"\n" + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
"Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n" + "Write-Output \"Downloading bundle: $url\"\n"
"Add-Type -AssemblyName System.IO.Compression.FileSystem\n" + "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
"[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n" + "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n"
"Remove-Item -LiteralPath $zip -Force\n" + "$zipSize = (Get-Item -LiteralPath $zip).Length\n"
"Write-Output \"Bundle extracted to $staging\"\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, target=target,
) )
staging = RDP_REMOTE_STAGING
zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES) zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES)
if not zip_bytes: if not zip_bytes:
return WinRmCmdResult( return WinRmCmdResult(
@@ -496,12 +516,13 @@ def run_winrm_rdp_monitor_update(
) )
bundle_token = register_rdp_bundle_zip(zip_bytes) bundle_token = register_rdp_bundle_zip(zip_bytes)
bundle_url = _bundle_download_url(bundle_token) bundle_url = _bundle_download_url(bundle_token)
staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}"
prep = run_winrm_ps( prep = run_winrm_ps(
target=target, target=target,
user=user, user=user,
password=password, password=password,
script=_wrap_powershell_body(_prepare_staging_body(staging)), script=_wrap_powershell_body(_prepare_staging_body()),
timeout_sec=120, timeout_sec=120,
) )
if not prep.ok: if not prep.ok:
@@ -511,7 +532,7 @@ def run_winrm_rdp_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
script=_wrap_powershell_body(_download_bundle_body(staging, bundle_url)), script=_wrap_powershell_body(_download_bundle_body(bundle_url)),
timeout_sec=300, timeout_sec=300,
) )
if not download.ok: if not download.ok:
@@ -531,10 +552,10 @@ def run_winrm_rdp_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
script=_wrap_powershell_body(_deploy_from_staging_body(staging)), script=_wrap_powershell_body(_deploy_from_staging_body()),
timeout_sec=900, 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( stdout = "\n\n".join(
part.strip() part.strip()
for part in [header, prep.stdout, download.stdout, deploy.stdout] for part in [header, prep.stdout, download.stdout, deploy.stdout]
+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.4.12" APP_VERSION = "0.4.14"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+17 -6
View File
@@ -11,7 +11,8 @@ from app.services.rdp_bundle_delivery import (
) )
from app.services.winrm_connect import ( from app.services.winrm_connect import (
RDP_BUNDLE_REQUIRED, RDP_BUNDLE_REQUIRED,
RDP_REMOTE_STAGING, RDP_LEGACY_STAGING,
RDP_REMOTE_STAGING_DIRNAME,
WinRmCmdResult, WinRmCmdResult,
_clixml_to_plain, _clixml_to_plain,
_custom_deploy_body, _custom_deploy_body,
@@ -37,30 +38,40 @@ def test_decode_winrm_bytes_handles_utf8_multibyte():
def test_deploy_from_staging_includes_deploy_log_tail(): 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 "deploy.log" in script
assert "Get-Content" in script assert "Get-Content" in script
assert "-Encoding UTF8" in script assert "-Encoding UTF8" in script
assert RDP_REMOTE_STAGING_DIRNAME in script
def test_prepare_staging_recreates_remote_dir(): def test_prepare_staging_recreates_remote_dir():
script = _prepare_staging_body(RDP_REMOTE_STAGING) script = _prepare_staging_body()
assert "Remove-Item" in script 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(): def test_download_bundle_uses_invoke_webrequest():
script = _download_bundle_body( script = _download_bundle_body(
RDP_REMOTE_STAGING,
"https://sac.example/api/v1/agent/rdp-bundle/token", "https://sac.example/api/v1/agent/rdp-bundle/token",
) )
assert "Invoke-WebRequest" in script assert "Invoke-WebRequest" in script
assert "$env:TEMP" in script
assert RDP_REMOTE_STAGING_DIRNAME in script
assert "ZipFile]::ExtractToDirectory" 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 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(): 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 "-SourceShareRoot" in script
assert "Deploy-LoginMonitor.ps1" in script assert "Deploy-LoginMonitor.ps1" in script
+2
View File
@@ -9,6 +9,8 @@ SAC_DB_POOL_SIZE=15
SAC_DB_MAX_OVERFLOW=25 SAC_DB_MAX_OVERFLOW=25
SAC_PUBLIC_URL=https://sac.kalinamall.ru 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 JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
# API key для агентов: Authorization: Bearer <ключ> # API key для агентов: Authorization: Bearer <ключ>