e38f62340f
Ingest correlates direct RDP logoff with open login events on the same host and user. Terminate-session treats missing qwinsta as already logged off. Co-authored-by: Cursor <cursoragent@cursor.com>
553 lines
18 KiB
Python
553 lines
18 KiB
Python
"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import Event, Host
|
|
from app.services.event_actor_user import extract_event_actor_user
|
|
from app.services.linux_admin_settings import LinuxAdminConfig
|
|
from app.services.ssh_connect import (
|
|
HostNotLinuxError as SshHostNotLinuxError,
|
|
HostTargetMissingError as SshHostTargetMissingError,
|
|
SshCommandResult,
|
|
is_linux_host,
|
|
iter_ssh_targets,
|
|
run_ssh_command,
|
|
)
|
|
from app.services.win_admin_settings import WinAdminConfig
|
|
from app.services.winrm_connect import (
|
|
HostNotWindowsError,
|
|
HostTargetMissingError,
|
|
WinRmCmdResult,
|
|
is_windows_host,
|
|
run_winrm_logoff,
|
|
run_winrm_on_host_targets,
|
|
run_winrm_qwinsta,
|
|
)
|
|
|
|
SESSION_EVENT_TYPES_LINUX = frozenset(
|
|
{
|
|
"session.logind.new",
|
|
"ssh.login.success",
|
|
"privilege.sudo.command",
|
|
}
|
|
)
|
|
SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"})
|
|
|
|
SESSION_TERMINATED_AT_KEY = "session_terminated_at"
|
|
SESSION_TERMINATED_BY_KEY = "session_terminated_by"
|
|
|
|
_LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE)
|
|
_LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE)
|
|
_LOGIND_STATES = frozenset(
|
|
{
|
|
"active",
|
|
"online",
|
|
"closing",
|
|
"opening",
|
|
"degraded",
|
|
"lingering",
|
|
"preparing",
|
|
"unknown",
|
|
}
|
|
)
|
|
_LOGIND_TTY_RE = re.compile(r"^(pts|tty)/", re.IGNORECASE)
|
|
_NO_SESSION_MARKERS = ("no session", "unknown session", "does not exist")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HostSessionRow:
|
|
session_id: str
|
|
user: str
|
|
tty: str | None = None
|
|
state: str | None = None
|
|
source_ip: str | None = None
|
|
session_name: str | None = None
|
|
|
|
|
|
def _details_dict(event: Event) -> dict:
|
|
raw = event.details
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
def _event_login_user(event: Event) -> str:
|
|
actor = extract_event_actor_user(event.type, event.details)
|
|
if actor:
|
|
return actor.strip()
|
|
return str(_details_dict(event).get("user") or "").strip()
|
|
|
|
|
|
def event_session_terminated(event: Event, db: Session | None = None) -> bool:
|
|
from app.services.rdp_session_logoff import (
|
|
event_closed_by_logoff,
|
|
resolve_workstation_login_closed_by_logoff,
|
|
)
|
|
from app.services.rdg_workstation_session import (
|
|
event_closed_by_rdg,
|
|
resolve_workstation_login_closed,
|
|
)
|
|
|
|
details = _details_dict(event)
|
|
if details.get("session_terminated") is True:
|
|
return True
|
|
at = details.get(SESSION_TERMINATED_AT_KEY)
|
|
if at is not None and str(at).strip() != "":
|
|
return True
|
|
if event_closed_by_rdg(event):
|
|
return True
|
|
if event_closed_by_logoff(event):
|
|
return True
|
|
if db is not None and event.type == "rdp.login.success":
|
|
if resolve_workstation_login_closed_by_logoff(db, event):
|
|
return True
|
|
return resolve_workstation_login_closed(db, event)
|
|
return False
|
|
|
|
|
|
def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None:
|
|
details = dict(_details_dict(event))
|
|
details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat()
|
|
if by_username and by_username.strip():
|
|
details[SESSION_TERMINATED_BY_KEY] = by_username.strip()
|
|
event.details = details
|
|
|
|
|
|
def event_session_id(event: Event) -> str | None:
|
|
details = _details_dict(event)
|
|
for key in ("session_id", "sid"):
|
|
val = details.get(key)
|
|
if val is not None and str(val).strip():
|
|
return str(val).strip()
|
|
return None
|
|
|
|
|
|
def event_supports_session_terminate(event: Event) -> bool:
|
|
if event.type in SESSION_EVENT_TYPES_LINUX:
|
|
return is_linux_host_event(event)
|
|
if event.type in SESSION_EVENT_TYPES_WINDOWS:
|
|
return is_windows_host_event(event)
|
|
return False
|
|
|
|
|
|
def is_linux_host_event(event: Event) -> bool:
|
|
host = event.host
|
|
return host is not None and is_linux_host(host)
|
|
|
|
|
|
def is_windows_host_event(event: Event) -> bool:
|
|
host = event.host
|
|
return host is not None and is_windows_host(host)
|
|
|
|
|
|
def _normalize_logind_tty(value: object) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
if not text or text == "-":
|
|
return None
|
|
return text
|
|
|
|
|
|
def _logind_row_priority(row: HostSessionRow) -> tuple[int, int]:
|
|
"""Prefer interactive TTY sessions over ephemeral scope/SSH-exec rows (tty «-»)."""
|
|
has_tty = 1 if row.tty and _LOGIND_TTY_RE.match(row.tty) else 0
|
|
try:
|
|
sid_num = int(row.session_id)
|
|
except ValueError:
|
|
sid_num = 0
|
|
return (has_tty, sid_num)
|
|
|
|
|
|
def filter_logind_session_rows(rows: list[HostSessionRow]) -> list[HostSessionRow]:
|
|
"""Drop no-TTY duplicates when the same user already has a pts/tty session."""
|
|
if len(rows) < 2:
|
|
return rows
|
|
|
|
by_user: dict[str, list[HostSessionRow]] = {}
|
|
for row in rows:
|
|
key = row.user.casefold()
|
|
by_user.setdefault(key, []).append(row)
|
|
|
|
out: list[HostSessionRow] = []
|
|
for group in by_user.values():
|
|
if len(group) == 1:
|
|
out.append(group[0])
|
|
continue
|
|
with_tty = [r for r in group if r.tty and _LOGIND_TTY_RE.match(r.tty)]
|
|
if with_tty:
|
|
out.append(max(with_tty, key=_logind_row_priority))
|
|
continue
|
|
out.append(max(group, key=_logind_row_priority))
|
|
out.sort(key=lambda r: (r.user.casefold(), -_logind_row_priority(r)[1]))
|
|
return out
|
|
|
|
|
|
def parse_loginctl_sessions_json(stdout: str) -> list[HostSessionRow]:
|
|
text = stdout.strip()
|
|
if not text:
|
|
return []
|
|
try:
|
|
payload = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
if not isinstance(payload, list):
|
|
return []
|
|
|
|
rows: list[HostSessionRow] = []
|
|
for item in payload:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
sid = str(item.get("session") or "").strip()
|
|
user = str(item.get("user") or "").strip()
|
|
if not sid or not user:
|
|
continue
|
|
if not _LOGIND_SESSION_ID_RE.match(sid):
|
|
continue
|
|
if not _LOGIND_USER_RE.match(user):
|
|
continue
|
|
state = str(item.get("state") or "").strip().lower() or None
|
|
if state and state not in _LOGIND_STATES:
|
|
continue
|
|
rows.append(
|
|
HostSessionRow(
|
|
session_id=sid,
|
|
user=user,
|
|
tty=_normalize_logind_tty(item.get("tty")),
|
|
state=state,
|
|
)
|
|
)
|
|
return filter_logind_session_rows(rows)
|
|
|
|
|
|
def _looks_like_loginctl_row(parts: list[str]) -> bool:
|
|
if len(parts) < 6:
|
|
return False
|
|
sid, uid, user, state = parts[0], parts[1], parts[2], parts[5].lower()
|
|
if not _LOGIND_SESSION_ID_RE.match(sid):
|
|
return False
|
|
if not uid.isdigit():
|
|
return False
|
|
if not _LOGIND_USER_RE.match(user):
|
|
return False
|
|
return state in _LOGIND_STATES
|
|
|
|
|
|
def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]:
|
|
rows: list[HostSessionRow] = []
|
|
for line in stdout.splitlines():
|
|
text = line.strip()
|
|
if not text or text.startswith("SESSION"):
|
|
continue
|
|
parts = text.split()
|
|
if not _looks_like_loginctl_row(parts):
|
|
continue
|
|
sid, user = parts[0], parts[2]
|
|
tty = parts[4] if len(parts) > 4 and parts[4] != "-" else None
|
|
state = parts[5] if len(parts) > 5 else None
|
|
rows.append(
|
|
HostSessionRow(
|
|
session_id=sid,
|
|
user=user,
|
|
tty=_normalize_logind_tty(tty),
|
|
state=state,
|
|
)
|
|
)
|
|
return filter_logind_session_rows(rows)
|
|
|
|
|
|
def _shell_quote(value: str) -> str:
|
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
|
|
|
|
def _linux_session_exists_message(stderr: str, stdout: str) -> bool:
|
|
blob = f"{stderr}\n{stdout}".casefold()
|
|
return any(marker in blob for marker in _NO_SESSION_MARKERS)
|
|
|
|
|
|
def _linux_show_session_user(host: Host, cfg: LinuxAdminConfig, session_id: str) -> str | None:
|
|
sid = session_id.strip()
|
|
if not sid:
|
|
return None
|
|
remote_cmd = f"loginctl show-session {_shell_quote(sid)} -p Name --value"
|
|
targets = iter_ssh_targets(host)
|
|
for target in targets:
|
|
result = run_ssh_command(
|
|
target=target,
|
|
user=cfg.user,
|
|
password=cfg.password,
|
|
remote_cmd=remote_cmd,
|
|
connect_timeout_sec=15,
|
|
command_timeout_sec=30,
|
|
need_root=True,
|
|
login_shell=False,
|
|
)
|
|
if not result.ok:
|
|
continue
|
|
user = result.stdout.strip().splitlines()[-1].strip() if result.stdout.strip() else ""
|
|
if user and _LOGIND_USER_RE.match(user):
|
|
return user
|
|
return None
|
|
|
|
|
|
def list_linux_sessions(host: Host, cfg: LinuxAdminConfig) -> tuple[list[HostSessionRow], SshCommandResult]:
|
|
if not is_linux_host(host):
|
|
raise SshHostNotLinuxError("Host is not Linux")
|
|
targets = iter_ssh_targets(host)
|
|
json_cmd = "loginctl list-sessions --output=json --no-legend"
|
|
text_cmd = "loginctl list-sessions --no-legend --no-pager"
|
|
last: SshCommandResult | None = None
|
|
for target in targets:
|
|
result = run_ssh_command(
|
|
target=target,
|
|
user=cfg.user,
|
|
password=cfg.password,
|
|
remote_cmd=json_cmd,
|
|
connect_timeout_sec=15,
|
|
command_timeout_sec=45,
|
|
need_root=True,
|
|
login_shell=False,
|
|
)
|
|
last = result
|
|
if result.ok and result.stdout.strip():
|
|
rows = parse_loginctl_sessions_json(result.stdout)
|
|
if rows:
|
|
return rows, result
|
|
rows = parse_loginctl_sessions(result.stdout)
|
|
if rows:
|
|
return rows, result
|
|
result = run_ssh_command(
|
|
target=target,
|
|
user=cfg.user,
|
|
password=cfg.password,
|
|
remote_cmd=text_cmd,
|
|
connect_timeout_sec=15,
|
|
command_timeout_sec=45,
|
|
need_root=True,
|
|
login_shell=False,
|
|
)
|
|
last = result
|
|
if result.ok and result.stdout.strip():
|
|
return parse_loginctl_sessions(result.stdout), result
|
|
assert last is not None
|
|
return [], last
|
|
|
|
|
|
def terminate_linux_session(
|
|
host: Host,
|
|
cfg: LinuxAdminConfig,
|
|
session_id: str,
|
|
) -> SshCommandResult:
|
|
sid = session_id.strip()
|
|
if not sid:
|
|
return SshCommandResult(ok=False, message="session_id is required", target="")
|
|
if not is_linux_host(host):
|
|
raise SshHostNotLinuxError("Host is not Linux")
|
|
targets = iter_ssh_targets(host)
|
|
remote_cmd = f"loginctl terminate-session {_shell_quote(sid)}"
|
|
last: SshCommandResult | None = None
|
|
session_user = _linux_show_session_user(host, cfg, sid)
|
|
for target in targets:
|
|
result = run_ssh_command(
|
|
target=target,
|
|
user=cfg.user,
|
|
password=cfg.password,
|
|
remote_cmd=remote_cmd,
|
|
connect_timeout_sec=15,
|
|
command_timeout_sec=45,
|
|
need_root=True,
|
|
login_shell=False,
|
|
)
|
|
last = result
|
|
if result.ok:
|
|
return result
|
|
if session_user:
|
|
return _terminate_linux_user_sessions(host, cfg, session_user)
|
|
if last is not None and _linux_session_exists_message(last.stderr, last.stdout):
|
|
return SshCommandResult(
|
|
ok=False,
|
|
message=(
|
|
f"Сессия {sid} не найдена (устарела). Обновите список и повторите "
|
|
"или завершите все сессии пользователя."
|
|
),
|
|
target=last.target,
|
|
stdout=last.stdout,
|
|
stderr=last.stderr,
|
|
exit_code=last.exit_code,
|
|
)
|
|
assert last is not None
|
|
return last
|
|
|
|
|
|
def _normalize_sam_account(user: str) -> str:
|
|
text = (user or "").strip()
|
|
if "\\" in text:
|
|
return text.split("\\")[-1].strip().casefold()
|
|
if "@" in text:
|
|
return text.split("@")[0].strip().casefold()
|
|
return text.casefold()
|
|
|
|
|
|
def windows_user_matches_session(login_user: str, session_user: str) -> bool:
|
|
login = _normalize_sam_account(login_user)
|
|
session = _normalize_sam_account(session_user)
|
|
return bool(login and session and login == session)
|
|
|
|
|
|
def filter_windows_sessions_for_user(
|
|
sessions: list[HostSessionRow],
|
|
login_user: str,
|
|
) -> list[HostSessionRow]:
|
|
user = (login_user or "").strip()
|
|
if not user:
|
|
return sessions
|
|
return [s for s in sessions if windows_user_matches_session(user, s.user)]
|
|
|
|
|
|
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
|
|
rows: list[HostSessionRow] = []
|
|
norm_filter = (filter_user or "").strip().lower()
|
|
|
|
def norm_user(value: str) -> str:
|
|
return value.replace("B26\\", "").replace("b26\\", "").lower()
|
|
|
|
for line in stdout.splitlines():
|
|
text = line.strip()
|
|
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
|
continue
|
|
parts = text.split()
|
|
if len(parts) < 4:
|
|
continue
|
|
session_name = parts[0].lstrip(">")
|
|
user_name = parts[1]
|
|
try:
|
|
sid = int(parts[2])
|
|
except ValueError:
|
|
continue
|
|
state = " ".join(parts[3:])
|
|
if norm_filter and norm_filter not in norm_user(user_name):
|
|
continue
|
|
rows.append(
|
|
HostSessionRow(
|
|
session_id=str(sid),
|
|
user=user_name,
|
|
session_name=session_name,
|
|
state=state,
|
|
)
|
|
)
|
|
|
|
if rows or not norm_filter:
|
|
return rows
|
|
|
|
return parse_qwinsta_sessions(stdout, filter_user=None)
|
|
|
|
|
|
def list_windows_sessions(host: Host, cfg: WinAdminConfig) -> tuple[list[HostSessionRow], WinRmCmdResult | None]:
|
|
if not is_windows_host(host):
|
|
raise HostNotWindowsError("Host is not Windows")
|
|
|
|
def action(target: str) -> WinRmCmdResult:
|
|
return run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password)
|
|
|
|
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
|
|
if result is None or not result.ok:
|
|
return [], result
|
|
return parse_qwinsta_sessions(result.stdout), result
|
|
|
|
|
|
def terminate_windows_session(
|
|
host: Host,
|
|
cfg: WinAdminConfig,
|
|
session_id: str,
|
|
) -> WinRmCmdResult | None:
|
|
try:
|
|
sid = int(session_id.strip())
|
|
except ValueError:
|
|
return WinRmCmdResult(ok=False, message="Invalid Windows session_id", target="")
|
|
|
|
def action(target: str) -> WinRmCmdResult:
|
|
return run_winrm_logoff(
|
|
target=target,
|
|
user=cfg.user,
|
|
password=cfg.password,
|
|
session_id=sid,
|
|
)
|
|
|
|
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
|
|
return result
|
|
|
|
|
|
def terminate_session_for_event(
|
|
event: Event,
|
|
*,
|
|
linux_cfg: LinuxAdminConfig,
|
|
win_cfg: WinAdminConfig,
|
|
session_id: str | None = None,
|
|
) -> SshCommandResult | WinRmCmdResult:
|
|
host = event.host
|
|
if host is None:
|
|
raise ValueError("Event has no host")
|
|
|
|
sid = (session_id or event_session_id(event) or "").strip()
|
|
if is_linux_host(host):
|
|
if not sid:
|
|
user = _event_login_user(event)
|
|
if user:
|
|
return _terminate_linux_user_sessions(host, linux_cfg, user)
|
|
raise ValueError("session_id is required for this event")
|
|
return terminate_linux_session(host, linux_cfg, sid)
|
|
|
|
if is_windows_host(host):
|
|
if not sid:
|
|
user = _event_login_user(event)
|
|
sessions, qwinsta = list_windows_sessions(host, win_cfg)
|
|
if not qwinsta or not qwinsta.ok:
|
|
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
|
|
matched = filter_windows_sessions_for_user(sessions, user) if user else sessions
|
|
if len(matched) == 1:
|
|
sid = matched[0].session_id
|
|
elif not matched:
|
|
# Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
|
|
return WinRmCmdResult(
|
|
ok=True,
|
|
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
|
|
target=qwinsta.target,
|
|
stdout=qwinsta.stdout,
|
|
)
|
|
else:
|
|
raise ValueError("Multiple sessions; specify session_id")
|
|
result = terminate_windows_session(host, win_cfg, sid)
|
|
if result is None:
|
|
raise ValueError("WinRM logoff failed")
|
|
return result
|
|
|
|
raise ValueError("Unsupported host OS for session terminate")
|
|
|
|
|
|
def _terminate_linux_user_sessions(host: Host, cfg: LinuxAdminConfig, user: str) -> SshCommandResult:
|
|
safe_user = _shell_quote(user.strip())
|
|
remote_cmd = f"loginctl terminate-user {safe_user}"
|
|
targets = iter_ssh_targets(host)
|
|
last: SshCommandResult | None = None
|
|
for target in targets:
|
|
result = run_ssh_command(
|
|
target=target,
|
|
user=cfg.user,
|
|
password=cfg.password,
|
|
remote_cmd=remote_cmd,
|
|
connect_timeout_sec=15,
|
|
command_timeout_sec=45,
|
|
need_root=True,
|
|
login_shell=False,
|
|
)
|
|
last = result
|
|
if result.ok:
|
|
return result
|
|
assert last is not None
|
|
return last
|