Compare commits
19 Commits
9bfc5e23f7
...
d95b93992d
| Author | SHA1 | Date | |
|---|---|---|---|
| d95b93992d | |||
| d20517804d | |||
| 91bd6afd1f | |||
| dd2356cf04 | |||
| 3475c1811b | |||
| 91be282ec6 | |||
| 310e7f699d | |||
| e7d5b1c60b | |||
| 69fac2f26e | |||
| c222dd7d41 | |||
| 6565b7d679 | |||
| b148b558c3 | |||
| 1ad01534f1 | |||
| 253b80c500 | |||
| f569293d52 | |||
| d47131cd9f | |||
| 1639261cde | |||
| fa1bd41c92 | |||
| 3cabf12f68 |
@@ -0,0 +1,2 @@
|
|||||||
|
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
agent_state = (host.agent_update_state or "").strip().lower()
|
||||||
|
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"
|
status = payload.get("status") or host.agent_update_state or "unknown"
|
||||||
active = status == "running" or host.agent_update_state == "running"
|
active = status == "running"
|
||||||
return {
|
return {
|
||||||
"active": active,
|
"active": active,
|
||||||
"host_id": host.id,
|
"host_id": host.id,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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}"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
|
| 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** |
|
||||||
|
|||||||
+1
-1
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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-хосты **пачками по 2–3**, не все подряд.
|
||||||
|
- [ ] На зрелых хостах перевести **`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.*
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
|
||||||
<HostActionLogModal
|
<HostActionLogModal
|
||||||
v-for="entry in openLogs"
|
v-if="entry.open"
|
||||||
:key="entry.id"
|
:host-id="entry.hostId"
|
||||||
:open="true"
|
:open="true"
|
||||||
:title="entry.title"
|
:title="entry.title"
|
||||||
:loading="entry.loading"
|
:loading="entry.loading"
|
||||||
:ok="entry.ok"
|
:ok="entry.ok"
|
||||||
:message="entry.message"
|
:message="entry.message"
|
||||||
:output="entry.output"
|
:output="entry.output"
|
||||||
|
:log-placeholder="entry.logPlaceholder"
|
||||||
|
:log-visible="entry.logVisible"
|
||||||
|
:session-started-at="entry.sessionStartedAt"
|
||||||
@close="closeHostRemoteActionLog(entry.id)"
|
@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;
|
||||||
|
}
|
||||||
|
if (job.message) {
|
||||||
|
entry.message = job.message;
|
||||||
|
}
|
||||||
|
const nextOutput = (job.output || job.stdout || "").trim();
|
||||||
|
if (nextOutput) {
|
||||||
entry.output = job.output || job.stdout || "";
|
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);
|
||||||
|
|||||||
@@ -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,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}`;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user