Compare commits

...

19 Commits

Author SHA1 Message Date
PapaTramp d95b93992d fix: parse Disc qwinsta rows so RDG flap auto-logoff works (0.5.14)
Empty SESSIONNAME broke session matching; auto-disconnect failed with 'No matching Windows session'.
2026-07-14 10:32:17 +10:00
PapaTramp d20517804d docs: rdp.session.logoff is workstation-only (agent 2.1.12-SAC) 2026-07-13 17:36:31 +10:00
PapaTramp 91bd6afd1f fix: start sac-api via /usr/bin/bash to avoid systemd 203/EXEC
ExecStart no longer relies on shebang when CRLF slips into deploy scripts.
sac-deploy strips CR from all deploy/*.sh and waits for active before health check.
2026-07-13 17:29:28 +10:00
PapaTramp dd2356cf04 fix: LF line endings in deploy shell scripts (systemd 203/EXEC)
CRLF in sac-api-start.sh shebang broke sac-api with status=203/EXEC on Linux.
Add .gitattributes for *.sh and strip CR in sac-deploy before chmod.
2026-07-13 17:23:58 +10:00
PapaTramp 3475c1811b fix: platform-specific placeholder in agent update log modal (v0.5.13)
Windows WinRM updates no longer show the Linux /var/log/update_script.log hint while output is still empty.
2026-07-13 17:18:49 +10:00
PapaTramp 91be282ec6 feat: close rdp.login.success on rdp.session.logoff (v0.5.12)
Ingest correlates direct RDP logoff with open login events on the same host and user. Terminate-session treats missing qwinsta as already logged off.
2026-07-13 17:10:24 +10:00
PapaTramp 310e7f699d fix: ignore stale SSH update job until running or session start (v0.5.10)
Modal finish only after sawRunning or started_at after sessionOpenedAt, so a previous success no longer closes the window instantly while SAC updater is still running on the host.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:05:56 +10:00
PapaTramp e7d5b1c60b fix: SSH update modal polls locally, collapsed log by default (v0.5.9)
Modal owns job poll and completion UI with countdown; log hidden until user clicks Показать лог; db.refresh on remote-job; cache-bust poll requests.
2026-07-08 14:48:31 +10:00
PapaTramp 69fac2f26e fix: live SSH update log re-renders in modal (v0.5.7)
Remove cached openLogs computed that blocked Vue updates to output/loading; preserve log tail on empty poll; auto-scroll log panel.
2026-07-08 14:41:14 +10:00
PapaTramp c222dd7d41 fix: SSH update log modal shows completion and full tail (v0.5.6)
Fetch final update_script.log tail after agent-update, update title/message on success, show log by default; auto-close after 30s unchanged.
2026-07-08 14:26:28 +10:00
PapaTramp 6565b7d679 docs: mark ssh-monitor 4.2 ingest backlog items done
Shutdown sac-fail.count suppress and SAC_TIMEOUT_SEC docs shipped in ssh-monitor 2.3.2-SAC.
2026-07-08 14:12:40 +10:00
PapaTramp b148b558c3 feat: ingest burst hardening — defer lifecycle/auth, workers, nginx (v0.5.5)
Defer notify_lifecycle and notify_auth_login after commit; SAC_UVICORN_WORKERS via sac-api-start.sh; nginx /api/v1/events timeout; mass-update confirm in Hosts UI.
2026-07-08 13:16:13 +10:00
PapaTramp 1ad01534f1 fix: remote job stuck after SSH update completes (v0.5.4)
Treat agent_update_state as source of truth for active job; poll log thread no longer overwrites finished status.
2026-07-08 12:30:14 +10:00
PapaTramp 253b80c500 fix: suppress sudo Telegram during SAC agent update (v0.5.3) 2026-07-08 12:23:29 +10:00
PapaTramp f569293d52 fix: hide update log when user clicks Скрыть лог 2026-07-08 12:07:19 +10:00
PapaTramp d47131cd9f feat: live update log in UI and suppress lifecycle Telegram during SAC update
Poll remote update_script.log while SSH update runs; skip lifecycle notify
when host agent_update_state is running (SAC v0.5.1).
2026-07-08 11:46:53 +10:00
PapaTramp 1639261cde docs: backlog for ingest resilience during mass agent updates
Operational and code TODOs from mass SSH-update incident; does not
block ssh-monitor phase 2.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 11:28:57 +10:00
PapaTramp fa1bd41c92 fix: sac-deploy validate sac-api.env without bash source
Preflight pydantic parse catches inline comments in env values; alembic
and stale remote_action reset use SAC_CONFIG_FILE. Document env format
and ssh_known_hosts in runbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:21:49 +10:00
PapaTramp 3cabf12f68 fix: SAC-first ssh-monitor update (UPDATE_VIA_SAC, bootstrap --deploy)
Pass UPDATE_VIA_SAC to the remote updater and run bootstrap with --deploy
so Linux hosts need only the SAC update button after initial config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 09:44:24 +10:00
42 changed files with 1574 additions and 86 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
*.sh text eol=lf
+1 -1
View File
@@ -13,7 +13,7 @@
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
**Версия:** `0.5.0` · **Деплой:** `sudo /opt/sac-deploy.sh` **Версия:** `0.5.14` · **Деплой:** `sudo /opt/sac-deploy.sh`
## Возможности ## Возможности
+1 -1
View File
@@ -13,7 +13,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
| **security-alert-center** | SAC server (Ubuntu 24.04) | | **security-alert-center** | SAC server (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
**Version:** `0.5.0` · **Deploy:** `sudo /opt/sac-deploy.sh` **Version:** `0.5.14` · **Deploy:** `sudo /opt/sac-deploy.sh`
## Features ## Features
+10 -2
View File
@@ -52,7 +52,9 @@ from app.services.notify_dispatch import (
notify_lifecycle, notify_lifecycle,
notify_problem, notify_problem,
notify_rdg_connection, notify_rdg_connection,
schedule_notify_auth_login,
schedule_notify_daily_report, schedule_notify_daily_report,
schedule_notify_lifecycle,
) )
router = APIRouter(prefix="/events", tags=["events"]) router = APIRouter(prefix="/events", tags=["events"])
@@ -100,15 +102,17 @@ def post_event(
problem = None problem = None
problem_created = False problem_created = False
deferred_daily_report_id: int | None = None deferred_daily_report_id: int | None = None
deferred_lifecycle_id: int | None = None
deferred_auth_login_id: int | None = None
if created: if created:
problem, problem_created = maybe_create_problem(db, event) problem, problem_created = maybe_create_problem(db, event)
maybe_auto_disconnect_stuck_rdp_session(db, event) maybe_auto_disconnect_stuck_rdp_session(db, event)
if event.type in DAILY_REPORT_EVENT_TYPES: if event.type in DAILY_REPORT_EVENT_TYPES:
deferred_daily_report_id = event.id deferred_daily_report_id = event.id
elif event.type == LIFECYCLE_EVENT_TYPE: elif event.type == LIFECYCLE_EVENT_TYPE:
notify_lifecycle(event, db=db) deferred_lifecycle_id = event.id
elif event.type in AUTH_LOGIN_SUCCESS_TYPES: elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
notify_auth_login(event, db=db) deferred_auth_login_id = event.id
elif event.type in RDG_CONNECTION_TYPES: elif event.type in RDG_CONNECTION_TYPES:
notify_rdg_connection(event, db=db) notify_rdg_connection(event, db=db)
else: else:
@@ -122,6 +126,10 @@ def post_event(
if deferred_daily_report_id is not None: if deferred_daily_report_id is not None:
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id) background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
if deferred_lifecycle_id is not None:
background_tasks.add_task(schedule_notify_lifecycle, deferred_lifecycle_id)
if deferred_auth_login_id is not None:
background_tasks.add_task(schedule_notify_auth_login, deferred_auth_login_id)
body = _ingest_response(event, created=created) body = _ingest_response(event, created=created)
if problem is not None: if problem is not None:
+2
View File
@@ -567,6 +567,7 @@ def update_host_agent_via_ssh(
host, host,
title=title, title=title,
runner=run_ssh_monitor_update_action, runner=run_ssh_monitor_update_action,
poll_remote_log=True,
) )
except RemoteActionAlreadyRunningError as exc: except RemoteActionAlreadyRunningError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -652,6 +653,7 @@ def get_host_remote_job(
host = db.get(Host, host_id) host = db.get(Host, host_id)
if host is None: if host is None:
raise HTTPException(status_code=404, detail="Host not found") raise HTTPException(status_code=404, detail="Host not found")
db.refresh(host)
return HostRemoteActionJobResponse(**get_remote_action_status(host)) return HostRemoteActionJobResponse(**get_remote_action_status(host))
+1
View File
@@ -36,6 +36,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac" database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
sac_db_pool_size: int = 15 sac_db_pool_size: int = 15
sac_db_max_overflow: int = 25 sac_db_max_overflow: int = 25
sac_uvicorn_workers: int = 4
sac_public_url: str = "http://localhost:8000" sac_public_url: str = "http://localhost:8000"
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL. # URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
sac_agent_bundle_base_url: str = "" sac_agent_bundle_base_url: str = ""
+1
View File
@@ -23,6 +23,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
# RDP / Windows # RDP / Windows
"rdp.login.success": "info", "rdp.login.success": "info",
"rdp.login.failed": "warning", "rdp.login.failed": "warning",
"rdp.session.logoff": "info",
"rdp.shadow.control.started": "warning", "rdp.shadow.control.started": "warning",
"rdp.shadow.control.stopped": "info", "rdp.shadow.control.stopped": "info",
"rdp.shadow.control.permission": "warning", "rdp.shadow.control.permission": "warning",
+1
View File
@@ -15,6 +15,7 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
"session.logind.failed", "session.logind.failed",
"rdp.login.success", "rdp.login.success",
"rdp.login.failed", "rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started", "rdp.shadow.control.started",
"rdp.shadow.control.stopped", "rdp.shadow.control.stopped",
"rdp.shadow.control.permission", "rdp.shadow.control.permission",
+155 -4
View File
@@ -16,7 +16,12 @@ from app.services.agent_update import execute_agent_update_fallback
from app.services.agent_update_types import AgentUpdateFallbackResult from app.services.agent_update_types import AgentUpdateFallbackResult
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.agent_update_settings import get_effective_agent_update_config from app.services.agent_update_settings import get_effective_agent_update_config
from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult from app.services.ssh_connect import (
iter_ssh_targets,
run_ssh_monitor_update,
SshCommandResult,
tail_ssh_monitor_update_log,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -76,6 +81,61 @@ def _result_payload(
} }
def _completion_title(base_title: str, result: SshCommandResult) -> str:
if result.ok:
return f"{base_title} — готово"
return f"{base_title} — ошибка"
def _completion_message(result: SshCommandResult) -> str:
if result.ok:
version = (result.agent_version or "").strip()
if version:
return f"Обновление завершено успешно. Версия агента: {version}"
return "Обновление завершено успешно"
return (result.message or "Обновление не удалось").strip()
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
return ""
try:
targets = iter_ssh_targets(host)
except Exception:
return ""
for target in targets:
try:
tail = tail_ssh_monitor_update_log(
target=target,
user=cfg.user,
password=cfg.password,
lines=lines,
)
if tail:
return tail
except Exception:
logger.debug("final update log tail failed host_id=%s target=%s", host.id, target, exc_info=True)
return ""
def _enrich_ssh_update_payload(
payload: dict[str, Any],
*,
base_title: str,
result: SshCommandResult,
previous_output: str,
log_tail: str,
) -> dict[str, Any]:
payload["title"] = _completion_title(base_title, result)
payload["message"] = _completion_message(result)
if log_tail:
payload["output"] = log_tail
elif previous_output.strip():
payload["output"] = previous_output
return payload
def _mark_running(db: Session, host: Host, *, title: str) -> str: def _mark_running(db: Session, host: Host, *, title: str) -> str:
started_at = _utcnow().isoformat() started_at = _utcnow().isoformat()
host.agent_update_state = "running" host.agent_update_state = "running"
@@ -113,12 +173,70 @@ def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult)
host.agent_update_last_error = (result.message or "SSH update failed")[:2000] host.agent_update_last_error = (result.message or "SSH update failed")[:2000]
_REMOTE_LOG_POLL_SEC = 2.0
def _poll_ssh_update_log_loop(
host_id: int,
*,
stop: threading.Event,
) -> None:
"""Периодически подтягивает tail update_script.log в hosts.remote_action для UI."""
while not stop.wait(_REMOTE_LOG_POLL_SEC):
session = SessionLocal()
try:
row = session.get(Host, host_id)
if row is None or (row.agent_update_state or "").strip().lower() != "running":
continue
payload = dict(row.remote_action or {})
if (payload.get("status") or "").strip().lower() != "running":
continue
cfg = get_effective_linux_admin_config(session)
if not cfg.configured:
continue
try:
targets = iter_ssh_targets(row)
except Exception:
continue
tail = ""
for target in targets:
try:
tail = tail_ssh_monitor_update_log(
target=target,
user=cfg.user,
password=cfg.password,
lines=200,
)
except Exception:
logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True)
continue
if tail:
break
session.refresh(row)
if (row.agent_update_state or "").strip().lower() != "running":
continue
payload = dict(row.remote_action or {})
if (payload.get("status") or "").strip().lower() != "running":
continue
if tail:
payload["output"] = tail
payload["message"] = "Выполняется обновление… (лог с хоста)"
row.remote_action = payload
session.commit()
except Exception:
logger.debug("remote log poll failed host_id=%s", host_id, exc_info=True)
session.rollback()
finally:
session.close()
def start_host_remote_action( def start_host_remote_action(
db: Session, db: Session,
host: Host, host: Host,
*, *,
title: str, title: str,
runner: ActionRunner, runner: ActionRunner,
poll_remote_log: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
host_id = int(host.id) host_id = int(host.id)
with _lock: with _lock:
@@ -133,13 +251,35 @@ def start_host_remote_action(
def _worker() -> None: def _worker() -> None:
session = SessionLocal() session = SessionLocal()
stop_poll = threading.Event()
poll_thread: threading.Thread | None = None
if poll_remote_log and runner is run_ssh_monitor_update_action:
poll_thread = threading.Thread(
target=_poll_ssh_update_log_loop,
args=(host_id,),
kwargs={"stop": stop_poll},
name=f"sac-remote-log-{host_id}",
daemon=True,
)
poll_thread.start()
try: try:
row = session.get(Host, host_id) row = session.get(Host, host_id)
if row is None: if row is None:
return return
previous_output = (row.remote_action or {}).get("output") or ""
result = runner(session, row) result = runner(session, row)
started = (row.remote_action or {}).get("started_at") or started_at started = (row.remote_action or {}).get("started_at") or started_at
row.remote_action = _result_payload(result, title=title, started_at=started) payload = _result_payload(result, title=title, started_at=started)
if poll_remote_log and isinstance(result, SshCommandResult):
log_tail = _fetch_ssh_update_log_tail(session, row)
payload = _enrich_ssh_update_payload(
payload,
base_title=title,
result=result,
previous_output=previous_output,
log_tail=log_tail,
)
row.remote_action = payload
if isinstance(result, SshCommandResult): if isinstance(result, SshCommandResult):
_apply_ssh_update_result(session, row, result) _apply_ssh_update_result(session, row, result)
session.commit() session.commit()
@@ -167,6 +307,9 @@ def start_host_remote_action(
} }
session.commit() session.commit()
finally: finally:
stop_poll.set()
if poll_thread is not None:
poll_thread.join(timeout=5.0)
session.close() session.close()
with _lock: with _lock:
_running.discard(host_id) _running.discard(host_id)
@@ -276,8 +419,16 @@ def get_remote_action_status(host: Host) -> dict[str, Any]:
payload = dict(host.remote_action or {}) payload = dict(host.remote_action or {})
if not payload: if not payload:
return {"active": False, "host_id": host.id} return {"active": False, "host_id": host.id}
status = payload.get("status") or host.agent_update_state or "unknown" agent_state = (host.agent_update_state or "").strip().lower()
active = status == "running" or host.agent_update_state == "running" if agent_state == "running":
active = True
status = payload.get("status") or "running"
elif agent_state in ("success", "failed"):
active = False
status = payload.get("status") or agent_state
else:
status = payload.get("status") or host.agent_update_state or "unknown"
active = status == "running"
return { return {
"active": active, "active": active,
"host_id": host.id, "host_id": host.id,
+81 -16
View File
@@ -84,6 +84,10 @@ def _event_login_user(event: Event) -> str:
def event_session_terminated(event: Event, db: Session | None = None) -> bool: 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 ( from app.services.rdg_workstation_session import (
event_closed_by_rdg, event_closed_by_rdg,
resolve_workstation_login_closed, resolve_workstation_login_closed,
@@ -97,7 +101,11 @@ def event_session_terminated(event: Event, db: Session | None = None) -> bool:
return True return True
if event_closed_by_rdg(event): if event_closed_by_rdg(event):
return True return True
if event_closed_by_logoff(event):
return True
if db is not None and event.type == "rdp.login.success": 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 resolve_workstation_login_closed(db, event)
return False return False
@@ -376,28 +384,79 @@ def terminate_linux_session(
return last return last
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]: def _normalize_sam_account(user: str) -> str:
rows: list[HostSessionRow] = [] text = (user or "").strip()
norm_filter = (filter_user or "").strip().lower() if "\\" in text:
return text.split("\\")[-1].strip().casefold()
if "@" in text:
return text.split("@")[0].strip().casefold()
return text.casefold()
def norm_user(value: str) -> str:
return value.replace("B26\\", "").replace("b26\\", "").lower() 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 _qwinsta_sam_account(value: str) -> str:
text = (value or "").strip()
if "\\" in text:
text = text.split("\\")[-1]
if "@" in text:
text = text.split("@")[0]
return text.casefold()
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
"""Parse ``qwinsta`` output.
Disconnected sessions often have an empty SESSIONNAME column, so the line
becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens
and skipped those rows — that broke RDG flap auto-logoff for Disc sessions.
"""
rows: list[HostSessionRow] = []
filter_sam = _qwinsta_sam_account(filter_user or "")
skip_users = frozenset({"services"})
for line in stdout.splitlines(): for line in stdout.splitlines():
text = line.strip() text = line.strip()
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"): if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
continue continue
parts = text.split() parts = text.split()
if len(parts) < 4: id_idx: int | None = None
sid = 0
for i, part in enumerate(parts):
token = part.lstrip(">")
if token.isdigit():
id_idx = i
sid = int(token)
break
if id_idx is None or id_idx < 1:
continue continue
session_name = parts[0].lstrip(">") state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
user_name = parts[1] if not state or state.casefold().startswith("listen"):
try:
sid = int(parts[2])
except ValueError:
continue continue
state = " ".join(parts[3:]) before = parts[:id_idx]
if norm_filter and norm_filter not in norm_user(user_name): if len(before) == 1:
session_name = ""
user_name = before[0].lstrip(">")
else:
session_name = before[0].lstrip(">")
user_name = before[1]
if user_name.casefold() in skip_users:
continue
if filter_sam and filter_sam not in _qwinsta_sam_account(user_name):
continue continue
rows.append( rows.append(
HostSessionRow( HostSessionRow(
@@ -408,7 +467,7 @@ def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> li
) )
) )
if rows or not norm_filter: if rows or not filter_sam:
return rows return rows
return parse_qwinsta_sessions(stdout, filter_user=None) return parse_qwinsta_sessions(stdout, filter_user=None)
@@ -475,11 +534,17 @@ def terminate_session_for_event(
sessions, qwinsta = list_windows_sessions(host, win_cfg) sessions, qwinsta = list_windows_sessions(host, win_cfg)
if not qwinsta or not qwinsta.ok: if not qwinsta or not qwinsta.ok:
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed") 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 matched = filter_windows_sessions_for_user(sessions, user) if user else sessions
if len(matched) == 1: if len(matched) == 1:
sid = matched[0].session_id sid = matched[0].session_id
elif not matched: elif not matched:
raise ValueError("No matching Windows session for user") # Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
return WinRmCmdResult(
ok=True,
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
target=qwinsta.target,
stdout=qwinsta.stdout,
)
else: else:
raise ValueError("Multiple sessions; specify session_id") raise ValueError("Multiple sessions; specify session_id")
result = terminate_windows_session(host, win_cfg, sid) result = terminate_windows_session(host, win_cfg, sid)
+2
View File
@@ -9,6 +9,7 @@ from app.services.agent_update import process_agent_update_ingest
from app.services.daily_report_format import normalize_daily_report_details from app.services.daily_report_format import normalize_daily_report_details
from app.services.event_severity_overrides import apply_severity_override from app.services.event_severity_overrides import apply_severity_override
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
from app.services.rdp_session_logoff import close_workstation_session_for_rdp_logoff
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
@@ -128,4 +129,5 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
process_agent_update_ingest(db, host, payload.get("type", ""), details) process_agent_update_ingest(db, host, payload.get("type", ""), details)
close_workstation_session_for_rdg_end(db, event) close_workstation_session_for_rdg_end(db, event)
close_workstation_session_for_rdp_logoff(db, event)
return event, True return event, True
+88 -4
View File
@@ -4,7 +4,7 @@ import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Event, Problem from app.models import Event, Host, Problem
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
from app.services.notification_cooldown import should_notify_event, should_notify_problem from app.services.notification_cooldown import should_notify_event, should_notify_problem
from app.services.notification_policy import get_effective_notification_policy from app.services.notification_policy import get_effective_notification_policy
@@ -15,6 +15,14 @@ from app.services.notification_severity import severity_meets_minimum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
LIFECYCLE_EVENT_TYPE = "agent.lifecycle" LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
SUDO_MAINTENANCE_MARKERS = (
"update_ssh_monitor.sh",
"update_via_sac",
"update_script.log",
"/opt/scripts/update",
"agent-update-in-progress",
)
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"}) AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
RDG_CONNECTION_TYPES = frozenset({ RDG_CONNECTION_TYPES = frozenset({
@@ -67,6 +75,8 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push. # Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
if event.type == HEARTBEAT_TYPE: if event.type == HEARTBEAT_TYPE:
return return
if _should_suppress_sudo_notify(event, db=db):
return
if _skip_notifications_for_hidden_event(event, db): if _skip_notifications_for_hidden_event(event, db):
return return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
@@ -111,8 +121,68 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
_dispatch_lifecycle_channels(event, db=db, policy=policy) _dispatch_lifecycle_channels(event, db=db, policy=policy)
def _host_sac_update_running(event: Event, db: Session | None) -> bool:
"""Пока SAC выполняет agent-update на хосте — не слать шумные TG."""
if db is None or not event.host_id:
return False
host = db.get(Host, event.host_id)
if host is None:
return False
if (host.agent_update_state or "").strip().lower() == "running":
logger.info(
"notify skipped (host update running) type=%s host_id=%s event_id=%s",
event.type,
host.id,
event.event_id,
)
return True
return False
def _sudo_event_is_agent_maintenance(event: Event) -> bool:
details = event.details if isinstance(event.details, dict) else {}
cmd = str(details.get("command") or "").strip()
if not cmd:
cmd = str(event.summary or "").strip()
text = cmd.casefold()
return any(marker in text for marker in SUDO_MAINTENANCE_MARKERS)
def _should_suppress_sudo_notify(event: Event, *, db: Session | None) -> bool:
if event.type != PRIVILEGE_SUDO_TYPE:
return False
if _host_sac_update_running(event, db):
return True
if _sudo_event_is_agent_maintenance(event):
logger.info(
"notify sudo skipped maintenance command event_id=%s host_id=%s",
event.event_id,
event.host_id,
)
return True
return False
def _lifecycle_suppress_notifications(event: Event, *, db: Session | None = None) -> bool:
"""Не слать TG при штатном SAC/cron update (lifecycle с trigger deploy_recycle)."""
if _host_sac_update_running(event, db):
return True
details = event.details if isinstance(event.details, dict) else {}
trigger = str(details.get("trigger") or "").strip().lower()
if trigger in ("deploy_recycle", "sac_update", "agent_update"):
logger.info(
"notify lifecycle skipped trigger=%s event_id=%s",
trigger,
event.event_id,
)
return True
return False
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None: def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent).""" """Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
if _lifecycle_suppress_notifications(event, db=db):
return
if _skip_notifications_for_hidden_event(event, db): if _skip_notifications_for_hidden_event(event, db):
return return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
@@ -146,16 +216,30 @@ def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
def schedule_notify_daily_report(event_db_id: int) -> None: def schedule_notify_daily_report(event_db_id: int) -> None:
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST).""" """Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
_schedule_deferred_event_notify(event_db_id, notify_daily_report, label="daily report")
def schedule_notify_lifecycle(event_db_id: int) -> None:
"""Отложенное lifecycle-оповещение (после commit ingest)."""
_schedule_deferred_event_notify(event_db_id, notify_lifecycle, label="lifecycle")
def schedule_notify_auth_login(event_db_id: int) -> None:
"""Отложенное оповещение об успешном RDP/SSH входе (после commit ingest)."""
_schedule_deferred_event_notify(event_db_id, notify_auth_login, label="auth login")
def _schedule_deferred_event_notify(event_db_id: int, handler, *, label: str) -> None:
from app.database import SessionLocal from app.database import SessionLocal
db = SessionLocal() db = SessionLocal()
try: try:
event = db.get(Event, event_db_id) event = db.get(Event, event_db_id)
if event is None: if event is None:
logger.warning("deferred daily report notify: event id=%s not found", event_db_id) logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
return return
notify_daily_report(event, db=db) handler(event, db=db)
except Exception: except Exception:
logger.exception("deferred daily report notify failed event_db_id=%s", event_db_id) logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
finally: finally:
db.close() db.close()
@@ -56,13 +56,17 @@ def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> Non
def _login_already_closed(login_event: Event) -> bool: def _login_already_closed(login_event: Event) -> bool:
from app.services.rdp_session_logoff import event_closed_by_logoff
details = _details_dict(login_event) details = _details_dict(login_event)
if details.get("session_terminated") is True: if details.get("session_terminated") is True:
return True return True
at = details.get(SESSION_TERMINATED_AT_KEY) at = details.get(SESSION_TERMINATED_AT_KEY)
if at is not None and str(at).strip() != "": if at is not None and str(at).strip() != "":
return True return True
return event_closed_by_rdg(login_event) if event_closed_by_rdg(login_event):
return True
return event_closed_by_logoff(login_event)
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None: def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
@@ -155,13 +155,23 @@ def _disconnect_on_workstation(
login_event_id=login_event.id if login_event else None, login_event_id=login_event.id if login_event else None,
) )
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user) parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
matched = _sessions_for_user(parsed, user)
if not matched and qwinsta.stdout.strip(): if not matched and qwinsta.stdout.strip():
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), user) parsed = parse_qwinsta_sessions(qwinsta.stdout)
matched = _sessions_for_user(parsed, user)
if not matched: if not matched:
snippet = " ".join(qwinsta.stdout.split())[:240]
parsed_note = f", parsed={len(parsed)} session(s)" if parsed else ""
message = (
f"No matching Windows session for user {user} on {workstation.hostname}"
f"{parsed_note}"
)
if snippet:
message = f"{message}; qwinsta: {snippet}"
return AutoDisconnectResult( return AutoDisconnectResult(
ok=False, ok=False,
message="No matching Windows session for user", message=message,
trigger_event_id=trigger_event.id, trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id, workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None, login_event_id=login_event.id if login_event else None,
@@ -224,11 +234,13 @@ def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult
try: try:
workstation = resolve_client_workstation(db, rdg_success) workstation = resolve_client_workstation(db, rdg_success)
except ClientWorkstationNotFoundError as exc: except ClientWorkstationNotFoundError as exc:
return AutoDisconnectResult( result = AutoDisconnectResult(
ok=False, ok=False,
message=str(exc), message=str(exc),
trigger_event_id=event.id, trigger_event_id=event.id,
) )
_mark_auto_disconnect(event, result=result)
return result
rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1) rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1)
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
+148
View File
@@ -0,0 +1,148 @@
"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event
from app.services.host_sessions import (
SESSION_TERMINATED_AT_KEY,
_details_dict,
_event_login_user,
)
from app.services.rdg_workstation_session import (
WORKSTATION_LOGIN_TYPE,
event_closed_by_rdg,
users_match_rdg,
)
SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at"
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id"
LOGOFF_EVENT_TYPE = "rdp.session.logoff"
LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE})
def event_closed_by_logoff(event: Event) -> bool:
details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None:
details = dict(_details_dict(login_event))
details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat()
details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id
login_event.details = details
flag_modified(login_event, "details")
def _login_already_closed(login_event: Event) -> bool:
details = _details_dict(login_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(login_event):
return True
return event_closed_by_logoff(login_event)
def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None:
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
logoff_user = _event_login_user(logoff_event)
if not logoff_user:
return None
logoff_at = logoff_event.occurred_at
host_id = logoff_event.host_id
if host_id is None:
return None
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at <= logoff_at,
Event.id != logoff_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
logoff_details = _details_dict(logoff_event)
logoff_ip = str(logoff_details.get("ip_address") or "").strip()
for login in candidates:
if _login_already_closed(login):
continue
login_user = _event_login_user(login)
if not users_match_rdg(login_user, logoff_user):
continue
if logoff_ip and logoff_ip not in ("", "-"):
login_ip = str(_details_dict(login).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip:
continue
return login
return None
def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Runtime lookup for historical logins without persisted close flag."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if _login_already_closed(login_event):
return None
host_id = login_event.host_id
if host_id is None:
return None
login_user = _event_login_user(login_event)
if not login_user:
return None
login_at = login_event.occurred_at
login_ip = str(_details_dict(login_event).get("ip_address") or "").strip()
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == LOGOFF_EVENT_TYPE,
Event.occurred_at >= login_at,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for logoff in candidates:
if not users_match_rdg(_event_login_user(logoff), login_user):
continue
logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"):
if login_ip != logoff_ip:
continue
return logoff
return None
def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool:
if event_closed_by_logoff(login_event):
return True
return find_logoff_after_workstation_login(db, login_event) is not None
def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None:
"""On direct RDP logoff, mark matching open workstation login as session-closed."""
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
login = find_workstation_login_for_logoff(db, logoff_event)
if login is None:
return None
mark_login_closed_by_logoff(login, logoff_event=logoff_event)
return login
+41 -4
View File
@@ -9,6 +9,8 @@ from dataclasses import dataclass
from app.config import get_settings from app.config import get_settings
from app.models import Host from app.models import Host
SSH_MONITOR_UPDATE_STATE_FILE = "/var/lib/ssh-monitor/agent-update-in-progress"
SSH_MONITOR_UPDATE_LOG_PATH = "/var/log/update_script.log"
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh" SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update" SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update"
SSH_MONITOR_REPO_NAME = "ssh-monitor" SSH_MONITOR_REPO_NAME = "ssh-monitor"
@@ -433,7 +435,7 @@ def _ssh_monitor_update_invoke_command(
safe_repo = _shell_single_quote(repo_url.strip()) safe_repo = _shell_single_quote(repo_url.strip())
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main") safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
preflight = _ssh_monitor_preflight_git_remote(repo_url) preflight = _ssh_monitor_preflight_git_remote(repo_url)
return f"{preflight}; REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}" return f"{preflight}; UPDATE_VIA_SAC=1 REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}"
def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str: def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str:
@@ -449,7 +451,16 @@ def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && ' f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && ' f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && '
f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && ' f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && '
f'chmod 750 "$INSTALL" && REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL"' f'chmod 750 "$INSTALL" && UPDATE_VIA_SAC=1 REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL" --deploy'
)
def _ssh_monitor_mark_update_begin_prefix() -> str:
"""State-file до updater: lifecycle/sudo на агенте глушатся раньше bootstrap."""
return (
f"mkdir -p /var/lib/ssh-monitor && "
f"date +%s > {SSH_MONITOR_UPDATE_STATE_FILE} && "
f"chmod 600 {SSH_MONITOR_UPDATE_STATE_FILE} 2>/dev/null; "
) )
@@ -497,7 +508,7 @@ def run_ssh_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
remote_cmd=bootstrap_cmd, remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
command_timeout_sec=900, command_timeout_sec=900,
need_root=True, need_root=True,
login_shell=False, login_shell=False,
@@ -508,7 +519,7 @@ def run_ssh_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
remote_cmd=update_cmd, remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
command_timeout_sec=900, command_timeout_sec=900,
need_root=True, need_root=True,
login_shell=False, login_shell=False,
@@ -549,3 +560,29 @@ def run_ssh_monitor_update(
exit_code=updated.exit_code, exit_code=updated.exit_code,
) )
return updated return updated
def tail_ssh_monitor_update_log(
*,
target: str,
user: str,
password: str,
lines: int = 120,
) -> str:
"""Хвост /var/log/update_script.log на удалённом хосте (для live-лога в SAC UI)."""
safe_lines = max(20, min(int(lines), 400))
remote_cmd = (
f"test -r {SSH_MONITOR_UPDATE_LOG_PATH} && "
f"tail -n {safe_lines} {SSH_MONITOR_UPDATE_LOG_PATH} 2>/dev/null || true"
)
result = run_ssh_command(
target=target,
user=user,
password=password,
remote_cmd=remote_cmd,
command_timeout_sec=25,
need_root=True,
login_shell=False,
)
text = (result.stdout or "").strip()
return _truncate_output(text)
+1 -1
View File
@@ -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.5.0" APP_VERSION = "0.5.14"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+24
View File
@@ -247,6 +247,30 @@ def test_clear_stale_running_remote_actions(db_session):
assert host.remote_action["ok"] is False assert host.remote_action["ok"] is False
def test_get_remote_action_status_ignores_stale_running_payload(db_session):
from app.services.host_remote_actions import get_remote_action_status
host = Host(
hostname="done-linux",
os_family="linux",
product="ssh-monitor",
agent_update_state="success",
remote_action={
"status": "running",
"message": "Выполняется обновление… (лог с хоста)",
"output": "=== Script update completed successfully ===",
"ok": True,
"finished_at": "2026-07-08T02:21:11+00:00",
},
)
db_session.add(host)
db_session.commit()
status = get_remote_action_status(host)
assert status["active"] is False
assert status["agent_update_state"] == "success"
def test_cancel_host_remote_job_api(jwt_headers, client, db_session): def test_cancel_host_remote_job_api(jwt_headers, client, db_session):
host = Host( host = Host(
hostname="cancel-me", hostname="cancel-me",
+69
View File
@@ -8,6 +8,7 @@ from app.services.host_sessions import (
event_session_terminated, event_session_terminated,
event_supports_session_terminate, event_supports_session_terminate,
filter_logind_session_rows, filter_logind_session_rows,
filter_windows_sessions_for_user,
mark_event_session_terminated, mark_event_session_terminated,
parse_loginctl_sessions, parse_loginctl_sessions,
parse_loginctl_sessions_json, parse_loginctl_sessions_json,
@@ -73,6 +74,32 @@ def test_parse_qwinsta_sessions_filters_user():
assert filtered[0].session_id == "2" assert filtered[0].session_id == "2"
def test_parse_qwinsta_sessions_disconnected_without_sessionname():
"""Disc rows often omit SESSIONNAME; auto flap logoff relies on these."""
stdout = """SESSIONNAME USERNAME ID STATE
rdp-tcp#0 B26\\s.shelkovaya 2 Active
B26\\s.shelkovaya 5 Disc
rdp-tcp 65536 Listen
services 0 Disc
"""
rows = parse_qwinsta_sessions(stdout)
assert {(r.session_id, r.state.split()[0], r.session_name) for r in rows} == {
("2", "Active", "rdp-tcp#0"),
("5", "Disc", ""),
}
filtered = parse_qwinsta_sessions(stdout, filter_user=r"B26\s.shelkovaya")
assert [r.session_id for r in filtered] == ["2", "5"]
def test_parse_qwinsta_sessions_filters_domain_user():
stdout = """SESSIONNAME USERNAME ID STATE
rdp-tcp#1 B26\\bob 3 Active
"""
rows = parse_qwinsta_sessions(stdout, filter_user=r"B26\bob")
assert len(rows) == 1
assert rows[0].session_id == "3"
def test_event_supports_session_terminate_types(): def test_event_supports_session_terminate_types():
class HostStub: class HostStub:
os_family = "linux" os_family = "linux"
@@ -94,6 +121,48 @@ def test_event_login_user_without_orm_actor_user_attr():
assert _event_login_user(event) == "papatramp" assert _event_login_user(event) == "papatramp"
def test_filter_windows_sessions_for_user_matches_domain():
rows = [
HostSessionRow(session_id="2", user="B26\\papatramp", state="Active"),
HostSessionRow(session_id="3", user="B26\\alice", state="Active"),
]
matched = filter_windows_sessions_for_user(rows, "papatramp")
assert len(matched) == 1
assert matched[0].session_id == "2"
def test_terminate_session_for_event_windows_already_logged_off(monkeypatch):
host = SimpleNamespace(
os_family="windows",
product="rdp-login-monitor",
hostname="BIV-PC",
ipv4="192.168.165.39",
display_name=None,
inventory={},
)
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
host=host,
)
def fake_list(_host, _cfg):
return [], WinRmCmdResult(ok=True, message="ok", target="BIV-PC", stdout="SESSIONNAME USERNAME ID STATE")
monkeypatch.setattr(
"app.services.host_sessions.list_windows_sessions",
fake_list,
)
result = terminate_session_for_event(
event,
linux_cfg=LinuxAdminConfig(user="", password="", source="test"),
win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"),
)
assert result.ok is True
assert "уже вышел" in result.message
def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch): def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
host = SimpleNamespace( host = SimpleNamespace(
os_family="windows", os_family="windows",
+45
View File
@@ -133,3 +133,48 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
mock_daily.assert_called_once() mock_daily.assert_called_once()
mock_event.assert_not_called() mock_event.assert_not_called()
def test_ingest_lifecycle_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"type": "agent.lifecycle",
"title": "started",
"summary": "agent started",
"details": {"lifecycle": "started"},
}
with patch.object(events_api, "schedule_notify_lifecycle") as mock_defer:
with patch.object(events_api, "notify_lifecycle") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
def test_ingest_auth_login_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"category": "auth",
"type": "ssh.login.success",
"severity": "info",
"title": "SSH login",
"summary": "user@host",
}
with patch.object(events_api, "schedule_notify_auth_login") as mock_defer:
with patch.object(events_api, "notify_auth_login") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
@@ -174,3 +174,51 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
assert job["target"] == "ubabuba" assert job["target"] == "ubabuba"
if "product_version" in job: if "product_version" in job:
assert job["product_version"] is None or isinstance(job["product_version"], str) assert job["product_version"] is None or isinstance(job["product_version"], str)
def test_host_agent_update_job_shows_log_tail_and_completion_title(
jwt_headers, client, db_session, monkeypatch
):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
from app.models import Host
from app.services.ssh_connect import SshCommandResult
from tests.test_agent_update import wait_remote_job
get_settings.cache_clear()
host = Host(hostname="router", os_family="linux", product="ssh-monitor", ipv4="10.0.0.1")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
log_tail = (
"2026-07-08 14:18:15 INFO: === Script update completed successfully ===\n"
"2026-07-08 14:18:15 INFO: завершено успешно (код 0). Итог см. выше\n"
)
with (
patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update,
patch("app.services.host_remote_actions.tail_ssh_monitor_update_log") as mock_tail,
):
mock_update.return_value = SshCommandResult(
ok=True,
message="SSH OK (router), exit 0",
target="router",
stdout="",
exit_code=0,
agent_version="2.3.2-SAC",
)
mock_tail.return_value = log_tail
response = client.post(
f"/api/v1/hosts/{host.id}/actions/agent-update",
headers=jwt_headers,
)
assert response.status_code == 202
job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True
assert "готово" in (job.get("title") or "")
assert "2.3.2-SAC" in (job.get("message") or "")
assert "Script update completed successfully" in (job.get("output") or "")
mock_tail.assert_called()
+229
View File
@@ -0,0 +1,229 @@
"""Tests for direct RDP logoff → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
from app.services.event_summary import event_to_summary
from app.services.host_sessions import event_session_terminated
from app.services.ingest import ingest_event
from app.services.rdp_session_logoff import (
SESSION_CLOSED_BY_LOGOFF_AT_KEY,
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY,
close_workstation_session_for_rdp_logoff,
find_logoff_after_workstation_login,
resolve_workstation_login_closed_by_logoff,
)
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
def test_logoff_marks_workstation_login_closed_on_ingest(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={
"user": user,
"ip_address": "192.168.160.3",
"logon_type": 10,
"event_id_windows": 4634,
},
)
assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id
assert event_session_terminated(login, db=db_session) is True
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\Alice"},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_runtime_resolve_for_historical_login_without_flag(db_session):
from app.models import Event, Host
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = Host(
hostname="BIV-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.165.39",
)
db_session.add(host)
db_session.commit()
login = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="4624",
payload={},
details={"user": "papatramp", "ip_address": "192.168.160.3"},
)
logoff = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdp.session.logoff",
severity="info",
title="4634",
summary="4634",
payload={},
details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634},
)
db_session.add_all([login, logoff])
db_session.commit()
assert resolve_workstation_login_closed_by_logoff(db_session, login) is True
assert find_logoff_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_sam_domain_user_match_on_logoff(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\papatramp", "logon_type": 10},
)
_ingest(
db_session,
t0 + timedelta(minutes=30),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4647",
details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647},
)
db_session.refresh(login)
assert event_session_terminated(login, db=db_session) is True
+3
View File
@@ -7,6 +7,8 @@ DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:543
# SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00. # SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00.
SAC_DB_POOL_SIZE=15 SAC_DB_POOL_SIZE=15
SAC_DB_MAX_OVERFLOW=25 SAC_DB_MAX_OVERFLOW=25
# Uvicorn workers (ingest burst). Читает deploy/systemd/sac-api-start.sh; при >1 отключите in-process host_silence scan ниже.
SAC_UVICORN_WORKERS=4
SAC_PUBLIC_URL=https://sac.kalinamall.ru SAC_PUBLIC_URL=https://sac.kalinamall.ru
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS). # Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
@@ -128,6 +130,7 @@ SAC_MOBILE_REFRESH_EXPIRE_DAYS=90
CORS_ORIGINS=https://sac.kalinamall.ru CORS_ORIGINS=https://sac.kalinamall.ru
# SSH: verify host keys (RejectPolicy). Add keys to file before remote actions. # SSH: verify host keys (RejectPolicy). Add keys to file before remote actions.
# Комментарий — только отдельной строкой с #. Нельзя: SAC_SSH_AUTO_ADD_HOST_KEY=false ← текст
# SAC_SSH_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts # SAC_SSH_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts
# SAC_SSH_AUTO_ADD_HOST_KEY=false # SAC_SSH_AUTO_ADD_HOST_KEY=false
+13
View File
@@ -44,6 +44,19 @@ server {
proxy_read_timeout 960s; proxy_read_timeout 960s;
} }
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / { location / {
proxy_pass http://sac_api; proxy_pass http://sac_api;
proxy_http_version 1.1; proxy_http_version 1.1;
+13
View File
@@ -69,6 +69,19 @@ server {
proxy_read_timeout 960s; proxy_read_timeout 960s;
} }
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / { location / {
proxy_pass http://sac_api; proxy_pass http://sac_api;
proxy_http_version 1.1; proxy_http_version 1.1;
+27 -9
View File
@@ -56,12 +56,16 @@ fi
log "pip install -r requirements.txt" log "pip install -r requirements.txt"
sudo -u "${APP_USER}" "${VENV}/bin/pip" install -q -r "${APP_ROOT}/backend/requirements.txt" sudo -u "${APP_USER}" "${VENV}/bin/pip" install -q -r "${APP_ROOT}/backend/requirements.txt"
log "Проверка ${CONFIG_FILE} (pydantic; без bash source — inline-комментарии в значениях ломают deploy)"
sudo -u "${APP_USER}" bash -c "
export SAC_CONFIG_FILE='${CONFIG_FILE}'
cd '${APP_ROOT}/backend'
'${VENV}/bin/python' -c 'from app.config import get_settings; get_settings(); print(\"config: OK\")'
" || die "Неверный ${CONFIG_FILE}: одна переменная = одна строка, комментарии только отдельной строкой с # (не «false ← …» после значения)"
log "alembic upgrade head" log "alembic upgrade head"
sudo -u "${APP_USER}" bash -c " sudo -u "${APP_USER}" bash -c "
set -a export SAC_CONFIG_FILE='${CONFIG_FILE}'
# shellcheck source=/dev/null
source '${CONFIG_FILE}'
set +a
cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head
" "
@@ -89,6 +93,16 @@ if [ -f "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" ]; then
systemctl daemon-reload systemctl daemon-reload
fi fi
fi fi
START_SH="${APP_ROOT}/deploy/systemd/sac-api-start.sh"
if [ -f "${START_SH}" ]; then
sed -i 's/\r$//' "${START_SH}"
chmod 755 "${START_SH}"
fi
for _sh in "${APP_ROOT}"/deploy/sac-deploy.sh "${APP_ROOT}"/deploy/systemd/*.sh; do
[ -f "${_sh}" ] || continue
sed -i 's/\r$//' "${_sh}"
chmod 755 "${_sh}" 2>/dev/null || true
done
log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)" log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)"
sudo -u "${APP_USER}" bash -c " sudo -u "${APP_USER}" bash -c "
@@ -107,10 +121,7 @@ print('db: OK')
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)" log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
log "Сброс зависших remote_action (running без worker после restart)" log "Сброс зависших remote_action (running без worker после restart)"
sudo -u "${APP_USER}" bash -c " sudo -u "${APP_USER}" bash -c "
set -a export SAC_CONFIG_FILE='${CONFIG_FILE}'
# shellcheck source=/dev/null
source '${CONFIG_FILE}'
set +a
cd '${APP_ROOT}/backend' cd '${APP_ROOT}/backend'
'${VENV}/bin/python' -c \" '${VENV}/bin/python' -c \"
from app.database import SessionLocal from app.database import SessionLocal
@@ -125,7 +136,14 @@ finally:
\" \"
" "
systemctl restart "${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}"
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active" sleep 2
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if systemctl is-active --quiet "${SERVICE_NAME}"; then
break
fi
sleep 1
done
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active — journalctl -u ${SERVICE_NAME} -n 40 --no-pager"
HEALTH_OK=0 HEALTH_OK=0
for _ in 1 2 3 4 5 6; do for _ in 1 2 3 4 5 6; do
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Uvicorn launcher: SAC_UVICORN_WORKERS из sac-api.env (без systemd EnvironmentFile).
set -euo pipefail
APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}"
CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}"
VENV="${APP_ROOT}/backend/.venv"
HOST="127.0.0.1"
PORT="8000"
WORKERS=4
_read_env_int() {
local key="$1" default="$2" line val
[ -f "$CONFIG_FILE" ] || {
printf '%s\n' "$default"
return 0
}
line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" 2>/dev/null | tail -1)" || {
printf '%s\n' "$default"
return 0
}
val="${line#*=}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val#\"}"
val="${val%\"}"
val="${val#\'}"
val="${val%\'}"
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
printf '%s\n' "$val"
else
printf '%s\n' "$default"
fi
}
WORKERS="$(_read_env_int SAC_UVICORN_WORKERS 4)"
exec "${VENV}/bin/uvicorn" app.main:app \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--timeout-graceful-shutdown 30
+1 -1
View File
@@ -13,7 +13,7 @@ WorkingDirectory=/opt/security-alert-center/backend
# Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д. # Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д.
Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env
Environment=PYTHONPATH=/opt/security-alert-center/backend Environment=PYTHONPATH=/opt/security-alert-center/backend
ExecStart=/opt/security-alert-center/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 --timeout-graceful-shutdown 30 ExecStart=/usr/bin/bash /opt/security-alert-center/deploy/systemd/sac-api-start.sh
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
TimeoutStopSec=20 TimeoutStopSec=20
+1
View File
@@ -185,6 +185,7 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
| 4624 успех | `rdp.login.success` | info | | 4624 успех | `rdp.login.success` | info |
| 4625 неудача | `rdp.login.failed` | warning | | 4625 неудача | `rdp.login.failed` | warning |
| 4634 / 4647 выход (прямой RDP, **только рабочая станция**) | `rdp.session.logoff` | info | | 4634 / 4647 выход (прямой RDP, **только рабочая станция**) | `rdp.session.logoff` | info |
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
| RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** | | RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** |
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** | | RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
| WinRM **91** inbound shell (Enter-PSSession) | `winrm.session.started` | **warning** | | WinRM **91** inbound shell (Enter-PSSession) | `winrm.session.started` | **warning** |
+1 -1
View File
@@ -115,7 +115,7 @@ sudo /opt/sac-deploy.sh
Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу. Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу.
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **2 worker** uvicorn (`deploy/systemd/sac-api.service`). **Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **4 worker** uvicorn по умолчанию (`SAC_UVICORN_WORKERS`, `deploy/systemd/sac-api-start.sh`).
Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh` Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh`
+1
View File
@@ -94,6 +94,7 @@
"session.logind.new", "session.logind.new",
"rdp.login.success", "rdp.login.success",
"rdp.login.failed", "rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started", "rdp.shadow.control.started",
"rdp.shadow.control.stopped", "rdp.shadow.control.stopped",
"rdp.shadow.control.permission", "rdp.shadow.control.permission",
+50
View File
@@ -0,0 +1,50 @@
# Backlog: ingest и массовое обновление агентов
**Статус:** ToDo (не в текущем релизе).
**Контекст:** массовый SSH-update через SAC (10+ хостов) + `sac-deploy` в одно окно → часть POST в ingest «теряется» с точки зрения агента (`SAC POST HTTP :`), flood в Telegram (fallback + watchdog).
**Связано:** [runbook-ops.md](runbook-ops.md), [agent-integration.md](agent-integration.md), ssh-monitor [security-roadmap.ru.md](https://git.kalinamall.ru/PapaTramp/ssh-monitor/src/branch/main/docs/security-roadmap.ru.md).
Увеличение `SAC_DB_POOL_SIZE` / defer daily в **0.5.0** лечит штурм суточных отчётов и пул БД; **не** снимает узкие места ниже при одновременном restart многих агентов.
---
## Операционно (сразу, без кода)
- [ ] **Не совмещать** `sudo /opt/sac-deploy.sh` и массовое «Обновить ssh-monitor (SSH)» по многим хостам — сначала deploy SAC, потом агенты (или наоборот).
- [ ] Обновлять Linux-хосты **пачками по 23**, не все подряд.
- [ ] На зрелых хостах перевести **`UseSAC=exclusive`** (нет дубля в Telegram с агента при fallback); watchdog по-прежнему шлёт в Telegram сам.
- [ ] Перед первым SSH-update с SAC: **`ssh-keyscan`** в `config/ssh_known_hosts` (см. runbook).
- [ ] В `sac-api.env`: только `KEY=value` на строку, комментарии отдельной строкой с `#`.
---
## SAC (backend / deploy)
- [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*``schedule_notify_daily_report`), чтобы ingest не ждал Telegram API — **0.5.5**
- [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh`**0.5.5**
- [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout`**0.5.5**
- [ ] Опционально: метрики/лог длительности ingest и очереди при burst.
- [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками») — **0.5.5**
---
## ssh-monitor (агент)
- [x] При **shutdown** / `SIGTERM` не увеличивать `sac-fail.count`**2.3.2-SAC**
- [x] После успешного heartbeat сбрасывать fail counter (успешный POST ingest) — уже было
- [x] Watchdog: не слать Telegram при штатном restart во время SAC-update (state file `/var/lib/ssh-monitor/agent-update-in-progress` от updater) — **2.2.1-SAC**
- [x] Sudo bootstrap/update через SAC: не слать Telegram (`ssh_monitor_sudo_is_sac_maintenance`, state-file раньше) — **2.2.3-SAC**
- [x] SAC: suppress `privilege.sudo.command` при `agent_update_state=running` / maintenance command — **0.5.3**
- [x] Документировать рекомендуемый `SAC_TIMEOUT_SEC` при тяжёлом ingest — **2.3.2-SAC** (`docs/sac-ingest.ru.md`)
---
## Принято / сделано
- [x] Pool БД 15+25, defer daily push — SAC **0.5.0**
- [x] `sac-deploy.sh` без `source` конфига, preflight pydantic — SAC `fa1bd41`
- [x] Watchdog: строка `🖥️ Сервер:` в Telegram — ssh-monitor **2.1.9-SAC**
---
*После реализации пунктов SAC — bump `APP_VERSION` и runbook.*
+26
View File
@@ -18,6 +18,32 @@ curl -sS https://sac.kalinamall.ru/health | jq .
Ожидается `status: ok`, `database: ok`. При устаревших heartbeat агентов — `status: degraded`, поле `hosts_stale` > 0. Ожидается `status: ok`, `database: ok`. При устаревших heartbeat агентов — `status: degraded`, поле `hosts_stale` > 0.
### `sac-api.env`: формат строк
Файл читается **pydantic** (`SAC_CONFIG_FILE`), не как произвольный bash-скрипт.
- Одна переменная — одна строка: `SAC_SSH_AUTO_ADD_HOST_KEY=false`
- Комментарии — **отдельной** строкой с `#` в начале
- **Нельзя** inline после значения: `false ← так и оставляем` — deploy упадёт на `alembic` с `bool_parsing`
Проверка без деплоя:
```bash
sudo -u sac bash -c 'export SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env; cd /opt/security-alert-center/backend && .venv/bin/python -c "from app.config import get_settings; get_settings(); print(\"OK\")"'
```
### Linux SSH update: `known_hosts`
Перед «Обновить ssh-monitor (SSH)» ключ хоста должен быть в файле (по умолчанию `config/ssh_known_hosts`):
```bash
ssh-keyscan -H 10.10.7.2 | sudo tee -a /opt/security-alert-center/config/ssh_known_hosts
sudo chown sac:sac /opt/security-alert-center/config/ssh_known_hosts
sudo chmod 600 /opt/security-alert-center/config/ssh_known_hosts
```
Иначе SAC: `Server '…' not found in known_hosts`. `SAC_SSH_AUTO_ADD_HOST_KEY=true` для prod не рекомендуется.
## Мобильные устройства (Seaca) ## Мобильные устройства (Seaca)
См. [seaca-mobile.md](seaca-mobile.md) и [seaca-fcm.md](seaca-fcm.md). См. [seaca-mobile.md](seaca-mobile.md) и [seaca-fcm.md](seaca-fcm.md).
+3 -1
View File
@@ -613,7 +613,9 @@ export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteA
} }
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> { export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`); return apiFetch<HostRemoteActionJobStatus>(
`/api/v1/hosts/${hostId}/actions/remote-job?_ts=${Date.now()}`,
);
} }
export interface HostSessionItem { export interface HostSessionItem {
+269 -16
View File
@@ -1,31 +1,60 @@
<template> <template>
<div v-if="open" class="host-action-log-dock" aria-live="polite"> <div v-if="open" class="host-action-log-dock" aria-live="polite">
<div class="host-action-log-panel" role="dialog" :aria-label="title"> <div
class="host-action-log-panel"
:class="{ 'host-action-log-panel-done': !isLoading && ok === true, 'host-action-log-panel-fail': !isLoading && ok === false }"
role="dialog"
:aria-label="displayTitle"
>
<div class="host-action-log-header"> <div class="host-action-log-header">
<h3>{{ title }}</h3> <h3>
<span v-if="!isLoading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true"></span>
<span v-else-if="!isLoading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true"></span>
{{ displayTitle }}
</h3>
<button <button
type="button" type="button"
class="host-action-log-icon-btn" class="host-action-log-icon-btn"
:title="loading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'" :title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
:aria-label="loading ? 'Свернуть' : 'Закрыть'" :aria-label="isLoading ? 'Свернуть' : 'Закрыть'"
@click="emit('close')" @click="emit('close')"
> >
× ×
</button> </button>
</div> </div>
<p v-if="loading" class="host-action-log-status"> <p v-if="isLoading" class="host-action-log-status">
<span class="host-action-log-spinner" aria-hidden="true" /> <span class="host-action-log-spinner" aria-hidden="true" />
Выполняется на удалённом хосте Можно запустить обновление других хостов. Выполняется на удалённом хосте Можно запустить обновление других хостов.
</p> </p>
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p> <p
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p> v-else-if="ok === true"
<pre v-if="output" class="host-action-log-output">{{ output }}</pre> class="host-action-log-message success host-action-log-result"
>
{{ displayMessage }}
</p>
<p
v-else-if="ok === false"
class="host-action-log-message error host-action-log-result"
>
{{ displayMessage }}
</p>
<p v-else-if="displayMessage && isLoading" class="muted host-action-log-sub host-action-log-message">
{{ displayMessage }}
</p>
<p v-if="!isLoading && ok === true && autoCloseSec > 0" class="muted host-action-log-sub">
Окно закроется автоматически через {{ autoCloseSec }} с
</p>
<pre
v-show="logVisible"
ref="logPreRef"
class="host-action-log-output"
>{{ displayOutput }}</pre>
<div class="host-action-log-actions"> <div class="host-action-log-actions">
<button v-if="loading" type="button" class="secondary" @click="emit('close')"> <button type="button" class="secondary" @click="emit('toggle-log')">
Свернуть {{ logVisible ? "Скрыть лог" : "Показать лог" }}
</button> </button>
<button v-else type="button" class="secondary" @click="emit('close')"> <button type="button" class="secondary" @click="emit('close')">
Закрыть {{ isLoading ? "Свернуть" : "Закрыть" }}
</button> </button>
</div> </div>
</div> </div>
@@ -33,24 +62,210 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { fetchHostRemoteJob, type HostRemoteActionJobStatus } from "../api";
const LOG_POLL_MS = 1500;
const AUTO_CLOSE_MS = 30_000;
const props = defineProps<{ const props = defineProps<{
hostId: number;
open: boolean; open: boolean;
title: string; title: string;
loading: boolean; loading: boolean;
ok: boolean | null; ok: boolean | null;
message: string; message: string;
output: string; output: string;
logPlaceholder: string;
logVisible: boolean;
sessionStartedAt: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: [];
"toggle-log": [];
finished: [job: HostRemoteActionJobStatus];
}>(); }>();
const messageClass = computed(() => { const logPreRef = ref<HTMLElement | null>(null);
if (props.loading || props.ok == null) return "muted"; const polledOutput = ref("");
return props.ok ? "success" : "error"; const polledMessage = ref("");
const isLoading = ref(true);
const ok = ref<boolean | null>(null);
const localTitle = ref("");
const localMessage = ref("");
const autoCloseSec = ref(0);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let finishedEmitted = false;
let sawRunning = false;
function isTerminalJob(job: HostRemoteActionJobStatus): boolean {
const st = (job.status || "").toLowerCase();
return st === "success" || st === "failed";
}
function jobStartedAfterSession(job: HostRemoteActionJobStatus): boolean {
const started = job.started_at;
if (!started) return false;
const jobMs = Date.parse(started);
const sessionMs = Date.parse(props.sessionStartedAt);
if (Number.isNaN(jobMs) || Number.isNaN(sessionMs)) return false;
return jobMs >= sessionMs - 3000;
}
function isJobRunning(job: HostRemoteActionJobStatus): boolean {
if (job.active) return true;
const st = (job.status || "").toLowerCase();
if (st === "running") return true;
return (job.agent_update_state || "").toLowerCase() === "running";
}
const displayTitle = computed(() => {
if (localTitle.value) return localTitle.value;
return props.title;
});
const displayMessage = computed(() => {
if (localMessage.value) return localMessage.value;
if (polledMessage.value) return polledMessage.value;
return props.message;
});
const displayOutput = computed(() => {
const text = (polledOutput.value || props.output).trim();
return text || props.logPlaceholder;
});
function stopPoll() {
if (pollTimer != null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function stopCountdown() {
if (countdownTimer != null) {
clearInterval(countdownTimer);
countdownTimer = null;
}
autoCloseSec.value = 0;
}
function startCountdown() {
stopCountdown();
autoCloseSec.value = Math.ceil(AUTO_CLOSE_MS / 1000);
countdownTimer = setInterval(() => {
if (autoCloseSec.value <= 1) {
stopCountdown();
return;
}
autoCloseSec.value -= 1;
}, 1000);
}
function applyFinishedJob(job: HostRemoteActionJobStatus) {
isLoading.value = false;
ok.value = job.ok ?? job.status === "success";
if (job.title) localTitle.value = job.title;
if (job.message) localMessage.value = job.message;
const out = (job.output || job.stdout || "").trim();
if (out) polledOutput.value = out;
if (ok.value) startCountdown();
}
async function pollJobOnce() {
try {
const job = await fetchHostRemoteJob(props.hostId);
const out = (job.output || job.stdout || "").trim();
if (out) polledOutput.value = out;
if (job.message && isLoading.value) polledMessage.value = job.message;
if (isJobRunning(job)) {
sawRunning = true;
return;
}
const canFinish =
isTerminalJob(job) && (sawRunning || jobStartedAfterSession(job));
if (canFinish) {
if (!finishedEmitted) {
finishedEmitted = true;
applyFinishedJob(job);
emit("finished", job);
}
stopPoll();
}
} catch {
/* следующий poll */
}
}
function startPoll() {
stopPoll();
void pollJobOnce();
pollTimer = setInterval(() => void pollJobOnce(), LOG_POLL_MS);
}
function resetLocalState() {
finishedEmitted = false;
sawRunning = false;
isLoading.value = true;
ok.value = null;
localTitle.value = "";
localMessage.value = "";
polledOutput.value = props.output.trim();
polledMessage.value = "";
stopCountdown();
}
watch(
() => props.open,
(open) => {
if (open && isLoading.value) {
startPoll();
} else {
stopPoll();
}
},
{ immediate: true },
);
watch(
() => props.loading,
(loading) => {
if (loading) {
resetLocalState();
if (props.open) startPoll();
return;
}
if (!finishedEmitted && props.ok != null) {
finishedEmitted = true;
isLoading.value = false;
ok.value = props.ok;
localTitle.value = props.title;
localMessage.value = props.message;
if (props.output.trim()) polledOutput.value = props.output.trim();
if (props.ok) startCountdown();
stopPoll();
}
},
{ immediate: true },
);
watch(
() => [displayOutput.value, props.logVisible] as const,
async () => {
if (!props.logVisible) return;
await nextTick();
const el = logPreRef.value;
if (el) el.scrollTop = el.scrollHeight;
},
);
onUnmounted(() => {
stopPoll();
stopCountdown();
}); });
</script> </script>
@@ -71,6 +286,14 @@ const messageClass = computed(() => {
padding: 0.85rem 1rem 1rem; padding: 0.85rem 1rem 1rem;
} }
.host-action-log-panel-done {
border-color: #3fb950;
}
.host-action-log-panel-fail {
border-color: #f85149;
}
.host-action-log-header { .host-action-log-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -82,6 +305,22 @@ const messageClass = computed(() => {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
line-height: 1.3; line-height: 1.3;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.host-action-log-mark {
flex-shrink: 0;
font-weight: 700;
}
.host-action-log-mark-ok {
color: #3fb950;
}
.host-action-log-mark-fail {
color: #f85149;
} }
.host-action-log-icon-btn { .host-action-log-icon-btn {
@@ -122,6 +361,20 @@ const messageClass = computed(() => {
word-break: break-word; word-break: break-word;
} }
.host-action-log-result {
margin: 0.65rem 0 0;
font-size: 0.95rem;
font-weight: 500;
}
.host-action-log-message.success {
color: #3fb950;
}
.host-action-log-message.error {
color: #f85149;
}
.host-action-log-spinner { .host-action-log-spinner {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
+22 -13
View File
@@ -1,16 +1,23 @@
<template> <template>
<div v-if="openLogs.length" class="host-action-log-stack" aria-live="polite"> <div v-if="hasOpenLogs" class="host-action-log-stack" aria-live="polite">
<HostActionLogModal <template v-for="entry in hostRemoteActionLogs" :key="entry.id">
v-for="entry in openLogs" <HostActionLogModal
:key="entry.id" v-if="entry.open"
:open="true" :host-id="entry.hostId"
:title="entry.title" :open="true"
:loading="entry.loading" :title="entry.title"
:ok="entry.ok" :loading="entry.loading"
:message="entry.message" :ok="entry.ok"
:output="entry.output" :message="entry.message"
@close="closeHostRemoteActionLog(entry.id)" :output="entry.output"
/> :log-placeholder="entry.logPlaceholder"
:log-visible="entry.logVisible"
:session-started-at="entry.sessionStartedAt"
@close="closeHostRemoteActionLog(entry.id)"
@toggle-log="toggleHostRemoteActionLog(entry.id)"
@finished="syncHostRemoteActionJobFinished(entry.id, $event)"
/>
</template>
</div> </div>
</template> </template>
@@ -20,9 +27,11 @@ import HostActionLogModal from "./HostActionLogModal.vue";
import { import {
closeHostRemoteActionLog, closeHostRemoteActionLog,
hostRemoteActionLogs, hostRemoteActionLogs,
syncHostRemoteActionJobFinished,
toggleHostRemoteActionLog,
} from "../composables/useHostRemoteAction"; } from "../composables/useHostRemoteAction";
const openLogs = computed(() => hostRemoteActionLogs.filter((e) => e.open)); const hasOpenLogs = computed(() => hostRemoteActionLogs.some((e) => e.open));
</script> </script>
<style scoped> <style scoped>
@@ -16,11 +16,16 @@ export type HostRemoteActionLogEntry = {
id: string; id: string;
hostId: number; hostId: number;
open: boolean; open: boolean;
logVisible: boolean;
loading: boolean; loading: boolean;
ok: boolean | null; ok: boolean | null;
title: string; title: string;
message: string; message: string;
output: string; output: string;
/** Текст в окне лога, пока output с сервера ещё пустой */
logPlaceholder: string;
/** ISO-время открытия окна — отсекаем stale success от прошлого job */
sessionStartedAt: string;
}; };
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]); export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
@@ -81,12 +86,22 @@ function scheduleSuccessAutoClose(entryId: string) {
} }
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) { function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.title = job.title || entry.title; if (job.title) {
entry.message = job.message || entry.message; entry.title = job.title;
entry.output = job.output || job.stdout || ""; }
if (job.message) {
entry.message = job.message;
}
const nextOutput = (job.output || job.stdout || "").trim();
if (nextOutput) {
entry.output = job.output || job.stdout || "";
}
} }
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) { function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
if (!entry.loading && entry.ok != null) {
return;
}
entry.loading = false; entry.loading = false;
entry.ok = job.ok ?? job.status === "success"; entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job); applyJobToEntry(entry, job);
@@ -96,6 +111,16 @@ function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobSt
} }
} }
export function syncHostRemoteActionJobFinished(
entryId: string,
job: HostRemoteActionJobStatus,
): void {
const entry = findLogById(entryId);
if (!entry) return;
finishEntry(entry, job);
void patchHostFromJob(entry.hostId, job);
}
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> { async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
for (;;) { for (;;) {
const job = await fetchHostRemoteJob(hostId); const job = await fetchHostRemoteJob(hostId);
@@ -166,6 +191,7 @@ async function executeRemoteAction(
export async function pollHostRemoteAction( export async function pollHostRemoteAction(
hostId: number, hostId: number,
title: string, title: string,
logPlaceholder?: string,
): Promise<HostRemoteActionJobStatus | null> { ): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId); const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) { if (existingPromise) {
@@ -180,11 +206,14 @@ export async function pollHostRemoteAction(
id: newLogId(), id: newLogId(),
hostId, hostId,
open: true, open: true,
logVisible: false,
loading: true, loading: true,
ok: null, ok: null,
title, title,
message: "Выполняется на удалённом хосте…", message: "Выполняется на удалённом хосте…",
output: "", output: "",
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
sessionStartedAt: new Date().toISOString(),
}; };
hostRemoteActionLogs.push(entry); hostRemoteActionLogs.push(entry);
@@ -215,6 +244,7 @@ export async function runHostRemoteAction(
hostId: number, hostId: number,
title: string, title: string,
kind: HostRemoteActionKind, kind: HostRemoteActionKind,
logPlaceholder?: string,
): Promise<HostRemoteActionJobStatus | null> { ): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId); const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) { if (existingPromise) {
@@ -229,11 +259,14 @@ export async function runHostRemoteAction(
id: newLogId(), id: newLogId(),
hostId, hostId,
open: true, open: true,
logVisible: false,
loading: true, loading: true,
ok: null, ok: null,
title, title,
message: "Запуск на сервере SAC…", message: "Запуск на сервере SAC…",
output: "", output: "",
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
sessionStartedAt: new Date().toISOString(),
}; };
hostRemoteActionLogs.push(entry); hostRemoteActionLogs.push(entry);
@@ -246,6 +279,13 @@ export async function runHostRemoteAction(
} }
} }
export function toggleHostRemoteActionLog(entryId: string) {
const entry = findLogById(entryId);
if (!entry) return;
entry.logVisible = !entry.logVisible;
entry.open = true;
}
export function closeHostRemoteActionLog(entryId: string) { export function closeHostRemoteActionLog(entryId: string) {
clearAutoCloseTimer(entryId); clearAutoCloseTimer(entryId);
const entry = findLogById(entryId); const entry = findLogById(entryId);
+34
View File
@@ -58,3 +58,37 @@ export function remoteActionTitleForHost(host: HostSummary): string {
} }
return `Обновление ssh-monitor (SSH): ${label}`; return `Обновление ssh-monitor (SSH): ${label}`;
} }
const LOG_PLACEHOLDER_SSH_UPDATE =
"Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
const LOG_PLACEHOLDER_WINRM =
"Ожидание вывода с хоста…\n(WinRM: Deploy-LoginMonitor.ps1)";
const LOG_PLACEHOLDER_GENERIC = "Ожидание лога с хоста…";
export function remoteActionLogPlaceholder(
host: Pick<HostSummary, "os_family" | "product"> | null | undefined,
kind: HostRemoteActionKind | null,
): string {
if (kind === "ssh-update") {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
if (host && isWindowsAgentHost(host)) {
return LOG_PLACEHOLDER_WINRM;
}
if (kind === "fallback") {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
return LOG_PLACEHOLDER_GENERIC;
}
export function remoteActionLogPlaceholderFromTitle(title: string): string {
if (/winrm/i.test(title)) {
return LOG_PLACEHOLDER_WINRM;
}
if (/ssh-monitor|\/var\/log\/update_script/i.test(title)) {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
return LOG_PLACEHOLDER_GENERIC;
}
+1 -1
View File
@@ -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.5.0"; export const APP_VERSION = "0.5.14";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+4 -1
View File
@@ -315,6 +315,7 @@ import {
import { import {
isLinuxAgentHost, isLinuxAgentHost,
remoteActionKindForHost, remoteActionKindForHost,
remoteActionLogPlaceholder,
remoteActionTitleForHost, remoteActionTitleForHost,
type AgentGitVersions, type AgentGitVersions,
} from "../utils/hostAgentUpgrade"; } from "../utils/hostAgentUpgrade";
@@ -638,7 +639,7 @@ async function runRequestAgentUpdate() {
async function runAgentFallback() { async function runAgentFallback() {
agentControlMessage.value = ""; agentControlMessage.value = "";
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)"; const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
const job = await runHostRemoteAction(hostId.value, title, "fallback"); const job = await runHostRemoteAction(hostId.value, title, "fallback", remoteActionLogPlaceholder(host.value, "fallback"));
if (job) { if (job) {
agentControlOk.value = job.ok ?? false; agentControlOk.value = job.ok ?? false;
if (job.ok) { if (job.ok) {
@@ -706,6 +707,7 @@ async function runAgentUpdate() {
hostId.value, hostId.value,
"Обновление ssh-monitor (SSH)", "Обновление ssh-monitor (SSH)",
"ssh-update", "ssh-update",
remoteActionLogPlaceholder(host.value, "ssh-update"),
); );
if (job?.ok) { if (job?.ok) {
const detail = await fetchHost(hostId.value); const detail = await fetchHost(hostId.value);
@@ -728,6 +730,7 @@ async function runAgentUpgradeFromCell() {
hostId.value, hostId.value,
remoteActionTitleForHost(host.value), remoteActionTitleForHost(host.value),
kind, kind,
remoteActionLogPlaceholder(host.value, kind),
); );
if (job?.ok) { if (job?.ok) {
const detail = await fetchHost(hostId.value); const detail = await fetchHost(hostId.value);
+48 -2
View File
@@ -135,9 +135,12 @@ import { isAgentVersionOutdated } from "../utils/agentVersion";
import { import {
isGitAgentUpgradeAvailable, isGitAgentUpgradeAvailable,
remoteActionKindForHost, remoteActionKindForHost,
remoteActionLogPlaceholder,
remoteActionLogPlaceholderFromTitle,
remoteActionTitleForHost, remoteActionTitleForHost,
} from "../utils/hostAgentUpgrade"; } from "../utils/hostAgentUpgrade";
import { import {
hostRemoteActionLogs,
isHostRemoteActionActive, isHostRemoteActionActive,
pollHostRemoteAction, pollHostRemoteAction,
runHostRemoteAction, runHostRemoteAction,
@@ -244,6 +247,37 @@ function isVersionOutdated(h: HostSummary): boolean {
return isAgentVersionOutdated(h.product_version, reference); return isAgentVersionOutdated(h.product_version, reference);
} }
function countOutdatedUpgradeableHosts(): number {
return (data.value?.items ?? []).filter(
(h) => isGitAgentUpgradeAvailable(h, data.value) && isVersionOutdated(h),
).length;
}
function countActiveRemoteUpgrades(): number {
return hostRemoteActionLogs.filter((e) => e.loading).length;
}
function confirmMassUpgradeIfNeeded(hostname: string): boolean {
const active = countActiveRemoteUpgrades();
const outdated = countOutdatedUpgradeableHosts();
if (active >= 2) {
return window.confirm(
`Сейчас обновляется ${active} хост(ов). Рекомендуется пачками по 2–3. Продолжить обновление «${hostname}»?`,
);
}
if (outdated > 3 && active >= 1) {
return window.confirm(
`Устарело агентов на странице: ${outdated}, уже идёт обновление. Продолжить «${hostname}»?`,
);
}
if (outdated > 3) {
return window.confirm(
`Устарело агентов на странице: ${outdated}. Рекомендуется обновлять пачками по 2–3, не все сразу. Продолжить «${hostname}»?`,
);
}
return true;
}
async function startAgentUpgrade(h: HostSummary) { async function startAgentUpgrade(h: HostSummary) {
if (!isGitAgentUpgradeAvailable(h, data.value)) { if (!isGitAgentUpgradeAvailable(h, data.value)) {
return; return;
@@ -252,9 +286,17 @@ async function startAgentUpgrade(h: HostSummary) {
showHostRemoteActionLog(h.id); showHostRemoteActionLog(h.id);
return; return;
} }
if (!confirmMassUpgradeIfNeeded(h.hostname)) {
return;
}
const kind = remoteActionKindForHost(h); const kind = remoteActionKindForHost(h);
if (!kind) return; if (!kind) return;
void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind); void runHostRemoteAction(
h.id,
remoteActionTitleForHost(h),
kind,
remoteActionLogPlaceholder(h, kind),
);
} }
function openHost(id: number) { function openHost(id: number) {
@@ -415,7 +457,11 @@ interface HostDeleteResponse {
async function onManualAddStarted(payload: { hostId: number; title: string }) { async function onManualAddStarted(payload: { hostId: number; title: string }) {
await loadHosts(); await loadHosts();
void pollHostRemoteAction(payload.hostId, payload.title); void pollHostRemoteAction(
payload.hostId,
payload.title,
remoteActionLogPlaceholderFromTitle(payload.title),
);
} }
async function confirmDelete(h: HostSummary) { async function confirmDelete(h: HostSummary) {
+1
View File
@@ -94,6 +94,7 @@
"session.logind.new", "session.logind.new",
"rdp.login.success", "rdp.login.success",
"rdp.login.failed", "rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started", "rdp.shadow.control.started",
"rdp.shadow.control.stopped", "rdp.shadow.control.stopped",
"rdp.shadow.control.permission", "rdp.shadow.control.permission",