feat: RDG 302/303 display path labels and RDS session break (0.3.1)

Show RDS access via RDG-Comp or Haproxy-RDG-Comp, enable qwinsta/logoff on any RDG event with internal_ip.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 19:52:22 +10:00
parent 4eb41608ea
commit 24ca87a47a
19 changed files with 468 additions and 32 deletions
+2
View File
@@ -106,6 +106,8 @@ class Settings(BaseSettings):
sac_rdg_flap_window_min_sec: int = 1
sac_rdg_flap_window_max_sec: int = 10
sac_rdg_flap_dedup_sec: int = 30
# Внешние IP HAProxy/RDG-прокси: если external_ip в списке — путь Haproxy-RDG-Comp, иначе RDG-Comp
sac_rdg_haproxy_external_ips: str = ""
# Windows admin for agent qwinsta/logoff (domain-wide)
sac_win_admin_user: str = ""
+2
View File
@@ -81,6 +81,8 @@ class EventSummary(BaseModel):
rdg_flap: bool = False
rdg_flap_pair_event_id: int | None = None
rdg_flap_qwinsta_event_id: int | None = None
rdg_access_path: str | None = None
rdg_qwinsta_enabled: bool = False
model_config = {"from_attributes": True}
+16 -2
View File
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
from app.models.event import Event
from app.schemas.list_models import EventSummary
from app.services.event_actor_user import extract_event_actor_user
from app.services.rdg_display import build_rdg_display
from app.services.rdg_session_flap import resolve_rdg_flap_summary
@@ -16,6 +17,17 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
db, event
)
title = event.title
summary = event.summary
rdg_access_path: str | None = None
rdg_qwinsta_enabled = False
rdg_display = build_rdg_display(event, db)
if rdg_display is not None:
title = rdg_display.title
summary = rdg_display.summary
rdg_access_path = rdg_display.access_path
rdg_qwinsta_enabled = rdg_display.qwinsta_enabled
return EventSummary(
id=event.id,
event_id=event.event_id,
@@ -28,10 +40,12 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
category=event.category,
type=event.type,
severity=event.severity,
title=event.title,
summary=event.summary,
title=title,
summary=summary,
actor_user=extract_event_actor_user(event.type, event.details),
rdg_flap=rdg_flap,
rdg_flap_pair_event_id=rdg_flap_pair_event_id,
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
rdg_access_path=rdg_access_path,
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
)
+132
View File
@@ -0,0 +1,132 @@
"""RD Gateway event labels (access path, UI title/summary)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event
from app.services.rdg_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import event_internal_ip
RDG_TYPES = frozenset(
{
"rdg.connection.success",
"rdg.connection.disconnected",
"rdg.connection.failed",
}
)
ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp"
ACCESS_PATH_DIRECT = "RDG-Comp"
@dataclass(frozen=True)
class RdgDisplayInfo:
title: str
summary: str
access_path: str | None
internal_ip: str | None
qwinsta_enabled: bool
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _event_external_ip(event: Event) -> str:
details = _details_dict(event)
for key in ("external_ip", "source_ip", "ip_address"):
val = details.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return ""
def _parse_haproxy_ips() -> frozenset[str]:
raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip()
if not raw:
return frozenset()
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
return frozenset(p for p in parts if p)
def classify_rdg_access_path(external_ip: str) -> str | None:
ip = (external_ip or "").strip()
if not ip or ip in ("-", "N/A"):
return None
if ip in _parse_haproxy_ips():
return ACCESS_PATH_HAPROXY
return ACCESS_PATH_DIRECT
def _windows_event_label(event: Event) -> str:
details = _details_dict(event)
win_id = details.get("event_id_windows")
if win_id is None:
return ""
text = str(win_id).strip()
return f"Win{text}" if text else ""
def _action_label(event: Event) -> str:
if event.type == "rdg.connection.success":
return "подключение"
if event.type == "rdg.connection.disconnected":
return "отключение"
return "ошибка"
def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None:
if event.type not in RDG_TYPES:
return None
details = _details_dict(event)
internal_ip = event_internal_ip(event)
external_ip = _event_external_ip(event)
access_path = classify_rdg_access_path(external_ip)
user = str(details.get("user") or "").strip()
gateway = event.host.hostname if event.host else ""
client_label = internal_ip or ""
if db and internal_ip:
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is not None:
client_label = f"{client_host.hostname} ({internal_ip})"
path_note = f" ({access_path})" if access_path else ""
win_note = _windows_event_label(event)
action = _action_label(event)
title = f"RDS {action}{client_label}{path_note}"
if win_note:
title = f"{title} · {win_note}"
summary_parts: list[str] = []
if user:
summary_parts.append(user)
if external_ip:
summary_parts.append(f"внешний {external_ip}")
summary_parts.append(f"шлюз {gateway}")
if win_note:
summary_parts.append(win_note)
summary = " · ".join(summary_parts)
qwinsta_enabled = bool(internal_ip)
return RdgDisplayInfo(
title=title,
summary=summary,
access_path=access_path,
internal_ip=internal_ip or None,
qwinsta_enabled=qwinsta_enabled,
)
def event_supports_rdg_client_qwinsta(event: Event) -> bool:
if event.type not in RDG_TYPES:
return False
return bool(event_internal_ip(event))
+10 -9
View File
@@ -10,7 +10,8 @@ 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, resolve_rdg_flap_summary
from app.services.rdg_display import event_supports_rdg_client_qwinsta
from app.services.rdg_session_flap import event_internal_ip
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import (
WinRmCmdResult,
@@ -30,13 +31,13 @@ def _require_win_admin(db: Session):
return cfg
def _require_rdg_flap(db: Session, event: Event) -> None:
flap, _, qwinsta_id = resolve_rdg_flap_summary(db, event)
if flap and qwinsta_id == event.id:
def _require_rdg_client_qwinsta(event: Event) -> None:
if event_supports_rdg_client_qwinsta(event):
return
if event_has_rdg_flap(event):
return
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
raise HTTPException(
status_code=400,
detail="Event is not an RD Gateway connection with internal_ip (client workstation)",
)
def _resolve_client(db: Session, event: Event) -> Host:
@@ -91,7 +92,7 @@ def _persist_command(
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
_require_rdg_flap(db, event)
_require_rdg_client_qwinsta(event)
cfg = _require_win_admin(db)
client_host = _resolve_client(db, event)
@@ -134,7 +135,7 @@ def execute_logoff_via_winrm(
session_id: int,
requested_by: str,
) -> AgentCommand:
_require_rdg_flap(db, event)
_require_rdg_client_qwinsta(event)
cfg = _require_win_admin(db)
client_host = _resolve_client(db, event)
+10 -5
View File
@@ -544,18 +544,23 @@ def format_event_mobile_push(event: Event) -> tuple[str, str]:
def _format_rdg_html(event: Event) -> str:
from app.services.rdg_display import build_rdg_display
details = _details_dict(event)
display = build_rdg_display(event)
if event.type.endswith(".success"):
header = "✅ RD Gateway: подключение"
header = "✅ RDS через RD Gateway"
elif event.type.endswith(".disconnected"):
header = "️ RD Gateway: отключение"
header = "️ RDS отключение (RD Gateway)"
else:
header = "❌ RD Gateway: ошибка"
header = "❌ RDS ошибка (RD Gateway)"
msg = f"<b>{header}</b>\n"
if display is not None and display.access_path:
msg += _line("🔀", "Путь", html_escape(display.access_path))
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
msg += _line("🏢", "Хост", host_label(event.host))
msg += _line("🚪", "Шлюз RDG", host_label(event.host))
msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address")))
msg += _line("🏠", "Внутренний IP", html_escape(_detail(details, "internal_ip", default="-")))
msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-")))
err = _detail(details, "gateway_error_code", "error_code", default="")
if err != "-":
msg += _line("⚠️", "Код ошибки", html_escape(err))
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.3.0"
APP_VERSION = "0.3.1"
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.3.0"
assert APP_VERSION == "0.3.1"
assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.3.0"
assert APP_VERSION_LABEL == "Security Alert Center v.0.3.1"
+127
View File
@@ -0,0 +1,127 @@
"""Tests for RDG event display (access path, title/summary)."""
from datetime import datetime, timezone
import pytest
from app.models import Event, Host
from app.services.event_summary import event_to_summary
from app.services.rdg_display import (
ACCESS_PATH_DIRECT,
ACCESS_PATH_HAPROXY,
build_rdg_display,
classify_rdg_access_path,
event_supports_rdg_client_qwinsta,
)
def _rdg_event(
db_session,
*,
gw: Host,
event_type: str = "rdg.connection.success",
external_ip: str = "10.0.0.5",
internal_ip: str = "192.168.160.3",
win_id: int = 302,
) -> Event:
event = Event(
event_id=f"ev-rdg-{win_id}",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type=event_type,
severity="info",
title="RD Gateway event 302",
summary="",
payload={},
details={
"user": r"B26\papatramp",
"external_ip": external_ip,
"internal_ip": internal_ip,
"event_id_windows": win_id,
},
)
db_session.add(event)
db_session.commit()
db_session.refresh(event)
return event
def test_classify_rdg_access_path_direct(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.5") == ACCESS_PATH_DIRECT
def test_classify_rdg_access_path_haproxy(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50,10.0.0.100")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.100") == ACCESS_PATH_HAPROXY
def test_build_rdg_display_title_and_path(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "")
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")
ws = Host(hostname="WS-PC", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.3")
db_session.add_all([gw, ws])
db_session.commit()
event = _rdg_event(db_session, gw=gw)
display = build_rdg_display(event, db_session)
assert display is not None
assert ACCESS_PATH_DIRECT in display.title
assert "WS-PC" in display.title
assert "192.168.160.3" in display.title
assert display.access_path == ACCESS_PATH_DIRECT
assert display.qwinsta_enabled is True
assert "papatramp" in display.summary
assert "шлюз K6A-DC3" in display.summary
def test_event_to_summary_enriches_rdg(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "10.0.0.5")
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 = _rdg_event(db_session, gw=gw, external_ip="10.0.0.5")
summary = event_to_summary(event, db_session)
assert summary.rdg_access_path == ACCESS_PATH_HAPROXY
assert summary.rdg_qwinsta_enabled is True
assert "RDS подключение" in summary.title
assert ACCESS_PATH_HAPROXY in summary.title
def test_event_supports_rdg_client_qwinsta_without_flap(db_session):
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 = _rdg_event(db_session, gw=gw)
assert event_supports_rdg_client_qwinsta(event) is True
@pytest.mark.parametrize(
"internal_ip",
["", None],
)
def test_event_supports_rdg_client_qwinsta_requires_internal_ip(db_session, internal_ip):
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 = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "")
if internal_ip is None:
event.details = {k: v for k, v in event.details.items() if k != "internal_ip"}
assert event_supports_rdg_client_qwinsta(event) is False
+76
View File
@@ -109,3 +109,79 @@ def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatc
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()
def test_qwinsta_without_rdg_flap(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 = Event(
event_id="ev-rdg-plain",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="302",
summary="",
payload={},
details={"user": r"B26\papatramp", "internal_ip": ws.ipv4},
)
db_session.add(event)
db_session.commit()
with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run:
mock_run.return_value = (
WinRmCmdResult(ok=True, message="OK", target="Andrisonova-PC", stdout="ok", 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
mock_run.assert_called_once()
def test_qwinsta_rejects_rdg_without_internal_ip(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-rdg-no-ip",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="302",
summary="",
payload={},
details={"user": r"B26\user"},
)
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 == 400
assert "internal_ip" in response.json()["detail"].lower()