312 lines
10 KiB
Python
312 lines
10 KiB
Python
"""RDG 302→303 session flap: detect, flag event, build Problem match."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
|
||
from app.config import get_settings
|
||
from app.models import Event
|
||
from app.services.problem_rules import RULE_RDG_SESSION_FLAP, RuleMatch
|
||
|
||
RDG_SUCCESS_TYPE = "rdg.connection.success"
|
||
RDG_END_TYPES = frozenset({"rdg.connection.disconnected", "rdg.connection.failed"})
|
||
|
||
|
||
def _event_user(event: Event) -> str:
|
||
details = event.details if isinstance(event.details, dict) else {}
|
||
user = details.get("user")
|
||
return str(user).strip() if user else ""
|
||
|
||
|
||
def _event_internal_ip(event: Event) -> str:
|
||
details = event.details if isinstance(event.details, dict) else {}
|
||
for key in ("internal_ip", "client_ip", "ip_address"):
|
||
val = details.get(key)
|
||
if val:
|
||
return str(val).strip()
|
||
return ""
|
||
|
||
|
||
def event_internal_ip(event: Event) -> str:
|
||
return _event_internal_ip(event)
|
||
|
||
|
||
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
||
|
||
|
||
def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
|
||
end_ip = _event_internal_ip(end_event)
|
||
success_ip = _event_internal_ip(success_event)
|
||
if not end_ip or not success_ip:
|
||
return True
|
||
return end_ip == success_ip
|
||
|
||
|
||
def _internal_ips_match_strict(end_event: Event, success_event: Event) -> bool:
|
||
"""Для «сессия завершена» — только при совпадении целевого ПК (оба IP заданы)."""
|
||
end_ip = _event_internal_ip(end_event)
|
||
success_ip = _event_internal_ip(success_event)
|
||
if not end_ip or not success_ip:
|
||
return False
|
||
return end_ip == success_ip
|
||
|
||
|
||
def _as_utc(dt: datetime) -> datetime:
|
||
if dt.tzinfo is None:
|
||
return dt.replace(tzinfo=timezone.utc)
|
||
return dt.astimezone(timezone.utc)
|
||
|
||
|
||
def find_rdg_success_before_end(db: Session, end_event: Event) -> Event | None:
|
||
if end_event.type not in RDG_END_TYPES:
|
||
return None
|
||
settings = get_settings()
|
||
min_sec = settings.sac_rdg_flap_window_min_sec
|
||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||
end_at = _as_utc(end_event.occurred_at)
|
||
|
||
window_start = end_at - timedelta(seconds=max_sec)
|
||
window_end = end_at - timedelta(seconds=min_sec)
|
||
|
||
candidates = db.scalars(
|
||
select(Event)
|
||
.where(
|
||
Event.host_id == end_event.host_id,
|
||
Event.type == RDG_SUCCESS_TYPE,
|
||
Event.occurred_at >= window_start,
|
||
Event.occurred_at <= window_end,
|
||
Event.id != end_event.id,
|
||
)
|
||
.order_by(Event.occurred_at.desc())
|
||
).all()
|
||
|
||
for prior in candidates:
|
||
if not _users_match(end_event, prior):
|
||
continue
|
||
if not _internal_ips_compatible(end_event, prior):
|
||
continue
|
||
prior_at = _as_utc(prior.occurred_at)
|
||
delta = (end_at - prior_at).total_seconds()
|
||
if min_sec <= delta <= max_sec:
|
||
return prior
|
||
return None
|
||
|
||
|
||
def mark_rdg_flap(event: Event, *, pair_event: Event) -> None:
|
||
details = dict(event.details) if isinstance(event.details, dict) else {}
|
||
details["rdg_flap"] = True
|
||
details["rdg_flap_pair_event_id"] = pair_event.id
|
||
event.details = details
|
||
flag_modified(event, "details")
|
||
|
||
|
||
def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None:
|
||
prior = find_rdg_success_before_end(db, event)
|
||
if prior is None:
|
||
return None
|
||
|
||
mark_rdg_flap(event, pair_event=prior)
|
||
user = _event_user(event)
|
||
internal_ip = _event_internal_ip(event)
|
||
ip_note = f", client {internal_ip}" if internal_ip else ""
|
||
delta_sec = int((_as_utc(event.occurred_at) - _as_utc(prior.occurred_at)).total_seconds())
|
||
return RuleMatch(
|
||
rule_id=RULE_RDG_SESSION_FLAP,
|
||
correlation_type="rdg.session.flap",
|
||
fingerprint_suffix=f"u{user}:ip{internal_ip or 'any'}",
|
||
title=f"RDG session flap: {user}",
|
||
summary=(
|
||
f"302→303 за {delta_sec} с "
|
||
f"({user}{ip_note}). Возможна зависшая сессия на ПК пользователя — qwinsta/logoff."
|
||
),
|
||
severity="warning",
|
||
)
|
||
|
||
|
||
def event_has_rdg_flap(event: Event) -> bool:
|
||
details = event.details if isinstance(event.details, dict) else {}
|
||
return details.get("rdg_flap") is True
|
||
|
||
|
||
def _stored_flap_pair_id(event: Event) -> int | None:
|
||
details = event.details if isinstance(event.details, dict) else {}
|
||
raw = details.get("rdg_flap_pair_event_id")
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
return int(raw)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
|
||
"""303 с rdg_flap, у которого пара — этот 302 (или вычисляется по окну)."""
|
||
if success_event.type != RDG_SUCCESS_TYPE:
|
||
return None
|
||
settings = get_settings()
|
||
min_sec = settings.sac_rdg_flap_window_min_sec
|
||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||
start_at = _as_utc(success_event.occurred_at)
|
||
window_start = start_at + timedelta(seconds=min_sec)
|
||
window_end = start_at + timedelta(seconds=max_sec)
|
||
|
||
candidates = db.scalars(
|
||
select(Event)
|
||
.where(
|
||
Event.host_id == success_event.host_id,
|
||
Event.type.in_(RDG_END_TYPES),
|
||
Event.occurred_at >= window_start,
|
||
Event.occurred_at <= window_end,
|
||
Event.id != success_event.id,
|
||
)
|
||
.order_by(Event.occurred_at.asc())
|
||
).all()
|
||
|
||
for end in candidates:
|
||
if not _users_match(end, success_event):
|
||
continue
|
||
if not _internal_ips_compatible(end, success_event):
|
||
continue
|
||
pair_id = _stored_flap_pair_id(end)
|
||
if pair_id == success_event.id:
|
||
return end
|
||
prior = find_rdg_success_before_end(db, end)
|
||
if prior is not None and prior.id == success_event.id:
|
||
return end
|
||
return None
|
||
|
||
|
||
def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
|
||
"""303 после 302 с паузой больше flap-окна — штатное завершение сессии."""
|
||
if success_event.type != RDG_SUCCESS_TYPE:
|
||
return None
|
||
settings = get_settings()
|
||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||
start_at = _as_utc(success_event.occurred_at)
|
||
after_flap = start_at + timedelta(seconds=max_sec)
|
||
|
||
candidates = db.scalars(
|
||
select(Event)
|
||
.where(
|
||
Event.host_id == success_event.host_id,
|
||
Event.type.in_(RDG_END_TYPES),
|
||
Event.occurred_at > after_flap,
|
||
Event.id != success_event.id,
|
||
)
|
||
.order_by(Event.occurred_at.asc())
|
||
).all()
|
||
|
||
for end in candidates:
|
||
if not _users_match(end, success_event):
|
||
continue
|
||
if not _internal_ips_match_strict(end, success_event):
|
||
continue
|
||
return end
|
||
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:
|
||
return False
|
||
if event.type != RDG_SUCCESS_TYPE:
|
||
return False
|
||
if not _event_internal_ip(event):
|
||
return False
|
||
if db is None:
|
||
return True
|
||
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
|
||
return True
|
||
|
||
|
||
def resolve_rdg_flap_summary(
|
||
db: Session, event: Event
|
||
) -> tuple[bool, int | None, int | None]:
|
||
"""
|
||
(rdg_flap, pair_event_id, qwinsta_event_id).
|
||
qwinsta_event_id — id события 302 для qwinsta (на 303 кнопку не показываем).
|
||
"""
|
||
if event_has_rdg_flap(event):
|
||
pair_id = _stored_flap_pair_id(event)
|
||
if event.type == RDG_SUCCESS_TYPE:
|
||
return True, pair_id, event.id
|
||
return True, pair_id, pair_id
|
||
|
||
if event.type in RDG_END_TYPES:
|
||
prior = find_rdg_success_before_end(db, event)
|
||
if prior is not None:
|
||
return True, prior.id, prior.id
|
||
|
||
if event.type == RDG_SUCCESS_TYPE:
|
||
end = find_rdg_end_after_success(db, event)
|
||
if end is not None:
|
||
return True, end.id, event.id
|
||
|
||
return False, None, None
|