634 lines
19 KiB
Python
634 lines
19 KiB
Python
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import re
|
|
import socket
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from app.config import get_settings
|
|
from app.models import Host
|
|
from app.services.agent_git_release import _cache_dir, clone_or_update_repo
|
|
|
|
_CLIXML_PROGRESS_NOISE = frozenset(
|
|
{
|
|
"Preparing modules for first use.",
|
|
"Preparing modules fo",
|
|
}
|
|
)
|
|
_CLIXML_MARKER = "#< CLIXML"
|
|
|
|
RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging"
|
|
RDP_BUNDLE_FILES = (
|
|
"Login_Monitor.ps1",
|
|
"Sac-Client.ps1",
|
|
"RdpMonitor-TaskQuery.ps1",
|
|
"version.txt",
|
|
"Deploy-LoginMonitor.ps1",
|
|
"Restart-RdpLoginMonitor.ps1",
|
|
"login_monitor.settings.example.ps1",
|
|
)
|
|
RDP_BUNDLE_REQUIRED = frozenset(
|
|
{
|
|
"Login_Monitor.ps1",
|
|
"Sac-Client.ps1",
|
|
"version.txt",
|
|
"Deploy-LoginMonitor.ps1",
|
|
}
|
|
)
|
|
_WINRM_FILE_CHUNK_BYTES = 20_000
|
|
|
|
|
|
class WinAdminNotConfiguredError(Exception):
|
|
pass
|
|
|
|
|
|
class HostNotWindowsError(Exception):
|
|
pass
|
|
|
|
|
|
class HostTargetMissingError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WinRmTestResult:
|
|
ok: bool
|
|
message: str
|
|
target: str
|
|
hostname: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WinRmCmdResult:
|
|
ok: bool
|
|
message: str
|
|
target: str
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
exit_code: int | None = None
|
|
|
|
|
|
def _winrm_session(
|
|
target: str,
|
|
user: str,
|
|
password: str,
|
|
*,
|
|
timeout_sec: int = 15,
|
|
):
|
|
from app.services.win_admin_settings import normalize_win_admin_user
|
|
|
|
user = normalize_win_admin_user(user.strip())
|
|
target = target.strip()
|
|
if not target or not user or not password:
|
|
raise WinAdminNotConfiguredError("Windows admin credentials or target missing")
|
|
|
|
try:
|
|
import winrm
|
|
except ImportError as exc:
|
|
raise RuntimeError("pywinrm is not installed on SAC server") from exc
|
|
|
|
operation_timeout_sec = max(5, timeout_sec)
|
|
read_timeout_sec = operation_timeout_sec + 15
|
|
endpoint = f"http://{target}:5985/wsman"
|
|
session = winrm.Session(
|
|
endpoint,
|
|
auth=(user, password),
|
|
transport="ntlm",
|
|
read_timeout_sec=read_timeout_sec,
|
|
operation_timeout_sec=operation_timeout_sec,
|
|
)
|
|
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,
|
|
user: str,
|
|
password: str,
|
|
remote_cmd: str,
|
|
timeout_sec: int = 30,
|
|
) -> WinRmCmdResult:
|
|
target = target.strip()
|
|
try:
|
|
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
|
|
result = session.run_cmd(remote_cmd)
|
|
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_qwinsta(*, target: str, user: str, password: str) -> WinRmCmdResult:
|
|
return run_winrm_cmd(target=target, user=user, password=password, remote_cmd="qwinsta", timeout_sec=45)
|
|
|
|
|
|
def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) -> WinRmCmdResult:
|
|
if session_id < 0:
|
|
return WinRmCmdResult(ok=False, message="Invalid session_id", target=target)
|
|
return run_winrm_cmd(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
remote_cmd=f"logoff {session_id} /v",
|
|
timeout_sec=45,
|
|
)
|
|
|
|
|
|
def _powershell_script_prelude() -> str:
|
|
return (
|
|
"$ProgressPreference = 'SilentlyContinue'\n"
|
|
"$InformationPreference = 'SilentlyContinue'\n"
|
|
)
|
|
|
|
|
|
def _powershell_literal_path(path: str) -> str:
|
|
return path.replace("'", "''")
|
|
|
|
|
|
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 _custom_deploy_body(script_path: str) -> str:
|
|
literal = _powershell_literal_path(script_path.strip())
|
|
return (
|
|
f"$p = '{literal}'\n"
|
|
"if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n"
|
|
"Write-Output \"Running: $p\"\n"
|
|
"& $p"
|
|
)
|
|
|
|
|
|
def _prepare_staging_body(staging_path: str) -> str:
|
|
literal = _powershell_literal_path(staging_path)
|
|
return (
|
|
f"$staging = '{literal}'\n"
|
|
"if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n"
|
|
"New-Item -ItemType Directory -Path $staging -Force | Out-Null\n"
|
|
"Write-Output \"Staging ready: $staging\"\n"
|
|
)
|
|
|
|
|
|
def _deploy_from_staging_body(staging_path: str) -> str:
|
|
literal = _powershell_literal_path(staging_path)
|
|
return (
|
|
f"$staging = '{literal}'\n"
|
|
"$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
|
|
"if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n"
|
|
"Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
|
|
"& $deploy -SourceShareRoot $staging\n"
|
|
)
|
|
|
|
|
|
def _write_file_chunk_body(remote_path: str, b64_chunk: str, *, append: bool) -> str:
|
|
literal = _powershell_literal_path(remote_path)
|
|
if append:
|
|
return (
|
|
f"$path = '{literal}'\n"
|
|
f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n"
|
|
"$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Append)\n"
|
|
"try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }\n"
|
|
)
|
|
return (
|
|
f"$path = '{literal}'\n"
|
|
f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n"
|
|
"$dir = Split-Path -LiteralPath $path -Parent\n"
|
|
"if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n"
|
|
"[System.IO.File]::WriteAllBytes($path, $bytes)\n"
|
|
)
|
|
|
|
|
|
def _winrm_push_file(
|
|
*,
|
|
target: str,
|
|
user: str,
|
|
password: str,
|
|
remote_path: str,
|
|
data: bytes,
|
|
) -> WinRmCmdResult:
|
|
last: WinRmCmdResult | None = None
|
|
if not data:
|
|
body = (
|
|
f"$path = '{_powershell_literal_path(remote_path)}'\n"
|
|
"$dir = Split-Path -LiteralPath $path -Parent\n"
|
|
"if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n"
|
|
"[System.IO.File]::WriteAllBytes($path, [byte[]]@())\n"
|
|
)
|
|
return run_winrm_ps(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
script=_wrap_powershell_body(body),
|
|
timeout_sec=120,
|
|
)
|
|
for offset in range(0, len(data), _WINRM_FILE_CHUNK_BYTES):
|
|
chunk = data[offset : offset + _WINRM_FILE_CHUNK_BYTES]
|
|
b64_chunk = base64.b64encode(chunk).decode("ascii")
|
|
body = _write_file_chunk_body(remote_path, b64_chunk, append=offset > 0)
|
|
last = run_winrm_ps(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
script=_wrap_powershell_body(body),
|
|
timeout_sec=120,
|
|
)
|
|
if not last.ok:
|
|
return last
|
|
assert last is not None
|
|
return last
|
|
|
|
|
|
def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
|
|
settings = get_settings()
|
|
return clone_or_update_repo(
|
|
repo_url,
|
|
git_branch,
|
|
_cache_dir(),
|
|
token=(settings.sac_agent_git_token or "").strip(),
|
|
)
|
|
|
|
|
|
def run_winrm_rdp_monitor_update(
|
|
*,
|
|
target: str,
|
|
user: str,
|
|
password: str,
|
|
script_path: str = "",
|
|
repo_url: str = "",
|
|
git_branch: str = "main",
|
|
) -> WinRmCmdResult:
|
|
target = target.strip()
|
|
if script_path.strip():
|
|
return run_winrm_ps(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
script=_wrap_powershell_body(_custom_deploy_body(script_path)),
|
|
timeout_sec=900,
|
|
)
|
|
|
|
effective_repo = (
|
|
repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
|
).strip()
|
|
effective_branch = (git_branch or "main").strip() or "main"
|
|
if not effective_repo:
|
|
return WinRmCmdResult(
|
|
ok=False,
|
|
message="rdp_git_repo_url is not configured in SAC",
|
|
target=target,
|
|
)
|
|
|
|
try:
|
|
repo_dir = _fetch_rdp_bundle_dir(effective_repo, effective_branch)
|
|
except Exception as exc:
|
|
return WinRmCmdResult(
|
|
ok=False,
|
|
message=f"SAC failed to fetch RDP bundle from git: {exc}",
|
|
target=target,
|
|
)
|
|
|
|
missing = [name for name in RDP_BUNDLE_REQUIRED if not (repo_dir / name).is_file()]
|
|
if missing:
|
|
return WinRmCmdResult(
|
|
ok=False,
|
|
message=f"RDP bundle incomplete on SAC: missing {', '.join(missing)}",
|
|
target=target,
|
|
)
|
|
|
|
staging = RDP_REMOTE_STAGING
|
|
prep = run_winrm_ps(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
script=_wrap_powershell_body(_prepare_staging_body(staging)),
|
|
timeout_sec=120,
|
|
)
|
|
if not prep.ok:
|
|
return prep
|
|
|
|
pushed: list[str] = []
|
|
log_parts = [prep.stdout]
|
|
for filename in RDP_BUNDLE_FILES:
|
|
local_file = repo_dir / filename
|
|
if not local_file.is_file():
|
|
continue
|
|
remote_file = f"{staging}\\{filename}"
|
|
push_result = _winrm_push_file(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
remote_path=remote_file,
|
|
data=local_file.read_bytes(),
|
|
)
|
|
log_parts.append(push_result.stdout)
|
|
if not push_result.ok:
|
|
return WinRmCmdResult(
|
|
ok=False,
|
|
message=f"Failed to push {filename} to client: {push_result.message}",
|
|
target=target,
|
|
stdout="\n".join(part.strip() for part in log_parts if part.strip()),
|
|
stderr=push_result.stderr,
|
|
exit_code=push_result.exit_code,
|
|
)
|
|
pushed.append(filename)
|
|
|
|
deploy = run_winrm_ps(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
script=_wrap_powershell_body(_deploy_from_staging_body(staging)),
|
|
timeout_sec=900,
|
|
)
|
|
header = f"SAC pushed {len(pushed)} file(s) from git to {staging}"
|
|
stdout = "\n\n".join(
|
|
part.strip()
|
|
for part in [header, "\n".join(part.strip() for part in log_parts if part.strip()), deploy.stdout]
|
|
if part.strip()
|
|
)
|
|
return WinRmCmdResult(
|
|
ok=deploy.ok,
|
|
message=deploy.message,
|
|
target=target,
|
|
stdout=stdout,
|
|
stderr=deploy.stderr,
|
|
exit_code=deploy.exit_code,
|
|
)
|
|
|
|
|
|
def run_winrm_on_host_targets(
|
|
host: Host,
|
|
*,
|
|
user: str,
|
|
password: str,
|
|
action,
|
|
) -> tuple[WinRmCmdResult | None, list[str]]:
|
|
"""Try WinRM targets in hostname-first order; action(target) -> WinRmCmdResult."""
|
|
attempts: list[str] = []
|
|
last: WinRmCmdResult | None = None
|
|
for target in iter_winrm_targets(host):
|
|
result = action(target=target)
|
|
last = result
|
|
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
|
if result.ok:
|
|
return result, attempts
|
|
return last, attempts
|
|
|
|
|
|
def resolve_windows_host_target(host: Host) -> str:
|
|
targets = iter_winrm_targets(host)
|
|
if not targets:
|
|
raise HostTargetMissingError("Host has no hostname or IPv4 for WinRM test")
|
|
return targets[0]
|
|
|
|
|
|
def _netbios_name_variants(name: str | None) -> list[str]:
|
|
"""Короткое имя ПК (NetBIOS), как Enter-PSSession -ComputerName Andrisonova-PC."""
|
|
text = (name or "").strip()
|
|
if not text:
|
|
return []
|
|
short = text.split(".")[0].split("\\")[0].strip()
|
|
if not short:
|
|
return []
|
|
if short.casefold() == text.casefold():
|
|
return [short]
|
|
return [short, text]
|
|
|
|
|
|
def _looks_like_computer_name(value: str) -> bool:
|
|
text = value.strip()
|
|
if not text or " " in text:
|
|
return False
|
|
return len(text) <= 63
|
|
|
|
|
|
def iter_winrm_targets(host: Host) -> list[str]:
|
|
"""WinRM + NTLM: сначала имя хоста (как Enter-PSSession -ComputerName), IP — последним."""
|
|
if not is_windows_host(host):
|
|
raise HostNotWindowsError("Host is not Windows")
|
|
|
|
seen: set[str] = set()
|
|
ordered: list[str] = []
|
|
|
|
def add(value: str | None) -> None:
|
|
text = (value or "").strip()
|
|
if not text or text.casefold() in seen:
|
|
return
|
|
seen.add(text.casefold())
|
|
ordered.append(text)
|
|
|
|
for variant in _netbios_name_variants(host.hostname):
|
|
add(variant)
|
|
|
|
inventory = host.inventory if isinstance(host.inventory, dict) else {}
|
|
computer_name = inventory.get("computer_name")
|
|
if isinstance(computer_name, str):
|
|
for variant in _netbios_name_variants(computer_name):
|
|
add(variant)
|
|
|
|
if host.display_name and _looks_like_computer_name(host.display_name):
|
|
for variant in _netbios_name_variants(host.display_name):
|
|
add(variant)
|
|
|
|
add(host.ipv4)
|
|
return ordered
|
|
|
|
|
|
def is_windows_host(host: Host) -> bool:
|
|
if (host.os_family or "").strip().lower() == "windows":
|
|
return True
|
|
return (host.product or "").strip() == "rdp-login-monitor"
|
|
|
|
|
|
def test_winrm_connection(
|
|
*,
|
|
target: str,
|
|
user: str,
|
|
password: str,
|
|
timeout_sec: int = 15,
|
|
) -> WinRmTestResult:
|
|
result = run_winrm_cmd(
|
|
target=target,
|
|
user=user,
|
|
password=password,
|
|
remote_cmd="hostname",
|
|
timeout_sec=timeout_sec,
|
|
)
|
|
if result.ok and result.stdout.strip():
|
|
host = result.stdout.strip().splitlines()[0]
|
|
return WinRmTestResult(
|
|
ok=True,
|
|
message=f"WinRM OK, hostname={host}",
|
|
target=result.target,
|
|
hostname=host,
|
|
)
|
|
return WinRmTestResult(
|
|
ok=result.ok,
|
|
message=result.message,
|
|
target=result.target,
|
|
hostname=None,
|
|
)
|