feat: RDG qwinsta/logoff via WinRM on client workstation (0.11.6)
SAC resolves internal_ip to a registered Windows host and runs qwinsta/logoff synchronously over WinRM instead of queueing commands to the gateway agent.
This commit is contained in:
@@ -1,17 +1,14 @@
|
||||
"""Queue and resolve agent commands (qwinsta, logoff)."""
|
||||
"""Queue and resolve agent commands (legacy poll path + helpers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import AgentCommand, Event, Host
|
||||
from app.services.rdg_session_flap import event_has_rdg_flap
|
||||
from app.models import AgentCommand, Host
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
|
||||
|
||||
@@ -47,56 +44,13 @@ def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def queue_qwinsta(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||
if not event_has_rdg_flap(event):
|
||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||
if not _win_admin_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
)
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=event.host_id,
|
||||
event_id=event.id,
|
||||
command_type="qwinsta",
|
||||
params={"user": user} if user else {},
|
||||
status="pending",
|
||||
requested_by=requested_by,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def queue_logoff(
|
||||
db: Session,
|
||||
event: Event,
|
||||
*,
|
||||
session_id: int,
|
||||
requested_by: str,
|
||||
) -> AgentCommand:
|
||||
if not event_has_rdg_flap(event):
|
||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||
if not _win_admin_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
)
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=event.host_id,
|
||||
event_id=event.id,
|
||||
command_type="logoff",
|
||||
params={"session_id": session_id},
|
||||
status="pending",
|
||||
requested_by=requested_by,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
def command_response_fields(cmd: AgentCommand) -> dict:
|
||||
params = cmd.params if isinstance(cmd.params, dict) else {}
|
||||
return {
|
||||
"target": params.get("winrm_target") or params.get("client_hostname"),
|
||||
"client_hostname": params.get("client_hostname"),
|
||||
"internal_ip": params.get("internal_ip"),
|
||||
}
|
||||
|
||||
|
||||
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Resolve RDG client workstation (session host) from event internal_ip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.rdg_session_flap import event_internal_ip
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
|
||||
class ClientWorkstationNotFoundError(Exception):
|
||||
def __init__(self, internal_ip: str) -> None:
|
||||
self.internal_ip = internal_ip
|
||||
super().__init__(
|
||||
f"Client workstation not found in SAC hosts for internal_ip={internal_ip}. "
|
||||
"Ensure the PC is registered with matching ipv4."
|
||||
)
|
||||
|
||||
|
||||
def find_windows_host_by_ipv4(db: Session, ipv4: str) -> Host | None:
|
||||
ip = (ipv4 or "").strip()
|
||||
if not ip:
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(Host)
|
||||
.where(
|
||||
Host.ipv4 == ip,
|
||||
or_(
|
||||
Host.os_family.ilike("windows"),
|
||||
Host.product == "rdp-login-monitor",
|
||||
),
|
||||
)
|
||||
.order_by(Host.last_seen_at.desc())
|
||||
).all()
|
||||
for host in rows:
|
||||
if is_windows_host(host):
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
def resolve_client_workstation(db: Session, event: Event) -> Host:
|
||||
internal_ip = event_internal_ip(event)
|
||||
if not internal_ip:
|
||||
raise ClientWorkstationNotFoundError("")
|
||||
host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if host is None:
|
||||
raise ClientWorkstationNotFoundError(internal_ip)
|
||||
return host
|
||||
@@ -31,6 +31,10 @@ def _event_internal_ip(event: Event) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def event_internal_ip(event: Event) -> str:
|
||||
return _event_internal_ip(event)
|
||||
|
||||
|
||||
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||||
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""RDG qwinsta/logoff via WinRM on client workstation (variant B)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AgentCommand, Event, Host
|
||||
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
||||
from app.services.rdg_session_flap import event_has_rdg_flap, event_internal_ip
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.winrm_connect import (
|
||||
WinRmCmdResult,
|
||||
run_winrm_logoff,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_qwinsta,
|
||||
)
|
||||
|
||||
|
||||
def _require_win_admin(db: Session):
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _require_rdg_flap(event: Event) -> None:
|
||||
if not event_has_rdg_flap(event):
|
||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||
|
||||
|
||||
def _resolve_client(db: Session, event: Event) -> Host:
|
||||
try:
|
||||
return resolve_client_workstation(db, event)
|
||||
except ClientWorkstationNotFoundError as exc:
|
||||
if not exc.internal_ip:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Event has no internal_ip (client workstation address)",
|
||||
) from exc
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _format_result_message(result: WinRmCmdResult, *, attempts: list[str]) -> str:
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return message
|
||||
|
||||
|
||||
def _persist_command(
|
||||
db: Session,
|
||||
*,
|
||||
client_host: Host,
|
||||
event: Event,
|
||||
command_type: str,
|
||||
params: dict,
|
||||
requested_by: str,
|
||||
result: WinRmCmdResult,
|
||||
attempts: list[str],
|
||||
) -> AgentCommand:
|
||||
now = datetime.now(timezone.utc)
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=client_host.id,
|
||||
event_id=event.id,
|
||||
command_type=command_type,
|
||||
params=params,
|
||||
status="completed" if result.ok else "failed",
|
||||
result_stdout=result.stdout or None,
|
||||
result_stderr=(result.stderr or _format_result_message(result, attempts=attempts) or None)
|
||||
if not result.ok
|
||||
else None,
|
||||
requested_by=requested_by,
|
||||
created_at=now,
|
||||
completed_at=now,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||
_require_rdg_flap(event)
|
||||
cfg = _require_win_admin(db)
|
||||
client_host = _resolve_client(db, event)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="qwinsta",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
|
||||
|
||||
def execute_logoff_via_winrm(
|
||||
db: Session,
|
||||
event: Event,
|
||||
*,
|
||||
session_id: int,
|
||||
requested_by: str,
|
||||
) -> AgentCommand:
|
||||
_require_rdg_flap(event)
|
||||
cfg = _require_win_admin(db)
|
||||
client_host = _resolve_client(db, event)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_logoff(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
session_id=session_id,
|
||||
),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"session_id": session_id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="logoff",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
@@ -28,6 +28,137 @@ class WinRmTestResult:
|
||||
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 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)
|
||||
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,
|
||||
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 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:
|
||||
@@ -100,70 +231,24 @@ def test_winrm_connection(
|
||||
password: str,
|
||||
timeout_sec: int = 15,
|
||||
) -> WinRmTestResult:
|
||||
target = target.strip()
|
||||
user = user.strip()
|
||||
if not target or not user or not password:
|
||||
raise WinAdminNotConfiguredError("Windows admin credentials or target missing")
|
||||
|
||||
from app.services.win_admin_settings import normalize_win_admin_user
|
||||
|
||||
user = normalize_win_admin_user(user)
|
||||
|
||||
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"
|
||||
try:
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
operation_timeout_sec=operation_timeout_sec,
|
||||
)
|
||||
result = session.run_cmd("hostname")
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
detail = str(exc)
|
||||
hint = ""
|
||||
if "credentials were rejected" in detail.lower():
|
||||
hint = (
|
||||
" Проверьте логин (B26\\user), пароль и права admin на ПК; "
|
||||
"подключайтесь по имени хоста (как Enter-PSSession -ComputerName), не по IP."
|
||||
)
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error ({target}): {detail}{hint}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
|
||||
target=target,
|
||||
)
|
||||
except Exception as exc:
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM error: {exc}",
|
||||
target=target,
|
||||
)
|
||||
|
||||
stdout = (result.std_out or b"").decode("utf-8", errors="replace").strip()
|
||||
stderr = (result.std_err or b"").decode("utf-8", errors="replace").strip()
|
||||
if result.status_code == 0 and stdout:
|
||||
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={stdout}",
|
||||
target=target,
|
||||
hostname=stdout,
|
||||
message=f"WinRM OK, hostname={host}",
|
||||
target=result.target,
|
||||
hostname=host,
|
||||
)
|
||||
detail = stderr or stdout or f"exit code {result.status_code}"
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM command failed: {detail}",
|
||||
target=target,
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target,
|
||||
hostname=None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user