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:
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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}"
|
||||||
|
|||||||
@@ -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,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}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user