diff --git a/README.md b/README.md index 53f4758..a5512ad 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | -**Версия:** `0.3.8` · **Деплой:** `sudo /opt/sac-deploy.sh` +**Версия:** `0.4.10` · **Деплой:** `sudo /opt/sac-deploy.sh` ## Возможности @@ -21,7 +21,7 @@ - **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 1–10 с) - **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown - **UI** — события, хосты, problems, отчёты, обзор (SSE live), настройки (admin) -- **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках) +- **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках); опционально — автоотключение зависшей сессии при flap - **Хосты** — статус агента, inventory, обновление ssh-monitor/RDP (SSH/WinRM), тест Git/SSH/WinRM; WinRM RDP: SAC тянет пакет с git, клиент скачивает zip с SAC, локальный `Deploy-LoginMonitor.ps1` (git на ПК не нужен) - **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC - **Роли** — `admin` / `monitor`; JWT, rate limit входа diff --git a/README_en.md b/README_en.md index 0a0aa2b..2136141 100644 --- a/README_en.md +++ b/README_en.md @@ -13,7 +13,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre | **security-alert-center** | SAC server (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | -**Version:** `0.3.8` · **Deploy:** `sudo /opt/sac-deploy.sh` +**Version:** `0.4.10` · **Deploy:** `sudo /opt/sac-deploy.sh` ## Features @@ -21,7 +21,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre - **Problems** — auto-correlation (incl. RDG 302→303 flap within 1–10 s) - **Notifications** — Telegram, email, webhook, FCM (Seaca) - **Web UI** — events, hosts, problems, reports, dashboard (SSE), settings (admin) -- **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required) +- **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required); optional auto-disconnect of stuck sessions - **Hosts** — agent status, inventory, agent updates (SSH/WinRM); WinRM RDP: SAC fetches from git, client downloads zip from SAC, runs local `Deploy-LoginMonitor.ps1` (no git on PC) - **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API - **Roles** — `admin` / `monitor` diff --git a/backend/alembic/versions/025_auto_rdp_flap_disconnect.py b/backend/alembic/versions/025_auto_rdp_flap_disconnect.py new file mode 100644 index 0000000..a6102cb --- /dev/null +++ b/backend/alembic/versions/025_auto_rdp_flap_disconnect.py @@ -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") diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 4b311bb..2f6ea78 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -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: diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 3cd29a7..94c3dfa 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -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 diff --git a/backend/app/models/ui_settings.py b/backend/app/models/ui_settings.py index 3e35a32..f39334b 100644 --- a/backend/app/models/ui_settings.py +++ b/backend/app/models/ui_settings.py @@ -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 + ) diff --git a/backend/app/services/rdg_session_flap.py b/backend/app/services/rdg_session_flap.py index f638246..6073d75 100644 --- a/backend/app/services/rdg_session_flap.py +++ b/backend/app/services/rdg_session_flap.py @@ -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 diff --git a/backend/app/services/rdp_flap_auto_disconnect.py b/backend/app/services/rdp_flap_auto_disconnect.py new file mode 100644 index 0000000..fdb0fc6 --- /dev/null +++ b/backend/app/services/rdp_flap_auto_disconnect.py @@ -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 diff --git a/backend/app/services/rdp_flap_settings.py b/backend/app/services/rdp_flap_settings.py new file mode 100644 index 0000000..b5a125d --- /dev/null +++ b/backend/app/services/rdp_flap_settings.py @@ -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) diff --git a/backend/app/version.py b/backend/app/version.py index bdd9d13..99b3444 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -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}" diff --git a/backend/tests/test_rdg_session_flap.py b/backend/tests/test_rdg_session_flap.py index 4aef21a..1d2c8e7 100644 --- a/backend/tests/test_rdg_session_flap.py +++ b/backend/tests/test_rdg_session_flap.py @@ -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" diff --git a/backend/tests/test_rdp_flap_auto_disconnect.py b/backend/tests/test_rdp_flap_auto_disconnect.py new file mode 100644 index 0000000..218f60b --- /dev/null +++ b/backend/tests/test_rdp_flap_auto_disconnect.py @@ -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,) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a9b068f..21952b9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -472,6 +472,23 @@ export function updateWinAdminSettings(payload: { }); } +export interface RdpFlapSettings { + auto_disconnect: boolean; + win_admin_configured: boolean; + source: string; +} + +export function fetchRdpFlapSettings(): Promise { + return apiFetch("/api/v1/settings/rdp-flap"); +} + +export function updateRdpFlapSettings(payload: { auto_disconnect: boolean }): Promise { + return apiFetch("/api/v1/settings/rdp-flap", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + export interface HostWinRmTestResult { ok: boolean; message: string; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 2709179..eaf5f65 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.4.8"; +export const APP_VERSION = "0.4.10"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 11f1e6c..ad864fb 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -159,6 +159,28 @@ + +
+

