feat: host session list/terminate and event session actions (0.20.28)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,13 @@ from app.services.agent_commands import (
|
|||||||
get_command_by_uuid,
|
get_command_by_uuid,
|
||||||
)
|
)
|
||||||
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
|
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.ingest import ingest_event
|
||||||
from app.services.event_summary import event_to_summary
|
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.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||||
@@ -214,6 +221,64 @@ class LogoffActionBody(BaseModel):
|
|||||||
session_id: int
|
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)
|
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
|
||||||
def post_event_qwinsta(
|
def post_event_qwinsta(
|
||||||
event_db_id: int,
|
event_db_id: int,
|
||||||
|
|||||||
@@ -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_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.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.host_delete import delete_host_and_related
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
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.win_admin_settings import get_effective_win_admin_config
|
||||||
@@ -302,6 +309,48 @@ class HostRemoteActionJobResponse(BaseModel):
|
|||||||
finished_at: str | None = None
|
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(
|
def _ssh_action_response(
|
||||||
result: SshCommandResult,
|
result: SshCommandResult,
|
||||||
*,
|
*,
|
||||||
@@ -550,6 +599,115 @@ def get_host_remote_job(
|
|||||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
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)
|
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||||
def patch_host_agent_config(
|
def patch_host_agent_config(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.20.27"
|
APP_VERSION = "0.20.28"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.20.27"
|
assert APP_VERSION == "0.20.28"
|
||||||
assert APP_NAME == "Security Alert Center"
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -573,6 +573,56 @@ export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobS
|
|||||||
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
|
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostSessionItem {
|
||||||
|
session_id: string;
|
||||||
|
user: string;
|
||||||
|
tty?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
source_ip?: string | null;
|
||||||
|
session_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostSessionsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
target?: string | null;
|
||||||
|
sessions: HostSessionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostSessionTerminateResponse {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
target?: string | null;
|
||||||
|
stdout?: string | null;
|
||||||
|
stderr?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHostSessions(hostId: number): Promise<HostSessionsResponse> {
|
||||||
|
return apiFetch<HostSessionsResponse>(`/api/v1/hosts/${hostId}/actions/sessions/list`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminateHostSession(
|
||||||
|
hostId: number,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<HostSessionTerminateResponse> {
|
||||||
|
return apiFetch<HostSessionTerminateResponse>(`/api/v1/hosts/${hostId}/actions/sessions/terminate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ session_id: sessionId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postEventTerminateSession(
|
||||||
|
eventId: number,
|
||||||
|
sessionId?: string,
|
||||||
|
): Promise<HostSessionTerminateResponse> {
|
||||||
|
return apiFetch<HostSessionTerminateResponse>(`/api/v1/events/${eventId}/actions/terminate-session`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(sessionId ? { session_id: sessionId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostAgentConfigResponse {
|
export interface HostAgentConfigResponse {
|
||||||
config_revision: number;
|
config_revision: number;
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card host-sessions">
|
||||||
|
<h2>Залогиненные пользователи</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Текущие сессии на хосте (Linux: loginctl, Windows: qwinsta). Требуются учётные данные admin в настройках SAC.
|
||||||
|
</p>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<template v-if="loaded">
|
||||||
|
<p v-if="!sessions.length" class="muted">Активных сессий не найдено.</p>
|
||||||
|
<table v-else>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>TTY / имя</th>
|
||||||
|
<th>Состояние</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="s in sessions" :key="s.session_id + s.user">
|
||||||
|
<td>{{ s.user }}</td>
|
||||||
|
<td><code>{{ s.session_id }}</code></td>
|
||||||
|
<td>{{ s.tty || s.session_name || "—" }}</td>
|
||||||
|
<td>{{ s.state || "—" }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="terminatingId === s.session_id"
|
||||||
|
@click="terminate(s.session_id)"
|
||||||
|
>
|
||||||
|
{{ terminatingId === s.session_id ? "…" : "Завершить" }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="message" :class="messageOk ? 'success' : 'error'">{{ message }}</p>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="secondary" :disabled="loading" @click="load">
|
||||||
|
{{ loading ? "Загрузка…" : loaded ? "Обновить" : "Показать пользователей" }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import {
|
||||||
|
fetchHostSessions,
|
||||||
|
terminateHostSession,
|
||||||
|
type HostSessionItem,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
|
const props = defineProps<{ hostId: number }>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
const sessions = ref<HostSessionItem[]>([]);
|
||||||
|
const error = ref("");
|
||||||
|
const message = ref("");
|
||||||
|
const messageOk = ref(true);
|
||||||
|
const terminatingId = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
message.value = "";
|
||||||
|
try {
|
||||||
|
const res = await fetchHostSessions(props.hostId);
|
||||||
|
if (!res.ok) {
|
||||||
|
error.value = res.message;
|
||||||
|
sessions.value = [];
|
||||||
|
} else {
|
||||||
|
sessions.value = res.sessions;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки сессий";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminate(sessionId: string) {
|
||||||
|
if (!window.confirm(`Завершить сессию ${sessionId}?`)) return;
|
||||||
|
terminatingId.value = sessionId;
|
||||||
|
message.value = "";
|
||||||
|
try {
|
||||||
|
const res = await terminateHostSession(props.hostId, sessionId);
|
||||||
|
messageOk.value = res.ok;
|
||||||
|
message.value = res.message;
|
||||||
|
if (res.ok) {
|
||||||
|
sessions.value = sessions.value.filter((s) => s.session_id !== sessionId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
messageOk.value = false;
|
||||||
|
message.value = e instanceof Error ? e.message : "Ошибка завершения сессии";
|
||||||
|
} finally {
|
||||||
|
terminatingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.host-sessions table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-sessions button.secondary {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { EventSummary } from "../api";
|
||||||
|
|
||||||
|
const LINUX_SESSION_EVENT_TYPES = new Set([
|
||||||
|
"session.logind.new",
|
||||||
|
"ssh.login.success",
|
||||||
|
"privilege.sudo.command",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const WINDOWS_SESSION_EVENT_TYPES = new Set(["rdp.login.success"]);
|
||||||
|
|
||||||
|
export function eventSupportsSessionTerminate(event: Pick<EventSummary, "type">): boolean {
|
||||||
|
return (
|
||||||
|
LINUX_SESSION_EVENT_TYPES.has(event.type) ||
|
||||||
|
WINDOWS_SESSION_EVENT_TYPES.has(event.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.20.27";
|
export const APP_VERSION = "0.20.28";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -67,6 +67,15 @@
|
|||||||
>
|
>
|
||||||
qwinsta
|
qwinsta
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="eventSupportsSessionTerminate(e)"
|
||||||
|
type="button"
|
||||||
|
class="secondary events-qwinsta-btn"
|
||||||
|
:disabled="sessionTerminateLoadingId === e.id"
|
||||||
|
@click="runSessionTerminate(e)"
|
||||||
|
>
|
||||||
|
{{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -95,7 +104,7 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
import { apiFetch, type EventListResponse } from "../api";
|
import { apiFetch, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
|
||||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||||
@@ -108,10 +117,12 @@ import {
|
|||||||
type EventsListState,
|
type EventsListState,
|
||||||
} from "../utils/eventsListQuery";
|
} from "../utils/eventsListQuery";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
import { eventSupportsSessionTerminate } from "../utils/sessionActions";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
||||||
|
const sessionTerminateLoadingId = ref<number | null>(null);
|
||||||
|
|
||||||
const data = ref<EventListResponse | null>(null);
|
const data = ref<EventListResponse | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -205,6 +216,22 @@ useSacLiveEventStream(() => {
|
|||||||
void load(undefined, { syncUrl: false, silent: true });
|
void load(undefined, { syncUrl: false, silent: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function runSessionTerminate(event: EventSummary) {
|
||||||
|
const label = event.actor_user || event.title;
|
||||||
|
if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return;
|
||||||
|
sessionTerminateLoadingId.value = event.id;
|
||||||
|
try {
|
||||||
|
const res = await postEventTerminateSession(event.id);
|
||||||
|
if (!res.ok) {
|
||||||
|
window.alert(res.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
|
||||||
|
} finally {
|
||||||
|
sessionTerminateLoadingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
load(1);
|
load(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<HostSessionsPanel v-if="showAgentConfig" :host-id="host.id" />
|
||||||
|
|
||||||
<section v-if="inventory" class="host-inventory card">
|
<section v-if="inventory" class="host-inventory card">
|
||||||
<h2>Железо и ПО</h2>
|
<h2>Железо и ПО</h2>
|
||||||
<div v-if="windowsInfo" class="inv-block">
|
<div v-if="windowsInfo" class="inv-block">
|
||||||
@@ -294,6 +296,7 @@ import {
|
|||||||
type HostDetail,
|
type HostDetail,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { emitHostPatch } from "../utils/hostPatchBus";
|
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||||
|
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
|
||||||
|
|
||||||
const HOST_ACTION_FEEDBACK_MS = 30_000;
|
const HOST_ACTION_FEEDBACK_MS = 30_000;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user