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:
PTah
2026-07-02 16:50:03 +10:00
parent 10ae670962
commit 3765d4d476
15 changed files with 854 additions and 7 deletions
+59 -1
View File
@@ -210,6 +210,57 @@ def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Even
return None
def _users_and_ip_match(left: Event, right: Event) -> bool:
return _users_match(left, right) and _internal_ips_match_strict(left, right)
def find_later_rdg_success_after(
db: Session,
*,
anchor: Event,
after: datetime,
) -> Event | None:
"""Поздний 302 на том же шлюзе, user и client PC — пользователь снова зашёл через RDG."""
if anchor.type != RDG_SUCCESS_TYPE:
return None
after_at = _as_utc(after)
candidates = db.scalars(
select(Event)
.where(
Event.host_id == anchor.host_id,
Event.type == RDG_SUCCESS_TYPE,
Event.occurred_at > after_at,
Event.id != anchor.id,
)
.order_by(Event.occurred_at.asc())
).all()
for later in candidates:
if not _users_and_ip_match(later, anchor):
continue
return later
return None
def _flap_auto_disconnect_succeeded(flap_end: Event) -> bool:
details = flap_end.details if isinstance(flap_end.details, dict) else {}
block = details.get("rdp_flap_auto_disconnect")
if not isinstance(block, dict):
return False
return block.get("ok") is True
def _flap_workstation_session_closed(db: Session, flap_end: Event) -> bool:
from app.services.host_sessions import event_session_terminated
from app.services.rdg_workstation_session import find_workstation_login_for_rdg_end
login = find_workstation_login_for_rdg_end(db, flap_end)
if login is None:
return False
return event_session_terminated(login, db=db)
def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool:
"""Кнопка qwinsta/logoff только на 302, пока сессия может быть активна (или RDG flap)."""
if event.type in RDG_END_TYPES:
@@ -220,7 +271,14 @@ def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool:
return False
if db is None:
return True
if find_rdg_end_after_success(db, event) is not None:
flap_end = find_rdg_end_after_success(db, event)
if flap_end is not None:
if _flap_auto_disconnect_succeeded(flap_end):
return False
if _flap_workstation_session_closed(db, flap_end):
return False
if find_later_rdg_success_after(db, anchor=event, after=flap_end.occurred_at) is not None:
return False
return True
if find_normal_rdg_end_after_success(db, event) is not None:
return False
@@ -0,0 +1,320 @@
"""Auto logoff stuck RDP sessions when RDG flap (or direct login failure) is detected."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event, Host
from app.services.host_sessions import (
list_windows_sessions,
mark_event_session_terminated,
parse_qwinsta_sessions,
terminate_windows_session,
)
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
from app.services.rdg_session_flap import (
RDG_END_TYPES,
RDG_SUCCESS_TYPE,
_event_user,
_stored_flap_pair_id,
event_has_rdg_flap,
find_rdg_success_before_end,
)
from app.services.rdg_winrm_actions import execute_logoff_via_winrm
from app.services.rdg_workstation_session import (
WORKSTATION_LOGIN_TYPE,
_event_login_user,
_login_already_closed,
find_workstation_login_for_rdg_end,
users_match_rdg,
)
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
from app.services.win_admin_settings import get_effective_win_admin_config
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
AUTO_DISCONNECT_BY = "auto:rdp_flap"
RDP_LOGIN_FAILED = "rdp.login.failed"
AUTO_DISCONNECT_DETAILS_KEY = "rdp_flap_auto_disconnect"
@dataclass(frozen=True)
class AutoDisconnectResult:
ok: bool
message: str
trigger_event_id: int
workstation_host_id: int | None = None
login_event_id: int | None = None
session_ids: tuple[int, ...] = ()
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _already_auto_disconnected(event: Event) -> bool:
details = _details_dict(event)
block = details.get(AUTO_DISCONNECT_DETAILS_KEY)
if not isinstance(block, dict):
return False
return block.get("ok") is True
def _mark_auto_disconnect(event: Event, *, result: AutoDisconnectResult) -> None:
details = dict(_details_dict(event))
details[AUTO_DISCONNECT_DETAILS_KEY] = {
"ok": result.ok,
"message": result.message,
"workstation_host_id": result.workstation_host_id,
"login_event_id": result.login_event_id,
"session_ids": list(result.session_ids),
"at": datetime.now(timezone.utc).isoformat(),
}
event.details = details
flag_modified(event, "details")
def _norm_user_filter(user: str) -> str:
text = (user or "").strip()
if "\\" in text:
return text.split("\\")[-1].strip().lower()
if "@" in text:
return text.split("@")[0].strip().lower()
return text.lower()
def _sessions_for_user(sessions, user: str):
needle = _norm_user_filter(user)
if not needle:
return []
matched = []
for row in sessions:
if needle in _norm_user_filter(row.user):
matched.append(row)
return matched
def find_open_workstation_login(db: Session, *, host_id: int, user: str) -> Event | None:
if not user.strip():
return None
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == WORKSTATION_LOGIN_TYPE,
)
.order_by(Event.occurred_at.desc())
).all()
for login in candidates:
if _login_already_closed(login):
continue
if users_match_rdg(_event_login_user(login), user):
return login
return None
def _rdg_pair_success_event(db: Session, event: Event) -> Event | None:
if event.type == RDG_SUCCESS_TYPE and event_has_rdg_flap(event):
pair_id = _stored_flap_pair_id(event)
if pair_id is not None:
return db.get(Event, pair_id)
return event
if event.type in RDG_END_TYPES:
pair_id = _stored_flap_pair_id(event)
if pair_id is not None:
return db.get(Event, pair_id)
return find_rdg_success_before_end(db, event)
return None
def _disconnect_on_workstation(
db: Session,
*,
trigger_event: Event,
workstation: Host,
rdg_event: Event | None,
user: str,
login_event: Event | None,
) -> AutoDisconnectResult:
win_cfg = get_effective_win_admin_config(db)
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
if qwinsta is None or not qwinsta.ok:
message = qwinsta.message if qwinsta else "qwinsta failed"
return AutoDisconnectResult(
ok=False,
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
)
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user)
if not matched and qwinsta.stdout.strip():
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), user)
if not matched:
return AutoDisconnectResult(
ok=False,
message="No matching Windows session for user",
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
)
logged_off: list[int] = []
errors: list[str] = []
for row in matched:
if rdg_event is not None:
cmd = execute_logoff_via_winrm(
db,
rdg_event,
session_id=int(row.session_id),
requested_by=AUTO_DISCONNECT_BY,
)
if cmd.status == "completed":
logged_off.append(int(row.session_id))
else:
errors.append(cmd.result_stderr or cmd.result_stdout or f"logoff {row.session_id} failed")
else:
result = terminate_windows_session(workstation, win_cfg, row.session_id)
if result is not None and result.ok:
logged_off.append(int(row.session_id))
else:
errors.append(result.message if result else f"logoff {row.session_id} failed")
if login_event is not None and logged_off:
mark_event_session_terminated(login_event, by_username=AUTO_DISCONNECT_BY)
if logged_off:
message = f"Auto logoff session(s) {', '.join(str(s) for s in logged_off)} on {workstation.hostname}"
ok = True
else:
ok = False
message = "; ".join(errors) if errors else "logoff failed"
return AutoDisconnectResult(
ok=ok,
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
session_ids=tuple(logged_off),
)
def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult | None:
if not event_has_rdg_flap(event):
return None
if _already_auto_disconnected(event):
return None
rdg_success = _rdg_pair_success_event(db, event)
if rdg_success is None:
return None
user = _event_user(event) or _event_user(rdg_success)
if not user:
return None
try:
workstation = resolve_client_workstation(db, rdg_success)
except ClientWorkstationNotFoundError as exc:
return AutoDisconnectResult(
ok=False,
message=str(exc),
trigger_event_id=event.id,
)
rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1)
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
result = _disconnect_on_workstation(
db,
trigger_event=event,
workstation=workstation,
rdg_event=rdg_success,
user=user,
login_event=login_event,
)
_mark_auto_disconnect(event, result=result)
return result
def _auto_disconnect_direct_rdp_failed(db: Session, event: Event) -> AutoDisconnectResult | None:
if event.type != RDP_LOGIN_FAILED:
return None
if _already_auto_disconnected(event):
return None
host = event.host
if host is None:
return None
user = _event_login_user(event)
if not user:
return None
login_event = find_open_workstation_login(db, host_id=host.id, user=user)
if login_event is None:
return None
result = _disconnect_on_workstation(
db,
trigger_event=event,
workstation=host,
rdg_event=None,
user=user,
login_event=login_event,
)
_mark_auto_disconnect(event, result=result)
return result
def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDisconnectResult | None:
"""Log off stuck user session when auto-disconnect is enabled."""
if not get_effective_rdp_flap_settings(db).auto_disconnect:
return None
win_cfg = get_effective_win_admin_config(db)
if not win_cfg.configured:
logger.warning(
"auto rdp flap disconnect skipped: win admin not configured (event_id=%s)",
event.event_id,
)
return None
result = _auto_disconnect_rdg_flap(db, event)
if result is not None:
if result.ok:
logger.info(
"auto rdp flap disconnect ok event_id=%s sessions=%s",
event.event_id,
result.session_ids,
)
else:
logger.warning(
"auto rdp flap disconnect failed event_id=%s: %s",
event.event_id,
result.message,
)
return result
result = _auto_disconnect_direct_rdp_failed(db, event)
if result is not None:
if result.ok:
logger.info(
"auto direct rdp disconnect ok event_id=%s sessions=%s",
event.event_id,
result.session_ids,
)
else:
logger.warning(
"auto direct rdp disconnect failed event_id=%s: %s",
event.event_id,
result.message,
)
return result
+33
View File
@@ -0,0 +1,33 @@
"""RDP / RDG flap auto-disconnect settings (ui_settings singleton)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@dataclass(frozen=True)
class RdpFlapSettings:
auto_disconnect: bool
source: str = "db"
def get_effective_rdp_flap_settings(db: Session) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
return RdpFlapSettings(auto_disconnect=False, source="default")
return RdpFlapSettings(auto_disconnect=bool(row.auto_rdp_flap_disconnect), source="db")
def upsert_rdp_flap_settings(db: Session, *, auto_disconnect: bool) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
row.auto_rdp_flap_disconnect = bool(auto_disconnect)
db.commit()
db.refresh(row)
return get_effective_rdp_flap_settings(db)