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

This commit is contained in:
PTah
2026-06-20 19:41:05 +10:00
parent d3517a5706
commit 05408e17c3
7 changed files with 294 additions and 83 deletions
+162 -24
View File
@@ -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,
)