From 75f4c475df30bf5660085e402ba1250087224e9a Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 15:36:12 +1000 Subject: [PATCH] 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. --- backend/app/api/v1/events.py | 61 +++---- backend/app/services/agent_commands.py | 64 +------ backend/app/services/rdg_client_host.py | 50 ++++++ backend/app/services/rdg_session_flap.py | 4 + backend/app/services/rdg_winrm_actions.py | 172 ++++++++++++++++++ backend/app/services/winrm_connect.py | 209 +++++++++++++++------- backend/app/version.py | 2 +- backend/tests/test_health.py | 4 +- backend/tests/test_rdg_client_host.py | 55 ++++++ backend/tests/test_rdg_winrm_actions.py | 108 +++++++++++ frontend/src/api.ts | 3 + frontend/src/version.ts | 2 +- frontend/src/views/DashboardView.vue | 52 ++++-- 13 files changed, 612 insertions(+), 174 deletions(-) create mode 100644 backend/app/services/rdg_client_host.py create mode 100644 backend/app/services/rdg_winrm_actions.py create mode 100644 backend/tests/test_rdg_client_host.py create mode 100644 backend/tests/test_rdg_winrm_actions.py diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index fffa58d..2fc51a6 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -15,11 +15,11 @@ from app.database import get_db from app.models import Event, Host from app.schemas.list_models import EventDetail, EventListResponse, EventSummary from app.services.agent_commands import ( + command_response_fields, command_to_dict, 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.event_summary import event_to_summary from app.services.problems import maybe_create_problem @@ -182,6 +182,26 @@ class AgentCommandResponse(BaseModel): result_stderr: str | None = None created_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): @@ -197,18 +217,9 @@ def post_event_qwinsta( event = db.get(Event, event_db_id) if event is None: 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() - data = command_to_dict(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"), - ) + return _agent_command_response(cmd) @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) if event is None: raise HTTPException(status_code=404, detail="Event not found") - cmd = queue_logoff( + cmd = execute_logoff_via_winrm( db, event, session_id=body.session_id, requested_by=str(user), ) db.commit() - data = command_to_dict(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"), - ) + return _agent_command_response(cmd) @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) if cmd is None or cmd.event_id != event_db_id: raise HTTPException(status_code=404, detail="Command not found") - data = command_to_dict(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"), - ) + return _agent_command_response(cmd) @router.get("/{event_db_id}", response_model=EventDetail) diff --git a/backend/app/services/agent_commands.py b/backend/app/services/agent_commands.py index 4f4e450..362f23e 100644 --- a/backend/app/services/agent_commands.py +++ b/backend/app/services/agent_commands.py @@ -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]: diff --git a/backend/app/services/rdg_client_host.py b/backend/app/services/rdg_client_host.py new file mode 100644 index 0000000..b9c7e8a --- /dev/null +++ b/backend/app/services/rdg_client_host.py @@ -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 diff --git a/backend/app/services/rdg_session_flap.py b/backend/app/services/rdg_session_flap.py index 66730f7..24049f4 100644 --- a/backend/app/services/rdg_session_flap.py +++ b/backend/app/services/rdg_session_flap.py @@ -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) diff --git a/backend/app/services/rdg_winrm_actions.py b/backend/app/services/rdg_winrm_actions.py new file mode 100644 index 0000000..e76f11e --- /dev/null +++ b/backend/app/services/rdg_winrm_actions.py @@ -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, + ) diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index fe5b310..fe3c3c8 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -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, ) diff --git a/backend/app/version.py b/backend/app/version.py index 71c3a51..b939d43 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.11.5" +APP_VERSION = "0.11.6" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index a0605be..adeee00 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.11.5" + assert APP_VERSION == "0.11.6" 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" diff --git a/backend/tests/test_rdg_client_host.py b/backend/tests/test_rdg_client_host.py new file mode 100644 index 0000000..caacd7e --- /dev/null +++ b/backend/tests/test_rdg_client_host.py @@ -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" diff --git a/backend/tests/test_rdg_winrm_actions.py b/backend/tests/test_rdg_winrm_actions.py new file mode 100644 index 0000000..a2aca3d --- /dev/null +++ b/backend/tests/test_rdg_winrm_actions.py @@ -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() diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6d63932..42975f8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -180,6 +180,9 @@ export interface AgentCommandResponse { result_stderr?: string | null; created_at?: string | null; completed_at?: string | null; + target?: string | null; + client_hostname?: string | null; + internal_ip?: string | null; } export function postEventQwinsta(eventId: number): Promise { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 03d4ebe..257bf3e 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ 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}`; diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 83879b7..b60e2e0 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -305,7 +305,8 @@