fix(backend): loginctl JSON parse and filter ephemeral SSH sessions (0.20.34)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-22 16:00:02 +10:00
parent fe818e9946
commit 6e3f0ede4d
5 changed files with 242 additions and 74 deletions
+208 -61
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
@@ -49,6 +50,8 @@ _LOGIND_STATES = frozenset(
"unknown",
}
)
_LOGIND_TTY_RE = re.compile(r"^(pts|tty)/", re.IGNORECASE)
_NO_SESSION_MARKERS = ("no session", "unknown session", "does not exist")
@dataclass(frozen=True)
@@ -93,6 +96,86 @@ def is_windows_host_event(event: Event) -> bool:
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
@@ -122,11 +205,134 @@ def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]:
HostSessionRow(
session_id=sid,
user=user,
tty=tty,
tty=_normalize_logind_tty(tty),
state=state,
)
)
return rows
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 parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
@@ -167,65 +373,6 @@ def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> li
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"
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,
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
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
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")
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.33"
APP_VERSION = "0.20.34"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"