feat: RDP flap auto-disconnect and hide break button after reconnect (0.4.10)

Add optional auto logoff of stuck sessions on RDG flap and direct RDP failure. Hide the RDS break button on old flap 302 when the user later reconnects or the workstation session is closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 16:50:03 +10:00
parent 10ae670962
commit 3765d4d476
15 changed files with 854 additions and 7 deletions
@@ -0,0 +1,242 @@
"""Tests for RDP flap auto-disconnect setting and service."""
import uuid
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.config import get_settings
from app.models import Host
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.ingest import ingest_event
from app.services.problems import maybe_create_problem
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings, upsert_rdp_flap_settings
from app.services.winrm_connect import WinRmCmdResult
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "secret")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def rdg_hosts(db_session):
ws = Host(
hostname="TSA-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.163.100",
)
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()
return ws, gw
def test_rdp_flap_settings_default_disabled(db_session):
cfg = get_effective_rdp_flap_settings(db_session)
assert cfg.auto_disconnect is False
assert cfg.source == "default"
def test_rdp_flap_settings_upsert(db_session):
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
cfg = get_effective_rdp_flap_settings(db_session)
assert cfg.auto_disconnect is True
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
assert row is not None
assert row.auto_rdp_flap_disconnect is True
def test_auto_disconnect_skipped_when_disabled(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
details = {"user": user, "internal_ip": ws.ipv4}
gw_payload = {
"host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**gw_payload,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
**gw_payload,
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
maybe_create_problem(db_session, end)
mock_logoff = MagicMock()
monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, end)
assert result is None
mock_logoff.assert_not_called()
def test_auto_disconnect_rdg_flap_calls_logoff(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, gw = rdg_hosts
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
details = {"user": user, "internal_ip": ws.ipv4}
gw_payload = {
"host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**gw_payload,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
_ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="login",
summary="login",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
**gw_payload,
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
maybe_create_problem(db_session, end)
qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 5 Active\n"
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.list_windows_sessions",
lambda host, cfg: (
[],
WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout),
),
)
mock_logoff = MagicMock(return_value=SimpleNamespace(status="completed", result_stderr=None, result_stdout="ok"))
monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, end)
assert result is not None
assert result.ok is True
assert result.session_ids == (5,)
mock_logoff.assert_called_once()
assert end.details["rdp_flap_auto_disconnect"]["ok"] is True
def test_auto_disconnect_direct_rdp_failed(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, _gw = rdg_hosts
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
ws_payload = {
"host": {"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**ws_payload,
type="rdp.login.success",
category="auth",
severity="info",
title="login",
summary="login",
details={"user": user},
)
failed = _ingest(
db_session,
t0 + timedelta(seconds=30),
**ws_payload,
type="rdp.login.failed",
category="auth",
severity="warning",
title="failed",
summary="failed",
details={"user": user},
)
qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 3 Active\n"
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.list_windows_sessions",
lambda host, cfg: (
[],
WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout),
),
)
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.terminate_windows_session",
lambda host, cfg, sid: WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or ""),
)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, failed)
assert result is not None
assert result.ok is True
assert result.session_ids == (3,)