fix: WinRM RDP update via run_ps, strip CLIXML noise (0.20.11)

This commit is contained in:
2026-06-20 19:41:05 +10:00
parent d3517a5706
commit 05408e17c3
7 changed files with 294 additions and 83 deletions
+8 -5
View File
@@ -465,13 +465,16 @@ def post_agent_update_fallback(
if host is None: if host is None:
raise HTTPException(status_code=404, detail="Host not found") 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() db.commit()
return HostSshActionResponse( return HostSshActionResponse(
ok=ok, ok=result.ok,
message=message, message=result.message,
target=host.hostname, target=result.target or host.hostname,
product_version=version or (host.product_version if ok else None), 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, ssh_admin_ok=host.ssh_admin_ok,
) )
+60 -29
View File
@@ -8,6 +8,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Host from app.models import Host
from app.services.agent_update_types import AgentUpdateFallbackResult
from app.services.agent_update_settings import ( from app.services.agent_update_settings import (
PRODUCT_RDP, PRODUCT_RDP,
PRODUCT_SSH, PRODUCT_SSH,
@@ -147,18 +148,21 @@ def run_linux_agent_update_fallback(
*, *,
repo_url: str | None = None, repo_url: str | None = None,
git_branch: str | None = None, git_branch: str | None = None,
) -> tuple[bool, str, str | None]: ) -> AgentUpdateFallbackResult:
if not is_linux_host(host): 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: 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" last = AgentUpdateFallbackResult(ok=False, message="SSH fallback failed", product_version=None)
version: str | None = None
try: try:
targets = iter_ssh_targets(host) targets = iter_ssh_targets(host)
except (HostNotLinuxError, HostTargetMissingError) as exc: 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_repo = (repo_url or "").strip() or None
effective_branch = (git_branch or "main").strip() or "main" effective_branch = (git_branch or "main").strip() or "main"
@@ -170,23 +174,38 @@ def run_linux_agent_update_fallback(
repo_url=effective_repo, repo_url=effective_repo,
git_branch=effective_branch, 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: if result.ok:
version = result.agent_version return last
return True, result.message, version return last
return False, last_message, version
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): 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: 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: try:
targets = iter_winrm_targets(host) targets = iter_winrm_targets(host)
except (HostNotWindowsError, WinRmHostTargetMissingError) as exc: 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( result, attempts = run_winrm_on_host_targets(
host, host,
@@ -200,7 +219,7 @@ def run_windows_agent_update_fallback(host: Host, cfg_win, script_path: str) ->
), ),
) )
if result is None: if result is None:
return False, "WinRM fallback failed", None return AgentUpdateFallbackResult(ok=False, message="WinRM fallback failed", product_version=None)
message = result.message message = result.message
if attempts: if attempts:
message = f"{message} (пробовали: {', '.join(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 from app.services.ssh_connect import parse_ssh_monitor_version_text
version = parse_ssh_monitor_version_text(result.stdout) 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) cfg = get_effective_agent_update_config(db)
product = (host.product or "").strip() 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) agent_cfg = get_effective_agent_update_config(db)
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
branch = (agent_cfg.git_branch or "main").strip() or "main" 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, host,
linux_cfg, linux_cfg,
repo_url=repo_url, 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): elif product == PRODUCT_RDP or is_windows_host(host):
win_cfg = get_effective_win_admin_config(db) 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: 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) now = datetime.now(timezone.utc)
host.agent_update_last_at = now host.agent_update_last_at = now
if ok: if result.ok:
host.agent_update_state = "success" host.agent_update_state = "success"
host.agent_update_last_error = None host.agent_update_last_error = None
_clear_pending(host) _clear_pending(host)
if version: if result.product_version:
host.product_version = version host.product_version = result.product_version
if is_linux_host(host): if is_linux_host(host):
host.ssh_admin_ok = True host.ssh_admin_ok = True
host.ssh_admin_checked_at = now host.ssh_admin_checked_at = now
else: else:
host.agent_update_state = "failed" host.agent_update_state = "failed"
host.agent_update_last_error = message[:2000] host.agent_update_last_error = result.message[:2000]
db.flush() db.flush()
return ok, message, version return result
def process_agent_update_fallbacks(db: Session) -> list[dict]: 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] = [] results: list[dict] = []
for host in rows: for host in rows:
ok, message, version = execute_agent_update_fallback(db, host) result = execute_agent_update_fallback(db, host)
results.append( results.append(
{ {
"host_id": host.id, "host_id": host.id,
"hostname": host.hostname, "hostname": host.hostname,
"ok": ok, "ok": result.ok,
"message": message, "message": result.message,
"product_version": version, "product_version": result.product_version,
} }
) )
return results return results
@@ -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
+162 -24
View File
@@ -2,12 +2,20 @@
from __future__ import annotations from __future__ import annotations
import base64 import re
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass
from app.models import Host from app.models import Host
_CLIXML_PROGRESS_NOISE = frozenset(
{
"Preparing modules for first use.",
"Preparing modules fo",
}
)
_CLIXML_MARKER = "#< CLIXML"
class WinAdminNotConfiguredError(Exception): class WinAdminNotConfiguredError(Exception):
pass pass
@@ -71,6 +79,129 @@ def _winrm_session(
return session, winrm 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'<S N="Message">([^<]*)</S>', 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"<ToString>([^<]*)</ToString>", 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( def run_winrm_cmd(
*, *,
target: str, target: str,
@@ -109,15 +240,8 @@ def run_winrm_cmd(
stdout = (result.std_out or b"").decode("utf-8", errors="replace") stdout = (result.std_out or b"").decode("utf-8", errors="replace")
stderr = (result.std_err or b"").decode("utf-8", errors="replace") stderr = (result.std_err or b"").decode("utf-8", errors="replace")
exit_code = int(result.status_code) exit_code = int(result.status_code)
ok = exit_code == 0 stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
if ok: return _finalize_winrm_run(
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,
target=target, target=target,
stdout=stdout, stdout=stdout,
stderr=stderr, 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: def _powershell_script_prelude() -> str:
return base64.b64encode(script.encode("utf-16-le")).decode("ascii") return (
"$ProgressPreference = 'SilentlyContinue'\n"
"$InformationPreference = 'SilentlyContinue'\n"
)
def _powershell_literal_path(path: str) -> str: def _powershell_literal_path(path: str) -> str:
return path.replace("'", "''") 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() script = script_path.strip()
if script: if script:
literal = _powershell_literal_path(script) literal = _powershell_literal_path(script)
ps = ( body = (
f"$p = '{literal}'\n" 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" "& $p"
) )
else: else:
ps = ( body = (
"$candidates = @(\n" "$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:ProgramData 'LoginMonitor\\Deploy-LoginMonitor.ps1'),\n"
" (Join-Path $env:USERPROFILE 'Deploy-LoginMonitor.ps1')\n" " (Join-Path $env:USERPROFILE 'Deploy-LoginMonitor.ps1')\n"
")\n" ")\n"
"$p = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1\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" "& $p"
) )
encoded = _encode_powershell(ps) return _wrap_powershell_body(body)
return (
"powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass "
f"-EncodedCommand {encoded}"
)
def run_winrm_rdp_monitor_update( def run_winrm_rdp_monitor_update(
@@ -182,11 +320,11 @@ def run_winrm_rdp_monitor_update(
password: str, password: str,
script_path: str = "", script_path: str = "",
) -> WinRmCmdResult: ) -> WinRmCmdResult:
return run_winrm_cmd( return run_winrm_ps(
target=target, target=target,
user=user, user=user,
password=password, password=password,
remote_cmd=_winrm_rdp_update_remote_cmd(script_path), script=_rdp_update_powershell_script(script_path),
timeout_sec=900, timeout_sec=900,
) )
+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.10" APP_VERSION = "0.20.11"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+46 -23
View File
@@ -1,33 +1,56 @@
"""WinRM command builder tests.""" """WinRM command builder and CLIXML error parsing tests."""
import base64 from app.services.winrm_connect import (
_clixml_to_plain,
from app.services.winrm_connect import _winrm_rdp_update_remote_cmd _rdp_update_powershell_script,
_winrm_failure_detail,
)
def test_rdp_update_default_uses_encoded_command_without_broken_quotes(): def test_rdp_update_default_script_uses_rdp_login_monitor_path():
cmd = _winrm_rdp_update_remote_cmd("") script = _rdp_update_powershell_script("")
assert "-EncodedCommand" in cmd assert "$ProgressPreference = 'SilentlyContinue'" in script
assert "-Command" not in cmd assert "try {" in script
assert '"& {' not in cmd assert "Write-Output ('ERROR: '" in script
assert "RDP-login-monitor\\Deploy-LoginMonitor.ps1" in script
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_custom_script_uses_encoded_command(): def test_rdp_update_custom_script_uses_literal_path():
cmd = _winrm_rdp_update_remote_cmd(r"C:\ProgramData\LoginMonitor\Deploy-LoginMonitor.ps1") script = _rdp_update_powershell_script(r"C:\ProgramData\RDP-login-monitor\Deploy-LoginMonitor.ps1")
assert "-EncodedCommand" in cmd assert r"C:\ProgramData\RDP-login-monitor\Deploy-LoginMonitor.ps1" in script
encoded = cmd.rsplit(" ", 1)[-1]
script = base64.b64decode(encoded).decode("utf-16-le")
assert r"C:\ProgramData\LoginMonitor\Deploy-LoginMonitor.ps1" in script
assert "Test-Path -LiteralPath" in script assert "Test-Path -LiteralPath" in script
def test_rdp_update_custom_script_escapes_single_quotes(): def test_rdp_update_custom_script_escapes_single_quotes():
cmd = _winrm_rdp_update_remote_cmd(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1") script = _rdp_update_powershell_script(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1")
encoded = cmd.rsplit(" ", 1)[-1]
script = base64.b64decode(encoded).decode("utf-16-le")
assert "O''Brien" in script assert "O''Brien" in script
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():
clixml = (
"#< CLIXML <Objs Version=\"1.1.0.1\">"
'<Obj S="progress"><MS><PR N="Record"><AV>Preparing modules for first use.</AV></PR></MS></Obj>'
)
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 <Objs><Obj S=\"progress\"><AV>Preparing modules for first use.</AV></Obj></Objs>"
assert _clixml_to_plain(clixml) == ""
+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.10"; export const APP_VERSION = "0.20.11";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;