feat: host session list/terminate and event session actions (0.20.28)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-22 10:13:42 +10:00
parent ca0255e13b
commit d848d94604
12 changed files with 773 additions and 5 deletions
+65
View File
@@ -20,6 +20,13 @@ from app.services.agent_commands import (
get_command_by_uuid,
)
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
from app.services.host_sessions import (
event_session_id,
event_supports_session_terminate,
terminate_session_for_event,
)
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.win_admin_settings import get_effective_win_admin_config
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
@@ -214,6 +221,64 @@ class LogoffActionBody(BaseModel):
session_id: int
class EventSessionTerminateBody(BaseModel):
session_id: str | None = None
class EventSessionTerminateResponse(BaseModel):
ok: bool
message: str
target: str | None = None
stdout: str | None = None
stderr: str | None = None
@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse)
def post_event_terminate_session(
event_db_id: int,
body: EventSessionTerminateBody | None = Body(default=None),
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> EventSessionTerminateResponse:
event = db.scalar(
select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id)
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if not event_supports_session_terminate(event):
raise HTTPException(status_code=400, detail="Event type does not support session terminate")
linux_cfg = get_effective_linux_admin_config(db)
win_cfg = get_effective_win_admin_config(db)
sid = (body.session_id if body else None) or event_session_id(event)
try:
result = terminate_session_for_event(
event,
linux_cfg=linux_cfg,
win_cfg=win_cfg,
session_id=sid,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if hasattr(result, "exit_code"):
return EventSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=getattr(result, "target", None),
stdout=getattr(result, "stdout", None) or None,
stderr=getattr(result, "stderr", None) or None,
)
return EventSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=getattr(result, "target", None),
stdout=getattr(result, "stdout", None) or None,
stderr=getattr(result, "stderr", None) or None,
)
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
def post_event_qwinsta(
event_db_id: int,
+158
View File
@@ -29,6 +29,13 @@ from app.services.host_remote_actions import (
)
from app.services.agent_update_settings import get_effective_agent_update_config
from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config
from app.services.host_sessions import (
HostSessionRow,
list_linux_sessions,
list_windows_sessions,
terminate_linux_session,
terminate_windows_session,
)
from app.services.host_delete import delete_host_and_related
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.win_admin_settings import get_effective_win_admin_config
@@ -302,6 +309,48 @@ class HostRemoteActionJobResponse(BaseModel):
finished_at: str | None = None
class HostSessionItem(BaseModel):
session_id: str
user: str
tty: str | None = None
state: str | None = None
source_ip: str | None = None
session_name: str | None = None
class HostSessionsResponse(BaseModel):
ok: bool
message: str
target: str | None = None
sessions: list[HostSessionItem]
class HostSessionTerminateBody(BaseModel):
session_id: str
class HostSessionTerminateResponse(BaseModel):
ok: bool
message: str
target: str | None = None
stdout: str | None = None
stderr: str | None = None
def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]:
return [
HostSessionItem(
session_id=r.session_id,
user=r.user,
tty=r.tty,
state=r.state,
source_ip=r.source_ip,
session_name=r.session_name,
)
for r in rows
]
def _ssh_action_response(
result: SshCommandResult,
*,
@@ -550,6 +599,115 @@ def get_host_remote_job(
return HostRemoteActionJobResponse(**get_remote_action_status(host))
@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse)
def list_host_sessions(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSessionsResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
from app.services.ssh_connect import is_linux_host
from app.services.winrm_connect import is_windows_host
if is_linux_host(host):
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
try:
sessions, result = list_linux_sessions(host, cfg)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return HostSessionsResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
sessions=_session_items(sessions),
)
if is_windows_host(host):
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
sessions, result = list_windows_sessions(host, cfg)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=502, detail="WinRM qwinsta failed")
return HostSessionsResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
sessions=_session_items(sessions),
)
raise HTTPException(status_code=400, detail="Host OS does not support live sessions")
@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse)
def terminate_host_session(
host_id: int,
body: HostSessionTerminateBody,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSessionTerminateResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
from app.services.ssh_connect import is_linux_host
from app.services.winrm_connect import is_windows_host
sid = body.session_id.strip()
if not sid:
raise HTTPException(status_code=422, detail="session_id is required")
if is_linux_host(host):
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
try:
result = terminate_linux_session(host, cfg, sid)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return HostSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
stdout=result.stdout or None,
stderr=result.stderr or None,
)
if is_windows_host(host):
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
result = terminate_windows_session(host, cfg, sid)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=502, detail="WinRM logoff failed")
return HostSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
stdout=result.stdout or None,
stderr=result.stderr or None,
)
raise HTTPException(status_code=400, detail="Host OS does not support session terminate")
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
def patch_host_agent_config(
host_id: int,
+294
View File
@@ -0,0 +1,294 @@
"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM."""
from __future__ import annotations
import re
from dataclasses import dataclass
from app.models import Event, Host
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"})
@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_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 parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]:
rows: list[HostSessionRow] = []
for line in stdout.splitlines():
text = line.strip()
if not text:
continue
parts = text.split()
if len(parts) < 3:
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=tty,
state=state,
)
)
return rows
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_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)
cmd = "loginctl list-sessions --no-legend --no-pager 2>/dev/null || who -u"
last: SshCommandResult | None = None
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
)
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
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,
)
last = result
if result.ok:
return result
assert last is not None
return last
def _shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
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.actor_user or _details_dict(event).get("user") or "").strip()
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.actor_user or _details_dict(event).get("user") or "").strip()
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 = [s for s in sessions if user.lower() in s.user.lower()] if user else sessions
if len(matched) == 1:
sid = matched[0].session_id
elif not matched:
raise ValueError("No matching Windows session for user")
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,
)
last = result
if result.ok:
return result
assert last is not None
return last
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.27"
APP_VERSION = "0.20.28"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
assert APP_VERSION == "0.20.27"
assert APP_VERSION == "0.20.28"
assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.20.27"
assert APP_VERSION_LABEL == "Security Alert Center v.0.20.28"
+41
View File
@@ -0,0 +1,41 @@
"""Tests for host session list/parse helpers."""
from app.services.host_sessions import (
event_supports_session_terminate,
parse_loginctl_sessions,
parse_qwinsta_sessions,
)
def test_parse_loginctl_sessions():
stdout = "c1 1000 alice seat0 pts/0 active -\n 2 1001 bob - tty2 active -\n"
rows = parse_loginctl_sessions(stdout)
assert len(rows) == 2
assert rows[0].session_id == "c1"
assert rows[0].user == "alice"
assert rows[0].tty == "pts/0"
def test_parse_qwinsta_sessions_filters_user():
stdout = """SESSIONNAME USERNAME ID STATE
console Administrator 1 Active
rdp-tcp#0 B26\\alice 2 Active
"""
all_rows = parse_qwinsta_sessions(stdout)
assert len(all_rows) == 2
filtered = parse_qwinsta_sessions(stdout, filter_user="alice")
assert len(filtered) == 1
assert filtered[0].session_id == "2"
def test_event_supports_session_terminate_types():
class HostStub:
os_family = "linux"
product = "ssh-monitor"
class EventStub:
def __init__(self, event_type: str):
self.type = event_type
self.host = HostStub()
assert event_supports_session_terminate(EventStub("ssh.login.success")) is True