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:
@@ -0,0 +1,26 @@
|
||||
"""auto RDP flap disconnect setting in ui_settings
|
||||
|
||||
Revision ID: 025_auto_rdp_flap_disconnect
|
||||
Revises: 024_login_security
|
||||
Create Date: 2026-07-02
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "025_auto_rdp_flap_disconnect"
|
||||
down_revision = "024_login_security"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("auto_rdp_flap_disconnect", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "auto_rdp_flap_disconnect")
|
||||
@@ -38,6 +38,7 @@ from app.services.ingest import ingest_event
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||
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.schema_validate import validate_event_payload
|
||||
from app.services.notify_dispatch import (
|
||||
AUTH_LOGIN_SUCCESS_TYPES,
|
||||
@@ -97,6 +98,7 @@ def post_event(
|
||||
problem_created = False
|
||||
if created:
|
||||
problem, problem_created = maybe_create_problem(db, event)
|
||||
maybe_auto_disconnect_stuck_rdp_session(db, event)
|
||||
if event.type in DAILY_REPORT_EVENT_TYPES:
|
||||
notify_daily_report(event, db=db)
|
||||
elif event.type == LIFECYCLE_EVENT_TYPE:
|
||||
|
||||
@@ -50,6 +50,10 @@ from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
upsert_win_admin_settings,
|
||||
)
|
||||
from app.services.rdp_flap_settings import (
|
||||
get_effective_rdp_flap_settings,
|
||||
upsert_rdp_flap_settings,
|
||||
)
|
||||
from app.services.agent_git_release import get_git_release_versions
|
||||
from app.services.agent_update_settings import (
|
||||
get_effective_agent_update_config,
|
||||
@@ -520,6 +524,45 @@ def update_win_admin_settings(
|
||||
)
|
||||
|
||||
|
||||
class RdpFlapSettingsResponse(BaseModel):
|
||||
auto_disconnect: bool
|
||||
win_admin_configured: bool
|
||||
source: str = Field(description="db или default")
|
||||
|
||||
|
||||
class RdpFlapSettingsUpdate(BaseModel):
|
||||
auto_disconnect: bool
|
||||
|
||||
|
||||
@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||
def get_rdp_flap_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> RdpFlapSettingsResponse:
|
||||
cfg = get_effective_rdp_flap_settings(db)
|
||||
win_cfg = get_effective_win_admin_config(db)
|
||||
return RdpFlapSettingsResponse(
|
||||
auto_disconnect=cfg.auto_disconnect,
|
||||
win_admin_configured=win_cfg.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||
def update_rdp_flap_settings(
|
||||
body: RdpFlapSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> RdpFlapSettingsResponse:
|
||||
cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect)
|
||||
win_cfg = get_effective_win_admin_config(db)
|
||||
return RdpFlapSettingsResponse(
|
||||
auto_disconnect=cfg.auto_disconnect,
|
||||
win_admin_configured=win_cfg.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class LinuxAdminSettingsResponse(BaseModel):
|
||||
configured: bool
|
||||
user: str | None = None
|
||||
|
||||
@@ -35,3 +35,6 @@ class UiSettings(Base):
|
||||
agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
login_ip_whitelist: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
login_sync_fail2ban: Mapped[bool] = mapped_column(default=False, server_default="false", nullable=False)
|
||||
auto_rdp_flap_disconnect: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", nullable=False
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.4.8"
|
||||
APP_VERSION = "0.4.10"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -263,6 +263,51 @@ def test_rdg_qwinsta_enabled_while_session_open(db_session, rdg_settings):
|
||||
assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True
|
||||
|
||||
|
||||
def test_rdg_qwinsta_disabled_on_flap_302_after_later_success(db_session, rdg_settings):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = r"B26\m.semenova"
|
||||
details = {"user": user, "internal_ip": "192.168.164.45"}
|
||||
|
||||
flap_start = _ingest(
|
||||
db_session,
|
||||
t0,
|
||||
type="rdg.connection.success",
|
||||
category="auth",
|
||||
severity="warning",
|
||||
title="302",
|
||||
summary="302",
|
||||
details=details,
|
||||
)
|
||||
flap_end = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(seconds=5),
|
||||
type="rdg.connection.disconnected",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="303",
|
||||
summary="303",
|
||||
details=details,
|
||||
)
|
||||
evaluate_rdg_session_flap(db_session, flap_end)
|
||||
db_session.flush()
|
||||
|
||||
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is True
|
||||
|
||||
_ingest(
|
||||
db_session,
|
||||
t0 + timedelta(minutes=2, seconds=44),
|
||||
type="rdg.connection.success",
|
||||
category="auth",
|
||||
severity="warning",
|
||||
title="302",
|
||||
summary="302",
|
||||
details=details,
|
||||
)
|
||||
|
||||
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is False
|
||||
assert event_to_summary(flap_start, db_session).rdg_qwinsta_enabled is False
|
||||
|
||||
|
||||
def test_rdg_qwinsta_stays_enabled_when_unrelated_303_without_internal_ip(db_session, rdg_settings):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = "B26\\khodasevich"
|
||||
|
||||
@@ -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,)
|
||||
Reference in New Issue
Block a user