From 05408e17c39d8f91914abe14904acf43037002e2 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:41:05 +1000 Subject: [PATCH] fix: WinRM RDP update via run_ps, strip CLIXML noise (0.20.11) --- backend/app/api/v1/hosts.py | 13 +- backend/app/services/agent_update.py | 89 ++++++---- backend/app/services/agent_update_types.py | 16 ++ backend/app/services/winrm_connect.py | 186 ++++++++++++++++++--- backend/app/version.py | 2 +- backend/tests/test_winrm_connect.py | 69 +++++--- frontend/src/version.ts | 2 +- 7 files changed, 294 insertions(+), 83 deletions(-) create mode 100644 backend/app/services/agent_update_types.py diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 9ef8aa2..8227c8a 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -465,13 +465,16 @@ def post_agent_update_fallback( if host is None: raise HTTPException(status_code=404, detail="Host not found") - ok, message, version = execute_agent_update_fallback(db, host) + result = execute_agent_update_fallback(db, host) db.commit() return HostSshActionResponse( - ok=ok, - message=message, - target=host.hostname, - product_version=version or (host.product_version if ok else None), + ok=result.ok, + message=result.message, + target=result.target or host.hostname, + stdout=result.stdout or None, + stderr=result.stderr or None, + exit_code=result.exit_code, + product_version=result.product_version or (host.product_version if result.ok else None), ssh_admin_ok=host.ssh_admin_ok, ) diff --git a/backend/app/services/agent_update.py b/backend/app/services/agent_update.py index 5130c71..8649d23 100644 --- a/backend/app/services/agent_update.py +++ b/backend/app/services/agent_update.py @@ -8,6 +8,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.models import Host +from app.services.agent_update_types import AgentUpdateFallbackResult from app.services.agent_update_settings import ( PRODUCT_RDP, PRODUCT_SSH, @@ -147,18 +148,21 @@ def run_linux_agent_update_fallback( *, repo_url: str | None = None, git_branch: str | None = None, -) -> tuple[bool, str, str | None]: +) -> AgentUpdateFallbackResult: if not is_linux_host(host): - return False, "Host is not Linux", None + return AgentUpdateFallbackResult(ok=False, message="Host is not Linux", product_version=None) if not cfg_linux.configured: - return False, "Linux SSH admin is not configured", None + return AgentUpdateFallbackResult( + ok=False, + message="Linux SSH admin is not configured", + product_version=None, + ) - last_message = "SSH fallback failed" - version: str | None = None + last = AgentUpdateFallbackResult(ok=False, message="SSH fallback failed", product_version=None) try: targets = iter_ssh_targets(host) except (HostNotLinuxError, HostTargetMissingError) as exc: - return False, str(exc), None + return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None) effective_repo = (repo_url or "").strip() or None effective_branch = (git_branch or "main").strip() or "main" @@ -170,23 +174,38 @@ def run_linux_agent_update_fallback( repo_url=effective_repo, git_branch=effective_branch, ) - last_message = result.message + last = AgentUpdateFallbackResult( + ok=result.ok, + message=result.message, + product_version=result.agent_version, + target=result.target, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + ) if result.ok: - version = result.agent_version - return True, result.message, version - return False, last_message, version + return last + return last -def run_windows_agent_update_fallback(host: Host, cfg_win, script_path: str) -> tuple[bool, str, str | None]: +def run_windows_agent_update_fallback( + host: Host, + cfg_win, + script_path: str, +) -> AgentUpdateFallbackResult: if not is_windows_host(host): - return False, "Host is not Windows", None + return AgentUpdateFallbackResult(ok=False, message="Host is not Windows", product_version=None) if not cfg_win.configured: - return False, "Windows domain admin is not configured", None + return AgentUpdateFallbackResult( + ok=False, + message="Windows domain admin is not configured", + product_version=None, + ) try: targets = iter_winrm_targets(host) except (HostNotWindowsError, WinRmHostTargetMissingError) as exc: - return False, str(exc), None + return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None) result, attempts = run_winrm_on_host_targets( host, @@ -200,7 +219,7 @@ def run_windows_agent_update_fallback(host: Host, cfg_win, script_path: str) -> ), ) if result is None: - return False, "WinRM fallback failed", None + return AgentUpdateFallbackResult(ok=False, message="WinRM fallback failed", product_version=None) message = result.message if attempts: message = f"{message} (пробовали: {', '.join(attempts)})" @@ -209,10 +228,18 @@ def run_windows_agent_update_fallback(host: Host, cfg_win, script_path: str) -> from app.services.ssh_connect import parse_ssh_monitor_version_text version = parse_ssh_monitor_version_text(result.stdout) - return result.ok, message, version + return AgentUpdateFallbackResult( + ok=result.ok, + message=message, + product_version=version, + target=result.target, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + ) -def execute_agent_update_fallback(db: Session, host: Host) -> tuple[bool, str, str | None]: +def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbackResult: cfg = get_effective_agent_update_config(db) product = (host.product or "").strip() @@ -221,7 +248,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> tuple[bool, str, s agent_cfg = get_effective_agent_update_config(db) repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None branch = (agent_cfg.git_branch or "main").strip() or "main" - ok, message, version = run_linux_agent_update_fallback( + result = run_linux_agent_update_fallback( host, linux_cfg, repo_url=repo_url, @@ -229,27 +256,31 @@ def execute_agent_update_fallback(db: Session, host: Host) -> tuple[bool, str, s ) elif product == PRODUCT_RDP or is_windows_host(host): win_cfg = get_effective_win_admin_config(db) - ok, message, version = run_windows_agent_update_fallback(host, win_cfg, cfg.win_agent_update_script) + result = run_windows_agent_update_fallback(host, win_cfg, cfg.win_agent_update_script) else: - return False, f"Unsupported product for fallback update: {product}", None + return AgentUpdateFallbackResult( + ok=False, + message=f"Unsupported product for fallback update: {product}", + product_version=None, + ) now = datetime.now(timezone.utc) host.agent_update_last_at = now - if ok: + if result.ok: host.agent_update_state = "success" host.agent_update_last_error = None _clear_pending(host) - if version: - host.product_version = version + if result.product_version: + host.product_version = result.product_version if is_linux_host(host): host.ssh_admin_ok = True host.ssh_admin_checked_at = now else: host.agent_update_state = "failed" - host.agent_update_last_error = message[:2000] + host.agent_update_last_error = result.message[:2000] db.flush() - return ok, message, version + return result def process_agent_update_fallbacks(db: Session) -> list[dict]: @@ -270,14 +301,14 @@ def process_agent_update_fallbacks(db: Session) -> list[dict]: results: list[dict] = [] for host in rows: - ok, message, version = execute_agent_update_fallback(db, host) + result = execute_agent_update_fallback(db, host) results.append( { "host_id": host.id, "hostname": host.hostname, - "ok": ok, - "message": message, - "product_version": version, + "ok": result.ok, + "message": result.message, + "product_version": result.product_version, } ) return results diff --git a/backend/app/services/agent_update_types.py b/backend/app/services/agent_update_types.py new file mode 100644 index 0000000..486b9d1 --- /dev/null +++ b/backend/app/services/agent_update_types.py @@ -0,0 +1,16 @@ +"""Agent update fallback result with remote command streams.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgentUpdateFallbackResult: + ok: bool + message: str + product_version: str | None + target: str = "" + stdout: str = "" + stderr: str = "" + exit_code: int | None = None diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index d903dd2..4d3f02a 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -2,12 +2,20 @@ from __future__ import annotations -import base64 +import re import socket from dataclasses import dataclass from app.models import Host +_CLIXML_PROGRESS_NOISE = frozenset( + { + "Preparing modules for first use.", + "Preparing modules fo", + } +) +_CLIXML_MARKER = "#< CLIXML" + class WinAdminNotConfiguredError(Exception): pass @@ -71,6 +79,129 @@ def _winrm_session( return session, winrm +def _clixml_to_plain(text: str) -> str: + """Turn WinRM/PowerShell CLIXML blobs into readable text; drop progress noise.""" + raw = text or "" + if _CLIXML_MARKER not in raw: + return raw.strip() + + messages = re.findall(r'([^<]*)', raw, flags=re.IGNORECASE) + messages = [ + msg.strip() + for msg in messages + if msg.strip() and msg.strip() not in _CLIXML_PROGRESS_NOISE + ] + to_strings = re.findall(r"([^<]*)", raw, flags=re.IGNORECASE) + to_strings = [ + text.strip() + for text in to_strings + if text.strip() and "Preparing modules" not in text + ] + parts = [*messages, *to_strings] + if parts: + return "\n".join(parts) + + without = re.sub(r"#< CLIXML.*", "", raw, flags=re.DOTALL).strip() + return without + + +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 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 + + if stdout_plain: + return stdout_plain[:2000] + if stderr_plain: + return stderr_plain[:2000] + return ( + f"PowerShell exit code {exit_code} " + "(CLIXML без текста ошибки — проверьте Deploy-LoginMonitor.ps1 на ПК)" + ) + + +def _sanitize_winrm_streams(stdout: str, stderr: str) -> tuple[str, str]: + return _clixml_to_plain(stdout), _clixml_to_plain(stderr) + + +def _finalize_winrm_run( + *, + target: str, + stdout: str, + stderr: str, + exit_code: int, +) -> WinRmCmdResult: + ok = exit_code == 0 + if ok: + message = f"WinRM OK ({target}), exit 0" + if stdout.strip(): + message = f"{message}\n{stdout.strip().splitlines()[0][:200]}" + else: + detail = _winrm_failure_detail(stdout, stderr, exit_code) + message = f"WinRM command failed ({target}): {detail}" + return WinRmCmdResult( + ok=ok, + message=message, + target=target, + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + +def run_winrm_ps( + *, + target: str, + user: str, + password: str, + script: str, + timeout_sec: int = 30, +) -> WinRmCmdResult: + target = target.strip() + try: + session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec) + result = session.run_ps(script) + except winrm.exceptions.WinRMTransportError as exc: + detail = str(exc) + hint = "" + if "credentials were rejected" in detail.lower(): + hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК." + return WinRmCmdResult( + ok=False, + message=f"WinRM transport error ({target}): {detail}{hint}", + target=target, + ) + except (socket.timeout, TimeoutError): + return WinRmCmdResult( + ok=False, + message=f"WinRM timeout ({timeout_sec}s) to {target}:5985", + target=target, + ) + except WinAdminNotConfiguredError as exc: + return WinRmCmdResult(ok=False, message=str(exc), target=target) + except RuntimeError as exc: + return WinRmCmdResult(ok=False, message=str(exc), target=target) + except Exception as exc: + return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target) + + stdout = (result.std_out or b"").decode("utf-8", errors="replace") + stderr = (result.std_err or b"").decode("utf-8", errors="replace") + exit_code = int(result.status_code) + stdout, stderr = _sanitize_winrm_streams(stdout, stderr) + return _finalize_winrm_run( + target=target, + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + def run_winrm_cmd( *, target: str, @@ -109,15 +240,8 @@ def run_winrm_cmd( stdout = (result.std_out or b"").decode("utf-8", errors="replace") stderr = (result.std_err or b"").decode("utf-8", errors="replace") exit_code = int(result.status_code) - ok = exit_code == 0 - if ok: - message = f"WinRM OK ({target}), exit 0" - else: - detail = stderr.strip() or stdout.strip() or f"exit code {exit_code}" - message = f"WinRM command failed ({target}): {detail[:500]}" - return WinRmCmdResult( - ok=ok, - message=message, + stdout, stderr = _sanitize_winrm_streams(stdout, stderr) + return _finalize_winrm_run( target=target, stdout=stdout, stderr=stderr, @@ -141,38 +265,52 @@ def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) ) -def _encode_powershell(script: str) -> str: - return base64.b64encode(script.encode("utf-16-le")).decode("ascii") +def _powershell_script_prelude() -> str: + return ( + "$ProgressPreference = 'SilentlyContinue'\n" + "$InformationPreference = 'SilentlyContinue'\n" + ) def _powershell_literal_path(path: str) -> str: return path.replace("'", "''") -def _winrm_rdp_update_remote_cmd(script_path: str) -> str: +def _wrap_powershell_body(body: str) -> str: + indented = "\n".join(f" {line}" if line else "" for line in body.splitlines()) + return ( + _powershell_script_prelude() + + "try {\n" + + indented + + "\n} catch {\n" + + " Write-Output ('ERROR: ' + $_.Exception.Message)\n" + + " exit 1\n" + + "}\n" + ) + + +def _rdp_update_powershell_script(script_path: str) -> str: script = script_path.strip() if script: literal = _powershell_literal_path(script) - ps = ( + body = ( f"$p = '{literal}'\n" - "if (-not (Test-Path -LiteralPath $p)) { throw 'Deploy script not found' }\n" + "if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n" "& $p" ) else: - ps = ( + body = ( "$candidates = @(\n" + " (Join-Path $env:ProgramData 'RDP-login-monitor\\Deploy-LoginMonitor.ps1'),\n" " (Join-Path $env:ProgramData 'LoginMonitor\\Deploy-LoginMonitor.ps1'),\n" " (Join-Path $env:USERPROFILE 'Deploy-LoginMonitor.ps1')\n" ")\n" "$p = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1\n" - "if (-not $p) { throw 'Deploy-LoginMonitor.ps1 not found' }\n" + "if (-not $p) { throw 'Deploy-LoginMonitor.ps1 not found on client PC' }\n" + "Write-Output \"Running: $p\"\n" "& $p" ) - encoded = _encode_powershell(ps) - return ( - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass " - f"-EncodedCommand {encoded}" - ) + return _wrap_powershell_body(body) def run_winrm_rdp_monitor_update( @@ -182,11 +320,11 @@ def run_winrm_rdp_monitor_update( password: str, script_path: str = "", ) -> WinRmCmdResult: - return run_winrm_cmd( + return run_winrm_ps( target=target, user=user, password=password, - remote_cmd=_winrm_rdp_update_remote_cmd(script_path), + script=_rdp_update_powershell_script(script_path), timeout_sec=900, ) diff --git a/backend/app/version.py b/backend/app/version.py index 6e6cf6d..1e1c986 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.10" +APP_VERSION = "0.20.11" 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 fcad69d..d7a3e71 100644 --- a/backend/tests/test_winrm_connect.py +++ b/backend/tests/test_winrm_connect.py @@ -1,33 +1,56 @@ -"""WinRM command builder tests.""" +"""WinRM command builder and CLIXML error parsing tests.""" -import base64 - -from app.services.winrm_connect import _winrm_rdp_update_remote_cmd +from app.services.winrm_connect import ( + _clixml_to_plain, + _rdp_update_powershell_script, + _winrm_failure_detail, +) -def test_rdp_update_default_uses_encoded_command_without_broken_quotes(): - cmd = _winrm_rdp_update_remote_cmd("") - assert "-EncodedCommand" in cmd - assert "-Command" not in cmd - assert '"& {' not in cmd - - encoded = cmd.rsplit(" ", 1)[-1] - script = base64.b64decode(encoded).decode("utf-16-le") - assert "Deploy-LoginMonitor.ps1" in script - assert "Join-Path $env:ProgramData" in script +def test_rdp_update_default_script_uses_rdp_login_monitor_path(): + script = _rdp_update_powershell_script("") + assert "$ProgressPreference = 'SilentlyContinue'" in script + assert "try {" in script + assert "Write-Output ('ERROR: '" in script + assert "RDP-login-monitor\\Deploy-LoginMonitor.ps1" in script -def test_rdp_update_custom_script_uses_encoded_command(): - cmd = _winrm_rdp_update_remote_cmd(r"C:\ProgramData\LoginMonitor\Deploy-LoginMonitor.ps1") - assert "-EncodedCommand" in cmd - encoded = cmd.rsplit(" ", 1)[-1] - script = base64.b64decode(encoded).decode("utf-16-le") - assert r"C:\ProgramData\LoginMonitor\Deploy-LoginMonitor.ps1" in script +def test_rdp_update_custom_script_uses_literal_path(): + script = _rdp_update_powershell_script(r"C:\ProgramData\RDP-login-monitor\Deploy-LoginMonitor.ps1") + assert r"C:\ProgramData\RDP-login-monitor\Deploy-LoginMonitor.ps1" in script assert "Test-Path -LiteralPath" in script def test_rdp_update_custom_script_escapes_single_quotes(): - cmd = _winrm_rdp_update_remote_cmd(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1") - encoded = cmd.rsplit(" ", 1)[-1] - script = base64.b64decode(encoded).decode("utf-16-le") + script = _rdp_update_powershell_script(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1") assert "O''Brien" in script + + +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" + + +def test_winrm_failure_detail_never_returns_raw_clixml_progress(): + clixml = ( + "#< CLIXML " + 'Preparing modules for first use.' + ) + 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(): + clixml = "#< CLIXML Preparing modules for first use." + assert _clixml_to_plain(clixml) == "" diff --git a/frontend/src/version.ts b/frontend/src/version.ts index b7a865e..5defd9a 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.10"; +export const APP_VERSION = "0.20.11"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;