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:
PTah
2026-06-20 15:36:12 +10:00
parent 2eb06acb5b
commit 75f4c475df
13 changed files with 612 additions and 174 deletions
+27 -34
View File
@@ -15,11 +15,11 @@ from app.database import get_db
from app.models import Event, Host from app.models import Event, Host
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
from app.services.agent_commands import ( from app.services.agent_commands import (
command_response_fields,
command_to_dict, command_to_dict,
get_command_by_uuid, get_command_by_uuid,
queue_logoff,
queue_qwinsta,
) )
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
from app.services.ingest import ingest_event from app.services.ingest import ingest_event
from app.services.event_summary import event_to_summary from app.services.event_summary import event_to_summary
from app.services.problems import maybe_create_problem from app.services.problems import maybe_create_problem
@@ -182,6 +182,26 @@ class AgentCommandResponse(BaseModel):
result_stderr: str | None = None result_stderr: str | None = None
created_at: str | None = None created_at: str | None = None
completed_at: str | None = None completed_at: str | None = None
target: str | None = None
client_hostname: str | None = None
internal_ip: str | None = None
def _agent_command_response(cmd) -> AgentCommandResponse:
data = command_to_dict(cmd)
extra = command_response_fields(cmd)
return AgentCommandResponse(
command_uuid=data["id"],
command_type=data["type"],
status=data["status"],
result_stdout=data.get("result_stdout"),
result_stderr=data.get("result_stderr"),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
target=extra.get("target"),
client_hostname=extra.get("client_hostname"),
internal_ip=extra.get("internal_ip"),
)
class LogoffActionBody(BaseModel): class LogoffActionBody(BaseModel):
@@ -197,18 +217,9 @@ def post_event_qwinsta(
event = db.get(Event, event_db_id) event = db.get(Event, event_db_id)
if event is None: if event is None:
raise HTTPException(status_code=404, detail="Event not found") raise HTTPException(status_code=404, detail="Event not found")
cmd = queue_qwinsta(db, event, requested_by=str(user)) cmd = execute_qwinsta_via_winrm(db, event, requested_by=str(user))
db.commit() db.commit()
data = command_to_dict(cmd) return _agent_command_response(cmd)
return AgentCommandResponse(
command_uuid=data["id"],
command_type=data["type"],
status=data["status"],
result_stdout=data.get("result_stdout"),
result_stderr=data.get("result_stderr"),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
)
@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse) @router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse)
@@ -221,23 +232,14 @@ def post_event_logoff(
event = db.get(Event, event_db_id) event = db.get(Event, event_db_id)
if event is None: if event is None:
raise HTTPException(status_code=404, detail="Event not found") raise HTTPException(status_code=404, detail="Event not found")
cmd = queue_logoff( cmd = execute_logoff_via_winrm(
db, db,
event, event,
session_id=body.session_id, session_id=body.session_id,
requested_by=str(user), requested_by=str(user),
) )
db.commit() db.commit()
data = command_to_dict(cmd) return _agent_command_response(cmd)
return AgentCommandResponse(
command_uuid=data["id"],
command_type=data["type"],
status=data["status"],
result_stdout=data.get("result_stdout"),
result_stderr=data.get("result_stderr"),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
)
@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse) @router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse)
@@ -250,16 +252,7 @@ def get_event_action_status(
cmd = get_command_by_uuid(db, command_uuid) cmd = get_command_by_uuid(db, command_uuid)
if cmd is None or cmd.event_id != event_db_id: if cmd is None or cmd.event_id != event_db_id:
raise HTTPException(status_code=404, detail="Command not found") raise HTTPException(status_code=404, detail="Command not found")
data = command_to_dict(cmd) return _agent_command_response(cmd)
return AgentCommandResponse(
command_uuid=data["id"],
command_type=data["type"],
status=data["status"],
result_stdout=data.get("result_stdout"),
result_stderr=data.get("result_stderr"),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
)
@router.get("/{event_db_id}", response_model=EventDetail) @router.get("/{event_db_id}", response_model=EventDetail)
+9 -55
View File
@@ -1,17 +1,14 @@
"""Queue and resolve agent commands (qwinsta, logoff).""" """Queue and resolve agent commands (legacy poll path + helpers)."""
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.models import AgentCommand, Event, Host from app.models import AgentCommand, Host
from app.services.rdg_session_flap import event_has_rdg_flap
from app.services.win_admin_settings import get_effective_win_admin_config 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 return out
def queue_qwinsta(db: Session, event: Event, *, requested_by: str) -> AgentCommand: def command_response_fields(cmd: AgentCommand) -> dict:
if not event_has_rdg_flap(event): params = cmd.params if isinstance(cmd.params, dict) else {}
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap") return {
if not _win_admin_configured(): "target": params.get("winrm_target") or params.get("client_hostname"),
raise HTTPException( "client_hostname": params.get("client_hostname"),
status_code=503, "internal_ip": params.get("internal_ip"),
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 get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]: def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
+50
View File
@@ -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
+4
View File
@@ -31,6 +31,10 @@ def _event_internal_ip(event: Event) -> str:
return "" return ""
def event_internal_ip(event: Event) -> str:
return _event_internal_ip(event)
def _users_match(end_event: Event, success_event: Event) -> bool: def _users_match(end_event: Event, success_event: Event) -> bool:
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event) return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
+172
View File
@@ -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,
)
+145 -60
View File
@@ -28,6 +28,137 @@ class WinRmTestResult:
hostname: str | None = None 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: def resolve_windows_host_target(host: Host) -> str:
targets = iter_winrm_targets(host) targets = iter_winrm_targets(host)
if not targets: if not targets:
@@ -100,70 +231,24 @@ def test_winrm_connection(
password: str, password: str,
timeout_sec: int = 15, timeout_sec: int = 15,
) -> WinRmTestResult: ) -> WinRmTestResult:
target = target.strip() result = run_winrm_cmd(
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, target=target,
user=user,
password=password,
remote_cmd="hostname",
timeout_sec=timeout_sec,
) )
except (socket.timeout, TimeoutError): if result.ok and result.stdout.strip():
return WinRmTestResult( host = result.stdout.strip().splitlines()[0]
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:
return WinRmTestResult( return WinRmTestResult(
ok=True, ok=True,
message=f"WinRM OK, hostname={stdout}", message=f"WinRM OK, hostname={host}",
target=target, target=result.target,
hostname=stdout, hostname=host,
) )
detail = stderr or stdout or f"exit code {result.status_code}"
return WinRmTestResult( return WinRmTestResult(
ok=False, ok=result.ok,
message=f"WinRM command failed: {detail}", message=result.message,
target=target, target=result.target,
hostname=None,
) )
+1 -1
View File
@@ -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.11.5" APP_VERSION = "0.11.6"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.11.5" assert APP_VERSION == "0.11.6"
assert APP_NAME == "Security Alert Center" assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.5" assert APP_VERSION_LABEL == "Security Alert Center v.0.11.6"
+55
View File
@@ -0,0 +1,55 @@
"""Tests for RDG client workstation lookup."""
from datetime import datetime, timezone
from app.models import Host
from app.services.rdg_client_host import find_windows_host_by_ipv4, resolve_client_workstation
def test_find_windows_host_by_ipv4(db_session):
host = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
db_session.add(host)
db_session.commit()
found = find_windows_host_by_ipv4(db_session, "192.168.160.113")
assert found is not None
assert found.hostname == "Andrisonova-PC"
def test_resolve_client_workstation_from_event(db_session):
from app.models import Event
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
event = Event(
event_id="ev-1",
host_id=gw.id,
occurred_at=datetime(2026, 6, 20, tzinfo=timezone.utc),
received_at=datetime(2026, 6, 20, tzinfo=timezone.utc),
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="",
payload={},
details={"user": r"B26\user", "internal_ip": "192.168.160.113", "rdg_flap": True},
)
db_session.add(event)
db_session.commit()
client = resolve_client_workstation(db_session, event)
assert client.id == ws.id
assert client.hostname == "Andrisonova-PC"
+108
View File
@@ -0,0 +1,108 @@
"""API tests for RDG qwinsta/logoff via WinRM on client workstation."""
from datetime import datetime, timezone
from unittest.mock import patch
from app.models import Event, Host
from app.services.winrm_connect import WinRmCmdResult
def _flap_event(db_session, *, gw: Host, ws: Host) -> Event:
event = Event(
event_id="ev-flap-1",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="RD Gateway event 303",
summary="",
payload={},
details={
"user": r"B26\papatramp",
"internal_ip": ws.ipv4,
"rdg_flap": True,
},
)
db_session.add(event)
db_session.commit()
db_session.refresh(event)
return event
def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
event = _flap_event(db_session, gw=gw, ws=ws)
qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n"
with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run:
mock_run.return_value = (
WinRmCmdResult(
ok=True,
message="WinRM OK (Andrisonova-PC), exit 0",
target="Andrisonova-PC",
stdout=qwinsta_out,
exit_code=0,
),
["Andrisonova-PC=OK"],
)
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["status"] == "completed"
assert body["client_hostname"] == "Andrisonova-PC"
assert body["target"] == "Andrisonova-PC"
assert "papatramp" in (body["result_stdout"] or "")
mock_run.assert_called_once()
called_host = mock_run.call_args.args[0]
assert called_host.hostname == "Andrisonova-PC"
def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = Event(
event_id="ev-flap-2",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="",
payload={},
details={"user": r"B26\user", "internal_ip": "192.168.160.999", "rdg_flap": True},
)
db_session.add(event)
db_session.commit()
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
+3
View File
@@ -180,6 +180,9 @@ export interface AgentCommandResponse {
result_stderr?: string | null; result_stderr?: string | null;
created_at?: string | null; created_at?: string | null;
completed_at?: string | null; completed_at?: string | null;
target?: string | null;
client_hostname?: string | null;
internal_ip?: string | null;
} }
export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> { export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> {
+1 -1
View File
@@ -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.11.5"; export const APP_VERSION = "0.11.6";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+29 -15
View File
@@ -305,7 +305,8 @@
<div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal"> <div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true"> <div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
<h3>qwinsta событие #{{ qwinstaModal.eventId }}</h3> <h3>qwinsta {{ qwinstaModal.clientHostname || qwinstaModal.target || `#${qwinstaModal.eventId}` }}</h3>
<p v-if="qwinstaModal.internalIp" class="muted qwinsta-meta">Клиентский ПК: {{ qwinstaModal.internalIp }}</p>
<p v-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p> <p v-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p>
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента</p> <p v-else-if="qwinstaModal.loading">Ожидание ответа агента</p>
<template v-else> <template v-else>
@@ -412,6 +413,9 @@ const qwinstaModal = ref({
eventId: 0, eventId: 0,
actorUser: "", actorUser: "",
commandUuid: "", commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: false, loading: false,
error: "", error: "",
stdout: "", stdout: "",
@@ -494,6 +498,12 @@ function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: str
}, 2000); }, 2000);
} }
function applyCommandMeta(modal: typeof qwinstaModal.value, cmd: AgentCommandResponse) {
modal.target = cmd.target || "";
modal.clientHostname = cmd.client_hostname || "";
modal.internalIp = cmd.internal_ip || "";
}
async function runQwinsta(event: EventSummary) { async function runQwinsta(event: EventSummary) {
qwinstaLoadingId.value = event.id; qwinstaLoadingId.value = event.id;
qwinstaModal.value = { qwinstaModal.value = {
@@ -501,6 +511,9 @@ async function runQwinsta(event: EventSummary) {
eventId: event.id, eventId: event.id,
actorUser: event.actor_user || "", actorUser: event.actor_user || "",
commandUuid: "", commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: true, loading: true,
error: "", error: "",
stdout: "", stdout: "",
@@ -509,6 +522,7 @@ async function runQwinsta(event: EventSummary) {
}; };
try { try {
const cmd = await postEventQwinsta(event.id); const cmd = await postEventQwinsta(event.id);
applyCommandMeta(qwinstaModal.value, cmd);
qwinstaModal.value.commandUuid = cmd.command_uuid; qwinstaModal.value.commandUuid = cmd.command_uuid;
if (applyCommandResult(event.id, event.actor_user || "", cmd)) { if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
return; return;
@@ -530,25 +544,20 @@ async function runLogoff(sessionId: number) {
modal.error = ""; modal.error = "";
try { try {
const cmd = await postEventLogoff(modal.eventId, sessionId); const cmd = await postEventLogoff(modal.eventId, sessionId);
if (cmd.status === "pending") { applyCommandMeta(modal, cmd);
const deadline = Date.now() + 60000; if (cmd.status === "failed") {
while (Date.now() < deadline) { modal.error = cmd.result_stderr || cmd.result_stdout || "logoff failed";
await new Promise((r) => setTimeout(r, 2000)); return;
const polled = await fetchEventAction(modal.eventId, cmd.command_uuid); }
if (polled.status !== "pending") {
if (polled.status === "failed") {
modal.error = polled.result_stderr || "logoff failed";
} else {
modal.loading = true; modal.loading = true;
modal.stdout = ""; modal.stdout = "";
modal.sessions = []; modal.sessions = [];
const again = await postEventQwinsta(modal.eventId); const again = await postEventQwinsta(modal.eventId);
applyCommandMeta(modal, again);
if (applyCommandResult(modal.eventId, modal.actorUser, again)) {
return;
}
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser); pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
}
break;
}
}
}
} catch (e) { } catch (e) {
modal.error = e instanceof Error ? e.message : "logoff error"; modal.error = e instanceof Error ? e.message : "logoff error";
} finally { } finally {
@@ -849,6 +858,11 @@ onUnmounted(() => {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.qwinsta-meta {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.qwinsta-raw { .qwinsta-raw {
font-size: 0.8rem; font-size: 0.8rem;
max-height: 12rem; max-height: 12rem;