fix: WinRM RDP update via run_ps, strip CLIXML noise (0.20.11)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'<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(
|
||||
*,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user