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:
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.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)
+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
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]:
+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 ""
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)
+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,
)
+147 -62
View File
@@ -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,
)
+1 -1
View File
@@ -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}"
+2 -2
View File
@@ -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"
+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;
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<AgentCommandResponse> {
+1 -1
View File
@@ -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}`;
+33 -19
View File
@@ -305,7 +305,8 @@
<div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
<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-else-if="qwinstaModal.loading">Ожидание ответа агента</p>
<template v-else>
@@ -412,6 +413,9 @@ const qwinstaModal = ref({
eventId: 0,
actorUser: "",
commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: false,
error: "",
stdout: "",
@@ -494,6 +498,12 @@ function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: str
}, 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) {
qwinstaLoadingId.value = event.id;
qwinstaModal.value = {
@@ -501,6 +511,9 @@ async function runQwinsta(event: EventSummary) {
eventId: event.id,
actorUser: event.actor_user || "",
commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: true,
error: "",
stdout: "",
@@ -509,6 +522,7 @@ async function runQwinsta(event: EventSummary) {
};
try {
const cmd = await postEventQwinsta(event.id);
applyCommandMeta(qwinstaModal.value, cmd);
qwinstaModal.value.commandUuid = cmd.command_uuid;
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
return;
@@ -530,25 +544,20 @@ async function runLogoff(sessionId: number) {
modal.error = "";
try {
const cmd = await postEventLogoff(modal.eventId, sessionId);
if (cmd.status === "pending") {
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
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.stdout = "";
modal.sessions = [];
const again = await postEventQwinsta(modal.eventId);
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
}
break;
}
}
applyCommandMeta(modal, cmd);
if (cmd.status === "failed") {
modal.error = cmd.result_stderr || cmd.result_stdout || "logoff failed";
return;
}
modal.loading = true;
modal.stdout = "";
modal.sessions = [];
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);
} catch (e) {
modal.error = e instanceof Error ? e.message : "logoff error";
} finally {
@@ -849,6 +858,11 @@ onUnmounted(() => {
padding: 1rem 1.25rem;
}
.qwinsta-meta {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.qwinsta-raw {
font-size: 0.8rem;
max-height: 12rem;