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:
@@ -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 = ""
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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,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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -84,6 +84,9 @@ SAC_RDG_FLAP_WINDOW_MIN_SEC=1
|
||||
SAC_RDG_FLAP_WINDOW_MAX_SEC=10
|
||||
SAC_RDG_FLAP_DEDUP_SEC=30
|
||||
|
||||
# IP HAProxy (или другого RDG-прокси), через который виден вход: external_ip в событии 302/303
|
||||
# SAC_RDG_HAPROXY_EXTERNAL_IPS=192.168.160.50,10.0.0.100
|
||||
|
||||
# qwinsta/logoff на Windows-хостах (доменный admin)
|
||||
# SAC_WIN_ADMIN_USER=B26\\Administrator
|
||||
# SAC_WIN_ADMIN_PASSWORD=
|
||||
|
||||
@@ -187,6 +187,8 @@ export interface EventSummary {
|
||||
rdg_flap?: boolean;
|
||||
rdg_flap_pair_event_id?: number | null;
|
||||
rdg_flap_qwinsta_event_id?: number | null;
|
||||
rdg_access_path?: string | null;
|
||||
rdg_qwinsta_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentCommandResponse {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<span class="rdg-access-badge" :title="tooltip">{{ event.rdg_access_path }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { EventSummary } from "../api";
|
||||
|
||||
const props = defineProps<{
|
||||
event: EventSummary;
|
||||
}>();
|
||||
|
||||
const tooltip = computed(() => {
|
||||
const path = props.event.rdg_access_path;
|
||||
if (path === "Haproxy-RDG-Comp") {
|
||||
return "Вход на рабочий ПК через HAProxy → RD Gateway";
|
||||
}
|
||||
if (path === "RDG-Comp") {
|
||||
return "Прямой вход на рабочий ПК через RD Gateway";
|
||||
}
|
||||
return "Путь входа RDS через RD Gateway";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rdg-access-badge {
|
||||
display: inline-block;
|
||||
margin-right: 0.45rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #7ec8e3;
|
||||
background: rgba(126, 200, 227, 0.1);
|
||||
border: 1px solid rgba(126, 200, 227, 0.4);
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AgentCommandResponse,
|
||||
type EventSummary,
|
||||
} from "../api";
|
||||
import { rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
|
||||
export type QwinstaSessionRow = {
|
||||
sessionName: string;
|
||||
@@ -114,7 +115,7 @@ export function useRdgQwinsta() {
|
||||
}
|
||||
|
||||
async function runQwinsta(event: EventSummary) {
|
||||
const targetId = event.rdg_flap_qwinsta_event_id ?? event.id;
|
||||
const targetId = rdgQwinstaEventId(event) ?? event.id;
|
||||
qwinstaLoadingId.value = event.id;
|
||||
qwinstaModal.value = {
|
||||
open: true,
|
||||
|
||||
@@ -4,12 +4,31 @@ export function hasRdgFlapUi(event: EventSummary): boolean {
|
||||
return Boolean(event.rdg_flap || event.rdg_flap_qwinsta_event_id);
|
||||
}
|
||||
|
||||
export function rdgFlapQwinstaEventId(event: EventSummary): number | null {
|
||||
export function hasRdgAccessPath(event: EventSummary): boolean {
|
||||
return Boolean(event.rdg_access_path);
|
||||
}
|
||||
|
||||
export function rdgAccessPathLabel(event: EventSummary): string {
|
||||
return event.rdg_access_path || "";
|
||||
}
|
||||
|
||||
export function rdgQwinstaEventId(event: EventSummary): number | null {
|
||||
if (event.rdg_flap_qwinsta_event_id) return event.rdg_flap_qwinsta_event_id;
|
||||
return event.rdg_flap ? event.id : null;
|
||||
if (event.rdg_qwinsta_enabled) return event.id;
|
||||
if (event.rdg_flap) return event.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @deprecated use rdgQwinstaEventId */
|
||||
export function rdgFlapQwinstaEventId(event: EventSummary): number | null {
|
||||
return rdgQwinstaEventId(event);
|
||||
}
|
||||
|
||||
export function rdgFlapPairLabel(event: EventSummary): string {
|
||||
if (!event.rdg_flap_pair_event_id) return "";
|
||||
return event.type === "rdg.connection.success" ? "→" : "←";
|
||||
}
|
||||
|
||||
export function isRdgConnectionEvent(event: EventSummary): boolean {
|
||||
return event.type.startsWith("rdg.connection.");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||
export const APP_NAME = "Security Alert Center";
|
||||
export const APP_VERSION = "0.3.0";
|
||||
export const APP_VERSION = "0.3.1";
|
||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
|
||||
<td>
|
||||
<RdgAccessBadge v-if="e.rdg_access_path" :event="e" />
|
||||
<RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" />
|
||||
{{ e.title }}
|
||||
</td>
|
||||
@@ -289,13 +290,14 @@
|
||||
<td class="dash-actions">
|
||||
|
||||
<button
|
||||
v-if="rdgFlapQwinstaEventId(e)"
|
||||
v-if="rdgQwinstaEventId(e)"
|
||||
type="button"
|
||||
class="secondary dash-qwinsta-btn"
|
||||
:disabled="qwinstaLoadingId === e.id"
|
||||
title="qwinsta и завершение сеанса на рабочем ПК"
|
||||
@click="runQwinsta(e)"
|
||||
>
|
||||
qwinsta
|
||||
Обрыв RDS
|
||||
</button>
|
||||
<button
|
||||
v-if="eventSupportsSessionTerminate(e)"
|
||||
@@ -346,12 +348,13 @@ import {
|
||||
type DashboardSummary,
|
||||
type EventSummary,
|
||||
} from "../api";
|
||||
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||
import { useSacVersion } from "../composables/useSacVersion";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
import { hasRdgFlapUi, rdgFlapQwinstaEventId } from "../utils/rdgFlap";
|
||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
import { eventSupportsSessionTerminate } from "../utils/sessionActions";
|
||||
|
||||
const { appName, appVersion } = useSacVersion();
|
||||
|
||||
@@ -7,18 +7,23 @@
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
<template v-else-if="event">
|
||||
<h1>{{ event.title }}</h1>
|
||||
<p v-if="event.rdg_access_path" class="event-rdg-access-banner">
|
||||
<RdgAccessBadge :event="event" />
|
||||
<span class="muted">Вход на рабочий ПК через RD Gateway.</span>
|
||||
</p>
|
||||
<p v-if="hasRdgFlapUi(event)" class="event-rdg-flap-banner">
|
||||
<RdgFlapBadge :event="event" :detail-link-query="backToEventsQuery" />
|
||||
<span class="muted">302→303 за несколько секунд — возможна зависшая сессия на ПК пользователя.</span>
|
||||
</p>
|
||||
<p v-if="rdgFlapQwinstaEventId(event)" class="event-rdg-actions">
|
||||
<p v-if="rdgQwinstaEventId(event)" class="event-rdg-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="qwinstaLoadingId === event.id"
|
||||
title="qwinsta и завершение сеанса на рабочем ПК"
|
||||
@click="runQwinsta(event)"
|
||||
>
|
||||
qwinsta (RDG flap)
|
||||
Обрыв RDS
|
||||
</button>
|
||||
</p>
|
||||
<RdgQwinstaModal
|
||||
@@ -93,11 +98,12 @@
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiFetch, type EventDetail } from "../api";
|
||||
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||
import ReportBodyCard from "../components/ReportBodyCard.vue";
|
||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||
import { hasRdgFlapUi, rdgFlapQwinstaEventId } from "../utils/rdgFlap";
|
||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
|
||||
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
|
||||
|
||||
|
||||
@@ -54,18 +54,20 @@
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
<td><code>{{ e.type }}</code></td>
|
||||
<td>
|
||||
<RdgAccessBadge v-if="e.rdg_access_path" :event="e" />
|
||||
<RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" :detail-link-query="detailLinkQuery" />
|
||||
{{ e.title }}
|
||||
</td>
|
||||
<td class="events-actions">
|
||||
<button
|
||||
v-if="rdgFlapQwinstaEventId(e)"
|
||||
v-if="rdgQwinstaEventId(e)"
|
||||
type="button"
|
||||
class="secondary events-qwinsta-btn"
|
||||
:disabled="qwinstaLoadingId === e.id"
|
||||
title="qwinsta и завершение сеанса на рабочем ПК"
|
||||
@click="runQwinsta(e)"
|
||||
>
|
||||
qwinsta
|
||||
Обрыв RDS
|
||||
</button>
|
||||
<button
|
||||
v-if="eventSupportsSessionTerminate(e)"
|
||||
@@ -105,10 +107,11 @@ import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||
import { apiFetch, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
|
||||
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||
import { hasRdgFlapUi, rdgFlapQwinstaEventId } from "../utils/rdgFlap";
|
||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
import {
|
||||
apiParamsFromState,
|
||||
buildEventsListQuery,
|
||||
|
||||
Reference in New Issue
Block a user