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}`;