diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index 07abcc4..67ebd53 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -2,11 +2,15 @@ from __future__ import annotations +import base64 import re import socket from dataclasses import dataclass +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 _CLIXML_PROGRESS_NOISE = frozenset( { @@ -16,6 +20,26 @@ _CLIXML_PROGRESS_NOISE = frozenset( ) _CLIXML_MARKER = "#< CLIXML" +RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging" +RDP_BUNDLE_FILES = ( + "Login_Monitor.ps1", + "Sac-Client.ps1", + "RdpMonitor-TaskQuery.ps1", + "version.txt", + "Deploy-LoginMonitor.ps1", + "Restart-RdpLoginMonitor.ps1", + "login_monitor.settings.example.ps1", +) +RDP_BUNDLE_REQUIRED = frozenset( + { + "Login_Monitor.ps1", + "Sac-Client.ps1", + "version.txt", + "Deploy-LoginMonitor.ps1", + } +) +_WINRM_FILE_CHUNK_BYTES = 20_000 + class WinAdminNotConfiguredError(Exception): pass @@ -289,55 +313,103 @@ def _wrap_powershell_body(body: str) -> str: ) -def _rdp_update_powershell_script( - script_path: str, - *, - repo_url: str = "", - git_branch: str = "main", -) -> str: - script = script_path.strip() - if script: - literal = _powershell_literal_path(script) - body = ( - f"$p = '{literal}'\n" - "if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n" - "Write-Output \"Running: $p\"\n" - "& $p" - ) - return _wrap_powershell_body(body) +def _custom_deploy_body(script_path: str) -> str: + literal = _powershell_literal_path(script_path.strip()) + return ( + f"$p = '{literal}'\n" + "if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n" + "Write-Output \"Running: $p\"\n" + "& $p" + ) - safe_repo = _powershell_literal_path( - (repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git").strip() + +def _prepare_staging_body(staging_path: str) -> str: + literal = _powershell_literal_path(staging_path) + 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" ) - safe_branch = _powershell_literal_path((git_branch or "main").strip() or "main") - body = ( - f"$gitUrl = '{safe_repo}'\n" - f"$branch = '{safe_branch}'\n" - "$repoDir = Join-Path $env:ProgramData 'RDP-login-monitor\\_sac_git'\n" - "$gitCmd = Get-Command git.exe -ErrorAction SilentlyContinue\n" - "if (-not $gitCmd) {\n" - " throw 'git.exe not found on client PC — install Git for Windows or set win_agent_update_script in SAC'\n" - "}\n" - "Write-Output \"git: $gitUrl (branch $branch) -> $repoDir\"\n" - "if (-not (Test-Path -LiteralPath (Join-Path $repoDir '.git'))) {\n" - " if (Test-Path -LiteralPath $repoDir) { Remove-Item -LiteralPath $repoDir -Recurse -Force }\n" - " New-Item -ItemType Directory -Path (Split-Path $repoDir -Parent) -Force | Out-Null\n" - " & git.exe clone -b $branch $gitUrl $repoDir\n" - " if ($LASTEXITCODE -ne 0) { throw \"git clone failed (exit $LASTEXITCODE)\" }\n" - "} else {\n" - " Push-Location $repoDir\n" - " & git.exe fetch origin\n" - " & git.exe reset --hard \"origin/$branch\"\n" - " $code = $LASTEXITCODE\n" - " Pop-Location\n" - " if ($code -ne 0) { throw \"git reset failed (exit $code)\" }\n" - "}\n" - "$deploy = Join-Path $repoDir 'Deploy-LoginMonitor.ps1'\n" - "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in git checkout: $repoDir\" }\n" - "Write-Output \"Running: $deploy -SourceShareRoot $repoDir\"\n" - "& $deploy -SourceShareRoot $repoDir\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" + "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n" + "& $deploy -SourceShareRoot $staging\n" + ) + + +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" + ) + 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( + *, + 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( + repo_url, + git_branch, + _cache_dir(), + token=(settings.sac_agent_git_token or "").strip(), ) - return _wrap_powershell_body(body) def run_winrm_rdp_monitor_update( @@ -349,17 +421,102 @@ def run_winrm_rdp_monitor_update( repo_url: str = "", git_branch: str = "main", ) -> WinRmCmdResult: - return run_winrm_ps( + target = target.strip() + if script_path.strip(): + return run_winrm_ps( + target=target, + user=user, + password=password, + script=_wrap_powershell_body(_custom_deploy_body(script_path)), + timeout_sec=900, + ) + + effective_repo = ( + repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git" + ).strip() + effective_branch = (git_branch or "main").strip() or "main" + if not effective_repo: + return WinRmCmdResult( + ok=False, + message="rdp_git_repo_url is not configured in SAC", + target=target, + ) + + try: + repo_dir = _fetch_rdp_bundle_dir(effective_repo, effective_branch) + except Exception as exc: + return WinRmCmdResult( + ok=False, + message=f"SAC failed to fetch RDP bundle from git: {exc}", + target=target, + ) + + missing = [name for name in RDP_BUNDLE_REQUIRED if not (repo_dir / name).is_file()] + if missing: + return WinRmCmdResult( + ok=False, + message=f"RDP bundle incomplete on SAC: missing {', '.join(missing)}", + target=target, + ) + + staging = RDP_REMOTE_STAGING + prep = run_winrm_ps( target=target, user=user, password=password, - script=_rdp_update_powershell_script( - script_path, - repo_url=repo_url, - git_branch=git_branch, - ), + script=_wrap_powershell_body(_prepare_staging_body(staging)), + timeout_sec=120, + ) + 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( + target=target, + user=user, + password=password, + remote_path=remote_file, + data=local_file.read_bytes(), + ) + 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, + user=user, + password=password, + script=_wrap_powershell_body(_deploy_from_staging_body(staging)), timeout_sec=900, ) + header = f"SAC pushed {len(pushed)} file(s) from git 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] + if part.strip() + ) + return WinRmCmdResult( + ok=deploy.ok, + message=deploy.message, + target=target, + stdout=stdout, + stderr=deploy.stderr, + exit_code=deploy.exit_code, + ) def run_winrm_on_host_targets( diff --git a/backend/app/version.py b/backend/app/version.py index d9cc3d5..faf19dc 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.13" +APP_VERSION = "0.20.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 6d1c03e..ab6c543 100644 --- a/backend/tests/test_winrm_connect.py +++ b/backend/tests/test_winrm_connect.py @@ -1,42 +1,92 @@ -"""WinRM command builder and CLIXML error parsing tests.""" +"""WinRM command builder, bundle push and CLIXML error parsing tests.""" + +from pathlib import Path +from unittest.mock import patch from app.services.winrm_connect import ( + RDP_BUNDLE_REQUIRED, + RDP_REMOTE_STAGING, + WinRmCmdResult, _clixml_to_plain, - _rdp_update_powershell_script, + _custom_deploy_body, + _deploy_from_staging_body, + _prepare_staging_body, _winrm_failure_detail, + _write_file_chunk_body, + run_winrm_rdp_monitor_update, ) -def test_rdp_update_default_script_clones_git_and_runs_local_deploy(): - script = _rdp_update_powershell_script("") - assert "$ProgressPreference = 'SilentlyContinue'" in script - assert "NETLOGON" not in script - assert "_sac_git" in script - assert "git.kalinamall.ru/PapaTramp/RDP-login-monitor.git" in script - assert "-SourceShareRoot $repoDir" in script +def test_prepare_staging_recreates_remote_dir(): + script = _prepare_staging_body(RDP_REMOTE_STAGING) + assert "Remove-Item" in script + assert "_sac_staging" in script -def test_rdp_update_default_script_honors_sac_git_settings(): - script = _rdp_update_powershell_script( - "", - repo_url="https://git.example.com/org/rdp.git", - git_branch="release", - ) - assert "git.example.com/org/rdp.git" in script - assert "$branch = 'release'" 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_rdp_update_custom_script_uses_literal_path(): - script = _rdp_update_powershell_script(r"\\b26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1") - assert r"\\b26\NETLOGON\RDP-login-monitor\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_rdp_update_custom_script_escapes_single_quotes(): - script = _rdp_update_powershell_script(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1") +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): + 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] = [] + + def fake_run_ps(*, script: str, **kwargs) -> WinRmCmdResult: + 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: + return WinRmCmdResult( + ok=True, + message="WinRM OK (pc), exit 0", + target="pc", + stdout="deployed 1.2.3", + exit_code=0, + ) + return WinRmCmdResult(ok=True, message="chunk ok", target="pc", exit_code=0) + + with ( + patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo), + patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps), + ): + result = run_winrm_rdp_monitor_update( + target="pc", + user="B26\\admin", + password="pw", + repo_url="https://git.example.com/RDP-login-monitor.git", + ) + + 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" diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 251fa47..e5bfafb 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.13"; +export const APP_VERSION = "0.20.14"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;