RDP flapping

+

+ При включении SAC автоматически завершает зависшую сессию пользователя на целевом ПК + (WinRM qwinsta/logoff): при RDG flap 302→303 и при прямом RDP, если вход не удался из‑за + открытой сессии. Нужен настроенный доменный admin выше. +

+ +

+ Windows admin: {{ rdpFlapLoaded.win_admin_configured ? "настроен" : "не настроен" }} + · источник: {{ rdpFlapLoaded.source }} +

+
+ +
+
@@ -681,6 +703,8 @@ import { fetchMobileSettings, fetchUsers, fetchWinAdminSettings, + fetchRdpFlapSettings, + updateRdpFlapSettings, fetchLinuxAdminSettings, fetchAgentUpdateSettings, saveAgentUpdateSettings, @@ -702,6 +726,7 @@ import { type MobileEnrollmentCode, type MobileSettings, type AgentUpdateSettings, + type RdpFlapSettings, type UserSummary, } from "../api"; @@ -811,6 +836,8 @@ const emailLoaded = ref(null); const policyLoaded = ref(null); const uiLoaded = ref(null); const winAdminLoaded = ref(null); +const rdpFlapLoaded = ref(null); +const savingRdpFlap = ref(false); const linuxAdminLoaded = ref(null); const agentUpdateLoaded = ref(null); const loginSecurityLoaded = ref(null); @@ -887,6 +914,10 @@ const winAdminForm = reactive({ password: "", }); +const rdpFlapForm = reactive({ + auto_disconnect: false, +}); + const linuxAdminForm = reactive({ user: "", password: "", @@ -1114,6 +1145,28 @@ async function loadWinAdminSettings() { winAdminForm.password = ""; } +async function loadRdpFlapSettings() { + const data = await fetchRdpFlapSettings(); + rdpFlapLoaded.value = data; + rdpFlapForm.auto_disconnect = data.auto_disconnect; +} + +async function saveRdpFlapSettings() { + savingRdpFlap.value = true; + error.value = ""; + success.value = ""; + try { + rdpFlapLoaded.value = await updateRdpFlapSettings({ + auto_disconnect: rdpFlapForm.auto_disconnect, + }); + success.value = "Настройки RDP flapping сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения RDP flapping"; + } finally { + savingRdpFlap.value = false; + } +} + async function saveWinAdminSettings() { savingWinAdmin.value = true; error.value = ""; @@ -1428,6 +1481,7 @@ async function load() { await loadUiSettings(); await loadLoginSecuritySettings(); await loadWinAdminSettings(); + await loadRdpFlapSettings(); await loadLinuxAdminSettings(); await loadAgentUpdateSettings(); await loadSeverityOverrides(); @@ -1617,6 +1671,10 @@ onMounted(() => { margin-top: 0; } +.settings-card h3 { + margin-top: 1.25rem; +} + .settings-form { display: flex; flex-direction: column;