Compare commits

..

111 Commits

Author SHA1 Message Date
PTah f1e26552d2 chore: bump SAC to 0.5.18 after WinRM log title fix
Remote already used 0.5.17 for per-host credentials.
2026-07-15 10:00:37 +10:00
PTah f40aa538b0 fix: keep host name in WinRM update log titles (0.5.17)
Do not overwrite UI log titles with generic backend job.title when several logs are open.
2026-07-15 10:00:02 +10:00
PTah 9883e6aab3 feat: per-host WinRM/SSH credentials override (0.5.17)
Allow host-level login/password for home/workgroup PCs while keeping global domain admins in Settings.
2026-07-14 20:04:42 +10:00
PTah 3d18f73965 fix: use JS doc comment in sessionDuration.ts
Python-style triple quotes broke the Vite production build.
2026-07-14 16:19:41 +10:00
PTah 985d51c4ba feat: show RDP session duration in Overview and Events (0.5.16)
Add session_duration_sec to EventSummary and a Duration column (HH:MM:SS / Nd HH:MM:SS).
2026-07-14 16:18:50 +10:00
PTah 52125a5a98 fix: enrich empty rdp.login.success user from RDG 302 (0.5.15)
RCM 1149 often leaves Param1/Param2 empty through RD Gateway; backfill details.user from matching RDG success within 5 minutes, and still close empty-user sessions on RDG end.
2026-07-14 10:47:10 +10:00
PTah d95b93992d fix: parse Disc qwinsta rows so RDG flap auto-logoff works (0.5.14)
Empty SESSIONNAME broke session matching; auto-disconnect failed with 'No matching Windows session'.
2026-07-14 10:32:17 +10:00
PTah d20517804d docs: rdp.session.logoff is workstation-only (agent 2.1.12-SAC) 2026-07-13 17:36:31 +10:00
PTah 91bd6afd1f fix: start sac-api via /usr/bin/bash to avoid systemd 203/EXEC
ExecStart no longer relies on shebang when CRLF slips into deploy scripts.
sac-deploy strips CR from all deploy/*.sh and waits for active before health check.
2026-07-13 17:29:28 +10:00
PTah dd2356cf04 fix: LF line endings in deploy shell scripts (systemd 203/EXEC)
CRLF in sac-api-start.sh shebang broke sac-api with status=203/EXEC on Linux.
Add .gitattributes for *.sh and strip CR in sac-deploy before chmod.
2026-07-13 17:23:58 +10:00
PTah 3475c1811b fix: platform-specific placeholder in agent update log modal (v0.5.13)
Windows WinRM updates no longer show the Linux /var/log/update_script.log hint while output is still empty.
2026-07-13 17:18:49 +10:00
PTah 91be282ec6 feat: close rdp.login.success on rdp.session.logoff (v0.5.12)
Ingest correlates direct RDP logoff with open login events on the same host and user. Terminate-session treats missing qwinsta as already logged off.
2026-07-13 17:10:24 +10:00
PTah 310e7f699d fix: ignore stale SSH update job until running or session start (v0.5.10)
Modal finish only after sawRunning or started_at after sessionOpenedAt, so a previous success no longer closes the window instantly while SAC updater is still running on the host.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 09:44:24 +10:00
PTah 9bfc5e23f7 fix: harden push-github-snapshot.sh for macOS and secret scan
Strip BOM, skip self-sanitization, replace default DB password in mirror,
and simplify pre-push scan to private keys only.
2026-07-07 19:46:26 +10:00
PTah 9e43de6fc1 chore: release SAC 0.5.0
Bump version, document security hardening in README, add github snapshot
push script with URL/login/IP sanitization for public mirror.
2026-07-07 19:44:08 +10:00
PTah 80320ae698 fix: harden SAC security per audit (0.4.15)
Close audit findings: anti-spoof client IP for login rate limit, JWT/CORS
enforce on startup, SSH host-key verification and sudo via stdin, optional
WinRM HTTPS, SSE httpOnly cookie auth, ingest body limit, ILIKE escaping,
and HMAC API key hashing with legacy SHA-256 fallback.
2026-07-07 19:32:12 +10:00
PTah 939fe122c5 fix: WinRM RDP bundle staging in %TEMP% (0.4.14)
Move zip download and extract to user TEMP to avoid ProgramData access denied on DCs. Improve WinRM ERROR line parsing and add optional SAC_AGENT_BUNDLE_BASE_URL for LAN clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 12:11:54 +10:00
PTah c72e510fb0 fix: defer daily report push и увеличить pool БД (0.4.12)
FCM/каналы для report.daily.* после commit ingest (BackgroundTasks);
SAC_DB_POOL_SIZE=15, SAC_DB_MAX_OVERFLOW=25 для штурма 09:00.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 12:00:37 +10:00
PTah 6c43b8637e fix: dedupe host_silence problems under concurrent scans (0.4.11)
Treat acknowledged as active, dedupe by hostname, add PostgreSQL advisory lock and a unique index. Migration closes existing duplicate open problems.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 09:05:28 +10:00
PTah 3765d4d476 feat: RDP flap auto-disconnect and hide break button after reconnect (0.4.10)
Add optional auto logoff of stuck sessions on RDG flap and direct RDP failure. Hide the RDS break button on old flap 302 when the user later reconnects or the workstation session is closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:50:03 +10:00
PTah 10ae670962 fix: prevent duplicate host_silence problems per host (0.4.8)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:12:10 +10:00
PTah daeb7e965d fix: skip alerts for hidden event types, show them on host card (0.4.7)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:16:10 +10:00
PTah 606bf53019 feat: close workstation login on RDG 303 disconnect (0.4.6)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 08:16:06 +10:00
PTah fd1be5e7e4 feat: manual host add from Hosts list via WinRM/SSH (0.4.5)
Add host button probes Windows by hostname or Linux by IP, registers host in SAC and deploys agent in background.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 15:34:12 +10:00
PTah 5a7909bb55 fix: clear stale remote actions on sac-api startup (0.4.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:44:53 +10:00
PTah 86fd400f0b fix: reset stale remote agent updates after sac-api restart (0.4.3)
Clear zombie running jobs on deploy and add admin API to cancel a stuck job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:43:31 +10:00
PTah dd2ae51b43 feat: parallel agent upgrades with per-host log panels (0.4.2)
Allow multiple remote update jobs at once; each upgrade gets its own
bottom-right log dock that auto-closes 30s after success.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:22:31 +10:00
PTah 719d5c5719 feat: login IP whitelist, blocks UI and fail2ban sync (0.4.1)
Admin settings for web login whitelist, blocked IP list with unblock,
and optional fail2ban ignoreip sync for SSH jail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:06:10 +10:00
PTah bda02f67eb ui: grey out other upgrade arrows while host update runs (0.4.0)
Show inactive grey arrows on other hosts during a single remote agent update; bump SAC to 0.4.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:44:31 +10:00
PTah 9053ef0588 feat: one-click agent upgrade from hosts list (0.3.9)
Add Omada-style git version arrow in the agent version column to trigger WinRM/SSH background updates, and soften host detail live refresh with debounced incremental event patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:32:09 +10:00
PTah 0601aafd2d fix: hide ????????? after session terminate (0.3.8)
Persist session_terminated in event details, expose in API, and update Events/Dashboard UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:45:33 +10:00
PTah 8b3e43f30d fix: event terminate-session 500 on rdp.login.success (0.3.7)
Use extract_event_actor_user instead of missing ORM field; map WinRM/SSH errors to HTTP 400.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:37:37 +10:00
PTah 6330f31162 docs: sync README version lines with APP_VERSION 0.3.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:14:43 +10:00
PTah b47cbf03c5 ui: remove duplicate SAC version from overview page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:35:45 +10:00
PTah 407ef37004 fix: strict RDG 302/303 IP pairing keeps break button visible (0.3.6)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:30:16 +10:00
PTah 64b942447b fix: reconcile daily report active users count and list (0.3.5)
Normalize empty-session placeholders; sync stats with report body after ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 14:41:47 +10:00
PTah 8b52c5fe3c fix: show RDS break only on active or flap 302 events (0.3.4)
Hide Obryv RDS on 303 and on 302 after normal session end; keep for open sessions and RDG flap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:15:47 +10:00
PTah fbd213f323 chore: bump npm dompurify and vite for audit fixes (0.3.3)
dompurify 3.4.11, vite 6.4.3 — npm audit reports 0 vulnerabilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:13:43 +10:00
PTah 1b96a3b2c7 fix: RDG event label and prefix type filter (0.3.2)
Use event 302/303 instead of Win302 in RDG titles. Match event types by prefix in events list (e.g. rdg.connection).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:07:22 +10:00
PTah 24ca87a47a feat: RDG 302/303 display path labels and RDS session break (0.3.1)
Show RDS access via RDG-Comp or Haproxy-RDG-Comp, enable qwinsta/logoff on any RDG event with internal_ip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 19:52:22 +10:00
PTah 4eb41608ea chore: release SAC 0.3.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:07:54 +10:00
PTah 6e3f0ede4d fix(backend): loginctl JSON parse and filter ephemeral SSH sessions (0.20.34)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:00:02 +10:00
PTah fe818e9946 feat(frontend): terminate session button on Overview recent events (0.20.33)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 15:44:49 +10:00
PTah 5e26834e16 feat(frontend): git agent versions plate on Hosts list (0.20.32)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 15:17:47 +10:00
PTah 54c1d96933 fix(backend): non-login SSH and strict loginctl parse, SSH retry (0.20.31)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:54:56 +10:00
PTah a51a585b14 fix(frontend): restore host config/sessions row column flex
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:31:48 +10:00
PTah fc66b57e78 fix(frontend): match sessions panel height to agent config until loaded (0.20.30)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:31:38 +10:00
PTah ad2425b0ff fix(frontend): place host sessions panel beside agent config (0.20.29)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:22:20 +10:00
PTah d848d94604 feat: host session list/terminate and event session actions (0.20.28)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:13:42 +10:00
PTah ca0255e13b fix(frontend): live refresh Events list via SSE (0.20.27)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 09:11:19 +10:00
PTah d96d9d6333 fix(frontend): remove UTF-8 BOM from package.json — breaks vite build (0.20.26)
PostCSS config load failed with SyntaxError at position 0 on Linux deploy.
2026-06-21 20:50:13 +10:00
PTah 7397ec30cd fix(winrm): resolve System32 path for cmd test on minimal PATH (0.20.25)
WinRM run_cmd(hostname) failed on hosts where System32 is not in PATH.
2026-06-21 20:49:01 +10:00
PTah 75eec35365 fix(frontend): auto-close WinRM update success panel after 30s (0.20.24) 2026-06-21 20:41:57 +10:00
PTah b3d28cd5d8 fix(frontend): auto-close WinRM update success panel after 30s (0.20.24)
Remote action log dock closes on successful fallback; no duplicate inline message.
2026-06-21 20:41:42 +10:00
PTah 5bd1333928 fix(frontend): remove UTF-8 BOM from package.json — breaks vite build
PostCSS config load failed with SyntaxError at position 0 on Linux deploy.
2026-06-21 20:19:42 +10:00
PTah bf7c83c408 feat: hide event types from SAC UI via Settings checkbox (0.20.23)
Per-type «Показывать в событиях» in severity settings; filters lists, dashboard, hosts, and problems.
2026-06-21 20:13:40 +10:00
PTah 87ac1e09b0 fix: widen remote action log panel and wrap long WinRM output (0.20.22) 2026-06-21 11:30:37 +10:00
PTah 8adaf3bfd3 fix: WinRM update panel no longer blocks SAC navigation (0.20.21) 2026-06-21 11:07:28 +10:00
PTah 8a451a0c49 fix: alembic 022 down_revision must be 021 not 021_agent_git_release (0.20.20) 2026-06-21 11:04:07 +10:00
PTah 5635be1322 feat: background remote agent updates survive SAC navigation (0.20.19) 2026-06-21 11:01:26 +10:00
PTah d7bbcc5337 docs: WinRM RDP update flow and SAC 0.20.18 deployment notes
Обновлены README и руководства по fallback WinRM (git→zip→клиент), SAC_PUBLIC_URL и краткому 502 при деплое.
2026-06-20 20:03:57 +10:00
PTah f3a8952947 fix: friendly 502 handling, 2 uvicorn workers, file bundle cache (0.20.18) 2026-06-20 20:01:46 +10:00
PTah 4d9a9e7e2b fix: decode WinRM Cyrillic from CP866 and show deploy.log UTF-8 (0.20.17) 2026-06-20 19:58:54 +10:00
PTah 9484160045 fix: UTF-8 BOM in RDP bundle zip for Russian Windows PowerShell (0.20.16) 2026-06-20 19:54:12 +10:00
PTah 6acbfe22de fix: deliver RDP bundle via SAC HTTP zip download, not WinRM chunks (0.20.15) 2026-06-20 19:51:45 +10:00
PTah ca90f5c296 fix: SAC pushes RDP bundle from git via WinRM, no client git (0.20.14) 2026-06-20 19:49:20 +10:00
PTah 191fb4e2cf fix: WinRM RDP update only via git clone on client (0.20.13) 2026-06-20 19:46:22 +10:00
PTah 8c1a8fddb1 fix: WinRM RDP update via NETLOGON Deploy, git fallback (0.20.12) 2026-06-20 19:43:34 +10:00
PTah 05408e17c3 fix: WinRM RDP update via run_ps, strip CLIXML noise (0.20.11) 2026-06-20 19:41:05 +10:00
PTah d3517a5706 fix: WinRM RDP update PowerShell quoting via EncodedCommand (0.20.10)
Broken -Command quotes caused ParserError on remote hosts; run deploy script through base64-encoded PowerShell.
2026-06-20 19:35:57 +10:00
PTah 2c2f78eba9 fix: show host action log modal immediately on SSH/WinRM update (0.20.9)
Open progress dialog when agent update starts so long-running remote scripts do not look frozen.
2026-06-20 19:34:12 +10:00
PTah ec5ec62240 fix: enforce SAC git URL when updating ssh-monitor agents (0.20.8)
Pass REPO_URL/GIT_BRANCH from agent settings over SSH, preflight git remotes on stale hosts, and stop using GitHub origin on routers.
2026-06-20 19:28:14 +10:00
PTah 115289ef35 fix: show logoff actions in RDG qwinsta modal (0.20.7)
Put session table before raw output, add manual session ID logoff, improve qwinsta line parsing.
2026-06-20 19:20:52 +10:00
PTah fc9d630263 docs: refresh README; allow monitor JWT for RDG qwinsta/logoff (0.20.6)
Update platform docs for current features; Seaca and web operators with
monitor role can trigger WinRM actions via SAC API.
2026-06-20 19:02:58 +10:00
PTah 01d72d0f68 fix: show RDG flap badge on 302 and 303 events in SAC UI (0.20.5)
Seaca surfaced flap via Problems; the web UI only flagged 303 in DB and had
no visible label. Resolve flap pairs at read time and show badge plus qwinsta
on both sides of 302→303.
2026-06-20 17:27:41 +10:00
PTah a35e50ccd1 feat: qwinsta/logoff buttons on events list and detail (0.20.4)
RDG flap actions were only on the dashboard; add shared composable and
modal so operators can reset stuck sessions from События and event detail.
2026-06-20 17:24:16 +10:00
PTah 34a1670ea9 fix: nginx proxy timeout for long SSH/WinRM agent actions (0.20.3)
Raise proxy_read_timeout to 960s for agent-update and related API paths so bootstrap git clone does not hit 504 from nginx.
2026-06-20 17:12:31 +10:00
PTah c21043d845 feat: bootstrap ssh-monitor updater when missing on host (0.20.2)
SAC now clones the ssh-monitor repo and installs update_ssh_monitor.sh before running the update when the remote updater script is absent.
2026-06-20 17:01:06 +10:00
PTah 07b97bd111 fix: inline git test feedback and use form URLs (0.20.1)
Show git check result next to the button, scroll into view, and test unsaved repo URLs from the form.
2026-06-20 16:29:48 +10:00
PTah f411d8f070 feat: git-based agent version reference and unified outdated badge (0.20.0)
SAC reads latest RDP/ssh-monitor versions from configured git repos for outdated detection, update targets, and settings test-git.
2026-06-20 16:25:53 +10:00
PTah d820d941c1 fix: hide SSH/WinRM test feedback after 30s (0.12.1)
WinRM success message now auto-hides like SSH; both use 30 second timeout instead of 60.
2026-06-20 15:57:15 +10:00
PTah cd2f27792d feat: agent updates from SAC, poll config, SSH/WinRM fallback (0.12.0)
Add agent update settings, pending self-update via poll, desired config per host, and automatic or manual SSH/WinRM fallback when agents do not report agent.update events.
2026-06-20 15:52:10 +10:00
PTah 75f4c475df feat: RDG qwinsta/logoff via WinRM on client workstation (0.11.6)
SAC resolves internal_ip to a registered Windows host and runs qwinsta/logoff synchronously over WinRM instead of queueing commands to the gateway agent.
2026-06-20 15:36:12 +10:00
PTah 2eb06acb5b feat: SSH card UX, agent version sync, persistent ssh_admin_ok (0.11.5)
Silent SSH probe on host card open; manual test feedback auto-hides. Persist ssh_admin_ok in DB (migration 019). After agent update read version from host and refresh UI.
2026-06-20 01:01:56 +10:00
PTah 6fea8262fb fix: skip invalid/unresolvable SSH targets before connect (0.11.2)
Do not use human display_name as SSH host; skip short names that do not resolve on SAC (ubabuba -> IPv4 only).
2026-06-20 00:39:29 +10:00
PTah 2e839e0b16 fix: SSH sudo without TTY for non-root Linux admin (0.11.1)
Probe commands run without sudo; privileged update uses sudo -S. Add SSH connect and Linux admin API tests.
2026-06-20 00:36:12 +10:00
PTah cc8f50de89 feat: Linux SSH admin and remote ssh-monitor update (0.11.0) 2026-06-20 00:25:35 +10:00
PTah 279ea925a8 fix: WinRM try host.hostname first like Enter-PSSession (0.10.4) 2026-06-20 00:13:02 +10:00
PTah c8e3eef9d7 fix: WinRM NTLM username normalize and hostname-first targets (0.10.3) 2026-06-20 00:10:52 +10:00
PTah 2fe492b06b fix: WinRM read_timeout must exceed operation_timeout (0.10.2) 2026-06-20 00:06:26 +10:00
PTah 154ba3efc2 feat: Windows admin in Settings UI and WinRM host test (0.10.1)
Store domain admin in ui_settings (DB overrides env); qwinsta/logoff use effective config.
Host card: POST /hosts/{id}/actions/winrm-test via pywinrm.
2026-06-20 00:01:17 +10:00
PTah df4c93d243 chore: release 0.10.0 (agent control plane phase 1)
Minor release: RDG flap, qwinsta/logoff queue, dashboard UI, agent poll API.
2026-06-19 23:42:03 +10:00
PTah db95be39f3 docs: agent poll contract for RDP 2.0.39-SAC 2026-06-19 23:38:02 +10:00
PTah 2aec488226 feat: RDG session flap rule, qwinsta/logoff queue and dashboard UI (0.9.13)
Detect 302→303 within 1–10s, Problem with 30s dedup, rdg_flap flag.
Agent command poll API, admin qwinsta/logoff from Overview. Migration 016.
2026-06-19 23:34:01 +10:00
PTah b328d32f97 docs: agent control plane concept (v0.9.12)
Утверждённая концепция v0.7: RDG flap, qwinsta/logoff, обновления
агентов и desired config. Только документация и bump версии — runtime
без изменений.
2026-06-19 23:25:54 +10:00
PTah 308e075f35 merge: sync github roadmap with kalinamall 0.9.11 2026-06-19 22:34:14 +10:00
PapaTramp 63cc2954af Update roadmap.md 2026-06-19 14:18:58 +10:00
PapaTramp 45f45145f0 roadmap.md: small ToDo 2026-06-18 08:02:00 +10:00
PTah 55f047cd35 fix: Seaca push uses same lifecycle text as Telegram (0.9.11)
Reuse telegram templates for FCM title/body so agent start/restart
reasons reach mobile without Seaca app changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:35:17 +10:00
172 changed files with 18300 additions and 492 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
*.sh text eol=lf
+1
View File
@@ -44,3 +44,4 @@ pgdata/
# Logs
*.log
.cursorignore
+28 -62
View File
@@ -1,85 +1,51 @@
# Security Alert Center (SAC)
# Security Alert Center (SAC)
Центральная платформа сбора, хранения и отображения событий безопасности с Linux- и Windows-хостов.
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
**English:** [README_en.md](README_en.md)
## Агенты (отдельные репозитории)
## Репозитории
| Репозиторий | Назначение |
|-------------|------------|
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux: SSH, sudo, logind, брутфорс, баны IP |
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows: RDP/RDS, RD Gateway, WinRM, admin share C$/ADMIN$ |
| **security-alert-center** (этот репозиторий) | Ubuntu 24.04: API, БД, UI, оповещения |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android: мобильный клиент SAC, push (FCM) |
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux-агент |
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
## Статус
**Версия:** `0.5.18` · **Деплой:** `sudo /opt/sac-deploy.sh`
**Версия:** `0.9.0`
**Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
**Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`)
## Возможности
## Скриншоты UI
| Обзор | События | Проблемы |
|-------|---------|----------|
| ![Обзор SAC](docs/screenshots/ui-overview.png) | ![События SAC](docs/screenshots/ui-events.png) | ![Проблемы SAC](docs/screenshots/ui-problems.png) |
## Что мониторится
События приходят от агентов по [контракту ingest](docs/agent-integration.md) (`type`, `severity`, `details`):
- **Linux — [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor):** SSH (успех/неудача), **sudo**, **systemd-logind**, брутфорс и баны IP (ipset), пороги без бана, ежедневный отчёт, heartbeat.
- **Windows — [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor):**
- **RDP/RDS** (4624/4625);
- **RD Gateway** (302/303), **WinRM / PowerShell Remoting** (Enter-PSSession, ID 91);
- **админ-шары `C$` / `ADMIN$`** (Security **5140**, `smb.admin_share.access`);
- **RDS Shadow Control** (RCM 20506/20507/20510), блокировка учётки **4740**, ежедневный отчёт, heartbeat, **инвентаризация железа** (`agent.inventory`).
- **В SAC:** problems (корреляция), Telegram/email/webhook, лимит входа в UI, SSE-дашборд и live-обновление хостов, статус агентов по heartbeat, карточка хоста с железом.
**Примеры типов:** `ssh.login.*`, `privilege.sudo.command`, `rdp.login.*`, `winrm.session.started`, `smb.admin_share.access`, `agent.heartbeat`, `agent.inventory`, `report.daily.*` — [полная таблица](docs/agent-integration.md#3-маппинг-уведомлений--типы-событий).
## Возможности платформы
- Роли `admin` / `monitor`, управление пользователями в UI, JWT с проверкой активности в БД
- События, хосты, problems, dashboard, отчёты, live SSE
- Оповещения: Telegram, email, webhook; правила problems и cooldown
- Защита SPA, `DELETE /hosts` только для admin, login rate limit, DOMPurify для `report_html`
- Страницы ошибок `401` / `403` / `404` / `429`
- Статистика системы в sidebar (CPU, RAM, диск, latency БД)
- **Ingest** событий от агентов ([контракт](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, отчёты, inventory
- **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 110 с)
- **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown
- **UI** — события, хосты, problems, отчёты, обзор (SSE live), настройки (admin)
- **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках); опционально — автоотключение зависшей сессии при flap
- **Хосты** — статус агента, inventory, обновление ssh-monitor/RDP (SSH/WinRM), тест Git/SSH/WinRM; WinRM RDP: SAC тянет пакет с git, клиент скачивает zip с SAC, локальный `Deploy-LoginMonitor.ps1` (git на ПК не нужен)
- **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC
- **Роли** — `admin` / `monitor`; JWT, rate limit входа
- **Безопасность (0.5.0)** — проверка host key SSH, SSE через httpOnly-cookie, защита rate limit от спуфинга `X-Forwarded-For`, `SAC_SECURITY_ENFORCE`, HMAC для API-ключей
## Документация
| Документ | Описание |
|----------|----------|
| [docs/TZ.md](docs/TZ.md) | Техническое задание |
| [docs/architecture.md](docs/architecture.md) | Архитектура |
| [docs/agent-integration.md](docs/agent-integration.md) | Интеграция агентов, режим `UseSAC` |
| [docs/event-schema-v1.json](docs/event-schema-v1.json) | JSON Schema событий v1 |
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | **Ubuntu 24.04 — native (основной)** |
| [docs/install-ubuntu-24.04-docker.md](docs/install-ubuntu-24.04-docker.md) | Ubuntu 24.04 — Docker |
| [docs/deployment.md](docs/deployment.md) | Развёртывание и эксплуатация |
| [docs/ssl-certificate.md](docs/ssl-certificate.md) | TLS-сертификат |
| [docs/runbook-ops.md](docs/runbook-ops.md) | Runbook эксплуатации |
| [docs/agent-integration.md](docs/agent-integration.md) | Агенты, `UseSAC`, типы событий |
| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, обновления агентов |
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Установка (native) |
| [docs/deployment.md](docs/deployment.md) | Эксплуатация |
| [docs/seaca-mobile.md](docs/seaca-mobile.md) | Мобильные устройства |
## Быстрый старт (native)
## Быстрый старт
```bash
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# deploy/env.native.example -> config/sac-api.env
cd /opt/security-alert-center/backend
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/alembic upgrade head
# deploy/env.native.example config/sac-api.env (JWT_SECRET, CORS_ORIGINS, SAC_SECURITY_ENFORCE)
cd /opt/security-alert-center/backend && python3 -m venv .venv
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
```
Полная инструкция: [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md)
## Целевая платформа
- **Сервер SAC:** Ubuntu 24.04 LTS
- **Агенты:** ssh-monitor (Linux), RDP-login-monitor (Windows)
## Лицензия
MIT — см. [LICENSE](LICENSE).
Copyright (c) 2026 Andrey «PapaTramp» Lutsenko.
MIT — [LICENSE](LICENSE).
+27 -65
View File
@@ -1,88 +1,50 @@
# Security Alert Center (SAC)
# Security Alert Center (SAC)
Self-hosted hub for collecting, storing, and displaying security events from Linux and Windows hosts.
Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management.
**Русский:** [README.md](README.md)
## Agents (separate repositories)
## Repositories
| Repository | Role |
|------------|------|
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux: SSH, sudo, logind, brute-force, IP ban |
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows: RDP/RDS, RD Gateway, WinRM, admin share C$/ADMIN$ |
| **security-alert-center** (this repo) | Ubuntu 24.04: API, database, UI, notifications |
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux agent |
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows agent |
| **security-alert-center** | SAC server (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
## Status
**Version:** `0.5.18` · **Deploy:** `sudo /opt/sac-deploy.sh`
**Version:** `0.9.0`
**Stack:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
**Deploy:** `sudo /opt/sac-deploy.sh` (see `deploy/sac-deploy.sh`)
## Features
## UI screenshots
| Overview | Events | Problems |
|----------|--------|----------|
| ![SAC dashboard](docs/screenshots/ui-overview.png) | ![SAC events](docs/screenshots/ui-events.png) | ![SAC problems](docs/screenshots/ui-problems.png) |
## What is monitored
Agents send events via the [ingest contract](docs/agent-integration.md) (`type`, `severity`, `details`):
- **Linux — [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor):** SSH success/failure, **sudo**, **systemd-logind**, brute-force and IP ban (ipset), thresholds without ban, daily report, heartbeat.
- **Windows — [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor):**
- **RDP/RDS** (4624/4625);
- **RD Gateway** (302/303), **WinRM / PowerShell Remoting** (Enter-PSSession, event ID 91);
- **admin shares `C$` / `ADMIN$`** (Security **5140**, `smb.admin_share.access`);
- **RDS Shadow Control** (RCM 20506/20507/20510), account lockout **4740**, daily report, heartbeat, **hardware inventory** (`agent.inventory`).
- **SAC platform:** problems (correlation), Telegram/email/webhook, UI login rate limit, SSE dashboard and live host updates, agent online/stale by heartbeat, host detail with hardware snapshot.
**Event type examples:** `ssh.login.*`, `privilege.sudo.command`, `rdp.login.*`, `winrm.session.started`, `smb.admin_share.access`, `agent.heartbeat`, `agent.inventory`, `report.daily.*` — [full mapping](docs/agent-integration.md#3-маппинг-уведомлений--типы-событий).
## Platform features
- Roles `admin` / `monitor`, user management in UI, JWT validated against DB
- Events, hosts, problems, dashboard, reports, live SSE
- Notifications: Telegram, email, webhook; problem rules and cooldowns
- SPA hardening, admin-only `DELETE /hosts`, login rate limit, DOMPurify for `report_html`
- Custom error pages: `401` / `403` / `404` / `429`
- Sidebar system stats (CPU, RAM, disk, DB latency)
- **Ingest** from agents ([contract](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, reports, inventory
- **Problems** — auto-correlation (incl. RDG 302→303 flap within 110 s)
- **Notifications** — Telegram, email, webhook, FCM (Seaca)
- **Web UI** — events, hosts, problems, reports, dashboard (SSE), settings (admin)
- **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required); optional auto-disconnect of stuck sessions
- **Hosts** — agent status, inventory, agent updates (SSH/WinRM); WinRM RDP: SAC fetches from git, client downloads zip from SAC, runs local `Deploy-LoginMonitor.ps1` (no git on PC)
- **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API
- **Roles** — `admin` / `monitor`
- **Security (0.5.0)** — SSH host-key verification, SSE via httpOnly cookie, anti-spoof login rate limit, `SAC_SECURITY_ENFORCE`, HMAC API keys
## Documentation
| Document | Description |
|----------|-------------|
| [docs/TZ.md](docs/TZ.md) | Technical specification (Russian) |
| [docs/architecture.md](docs/architecture.md) | Architecture |
| [docs/agent-integration.md](docs/agent-integration.md) | Agent integration (`UseSAC` modes) |
| [docs/event-schema-v1.json](docs/event-schema-v1.json) | Event JSON Schema v1 |
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | **Ubuntu 24.04 native install** |
| [docs/install-ubuntu-24.04-docker.md](docs/install-ubuntu-24.04-docker.md) | Docker alternative |
| [docs/deployment.md](docs/deployment.md) | Deployment and operations |
| [docs/ssl-certificate.md](docs/ssl-certificate.md) | TLS certificate setup |
| [docs/runbook-ops.md](docs/runbook-ops.md) | Operations runbook |
| [docs/agent-integration.md](docs/agent-integration.md) | Agents, `UseSAC`, event types |
| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, agent updates |
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Native install |
| [docs/deployment.md](docs/deployment.md) | Operations |
## Quick start (native)
## Quick start
```bash
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# deploy/env.native.example -> config/sac-api.env
cd /opt/security-alert-center/backend
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/alembic upgrade head
# deploy/env.native.example config/sac-api.env
cd /opt/security-alert-center/backend && python3 -m venv .venv
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
```
Full guide: [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md)
## Target platform
- **SAC server:** Ubuntu 24.04 LTS
- **Agents:** ssh-monitor (Linux), RDP-login-monitor (Windows)
## GitHub topics
`security-monitoring`, `siem`, `incident-response`, `fastapi`, `vue`, `postgresql`, `telegram-bot`, `webhook`, `jwt`, `rbac`, `self-hosted`
## License
MIT — see [LICENSE](LICENSE).
Copyright (c) 2026 Andrey "PapaTramp" Lutsenko.
MIT — [LICENSE](LICENSE).
@@ -0,0 +1,46 @@
"""Revision ID: 016
Revises: 015
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "016"
down_revision: Union[str, None] = "015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"agent_commands",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("command_uuid", sa.String(length=36), nullable=False),
sa.Column("host_id", sa.Integer(), nullable=False),
sa.Column("event_id", sa.Integer(), nullable=True),
sa.Column("command_type", sa.String(length=32), nullable=False),
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
sa.Column("result_stdout", sa.Text(), nullable=True),
sa.Column("result_stderr", sa.Text(), nullable=True),
sa.Column("requested_by", sa.String(length=128), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("command_uuid"),
)
op.create_index("ix_agent_commands_command_uuid", "agent_commands", ["command_uuid"])
op.create_index("ix_agent_commands_host_id", "agent_commands", ["host_id"])
op.create_index("ix_agent_commands_status", "agent_commands", ["status"])
def downgrade() -> None:
op.drop_index("ix_agent_commands_status", table_name="agent_commands")
op.drop_index("ix_agent_commands_host_id", table_name="agent_commands")
op.drop_index("ix_agent_commands_command_uuid", table_name="agent_commands")
op.drop_table("agent_commands")
@@ -0,0 +1,25 @@
"""ui_settings: Windows domain admin for agent commands
Revision ID: 017
Revises: 016
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "017"
down_revision: Union[str, None] = "016"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("ui_settings", sa.Column("win_admin_user", sa.String(256), nullable=True))
op.add_column("ui_settings", sa.Column("win_admin_password", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("ui_settings", "win_admin_password")
op.drop_column("ui_settings", "win_admin_user")
@@ -0,0 +1,25 @@
"""ui_settings: Linux SSH admin for remote agent updates
Revision ID: 018
Revises: 017
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "018"
down_revision: Union[str, None] = "017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("ui_settings", sa.Column("linux_admin_user", sa.String(128), nullable=True))
op.add_column("ui_settings", sa.Column("linux_admin_password", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("ui_settings", "linux_admin_password")
op.drop_column("ui_settings", "linux_admin_user")
@@ -0,0 +1,25 @@
"""hosts: persist Linux SSH admin check status
Revision ID: 019
Revises: 018
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "019"
down_revision: Union[str, None] = "018"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("hosts", sa.Column("ssh_admin_ok", sa.Boolean(), nullable=True))
op.add_column("hosts", sa.Column("ssh_admin_checked_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("hosts", "ssh_admin_checked_at")
op.drop_column("hosts", "ssh_admin_ok")
@@ -0,0 +1,98 @@
"""agent control plane: host update/config + ui agent-update settings
Revision ID: 020
Revises: 019
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "020"
down_revision: Union[str, None] = "019"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"hosts",
sa.Column("pending_agent_update", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"hosts",
sa.Column("pending_update_requested_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"hosts",
sa.Column("pending_update_target_version", sa.String(length=64), nullable=True),
)
op.add_column(
"hosts",
sa.Column("agent_config", JSONB, nullable=True),
)
op.add_column(
"hosts",
sa.Column("agent_config_revision", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"hosts",
sa.Column("agent_update_state", sa.String(length=32), nullable=True),
)
op.add_column(
"hosts",
sa.Column("agent_update_last_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column("hosts", sa.Column("agent_update_last_error", sa.Text(), nullable=True))
op.add_column(
"ui_settings",
sa.Column("agent_update_mode", sa.String(length=16), nullable=False, server_default="gpo"),
)
op.add_column(
"ui_settings",
sa.Column("agent_update_fallback_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"ui_settings",
sa.Column("agent_update_fallback_minutes", sa.Integer(), nullable=False, server_default="15"),
)
op.add_column(
"ui_settings",
sa.Column("agent_recommended_rdp_version", sa.String(length=64), nullable=True),
)
op.add_column(
"ui_settings",
sa.Column("agent_recommended_ssh_version", sa.String(length=64), nullable=True),
)
op.add_column(
"ui_settings",
sa.Column("agent_min_rdp_version", sa.String(length=64), nullable=True),
)
op.add_column(
"ui_settings",
sa.Column("agent_min_ssh_version", sa.String(length=64), nullable=True),
)
op.add_column("ui_settings", sa.Column("win_agent_update_script", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("ui_settings", "win_agent_update_script")
op.drop_column("ui_settings", "agent_min_ssh_version")
op.drop_column("ui_settings", "agent_min_rdp_version")
op.drop_column("ui_settings", "agent_recommended_ssh_version")
op.drop_column("ui_settings", "agent_recommended_rdp_version")
op.drop_column("ui_settings", "agent_update_fallback_minutes")
op.drop_column("ui_settings", "agent_update_fallback_enabled")
op.drop_column("ui_settings", "agent_update_mode")
op.drop_column("hosts", "agent_update_last_error")
op.drop_column("hosts", "agent_update_last_at")
op.drop_column("hosts", "agent_update_state")
op.drop_column("hosts", "agent_config_revision")
op.drop_column("hosts", "agent_config")
op.drop_column("hosts", "pending_update_target_version")
op.drop_column("hosts", "pending_update_requested_at")
op.drop_column("hosts", "pending_agent_update")
@@ -0,0 +1,51 @@
"""agent git release repos and version cache
Revision ID: 021
Revises: 020
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "021"
down_revision: Union[str, None] = "020"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ui_settings",
sa.Column("agent_rdp_git_repo_url", sa.String(length=512), nullable=True),
)
op.add_column(
"ui_settings",
sa.Column("agent_ssh_git_repo_url", sa.String(length=512), nullable=True),
)
op.add_column(
"ui_settings",
sa.Column("agent_git_branch", sa.String(length=64), nullable=False, server_default="main"),
)
op.add_column(
"ui_settings",
sa.Column("agent_git_rdp_version", sa.String(length=64), nullable=True),
)
op.add_column(
"ui_settings",
sa.Column("agent_git_ssh_version", sa.String(length=64), nullable=True),
)
op.add_column(
"ui_settings",
sa.Column("agent_git_fetched_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("ui_settings", "agent_git_fetched_at")
op.drop_column("ui_settings", "agent_git_ssh_version")
op.drop_column("ui_settings", "agent_git_rdp_version")
op.drop_column("ui_settings", "agent_git_branch")
op.drop_column("ui_settings", "agent_ssh_git_repo_url")
op.drop_column("ui_settings", "agent_rdp_git_repo_url")
@@ -0,0 +1,27 @@
"""host remote_action JSON for background SSH/WinRM jobs
Revision ID: 022_host_remote_action
Revises: 021
Create Date: 2026-06-21
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "022_host_remote_action"
down_revision = "021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"hosts",
sa.Column("remote_action", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
def downgrade() -> None:
op.drop_column("hosts", "remote_action")
@@ -0,0 +1,34 @@
"""event type visibility settings
Revision ID: 023_event_type_visibility
Revises: 022_host_remote_action
Create Date: 2026-06-21
"""
from alembic import op
import sqlalchemy as sa
revision = "023_event_type_visibility"
down_revision = "022_host_remote_action"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"event_type_visibility",
sa.Column("event_type", sa.String(length=128), nullable=False),
sa.Column("show_in_events", sa.Boolean(), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("event_type"),
)
def downgrade() -> None:
op.drop_table("event_type_visibility")
@@ -0,0 +1,28 @@
"""login security whitelist in ui_settings
Revision ID: 024_login_security
Revises: 023_event_type_visibility
Create Date: 2026-06-25
"""
from alembic import op
import sqlalchemy as sa
revision = "024_login_security"
down_revision = "023_event_type_visibility"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("ui_settings", sa.Column("login_ip_whitelist", sa.Text(), nullable=True))
op.add_column(
"ui_settings",
sa.Column("login_sync_fail2ban", sa.Boolean(), server_default="false", nullable=False),
)
def downgrade() -> None:
op.drop_column("ui_settings", "login_sync_fail2ban")
op.drop_column("ui_settings", "login_ip_whitelist")
@@ -0,0 +1,26 @@
"""auto RDP flap disconnect setting in ui_settings
Revision ID: 025_auto_rdp_flap_disconnect
Revises: 024_login_security
Create Date: 2026-07-02
"""
from alembic import op
import sqlalchemy as sa
revision = "025_auto_rdp_flap_disconnect"
down_revision = "024_login_security"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ui_settings",
sa.Column("auto_rdp_flap_disconnect", sa.Boolean(), server_default="false", nullable=False),
)
def downgrade() -> None:
op.drop_column("ui_settings", "auto_rdp_flap_disconnect")
@@ -0,0 +1,74 @@
"""dedupe host_silence problems + unique active index
Revision ID: 026_host_silence_dedupe
Revises: 025_auto_rdp_flap_disconnect
Create Date: 2026-07-03
"""
from alembic import op
revision = "026_host_silence_dedupe"
down_revision = "025_auto_rdp_flap_disconnect"
branch_labels = None
depends_on = None
def _dedupe_host_silence_problems() -> None:
op.execute(
"""
WITH ranked AS (
SELECT
p.id,
ROW_NUMBER() OVER (
PARTITION BY p.host_id
ORDER BY p.last_seen_at DESC, p.id DESC
) AS rn
FROM problems p
WHERE p.rule_id = 'rule:host_silence'
AND p.status IN ('open', 'acknowledged')
)
UPDATE problems
SET status = 'resolved',
resolved_by = 'auto',
updated_at = NOW()
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
"""
)
op.execute(
"""
WITH ranked AS (
SELECT
p.id,
ROW_NUMBER() OVER (
PARTITION BY lower(h.hostname)
ORDER BY p.last_seen_at DESC, p.id DESC
) AS rn
FROM problems p
JOIN hosts h ON h.id = p.host_id
WHERE p.rule_id = 'rule:host_silence'
AND p.status IN ('open', 'acknowledged')
)
UPDATE problems
SET status = 'resolved',
resolved_by = 'auto',
updated_at = NOW()
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
"""
)
def upgrade() -> None:
_dedupe_host_silence_problems()
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_problems_host_silence_active
ON problems (host_id)
WHERE rule_id = 'rule:host_silence'
AND status IN ('open', 'acknowledged')
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_problems_host_silence_active")
@@ -0,0 +1,25 @@
"""per-host management credentials (SSH / WinRM override)
Revision ID: 027_host_mgmt_credentials
Revises: 026_host_silence_dedupe
Create Date: 2026-07-14
"""
from alembic import op
import sqlalchemy as sa
revision = "027_host_mgmt_credentials"
down_revision = "026_host_silence_dedupe"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True))
op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("hosts", "mgmt_password")
op.drop_column("hosts", "mgmt_user")
+76
View File
@@ -0,0 +1,76 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.api_key import get_api_key_auth
from app.database import get_db
from app.services.agent_commands import (
complete_command,
resolve_host_by_agent_instance_id,
)
from app.services.agent_poll import build_agent_poll_payload
router = APIRouter(prefix="/agent", tags=["agent"])
class AgentCommandResultBody(BaseModel):
status: str = Field(pattern="^(completed|failed)$")
stdout: str | None = None
stderr: str | None = None
class AgentCommandsPollResponse(BaseModel):
config_revision: int = 0
config: dict[str, Any] = Field(default_factory=dict)
update: dict[str, Any] = Field(default_factory=dict)
commands: list[dict[str, Any]] = Field(default_factory=list)
@router.get("/commands", response_model=AgentCommandsPollResponse)
def poll_agent_commands(
agent_instance_id: str = Query(..., min_length=1),
db: Session = Depends(get_db),
_api_key: str = Depends(get_api_key_auth),
) -> AgentCommandsPollResponse:
host = resolve_host_by_agent_instance_id(db, agent_instance_id)
if host is None:
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
payload = build_agent_poll_payload(db, host)
return AgentCommandsPollResponse(**payload)
@router.post("/commands/{command_uuid}/result")
def post_agent_command_result(
command_uuid: str,
body: AgentCommandResultBody,
db: Session = Depends(get_db),
_api_key: str = Depends(get_api_key_auth),
) -> dict[str, str]:
cmd = complete_command(
db,
command_uuid,
status=body.status,
stdout=body.stdout,
stderr=body.stderr,
)
if cmd is None:
raise HTTPException(status_code=404, detail="Command not found")
db.commit()
return {"status": cmd.status, "command_uuid": cmd.command_uuid}
@router.get("/rdp-bundle/{token}")
def download_rdp_bundle(token: str) -> Response:
from app.services.rdp_bundle_delivery import get_rdp_bundle_zip
data = get_rdp_bundle_zip(token)
if not data:
raise HTTPException(status_code=404, detail="Bundle not found or expired")
return Response(
content=data,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="sac-rdp-bundle.zip"'},
)
+16 -82
View File
@@ -1,156 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
from app.auth.stream_cookie import clear_stream_auth_cookie, set_stream_auth_cookie
from app.config import get_settings
from app.database import get_db
from app.services.login_rate_limit import (
ensure_login_allowed,
record_login_failure,
record_login_success,
)
from app.services.user_auth import authenticate_user
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
username: str
role: str
class MeResponse(BaseModel):
username: str
role: str
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse:
settings = get_settings()
if not settings.sac_admin_password and not _has_any_user(db):
raise HTTPException(
status_code=503,
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
)
ip_address = ensure_login_allowed(db, request)
user = authenticate_user(db, body.username, body.password)
if user is None:
record_login_failure(db, ip_address=ip_address, username=body.username)
db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
record_login_success(db, ip_address=ip_address, username=user.username)
db.commit()
token = create_access_token(user.username, user.role)
return TokenResponse(
payload = TokenResponse(
access_token=token,
username=user.username,
role=user.role,
)
response = JSONResponse(content=payload.model_dump())
set_stream_auth_cookie(response, token, settings)
return response
@router.post("/logout", status_code=204)
def logout() -> Response:
settings = get_settings()
response = Response(status_code=204)
clear_stream_auth_cookie(response, settings)
return response
@router.get("/me", response_model=MeResponse)
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
return MeResponse(username=current_user.username, role=current_user.role)
def _has_any_user(db: Session) -> bool:
from sqlalchemy import func, select
from app.models.user import User
try:
count = db.scalar(select(func.count()).select_from(User)) or 0
return count > 0
except Exception:
db.rollback()
return False
+30 -19
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
@@ -11,6 +11,7 @@ from app.database import get_db
from app.models import Event, Host, Problem
from app.schemas.list_models import EventSummary
from app.services.event_summary import event_to_summary
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
@@ -18,14 +19,19 @@ router = APIRouter(prefix="/dashboards", tags=["dashboards"])
def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
"""Последние события по времени приёма ingest (received_at)."""
rows = db.scalars(
hidden = get_hidden_event_types(db)
type_filter = visibility_type_filter(hidden)
stmt = (
select(Event)
.join(Host)
.options(joinedload(Event.host))
.order_by(Event.received_at.desc())
.limit(limit)
).all()
return [event_to_summary(e) for e in rows]
)
if type_filter is not None:
stmt = stmt.where(type_filter)
rows = db.scalars(stmt).all()
return [event_to_summary(e, db) for e in rows]
class TopHostItem(BaseModel):
@@ -61,10 +67,13 @@ def dashboard_summary(
_user: str = Depends(get_current_user),
) -> DashboardSummary:
since = datetime.now(timezone.utc) - timedelta(hours=24)
hidden = get_hidden_event_types(db)
type_filter = visibility_type_filter(hidden)
events_24h = db.scalar(
select(func.count()).select_from(Event).where(Event.received_at >= since)
) or 0
events_24h_stmt = select(func.count()).select_from(Event).where(Event.received_at >= since)
if type_filter is not None:
events_24h_stmt = events_24h_stmt.where(type_filter)
events_24h = db.scalar(events_24h_stmt) or 0
hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0
settings = get_settings()
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
@@ -100,12 +109,16 @@ def dashboard_summary(
or 0
)
top_host_rows = db.execute(
top_host_stmt = (
select(Host.id, Host.hostname, Host.display_name, func.count())
.select_from(Event)
.join(Host, Event.host_id == Host.id)
.where(Event.received_at >= since)
.group_by(Host.id, Host.hostname, Host.display_name)
)
if type_filter is not None:
top_host_stmt = top_host_stmt.where(type_filter)
top_host_rows = db.execute(
top_host_stmt.group_by(Host.id, Host.hostname, Host.display_name)
.order_by(func.count().desc())
.limit(10)
).all()
@@ -113,20 +126,18 @@ def dashboard_summary(
TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
]
top_type_stmt = select(Event.type, func.count()).where(Event.received_at >= since)
if type_filter is not None:
top_type_stmt = top_type_stmt.where(type_filter)
top_type_rows = db.execute(
select(Event.type, func.count())
.where(Event.received_at >= since)
.group_by(Event.type)
.order_by(func.count().desc())
.limit(10)
top_type_stmt.group_by(Event.type).order_by(func.count().desc()).limit(10)
).all()
top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows]
severity_rows = db.execute(
select(Event.severity, func.count())
.where(Event.received_at >= since)
.group_by(Event.severity)
).all()
severity_stmt = select(Event.severity, func.count()).where(Event.received_at >= since)
if type_filter is not None:
severity_stmt = severity_stmt.where(type_filter)
severity_rows = db.execute(severity_stmt.group_by(Event.severity)).all()
severity_24h = {row[0]: row[1] for row in severity_rows}
recent_events = fetch_recent_events(db, limit=8)
+216 -16
View File
@@ -1,22 +1,45 @@
import logging
import logging
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from app.auth.api_key import get_api_key_auth
from app.auth.jwt_auth import get_current_user
from app.auth.jwt_auth import get_current_user, CurrentUser
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
from app.utils.sql_like import escape_ilike_pattern
from app.services.agent_commands import (
command_response_fields,
command_to_dict,
get_command_by_uuid,
)
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
from app.services.host_sessions import (
event_session_id,
event_session_terminated,
event_supports_session_terminate,
mark_event_session_terminated,
terminate_session_for_event,
)
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError,
)
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
from app.services.ingest import ingest_event
from app.services.event_summary import event_to_summary
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
from app.services.problems import maybe_create_problem
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
from app.services.schema_validate import validate_event_payload
from app.services.notify_dispatch import (
AUTH_LOGIN_SUCCESS_TYPES,
@@ -29,6 +52,9 @@ from app.services.notify_dispatch import (
notify_lifecycle,
notify_problem,
notify_rdg_connection,
schedule_notify_auth_login,
schedule_notify_daily_report,
schedule_notify_lifecycle,
)
router = APIRouter(prefix="/events", tags=["events"])
@@ -57,6 +83,7 @@ def _ingest_response(event: Event, *, created: bool) -> IngestResponse:
@router.post("", response_model=IngestResponse)
def post_event(
background_tasks: BackgroundTasks,
payload: dict[str, Any] = Body(...),
db: Session = Depends(get_db),
_api_key: str = Depends(get_api_key_auth),
@@ -74,14 +101,18 @@ def post_event(
event, created = ingest_event(db, payload)
problem = None
problem_created = False
deferred_daily_report_id: int | None = None
deferred_lifecycle_id: int | None = None
deferred_auth_login_id: int | None = None
if created:
problem, problem_created = maybe_create_problem(db, event)
maybe_auto_disconnect_stuck_rdp_session(db, event)
if event.type in DAILY_REPORT_EVENT_TYPES:
notify_daily_report(event, db=db)
deferred_daily_report_id = event.id
elif event.type == LIFECYCLE_EVENT_TYPE:
notify_lifecycle(event, db=db)
deferred_lifecycle_id = event.id
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:
notify_rdg_connection(event, db=db)
else:
@@ -93,6 +124,13 @@ def post_event(
logger.info("ingest duplicate event_id=%s", event.event_id)
db.commit()
if deferred_daily_report_id is not None:
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)
if problem is not None:
body.problem_id = problem.id
@@ -122,28 +160,43 @@ def list_events(
type: str | None = None,
host_id: int | None = None,
hostname: str | None = None,
include_hidden: bool = False,
from_time: str | None = Query(None, alias="from"),
to_time: str | None = Query(None, alias="to"),
q: str | None = None,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EventListResponse:
if include_hidden and host_id is None:
raise HTTPException(
status_code=400,
detail="include_hidden requires host_id",
)
hidden = get_hidden_event_types(db)
type_filter = visibility_type_filter(hidden)
if include_hidden and host_id is not None:
type_filter = None
stmt = select(Event).join(Host).options(joinedload(Event.host))
count_stmt = select(func.count()).select_from(Event).join(Host)
if type_filter is not None:
stmt = stmt.where(type_filter)
count_stmt = count_stmt.where(type_filter)
if severity:
stmt = stmt.where(Event.severity == severity)
count_stmt = count_stmt.where(Event.severity == severity)
if type:
stmt = stmt.where(Event.type == type)
count_stmt = count_stmt.where(Event.type == type)
normalized = type.strip()
if normalized:
stmt = stmt.where(Event.type.ilike(f"{normalized}%"))
count_stmt = count_stmt.where(Event.type.ilike(f"{normalized}%"))
if host_id is not None:
stmt = stmt.where(Event.host_id == host_id)
count_stmt = count_stmt.where(Event.host_id == host_id)
if hostname:
like = f"%{hostname}%"
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
like = f"%{escape_ilike_pattern(hostname)}%"
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
dt_from = _parse_optional_dt(from_time)
dt_to = _parse_optional_dt(to_time, end_of_day=True)
if dt_from:
@@ -153,9 +206,9 @@ def list_events(
stmt = stmt.where(Event.occurred_at <= dt_to)
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
if q:
like = f"%{q}%"
stmt = stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
count_stmt = count_stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
like = f"%{escape_ilike_pattern(q)}%"
stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
total = db.scalar(count_stmt) or 0
rows = db.scalars(
@@ -164,10 +217,157 @@ def list_events(
.limit(page_size)
).all()
items = [event_to_summary(e) for e in rows]
items = [event_to_summary(e, db) for e in rows]
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
class AgentCommandResponse(BaseModel):
command_uuid: str
command_type: str
status: str
result_stdout: str | None = None
result_stderr: str | None = None
created_at: str | None = None
completed_at: str | None = None
target: str | None = None
client_hostname: str | None = None
internal_ip: str | None = None
def _agent_command_response(cmd) -> AgentCommandResponse:
data = command_to_dict(cmd)
extra = command_response_fields(cmd)
return AgentCommandResponse(
command_uuid=data["id"],
command_type=data["type"],
status=data["status"],
result_stdout=data.get("result_stdout"),
result_stderr=data.get("result_stderr"),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
target=extra.get("target"),
client_hostname=extra.get("client_hostname"),
internal_ip=extra.get("internal_ip"),
)
class LogoffActionBody(BaseModel):
session_id: int
class EventSessionTerminateBody(BaseModel):
session_id: str | None = None
class EventSessionTerminateResponse(BaseModel):
ok: bool
message: str
target: str | None = None
stdout: str | None = None
stderr: str | None = None
@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse)
def post_event_terminate_session(
event_db_id: int,
body: EventSessionTerminateBody | None = Body(default=None),
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> EventSessionTerminateResponse:
event = db.scalar(
select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id)
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if not event_supports_session_terminate(event):
raise HTTPException(status_code=400, detail="Event type does not support session terminate")
if event_session_terminated(event, db=db):
raise HTTPException(status_code=409, detail="Session already terminated for this event")
if event.host is None:
raise HTTPException(status_code=400, detail="Event has no host")
linux_cfg = get_effective_linux_admin_for_host(db, event.host)
win_cfg = get_effective_win_admin_for_host(db, event.host)
sid = (body.session_id if body else None) or event_session_id(event)
try:
result = terminate_session_for_event(
event,
linux_cfg=linux_cfg,
win_cfg=win_cfg,
session_id=sid,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response = EventSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=getattr(result, "target", None),
stdout=getattr(result, "stdout", None) or None,
stderr=getattr(result, "stderr", None) or None,
)
if result.ok:
mark_event_session_terminated(event, by_username=user.username)
db.commit()
return response
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
def post_event_qwinsta(
event_db_id: int,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> AgentCommandResponse:
event = db.get(Event, event_db_id)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
cmd = execute_qwinsta_via_winrm(db, event, requested_by=user.username)
db.commit()
return _agent_command_response(cmd)
@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse)
def post_event_logoff(
event_db_id: int,
body: LogoffActionBody,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> AgentCommandResponse:
event = db.get(Event, event_db_id)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
cmd = execute_logoff_via_winrm(
db,
event,
session_id=body.session_id,
requested_by=user.username,
)
db.commit()
return _agent_command_response(cmd)
@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse)
def get_event_action_status(
event_db_id: int,
command_uuid: str,
db: Session = Depends(get_db),
_user=Depends(get_current_user),
) -> AgentCommandResponse:
cmd = get_command_by_uuid(db, command_uuid)
if cmd is None or cmd.event_id != event_db_id:
raise HTTPException(status_code=404, detail="Command not found")
return _agent_command_response(cmd)
@router.get("/{event_db_id}", response_model=EventDetail)
def get_event(
event_db_id: int,
@@ -179,7 +379,7 @@ def get_event(
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
base = event_to_summary(event)
base = event_to_summary(event, db)
return EventDetail(
**base.model_dump(),
details=event.details,
+770 -12
View File
@@ -1,15 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from app.auth.jwt_auth import get_current_user, require_admin
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
from app.services.agent_version import latest_agent_versions_by_product
from app.utils.sql_like import escape_ilike_pattern
from app.services.agent_version import (
latest_agent_versions_by_product,
reference_agent_versions_by_product,
)
from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update import (
AgentUpdateNotAllowedError,
host_version_status,
request_agent_update,
)
from app.services.host_remote_actions import (
RemoteActionAlreadyRunningError,
cancel_host_remote_action,
clear_stale_running_remote_actions,
get_remote_action_status,
run_agent_fallback_action,
run_ssh_monitor_update_action,
start_host_remote_action,
)
from app.services.agent_update_settings import (
PRODUCT_RDP,
PRODUCT_SSH,
get_effective_agent_update_config,
)
from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config
from app.services.host_sessions import (
HostSessionRow,
list_linux_sessions,
list_windows_sessions,
terminate_linux_session,
terminate_windows_session,
)
from app.services.host_delete import delete_host_and_related
from app.services.host_mgmt_credentials import (
get_host_mgmt_access_view,
upsert_host_mgmt_credentials,
)
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
WinAdminNotConfiguredError,
WinRmTestResult,
iter_winrm_targets,
test_winrm_connection,
)
from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError,
LinuxAdminNotConfiguredError,
SshCommandResult,
iter_ssh_targets,
run_ssh_monitor_update,
test_ssh_connection,
)
from app.services.host_health import (
HEARTBEAT_TYPE,
agent_status as compute_agent_status,
@@ -17,10 +76,19 @@ from app.services.host_health import (
max_daily_report_time_by_host,
max_event_time_by_host,
)
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
router = APIRouter(prefix="/hosts", tags=["hosts"])
def _count_visible_events(db: Session, host_id: int, hidden: frozenset[str]) -> int:
stmt = select(func.count()).select_from(Event).where(Event.host_id == host_id)
type_filter = visibility_type_filter(hidden)
if type_filter is not None:
stmt = stmt.where(type_filter)
return int(db.scalar(stmt) or 0)
@router.get("", response_model=HostListResponse)
def list_hosts(
page: int = Query(1, ge=1),
@@ -38,8 +106,8 @@ def list_hosts(
stmt = stmt.where(Host.product == product)
count_stmt = count_stmt.where(Host.product == product)
if hostname:
like = f"%{hostname}%"
host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like)
like = f"%{escape_ilike_pattern(hostname)}%"
host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")
stmt = stmt.where(host_match)
count_stmt = count_stmt.where(host_match)
@@ -61,12 +129,19 @@ def list_hosts(
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
report_map = max_daily_report_time_by_host(db)
latest_map = latest_agent_versions_by_product(db)
update_cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(update_cfg, db=db)
reference_map = reference_agent_versions_by_product(
update_cfg,
latest_map,
git_versions=git_release.versions,
)
hidden = get_hidden_event_types(db)
items: list[HostSummary] = []
for host in rows:
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
event_count = _count_visible_events(db, host.id, hidden)
last_hb = hb_map.get(host.id)
items.append(
HostSummary(
@@ -86,6 +161,13 @@ def list_hosts(
agent_status=compute_agent_status(
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
),
version_status=host_version_status(
host,
cfg=update_cfg,
latest_map=latest_map,
git_versions=git_release.versions,
),
pending_agent_update=bool(host.pending_agent_update),
)
)
@@ -94,10 +176,18 @@ def list_hosts(
total=total,
page=page,
page_size=page_size,
latest_agent_versions=latest_agent_versions_by_product(db),
latest_agent_versions=latest_map,
reference_agent_versions=reference_map,
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
)
class HostManualAddBody(BaseModel):
platform: Literal["windows", "linux"]
target: str = Field(..., min_length=1, max_length=253)
def _host_detail_from_model(
host: Host,
*,
@@ -106,10 +196,12 @@ def _host_detail_from_model(
) -> HostDetail:
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
report_map = max_daily_report_time_by_host(db)
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
hidden = get_hidden_event_types(db)
event_count = _count_visible_events(db, host.id, hidden)
last_hb = hb_map.get(host.id)
latest_map = latest_agent_versions_by_product(db)
update_cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(update_cfg, db=db)
return HostDetail(
id=host.id,
hostname=host.hostname,
@@ -126,15 +218,36 @@ def _host_detail_from_model(
last_daily_report_at=report_map.get(host.id),
last_inventory_at=host.inventory_updated_at,
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
version_status=host_version_status(
host,
cfg=update_cfg,
latest_map=latest_map,
git_versions=git_release.versions,
),
pending_agent_update=bool(host.pending_agent_update),
agent_instance_id=host.agent_instance_id,
tags=host.tags if isinstance(host.tags, list) else [],
use_sac_mode=host.use_sac_mode,
inventory=host.inventory if isinstance(host.inventory, dict) else None,
inventory_updated_at=host.inventory_updated_at,
created_at=host.created_at,
ssh_admin_ok=host.ssh_admin_ok,
ssh_admin_checked_at=host.ssh_admin_checked_at,
pending_update_requested_at=host.pending_update_requested_at,
pending_update_target_version=host.pending_update_target_version,
agent_config_revision=int(host.agent_config_revision or 0),
agent_config=get_host_agent_settings(host) or None,
agent_update_state=host.agent_update_state,
agent_update_last_at=host.agent_update_last_at,
agent_update_last_error=host.agent_update_last_error,
)
def _set_ssh_admin_status(host: Host, ok: bool) -> None:
host.ssh_admin_ok = ok
host.ssh_admin_checked_at = datetime.now(timezone.utc)
@router.get("/{host_id}", response_model=HostDetail)
def get_host(
host_id: int,
@@ -156,6 +269,651 @@ class HostDeleteResponse(BaseModel):
deleted_problems: int
class HostWinRmTestResponse(BaseModel):
ok: bool
message: str
target: str
hostname: str | None = None
class HostSshActionResponse(BaseModel):
ok: bool
message: str
target: str
stdout: str | None = None
stderr: str | None = None
exit_code: int | None = None
product_version: str | None = None
ssh_admin_ok: bool | None = None
class HostAgentUpdateRequestResponse(BaseModel):
status: str
pending_agent_update: bool
target_version: str | None = None
agent_update_state: str | None = None
class HostAgentConfigUpdate(BaseModel):
settings: dict[str, object] = {}
class HostAgentConfigResponse(BaseModel):
config_revision: int
settings: dict[str, object]
class HostMgmtAccessResponse(BaseModel):
host_id: int
has_override: bool
user: str | None = None
password_set: bool = False
password_hint: str | None = None
effective_source: str
effective_configured: bool
effective_user: str | None = None
class HostMgmtAccessUpdate(BaseModel):
user: str | None = None
password: str | None = None
clear: bool = False
class HostRemoteActionStartResponse(BaseModel):
status: str
host_id: int
title: str
message: str
@router.post(
"/manual-add",
response_model=HostRemoteActionStartResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def post_host_manual_add(
body: HostManualAddBody,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionStartResponse:
from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add
try:
host = prepare_manual_host_add(db, platform=body.platform, target=body.target)
except ManualHostAddError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if body.platform == "windows":
title = f"Ручное добавление Windows: {host.hostname}"
else:
title = f"Ручное добавление Linux: {host.hostname}"
try:
start_host_remote_action(
db,
host,
title=title,
runner=run_agent_fallback_action,
)
except RemoteActionAlreadyRunningError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return HostRemoteActionStartResponse(
status="running",
host_id=host.id,
title=title,
message="Проверка пройдена, установка агента запущена на сервере SAC",
)
class HostRemoteActionJobResponse(BaseModel):
active: bool
host_id: int
hostname: str | None = None
status: str | None = None
title: str = ""
message: str = ""
stdout: str | None = None
stderr: str | None = None
output: str | None = None
ok: bool | None = None
exit_code: int | None = None
product_version: str | None = None
target: str | None = None
agent_update_state: str | None = None
started_at: str | None = None
finished_at: str | None = None
class HostSessionItem(BaseModel):
session_id: str
user: str
tty: str | None = None
state: str | None = None
source_ip: str | None = None
session_name: str | None = None
class HostSessionsResponse(BaseModel):
ok: bool
message: str
target: str | None = None
sessions: list[HostSessionItem]
class HostSessionTerminateBody(BaseModel):
session_id: str
class HostSessionTerminateResponse(BaseModel):
ok: bool
message: str
target: str | None = None
stdout: str | None = None
stderr: str | None = None
def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]:
return [
HostSessionItem(
session_id=r.session_id,
user=r.user,
tty=r.tty,
state=r.state,
source_ip=r.source_ip,
session_name=r.session_name,
)
for r in rows
]
def _ssh_action_response(
result: SshCommandResult,
*,
attempts: list[str] | None = None,
ssh_admin_ok: bool | None = None,
) -> HostSshActionResponse:
message = result.message
if attempts:
message = f"{message} (пробовали: {', '.join(attempts)})"
return HostSshActionResponse(
ok=result.ok,
message=message,
target=result.target,
stdout=result.stdout or None,
stderr=result.stderr or None,
exit_code=result.exit_code,
product_version=result.agent_version,
ssh_admin_ok=ssh_admin_ok,
)
def _run_ssh_action_on_host(
host: Host,
cfg,
*,
action,
) -> HostSshActionResponse:
try:
targets = iter_ssh_targets(host)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
last_result: SshCommandResult | None = None
attempts: list[str] = []
for target in targets:
try:
result = action(target=target, user=cfg.user, password=cfg.password)
except LinuxAdminNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
last_result = result
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
if result.ok:
return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None)
assert last_result is not None
return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None)
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
def test_host_winrm(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostWinRmTestResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
try:
targets = iter_winrm_targets(host)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
last_result = None
attempts: list[str] = []
for target in targets:
try:
result = test_winrm_connection(
target=target,
user=cfg.user,
password=cfg.password,
)
except WinAdminNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
last_result = result
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
if result.ok:
if len(targets) > 1:
result = WinRmTestResult(
ok=True,
message=f"{result.message} (пробовали: {', '.join(attempts)})",
target=result.target,
hostname=result.hostname,
)
return HostWinRmTestResponse(
ok=result.ok,
message=result.message,
target=result.target,
hostname=result.hostname,
)
assert last_result is not None
message = last_result.message
if len(attempts) > 1:
message = f"{message} (пробовали: {', '.join(attempts)})"
return HostWinRmTestResponse(
ok=False,
message=message,
target=last_result.target,
hostname=last_result.hostname,
)
@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse)
def test_host_ssh(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSshActionResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
_set_ssh_admin_status(host, response.ok)
db.commit()
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
@router.post(
"/{host_id}/actions/agent-update",
response_model=HostRemoteActionStartResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def update_host_agent_via_ssh(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionStartResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
host_label = (host.display_name or host.hostname or "").strip() or f"host#{host.id}"
title = f"Обновление ssh-monitor (SSH): {host_label}"
try:
start_host_remote_action(
db,
host,
title=title,
runner=run_ssh_monitor_update_action,
poll_remote_log=True,
)
except RemoteActionAlreadyRunningError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return HostRemoteActionStartResponse(
status="running",
host_id=host.id,
title=title,
message="Обновление запущено на сервере SAC и продолжится в фоне",
)
@router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse)
def post_request_agent_update(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostAgentUpdateRequestResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
try:
request_agent_update(db, host)
except AgentUpdateNotAllowedError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
db.commit()
return HostAgentUpdateRequestResponse(
status="requested",
pending_agent_update=host.pending_agent_update,
target_version=host.pending_update_target_version,
agent_update_state=host.agent_update_state,
)
@router.post(
"/{host_id}/actions/agent-update-fallback",
response_model=HostRemoteActionStartResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def post_agent_update_fallback(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionStartResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
from app.services.ssh_connect import is_linux_host
from app.services.winrm_connect import is_windows_host
product = (host.product or "").strip()
host_label = (host.display_name or host.hostname or "").strip() or f"host#{host.id}"
if product == PRODUCT_RDP or is_windows_host(host):
title = f"Обновление {host_label} через WinRM"
elif product == PRODUCT_SSH or is_linux_host(host):
title = f"Fallback SSH (ssh-monitor): {host_label}"
else:
title = f"Обновление агента (fallback): {host_label}"
try:
start_host_remote_action(
db,
host,
title=title,
runner=run_agent_fallback_action,
)
except RemoteActionAlreadyRunningError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return HostRemoteActionStartResponse(
status="running",
host_id=host.id,
title=title,
message="Обновление запущено на сервере SAC и продолжится в фоне",
)
@router.get("/{host_id}/actions/remote-job", response_model=HostRemoteActionJobResponse)
def get_host_remote_job(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionJobResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
db.refresh(host)
return HostRemoteActionJobResponse(**get_remote_action_status(host))
@router.post("/{host_id}/actions/remote-job/cancel", response_model=HostRemoteActionJobResponse)
def cancel_host_remote_job(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionJobResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
if not cancel_host_remote_action(db, host):
raise HTTPException(status_code=409, detail="No running remote action for this host")
db.refresh(host)
return HostRemoteActionJobResponse(**get_remote_action_status(host))
@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse)
def list_host_sessions(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSessionsResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
from app.services.ssh_connect import is_linux_host
from app.services.winrm_connect import is_windows_host
if is_linux_host(host):
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
try:
sessions, result = list_linux_sessions(host, cfg)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return HostSessionsResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
sessions=_session_items(sessions),
)
if is_windows_host(host):
cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
try:
sessions, result = list_windows_sessions(host, cfg)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=502, detail="WinRM qwinsta failed")
return HostSessionsResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
sessions=_session_items(sessions),
)
raise HTTPException(status_code=400, detail="Host OS does not support live sessions")
@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse)
def terminate_host_session(
host_id: int,
body: HostSessionTerminateBody,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSessionTerminateResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
from app.services.ssh_connect import is_linux_host
from app.services.winrm_connect import is_windows_host
sid = body.session_id.strip()
if not sid:
raise HTTPException(status_code=422, detail="session_id is required")
if is_linux_host(host):
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
try:
result = terminate_linux_session(host, cfg, sid)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return HostSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
stdout=result.stdout or None,
stderr=result.stderr or None,
)
if is_windows_host(host):
cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
try:
result = terminate_windows_session(host, cfg, sid)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=502, detail="WinRM logoff failed")
return HostSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
stdout=result.stdout or None,
stderr=result.stderr or None,
)
raise HTTPException(status_code=400, detail="Host OS does not support session terminate")
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
def patch_host_agent_config(
host_id: int,
body: HostAgentConfigUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostAgentConfigResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
try:
update_host_agent_config(db, host, body.settings)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
db.commit()
settings = get_host_agent_settings(host)
return HostAgentConfigResponse(
config_revision=int(host.agent_config_revision or 0),
settings=settings,
)
@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse)
def get_host_access(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostMgmtAccessResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
view = get_host_mgmt_access_view(db, host)
return HostMgmtAccessResponse(
host_id=view.host_id,
has_override=view.has_override,
user=view.user,
password_set=view.password_set,
password_hint=view.password_hint,
effective_source=view.effective_source,
effective_configured=view.effective_configured,
effective_user=view.effective_user,
)
@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse)
def put_host_access(
host_id: int,
body: HostMgmtAccessUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostMgmtAccessResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
view = upsert_host_mgmt_credentials(
db,
host,
user=body.user,
password=body.password,
clear=body.clear,
)
return HostMgmtAccessResponse(
host_id=view.host_id,
has_override=view.has_override,
user=view.user,
password_set=view.password_set,
password_hint=view.password_hint,
effective_source=view.effective_source,
effective_configured=view.effective_configured,
effective_user=view.effective_user,
)
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
def get_host_agent_config(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostAgentConfigResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
settings = get_host_agent_settings(host)
return HostAgentConfigResponse(
config_revision=int(host.agent_config_revision or 0),
settings=settings,
)
@router.delete("/{host_id}", response_model=HostDeleteResponse)
def delete_host(
host_id: int,
+8 -4
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
@@ -17,6 +17,8 @@ from app.auth.jwt_auth import get_current_user
from app.database import get_db
from app.models import Event, Host, Problem, ProblemEvent
from app.services.event_type_visibility import get_hidden_event_types
from app.utils.sql_like import escape_ilike_pattern
@@ -192,11 +194,11 @@ def list_problems(
if hostname:
like = f"%{hostname}%"
like = f"%{escape_ilike_pattern(hostname)}%"
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
if created_within_hours is not None:
@@ -269,6 +271,8 @@ def get_problem(
.order_by(Event.occurred_at.desc())
).all()
hidden = get_hidden_event_types(db)
event_rows = [event for event in event_rows if event.type not in hidden]
+2 -1
View File
@@ -1,10 +1,11 @@
from fastapi import APIRouter
from app.api.v1 import auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
from app.api.v1 import agent, auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(agent.router)
api_router.include_router(events.router)
api_router.include_router(hosts.router)
api_router.include_router(problems.router)
+436 -17
View File
@@ -1,3 +1,7 @@
from dataclasses import replace
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -33,10 +37,35 @@ from app.services.event_severity_overrides import (
list_severity_override_items,
replace_severity_overrides,
)
from app.services.event_type_visibility import replace_event_visibility
from app.services.ui_settings import (
get_effective_ui_settings,
upsert_ui_settings,
)
from app.services.linux_admin_settings import (
get_effective_linux_admin_config,
upsert_linux_admin_settings,
)
from app.services.win_admin_settings import (
get_effective_win_admin_config,
upsert_win_admin_settings,
)
from app.services.rdp_flap_settings import (
get_effective_rdp_flap_settings,
upsert_rdp_flap_settings,
)
from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update_settings import (
get_effective_agent_update_config,
upsert_agent_update_settings,
)
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
WinAdminNotConfiguredError,
resolve_windows_host_target,
test_winrm_connection,
)
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -351,6 +380,7 @@ class EventSeverityOverrideRow(BaseModel):
event_type: str
default_severity: str
override_severity: str | None = None
show_in_events: bool = True
class EventSeverityOverridesResponse(BaseModel):
@@ -360,8 +390,25 @@ class EventSeverityOverridesResponse(BaseModel):
class EventSeverityOverridesUpdate(BaseModel):
overrides: dict[str, str | None] = Field(
default_factory=dict,
description="event_type → severity; null или пустая строка сбрасывает override",
)
visibility: dict[str, bool | None] = Field(
default_factory=dict,
description="event_type → show_in_events; null или true сбрасывает скрытие (показывать)",
)
def _severity_override_rows(items) -> list[EventSeverityOverrideRow]:
return [
EventSeverityOverrideRow(
event_type=i.event_type,
default_severity=i.default_severity,
override_severity=i.override_severity,
show_in_events=i.show_in_events,
)
for i in items
]
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
@@ -371,14 +418,7 @@ def get_severity_overrides(
) -> EventSeverityOverridesResponse:
items = list_severity_override_items(db)
return EventSeverityOverridesResponse(
items=[
EventSeverityOverrideRow(
event_type=i.event_type,
default_severity=i.default_severity,
override_severity=i.override_severity,
)
for i in items
],
items=_severity_override_rows(items),
source=SOURCE_DB,
)
@@ -390,19 +430,19 @@ def update_severity_overrides(
_user=Depends(require_admin),
) -> EventSeverityOverridesResponse:
try:
items = replace_severity_overrides(db, body.overrides)
items = list_severity_override_items(db)
if body.overrides:
items = replace_severity_overrides(db, body.overrides)
if body.visibility:
replace_event_visibility(db, body.visibility)
items = list_severity_override_items(db)
if not body.overrides and not body.visibility:
raise HTTPException(status_code=422, detail="Нет изменений для сохранения")
db.commit()
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return EventSeverityOverridesResponse(
items=[
EventSeverityOverrideRow(
event_type=i.event_type,
default_severity=i.default_severity,
override_severity=i.override_severity,
)
for i in items
],
items=_severity_override_rows(items),
source=SOURCE_DB,
)
@@ -439,3 +479,382 @@ def update_ui_settings(
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
source=cfg.source,
)
class WinAdminSettingsResponse(BaseModel):
configured: bool
user: str | None = None
password_hint: str | None = None
source: str = Field(description="env или db")
class WinAdminSettingsUpdate(BaseModel):
user: str | None = None
password: str | None = None
@router.get("/win-admin", response_model=WinAdminSettingsResponse)
def get_win_admin_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WinAdminSettingsResponse:
cfg = get_effective_win_admin_config(db)
return WinAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
@router.put("/win-admin", response_model=WinAdminSettingsResponse)
def update_win_admin_settings(
body: WinAdminSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WinAdminSettingsResponse:
if body.user is not None and not body.user.strip():
raise HTTPException(status_code=422, detail="user must not be empty")
cfg = upsert_win_admin_settings(db, user=body.user, password=body.password)
return WinAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
class RdpFlapSettingsResponse(BaseModel):
auto_disconnect: bool
win_admin_configured: bool
source: str = Field(description="db или default")
class RdpFlapSettingsUpdate(BaseModel):
auto_disconnect: bool
@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse)
def get_rdp_flap_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> RdpFlapSettingsResponse:
cfg = get_effective_rdp_flap_settings(db)
win_cfg = get_effective_win_admin_config(db)
return RdpFlapSettingsResponse(
auto_disconnect=cfg.auto_disconnect,
win_admin_configured=win_cfg.configured,
source=cfg.source,
)
@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse)
def update_rdp_flap_settings(
body: RdpFlapSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> RdpFlapSettingsResponse:
cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect)
win_cfg = get_effective_win_admin_config(db)
return RdpFlapSettingsResponse(
auto_disconnect=cfg.auto_disconnect,
win_admin_configured=win_cfg.configured,
source=cfg.source,
)
class LinuxAdminSettingsResponse(BaseModel):
configured: bool
user: str | None = None
password_hint: str | None = None
source: str = Field(description="env или db")
class LinuxAdminSettingsUpdate(BaseModel):
user: str | None = None
password: str | None = None
@router.get("/linux-admin", response_model=LinuxAdminSettingsResponse)
def get_linux_admin_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LinuxAdminSettingsResponse:
cfg = get_effective_linux_admin_config(db)
return LinuxAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
@router.put("/linux-admin", response_model=LinuxAdminSettingsResponse)
def update_linux_admin_settings(
body: LinuxAdminSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LinuxAdminSettingsResponse:
if body.user is not None and not body.user.strip():
raise HTTPException(status_code=422, detail="user must not be empty")
cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password)
return LinuxAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
class AgentUpdateSettingsResponse(BaseModel):
mode: str
fallback_enabled: bool
fallback_after_minutes: int
recommended_rdp_version: str | None = None
recommended_ssh_version: str | None = None
min_rdp_version: str | None = None
min_ssh_version: str | None = None
win_agent_update_script: str | None = None
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str = "main"
git_latest_rdp_version: str | None = None
git_latest_ssh_version: str | None = None
git_fetched_at: datetime | None = None
git_errors: dict[str, str] = Field(default_factory=dict)
source: str = Field(description="env или db")
class AgentUpdateSettingsUpdate(BaseModel):
mode: str | None = None
fallback_enabled: bool | None = None
fallback_after_minutes: int | None = None
recommended_rdp_version: str | None = None
recommended_ssh_version: str | None = None
min_rdp_version: str | None = None
min_ssh_version: str | None = None
win_agent_update_script: str | None = None
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str | None = None
class AgentGitTestRequest(BaseModel):
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str | None = None
class AgentGitTestResponse(BaseModel):
git_latest_rdp_version: str | None = None
git_latest_ssh_version: str | None = None
git_fetched_at: datetime | None = None
git_errors: dict[str, str] = Field(default_factory=dict)
from_cache: bool = False
def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse:
git_latest_rdp = None
git_latest_ssh = None
git_fetched_at = None
git_errors: dict[str, str] = {}
if git_release is not None:
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None
git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None
git_fetched_at = git_release.fetched_at
git_errors = dict(git_release.errors)
return AgentUpdateSettingsResponse(
mode=cfg.mode,
fallback_enabled=cfg.fallback_enabled,
fallback_after_minutes=cfg.fallback_after_minutes,
recommended_rdp_version=cfg.recommended_rdp_version or None,
recommended_ssh_version=cfg.recommended_ssh_version or None,
min_rdp_version=cfg.min_rdp_version or None,
min_ssh_version=cfg.min_ssh_version or None,
win_agent_update_script=cfg.win_agent_update_script or None,
rdp_git_repo_url=cfg.rdp_git_repo_url or None,
ssh_git_repo_url=cfg.ssh_git_repo_url or None,
git_branch=cfg.git_branch or "main",
git_latest_rdp_version=git_latest_rdp,
git_latest_ssh_version=git_latest_ssh,
git_fetched_at=git_fetched_at,
git_errors=git_errors,
source=cfg.source,
)
@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse)
def get_agent_update_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> AgentUpdateSettingsResponse:
cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(cfg, db=db)
return _agent_update_to_response(cfg, git_release)
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
def update_agent_update_settings(
body: AgentUpdateSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> AgentUpdateSettingsResponse:
try:
cfg = upsert_agent_update_settings(
db,
mode=body.mode,
fallback_enabled=body.fallback_enabled,
fallback_after_minutes=body.fallback_after_minutes,
recommended_rdp_version=body.recommended_rdp_version,
recommended_ssh_version=body.recommended_ssh_version,
min_rdp_version=body.min_rdp_version,
min_ssh_version=body.min_ssh_version,
win_agent_update_script=body.win_agent_update_script,
rdp_git_repo_url=body.rdp_git_repo_url,
ssh_git_repo_url=body.ssh_git_repo_url,
git_branch=body.git_branch,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
return _agent_update_to_response(cfg, git_release)
@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse)
def test_agent_git_release(
body: AgentGitTestRequest | None = None,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> AgentGitTestResponse:
cfg = get_effective_agent_update_config(db)
if body is not None:
cfg = replace(
cfg,
rdp_git_repo_url=(body.rdp_git_repo_url or cfg.rdp_git_repo_url).strip(),
ssh_git_repo_url=(body.ssh_git_repo_url or cfg.ssh_git_repo_url).strip(),
git_branch=(body.git_branch or cfg.git_branch or "main").strip() or "main",
)
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
return AgentGitTestResponse(
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
git_fetched_at=git_release.fetched_at,
git_errors=dict(git_release.errors),
from_cache=git_release.from_cache,
)
class LoginSecuritySettingsResponse(BaseModel):
ip_whitelist: list[str] = Field(default_factory=list)
ip_whitelist_text: str = ""
sync_fail2ban: bool = False
max_failures: int = 3
window_minutes: int = 15
fail2ban_available: bool = False
source: str = "default"
class LoginSecuritySettingsUpdate(BaseModel):
ip_whitelist_text: str = ""
sync_fail2ban: bool | None = None
class LoginBlockItem(BaseModel):
ip_address: str
scope: str
failure_count: int | None = None
usernames: list[str] = Field(default_factory=list)
blocked_until: datetime | None = None
reason: str
class LoginUnblockRequest(BaseModel):
ip_address: str
scope: str = Field(default="both", description="web | ssh | both")
class LoginUnblockResponse(BaseModel):
messages: list[str] = Field(default_factory=list)
def _login_security_response(db: Session) -> LoginSecuritySettingsResponse:
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.fail2ban_sync import fail2ban_available
from app.services.login_security_settings import get_effective_login_security_config
cfg = get_effective_login_security_config(db)
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
text = (row.login_ip_whitelist or "") if row is not None else ""
return LoginSecuritySettingsResponse(
ip_whitelist=list(cfg.ip_whitelist),
ip_whitelist_text=text,
sync_fail2ban=cfg.sync_fail2ban,
max_failures=cfg.max_failures,
window_minutes=cfg.window_minutes,
fail2ban_available=fail2ban_available(),
source=cfg.source,
)
def _block_item(entry) -> LoginBlockItem:
return LoginBlockItem(
ip_address=entry.ip_address,
scope=entry.scope,
failure_count=entry.failure_count,
usernames=list(entry.usernames),
blocked_until=entry.blocked_until,
reason=entry.reason,
)
@router.get("/login-security", response_model=LoginSecuritySettingsResponse)
def get_login_security_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LoginSecuritySettingsResponse:
return _login_security_response(db)
@router.put("/login-security", response_model=LoginSecuritySettingsResponse)
def update_login_security_settings(
body: LoginSecuritySettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LoginSecuritySettingsResponse:
from app.services.login_security_settings import upsert_login_security_settings
upsert_login_security_settings(
db,
ip_whitelist_text=body.ip_whitelist_text,
sync_fail2ban=bool(body.sync_fail2ban),
)
return _login_security_response(db)
@router.get("/login-security/blocks", response_model=list[LoginBlockItem])
def list_login_security_blocks(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> list[LoginBlockItem]:
from app.services.login_security_settings import list_all_login_blocks
return [_block_item(e) for e in list_all_login_blocks(db)]
@router.post("/login-security/unblock", response_model=LoginUnblockResponse)
def unblock_login_security_ip(
body: LoginUnblockRequest,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LoginUnblockResponse:
from app.services.login_security_settings import unblock_login_ip
scope = (body.scope or "both").strip().lower()
if scope not in ("web", "ssh", "both"):
raise HTTPException(status_code=422, detail="scope must be web, ssh, or both")
messages = unblock_login_ip(db, body.ip_address, scope=scope)
return LoginUnblockResponse(messages=messages)
+7 -3
View File
@@ -1,13 +1,14 @@
import asyncio
import asyncio
import json
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Cookie, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import verify_access_token
from app.auth.stream_cookie import STREAM_COOKIE_NAME
from app.database import SessionLocal, get_db
from app.models import Event, Problem
from app.config import get_settings
@@ -67,9 +68,12 @@ async def _sse_generator():
def _sse_auth(
token: str = Query(..., description="JWT access_token"),
sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME),
db: Session = Depends(get_db),
) -> str:
token = (sac_stream or "").strip()
if not token:
raise HTTPException(status_code=401, detail="SSE authentication required")
return verify_access_token(token, db=db)
+20 -5
View File
@@ -1,4 +1,5 @@
import hashlib
import hashlib
import hmac
import secrets
from fastapi import Depends, HTTPException, Security
@@ -6,16 +7,28 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models import ApiKey
security_scheme = HTTPBearer(auto_error=False)
def hash_api_key(raw_key: str) -> str:
def _legacy_hash_api_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
def hash_api_key(raw_key: str) -> str:
secret = get_settings().jwt_secret.encode("utf-8")
return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest()
def verify_api_key_hash(raw_key: str, stored_hash: str) -> bool:
if hmac.compare_digest(hash_api_key(raw_key), stored_hash):
return True
return hmac.compare_digest(_legacy_hash_api_key(raw_key), stored_hash)
def generate_api_key() -> tuple[str, str, str]:
"""Returns (full_key, prefix, hash)."""
raw = f"sac_{secrets.token_urlsafe(32)}"
@@ -24,9 +37,11 @@ def generate_api_key() -> tuple[str, str, str]:
def verify_api_key(db: Session, raw_key: str) -> bool:
key_hash = hash_api_key(raw_key)
row = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active.is_(True)))
return row is not None
rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all()
for row in rows:
if verify_api_key_hash(raw_key, row.key_hash):
return True
return False
def get_api_key_auth(
+37
View File
@@ -0,0 +1,37 @@
"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header)."""
from __future__ import annotations
from fastapi import Response
from app.config import Settings
STREAM_COOKIE_NAME = "sac_stream"
STREAM_COOKIE_PATH = "/api/v1/stream"
def stream_cookie_kwargs(settings: Settings) -> dict[str, object]:
secure = settings.sac_public_url.lower().startswith("https://")
return {
"key": STREAM_COOKIE_NAME,
"httponly": True,
"secure": secure,
"samesite": "strict",
"path": STREAM_COOKIE_PATH,
"max_age": max(60, int(settings.jwt_expire_minutes) * 60),
}
def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None:
response.set_cookie(value=token, **stream_cookie_kwargs(settings))
def clear_stream_auth_cookie(response: Response, settings: Settings) -> None:
kwargs = stream_cookie_kwargs(settings)
response.delete_cookie(
key=kwargs["key"],
path=kwargs["path"],
secure=bool(kwargs["secure"]),
httponly=True,
samesite="strict",
)
+48 -1
View File
@@ -1,4 +1,4 @@
import os
import os
from functools import lru_cache
from pathlib import Path
@@ -34,10 +34,18 @@ class Settings(BaseSettings):
model_config = _settings_config()
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
sac_db_pool_size: int = 15
sac_db_max_overflow: int = 25
sac_uvicorn_workers: int = 4
sac_public_url: str = "http://localhost:8000"
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
sac_agent_bundle_base_url: str = ""
jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24
# Fail-fast on weak JWT/CORS defaults (disable only for local dev/tests).
sac_security_enforce: bool = True
sac_ingest_max_body_bytes: int = 2_097_152
sac_bootstrap_api_key: str = ""
sac_admin_username: str = "admin"
@@ -102,6 +110,41 @@ class Settings(BaseSettings):
sac_privilege_spike_window_minutes: int = 10
sac_privilege_spike_threshold: int = 10
# rule:rdg_session_flap — RD Gateway 302→303 within window
sac_rdg_flap_window_min_sec: int = 1
sac_rdg_flap_window_max_sec: int = 10
sac_rdg_flap_dedup_sec: int = 30
# Внешние IP HAProxy/RDG-прокси: если external_ip в списке — путь Haproxy-RDG-Comp, иначе RDG-Comp
sac_rdg_haproxy_external_ips: str = ""
# Windows admin for agent qwinsta/logoff (domain-wide)
sac_win_admin_user: str = ""
sac_win_admin_password: str = ""
sac_winrm_use_https: bool = False
sac_winrm_server_cert_validation: str = "validate"
# Linux SSH admin for remote agent update (fallback SSH, phase 5)
sac_linux_admin_user: str = ""
sac_linux_admin_password: str = ""
sac_ssh_known_hosts_file: str = "/opt/security-alert-center/config/ssh_known_hosts"
sac_ssh_auto_add_host_key: bool = False
# Agent updates (mode gpo|sac, recommended versions, WinRM fallback script)
sac_agent_update_mode: str = "gpo"
sac_agent_update_fallback_enabled: bool = True
sac_agent_update_fallback_minutes: int = 15
sac_agent_recommended_rdp_version: str = ""
sac_agent_recommended_ssh_version: str = ""
sac_agent_min_rdp_version: str = ""
sac_agent_min_ssh_version: str = ""
sac_win_agent_update_script: str = ""
sac_agent_rdp_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
sac_agent_ssh_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
sac_agent_git_branch: str = "main"
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
sac_agent_git_cache_ttl_minutes: int = 30
sac_agent_git_token: str = ""
# Retention (app.jobs.retention / systemd timer)
sac_events_retention_days: int = 90
sac_problems_retention_days: int = 180
@@ -110,6 +153,10 @@ class Settings(BaseSettings):
sac_login_max_failures: int = 3
sac_login_failure_window_minutes: int = 15
sac_login_alert_telegram: bool = True
sac_login_ip_whitelist: str = ""
sac_fail2ban_ssh_jail: str = "sshd"
sac_fail2ban_sync_enabled: bool = False
sac_fail2ban_ignoreip_file: str = "/etc/fail2ban/jail.d/sac-login-whitelist.local"
# Seaca mobile (FCM push)
sac_fcm_enabled: bool = False
+1
View File
@@ -23,6 +23,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
# RDP / Windows
"rdp.login.success": "info",
"rdp.login.failed": "warning",
"rdp.session.logoff": "info",
"rdp.shadow.control.started": "warning",
"rdp.shadow.control.stopped": "info",
"rdp.shadow.control.permission": "warning",
+5 -1
View File
@@ -6,7 +6,11 @@ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
_engine_kwargs: dict = {"pool_pre_ping": True}
if settings.database_url.startswith("postgresql"):
_engine_kwargs["pool_size"] = max(1, int(settings.sac_db_pool_size))
_engine_kwargs["max_overflow"] = max(0, int(settings.sac_db_max_overflow))
engine = create_engine(settings.database_url, **_engine_kwargs)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
@@ -36,6 +36,9 @@ def _run_scan_once() -> None:
try:
results = run_host_silence_scan(db)
notified = dispatch_host_silence_notifications(db, results)
from app.services.agent_update import process_agent_update_fallbacks
fallback_results = process_agent_update_fallbacks(db)
db.commit()
if results:
logger.info(
@@ -44,6 +47,12 @@ def _run_scan_once() -> None:
sum(1 for r in results if r.created),
notified,
)
if fallback_results:
logger.info(
"agent_update fallback: processed=%s ok=%s",
len(fallback_results),
sum(1 for r in fallback_results if r.get("ok")),
)
except Exception:
db.rollback()
raise
+26 -4
View File
@@ -1,4 +1,4 @@
import asyncio
import asyncio
import logging
from contextlib import asynccontextmanager, suppress
from pathlib import Path
@@ -13,7 +13,9 @@ from app.api.v1.router import api_router
from app.auth.api_key import hash_api_key
from app.config import get_settings
from app.database import SessionLocal
from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware
from app.models import ApiKey
from app.security_bootstrap import validate_security_settings, warn_relaxed_security
from app.services.user_auth import bootstrap_admin_user
from app.version import APP_VERSION, APP_VERSION_LABEL
@@ -53,15 +55,30 @@ def bootstrap_users() -> None:
db.close()
def bootstrap_stale_remote_actions() -> None:
db = SessionLocal()
try:
from app.services.host_remote_actions import clear_stale_running_remote_actions
cleared = clear_stale_running_remote_actions(db)
for hostname in cleared:
logger.info("Stale remote action reset on startup: %s", hostname)
finally:
db.close()
@asynccontextmanager
async def lifespan(_app: FastAPI):
settings = get_settings()
validate_security_settings(settings)
warn_relaxed_security(settings)
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
bootstrap_api_key()
bootstrap_users()
bootstrap_stale_remote_actions()
stop_scan = asyncio.Event()
scan_task: asyncio.Task | None = None
settings = get_settings()
if settings.sac_host_silence_scan_enabled:
from app.jobs.host_silence_background import host_silence_scan_loop
@@ -95,13 +112,18 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
wildcard = origins == ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins if origins != ["*"] else ["*"],
allow_credentials=True,
allow_origins=origins if not wildcard else ["*"],
allow_credentials=not wildcard,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(
IngestBodySizeLimitMiddleware,
max_bytes=settings.sac_ingest_max_body_bytes,
)
from app.api.v1.health import router as health_router
app.include_router(api_router, prefix="/api/v1")
@@ -0,0 +1,28 @@
"""Limit POST /api/v1/events body size (matches nginx client_max_body_size)."""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
class IngestBodySizeLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, *, max_bytes: int = 2_097_152) -> None:
super().__init__(app)
self._max_bytes = max_bytes
async def dispatch(self, request: Request, call_next):
if request.method == "POST" and request.url.path.rstrip("/") == "/api/v1/events":
content_length = request.headers.get("content-length")
if content_length:
try:
size = int(content_length)
except ValueError:
size = 0
if size > self._max_bytes:
return JSONResponse(
status_code=413,
content={"detail": f"Request body too large (max {self._max_bytes} bytes)"},
)
return await call_next(request)
+4
View File
@@ -1,3 +1,4 @@
from app.models.agent_command import AgentCommand
from app.models.api_key import ApiKey
from app.models.event import Event
from app.models.host import Host
@@ -8,6 +9,7 @@ from app.models.problem import Problem, ProblemEvent
from app.models.login_attempt import LoginAttempt
from app.models.ui_settings import UiSettings
from app.models.event_severity_override import EventSeverityOverride
from app.models.event_type_visibility import EventTypeVisibility
from app.models.user import User
from app.models.mobile_settings import MobileSettings
from app.models.mobile_enrollment_code import MobileEnrollmentCode
@@ -16,6 +18,7 @@ from app.models.mobile_refresh_token import MobileRefreshToken
__all__ = [
"ApiKey",
"AgentCommand",
"Event",
"Host",
"NotificationChannel",
@@ -25,6 +28,7 @@ __all__ = [
"ProblemEvent",
"User",
"EventSeverityOverride",
"EventTypeVisibility",
"LoginAttempt",
"UiSettings",
"MobileSettings",
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AgentCommand(Base):
__tablename__ = "agent_commands"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
command_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id", ondelete="CASCADE"), index=True)
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True)
command_type: Mapped[str] = mapped_column(String(32))
params: Mapped[dict | None] = mapped_column(JSONB, default=dict)
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
result_stdout: Mapped[str | None] = mapped_column(Text)
result_stderr: Mapped[str | None] = mapped_column(Text)
requested_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
host: Mapped["Host"] = relationship(back_populates="agent_commands")
event: Mapped["Event | None"] = relationship()
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class EventTypeVisibility(Base):
"""Per event-type UI visibility (default: show in events lists)."""
__tablename__ = "event_type_visibility"
event_type: Mapped[str] = mapped_column(String(128), primary_key=True)
show_in_events: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+15
View File
@@ -24,7 +24,22 @@ class Host(Base):
inventory: Mapped[dict | None] = mapped_column(JSONB)
inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings.
mgmt_user: Mapped[str | None] = mapped_column(String(256))
mgmt_password: Mapped[str | None] = mapped_column(Text)
pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false")
pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
agent_config: Mapped[dict | None] = mapped_column(JSONB)
agent_config_revision: Mapped[int] = mapped_column(default=0, server_default="0")
agent_update_state: Mapped[str | None] = mapped_column(String(32))
agent_update_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
agent_update_last_error: Mapped[str | None] = mapped_column(Text)
remote_action: Mapped[dict | None] = mapped_column(JSONB)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
events: Mapped[list["Event"]] = relationship(back_populates="host")
agent_commands: Mapped[list["AgentCommand"]] = relationship(back_populates="host")
+26 -1
View File
@@ -1,4 +1,6 @@
from sqlalchemy import Boolean
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -13,3 +15,26 @@ class UiSettings(Base):
id: Mapped[int] = mapped_column(primary_key=True)
show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True)
win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
linux_admin_user: Mapped[str | None] = mapped_column(String(128), nullable=True)
linux_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
agent_update_mode: Mapped[str] = mapped_column(String(16), default="gpo", nullable=False)
agent_update_fallback_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
agent_update_fallback_minutes: Mapped[int] = mapped_column(default=15, nullable=False)
agent_recommended_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_recommended_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_min_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_min_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True)
agent_rdp_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
agent_ssh_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
agent_git_branch: Mapped[str] = mapped_column(String(64), default="main", nullable=False)
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
login_ip_whitelist: Mapped[str | None] = mapped_column(Text, nullable=True)
login_sync_fail2ban: Mapped[bool] = mapped_column(default=False, server_default="false", nullable=False)
auto_rdp_flap_disconnect: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", nullable=False
)
+30
View File
@@ -18,6 +18,8 @@ class HostSummary(BaseModel):
last_daily_report_at: datetime | None = None
last_inventory_at: datetime | None = None
agent_status: str = "unknown"
version_status: str = "unknown"
pending_agent_update: bool = False
model_config = {"from_attributes": True}
@@ -30,6 +32,15 @@ class HostDetail(HostSummary):
inventory: dict | None = None
inventory_updated_at: datetime | None = None
created_at: datetime | None = None
ssh_admin_ok: bool | None = None
ssh_admin_checked_at: datetime | None = None
pending_update_requested_at: datetime | None = None
pending_update_target_version: str | None = None
agent_config_revision: int = 0
agent_config: dict | None = None
agent_update_state: str | None = None
agent_update_last_at: datetime | None = None
agent_update_last_error: str | None = None
class HostListResponse(BaseModel):
@@ -38,6 +49,18 @@ class HostListResponse(BaseModel):
page: int
page_size: int
latest_agent_versions: dict[str, str] = Field(default_factory=dict)
reference_agent_versions: dict[str, str] = Field(
default_factory=dict,
description="Эталон для «устарела»: max(git latest, min, max в fleet)",
)
git_latest_rdp_version: str | None = Field(
None,
description="Последняя версия rdp-login-monitor из git (version.txt / main)",
)
git_latest_ssh_version: str | None = Field(
None,
description="Последняя версия ssh-monitor из git (version.txt / main)",
)
class EventSummary(BaseModel):
@@ -55,6 +78,13 @@ class EventSummary(BaseModel):
title: str
summary: str
actor_user: str | None = None
session_duration_sec: int | None = None
rdg_flap: bool = False
rdg_flap_pair_event_id: int | None = None
rdg_flap_qwinsta_event_id: int | None = None
rdg_access_path: str | None = None
rdg_qwinsta_enabled: bool = False
session_terminated: bool = False
model_config = {"from_attributes": True}
+52
View File
@@ -0,0 +1,52 @@
"""Fail-fast checks for insecure production defaults."""
from __future__ import annotations
import logging
from urllib.parse import urlparse
from app.config import Settings
logger = logging.getLogger("sac")
_WEAK_JWT_SECRETS = frozenset(
{
"",
"change-me-in-production",
"change-me-openssl-rand-hex-32",
"CHANGE_ME_openssl_rand_hex_32",
}
)
def _is_local_dev_url(public_url: str) -> bool:
parsed = urlparse(public_url.strip() or "http://localhost:8000")
host = (parsed.hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1", "testserver"}
def validate_security_settings(settings: Settings) -> None:
"""Raise on dangerous defaults when SAC_SECURITY_ENFORCE=true."""
if not settings.sac_security_enforce:
return
if settings.jwt_secret in _WEAK_JWT_SECRETS:
raise RuntimeError(
"JWT_SECRET is missing or uses an insecure default. "
"Set a random value in sac-api.env (openssl rand -hex 32)."
)
if settings.cors_origins.strip() == "*" and not _is_local_dev_url(settings.sac_public_url):
raise RuntimeError(
"CORS_ORIGINS=* is not allowed with SAC_SECURITY_ENFORCE=true on non-local SAC_PUBLIC_URL. "
"Set CORS_ORIGINS to your UI origin, e.g. https://sac.example.com"
)
def warn_relaxed_security(settings: Settings) -> None:
if settings.sac_security_enforce:
return
if settings.jwt_secret in _WEAK_JWT_SECRETS:
logger.warning("JWT_SECRET uses insecure default — set a strong secret before production")
if settings.cors_origins.strip() == "*":
logger.warning("CORS_ORIGINS=* — restrict to explicit origins in production")
+109
View File
@@ -0,0 +1,109 @@
"""Queue and resolve agent commands (legacy poll path + helpers)."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import AgentCommand, Host
from app.services.win_admin_settings import (
get_effective_win_admin_config,
get_effective_win_admin_for_host,
)
def _win_admin_configured() -> bool:
return get_effective_win_admin_config().configured
def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
if host is not None and db is not None:
cfg = get_effective_win_admin_for_host(db, host)
else:
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
return None
return {
"user": cfg.user,
"password": cfg.password,
}
def command_to_dict(
cmd: AgentCommand,
*,
include_run_as: bool = False,
db: Session | None = None,
host: Host | None = None,
) -> dict:
out = {
"id": cmd.command_uuid,
"type": cmd.command_type,
"params": cmd.params if isinstance(cmd.params, dict) else {},
"status": cmd.status,
"result_stdout": cmd.result_stdout,
"result_stderr": cmd.result_stderr,
"created_at": cmd.created_at.isoformat() if cmd.created_at else None,
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
}
if include_run_as and cmd.status == "pending":
run_as = win_admin_run_as(db, host)
if run_as:
out["run_as"] = run_as
return out
def command_response_fields(cmd: AgentCommand) -> dict:
params = cmd.params if isinstance(cmd.params, dict) else {}
return {
"target": params.get("winrm_target") or params.get("client_hostname"),
"client_hostname": params.get("client_hostname"),
"internal_ip": params.get("internal_ip"),
}
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
return list(
db.scalars(
select(AgentCommand)
.where(
AgentCommand.host_id == host.id,
AgentCommand.status == "pending",
)
.order_by(AgentCommand.created_at.asc())
.limit(limit)
).all()
)
def resolve_host_by_agent_instance_id(db: Session, agent_instance_id: str) -> Host | None:
if not agent_instance_id.strip():
return None
return db.scalar(select(Host).where(Host.agent_instance_id == agent_instance_id.strip()))
def complete_command(
db: Session,
command_uuid: str,
*,
status: str,
stdout: str | None = None,
stderr: str | None = None,
) -> AgentCommand | None:
cmd = db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
if cmd is None:
return None
if cmd.status != "pending":
return cmd
cmd.status = status
cmd.result_stdout = stdout
cmd.result_stderr = stderr
cmd.completed_at = datetime.now(timezone.utc)
db.flush()
return cmd
def get_command_by_uuid(db: Session, command_uuid: str) -> AgentCommand | None:
return db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
+308
View File
@@ -0,0 +1,308 @@
"""Fetch latest agent release versions from git repositories."""
from __future__ import annotations
import hashlib
import re
import shutil
import subprocess
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
from app.services.agent_version import parse_agent_version
_SCRIPT_VERSION_RE = re.compile(r'\$ScriptVersion\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE)
_SSH_VERSION_RE = re.compile(r'^SSH_MONITOR_VERSION=["\']([^"\']+)["\']', re.MULTILINE)
_MEMORY_CACHE: dict[str, tuple[dict[str, str], float, str]] = {}
@dataclass(frozen=True)
class GitReleaseVersions:
versions: dict[str, str]
fetched_at: datetime | None
from_cache: bool
errors: dict[str, str] = field(default_factory=dict)
def normalize_git_repo_url(raw: str) -> str:
text = (raw or "").strip()
if not text:
return ""
if not text.startswith(("http://", "https://", "git@")):
text = f"https://{text.lstrip('/')}"
if text.startswith("git@"):
return text
if not text.endswith(".git"):
text = f"{text.rstrip('/')}.git"
return text
def git_repo_url_with_auth(repo_url: str, token: str) -> str:
normalized = normalize_git_repo_url(repo_url)
if not token or not normalized.startswith("https://"):
return normalized
parsed = urlparse(normalized)
host = parsed.netloc
path = parsed.path or ""
return f"https://oauth2:{token}@{host}{path}"
def _cache_key(cfg: AgentUpdateConfig) -> str:
parts = [
cfg.rdp_git_repo_url,
cfg.ssh_git_repo_url,
cfg.git_branch,
]
return hashlib.sha256("|".join(parts).encode()).hexdigest()
def _read_text(path: Path) -> str | None:
if not path.is_file():
return None
return path.read_text(encoding="utf-8-sig")
def parse_rdp_version_from_files(version_txt: str | None, login_monitor_ps1: str | None) -> str | None:
if version_txt:
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
if parse_agent_version(first_line):
return first_line
if login_monitor_ps1:
match = _SCRIPT_VERSION_RE.search(login_monitor_ps1)
if match:
candidate = match.group(1).strip()
if parse_agent_version(candidate):
return candidate
return None
def parse_ssh_version_from_files(ssh_monitor: str | None, version_txt: str | None) -> str | None:
if ssh_monitor:
match = _SSH_VERSION_RE.search(ssh_monitor)
if match:
candidate = match.group(1).strip()
if parse_agent_version(candidate):
return candidate
if version_txt:
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
if parse_agent_version(first_line):
return first_line
return None
def parse_product_version_from_dir(repo_dir: Path, product: str) -> str | None:
if product == PRODUCT_RDP:
return parse_rdp_version_from_files(
_read_text(repo_dir / "version.txt"),
_read_text(repo_dir / "Login_Monitor.ps1"),
)
if product == PRODUCT_SSH:
return parse_ssh_version_from_files(
_read_text(repo_dir / "ssh-monitor"),
_read_text(repo_dir / "version.txt"),
)
return None
def _run_git(args: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
timeout=120,
check=False,
)
def _repo_slug(repo_url: str) -> str:
normalized = normalize_git_repo_url(repo_url)
name = normalized.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
digest = hashlib.sha256(normalized.encode()).hexdigest()[:12]
return f"{name}-{digest}"
def clone_or_update_repo(repo_url: str, branch: str, cache_dir: Path, token: str = "") -> Path:
clone_url = git_repo_url_with_auth(repo_url, token)
normalized = normalize_git_repo_url(repo_url)
if not normalized:
raise ValueError("empty repo url")
branch_name = (branch or "main").strip() or "main"
target = cache_dir / _repo_slug(normalized)
cache_dir.mkdir(parents=True, exist_ok=True)
if target.is_dir() and (target / ".git").is_dir():
fetch = _run_git(["fetch", "--depth", "1", "origin", branch_name], cwd=target)
if fetch.returncode != 0:
shutil.rmtree(target, ignore_errors=True)
else:
checkout = _run_git(["checkout", "FETCH_HEAD"], cwd=target)
if checkout.returncode == 0:
return target
shutil.rmtree(target, ignore_errors=True)
clone = _run_git(
["clone", "--depth", "1", "--branch", branch_name, clone_url, str(target)],
)
if clone.returncode != 0:
err = (clone.stderr or clone.stdout or "git clone failed").strip()
raise RuntimeError(err)
return target
def fetch_product_version(repo_url: str, branch: str, product: str, cache_dir: Path, token: str = "") -> str:
repo_dir = clone_or_update_repo(repo_url, branch, cache_dir, token=token)
version = parse_product_version_from_dir(repo_dir, product)
if not version:
raise RuntimeError(f"version not found in {repo_url} ({product})")
return version
def recommended_from_git(cfg: AgentUpdateConfig, git_versions: dict[str, str], product: str) -> str:
git_version = (git_versions.get(product) or "").strip()
if git_version:
return git_version
manual = cfg.recommended_for_product(product)
return manual
def _ttl_seconds() -> int:
settings = get_settings()
return max(60, int(settings.sac_agent_git_cache_ttl_minutes) * 60)
def _cache_dir() -> Path:
settings = get_settings()
return Path(settings.sac_agent_git_cache_dir or "/tmp/sac-agent-repos")
def _db_cache_fresh(row: UiSettings | None, cfg: AgentUpdateConfig) -> bool:
if row is None or row.agent_git_fetched_at is None:
return False
fetched_at = row.agent_git_fetched_at
if fetched_at.tzinfo is None:
fetched_at = fetched_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - fetched_at).total_seconds()
if age > _ttl_seconds():
return False
if (row.agent_rdp_git_repo_url or "").strip() != cfg.rdp_git_repo_url:
return False
if (row.agent_ssh_git_repo_url or "").strip() != cfg.ssh_git_repo_url:
return False
if (row.agent_git_branch or "main").strip() != cfg.git_branch:
return False
return True
def _versions_from_db_row(row: UiSettings) -> dict[str, str]:
out: dict[str, str] = {}
rdp = (row.agent_git_rdp_version or "").strip()
ssh = (row.agent_git_ssh_version or "").strip()
if rdp:
out[PRODUCT_RDP] = rdp
if ssh:
out[PRODUCT_SSH] = ssh
return out
def _persist_db_cache(db: Session, cfg: AgentUpdateConfig, versions: dict[str, str]) -> datetime:
now = datetime.now(timezone.utc)
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
row.agent_rdp_git_repo_url = cfg.rdp_git_repo_url or None
row.agent_ssh_git_repo_url = cfg.ssh_git_repo_url or None
row.agent_git_branch = cfg.git_branch or "main"
row.agent_git_rdp_version = versions.get(PRODUCT_RDP) or None
row.agent_git_ssh_version = versions.get(PRODUCT_SSH) or None
row.agent_git_fetched_at = now
db.commit()
return now
def get_git_release_versions(
cfg: AgentUpdateConfig,
*,
db: Session | None = None,
force_refresh: bool = False,
) -> GitReleaseVersions:
key = _cache_key(cfg)
now_ts = time.time()
ttl = _ttl_seconds()
if not force_refresh:
cached = _MEMORY_CACHE.get(key)
if cached and now_ts - cached[1] <= ttl:
fetched_at = datetime.fromtimestamp(cached[1], tz=timezone.utc)
return GitReleaseVersions(versions=dict(cached[0]), fetched_at=fetched_at, from_cache=True)
if db is not None:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if _db_cache_fresh(row, cfg):
versions = _versions_from_db_row(row)
if versions:
_MEMORY_CACHE[key] = (versions, row.agent_git_fetched_at.timestamp(), key)
return GitReleaseVersions(
versions=versions,
fetched_at=row.agent_git_fetched_at,
from_cache=True,
)
settings = get_settings()
cache_dir = _cache_dir()
token = (settings.sac_agent_git_token or "").strip()
branch = cfg.git_branch or "main"
versions: dict[str, str] = {}
errors: dict[str, str] = {}
product_repo = {
PRODUCT_RDP: cfg.rdp_git_repo_url,
PRODUCT_SSH: cfg.ssh_git_repo_url,
}
for product, repo_url in product_repo.items():
if not repo_url:
errors[product] = "repo url not configured"
continue
try:
versions[product] = fetch_product_version(repo_url, branch, product, cache_dir, token=token)
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
errors[product] = str(exc).strip() or "fetch failed"
fetched_at: datetime | None = None
if versions:
fetched_at = datetime.now(timezone.utc)
_MEMORY_CACHE[key] = (dict(versions), fetched_at.timestamp(), key)
if db is not None:
fetched_at = _persist_db_cache(db, cfg, versions)
if not versions and db is not None:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is not None:
stale = _versions_from_db_row(row)
if stale:
return GitReleaseVersions(
versions=stale,
fetched_at=row.agent_git_fetched_at,
from_cache=True,
errors=errors,
)
return GitReleaseVersions(
versions=versions,
fetched_at=fetched_at,
from_cache=False,
errors=errors,
)
+74
View File
@@ -0,0 +1,74 @@
"""Desired agent config per host (poll delivery)."""
from __future__ import annotations
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Host
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
RDP_CONFIG_KEYS = frozenset(
{
"ServerDisplayName",
"EnableRcmShadowControlMonitoring",
"EnableWinRmInboundMonitoring",
"EnableAdminShareMonitoring",
"GetInventory",
"SacCommandPollIntervalSec",
}
)
SSH_CONFIG_KEYS = frozenset(
{
"SERVER_DISPLAY_NAME",
"HEARTBEAT_INTERVAL",
"DAILY_REPORT_ENABLED",
"UseSAC",
}
)
def _allowed_keys(host: Host) -> frozenset[str]:
product = (host.product or "").strip()
if product == PRODUCT_RDP or (host.os_family or "").strip().lower() == "windows":
return RDP_CONFIG_KEYS
if product == PRODUCT_SSH or (host.os_family or "").strip().lower() == "linux":
return SSH_CONFIG_KEYS
return frozenset()
def get_host_agent_settings(host: Host) -> dict:
raw = host.agent_config if isinstance(host.agent_config, dict) else {}
allowed = _allowed_keys(host)
if not allowed:
return {}
return {k: v for k, v in raw.items() if k in allowed}
def build_agent_config_payload(host: Host) -> dict:
settings = get_host_agent_settings(host)
if not settings:
return {}
return {"settings": settings}
def update_host_agent_config(db: Session, host: Host, settings: dict) -> Host:
allowed = _allowed_keys(host)
if not allowed:
raise ValueError("Host product does not support desired agent config")
current = host.agent_config if isinstance(host.agent_config, dict) else {}
merged = dict(current)
for key, value in settings.items():
if key not in allowed:
raise ValueError(f"Unsupported config key: {key}")
if value is None:
merged.pop(key, None)
else:
merged[key] = value
host.agent_config = merged
host.agent_config_revision = int(host.agent_config_revision or 0) + 1
flag_modified(host, "agent_config")
db.flush()
return host
+31
View File
@@ -0,0 +1,31 @@
"""Agent poll payload: commands, desired config, pending update."""
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from app.models import Host
from app.services.agent_commands import command_to_dict, get_command_for_host_poll
from app.services.agent_host_config import build_agent_config_payload
from app.services.agent_update_settings import get_effective_agent_update_config
def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
cfg = get_effective_agent_update_config(db)
pending = get_command_for_host_poll(db, host)
config_payload = build_agent_config_payload(host)
update_block: dict[str, Any] = {
"requested": bool(host.pending_agent_update),
"target_version": host.pending_update_target_version,
"source": "sac" if cfg.sac_managed else "gpo",
}
return {
"config_revision": int(host.agent_config_revision or 0),
"config": config_payload,
"update": update_block,
"commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
}
+325
View File
@@ -0,0 +1,325 @@
"""Request agent self-update, ingest lifecycle, SSH/WinRM fallback."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Host
from app.services.agent_update_types import AgentUpdateFallbackResult
from app.services.agent_update_settings import (
PRODUCT_RDP,
PRODUCT_SSH,
AgentUpdateConfig,
get_effective_agent_update_config,
)
from app.services.agent_git_release import get_git_release_versions, recommended_from_git
from app.services.agent_version import (
host_version_outdated,
latest_agent_versions_by_product,
)
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.ssh_connect import (
HostNotLinuxError,
HostTargetMissingError,
is_linux_host,
iter_ssh_targets,
run_ssh_monitor_update,
)
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError as WinRmHostTargetMissingError,
is_windows_host,
iter_winrm_targets,
run_winrm_on_host_targets,
run_winrm_rdp_monitor_update,
)
AGENT_UPDATE_STARTED = "agent.update.started"
AGENT_UPDATE_SUCCESS = "agent.update.success"
AGENT_UPDATE_FAILED = "agent.update.failed"
AGENT_UPDATE_TYPES = frozenset(
{AGENT_UPDATE_STARTED, AGENT_UPDATE_SUCCESS, AGENT_UPDATE_FAILED}
)
class AgentUpdateNotAllowedError(Exception):
pass
def resolve_target_version(
db: Session,
host: Host,
cfg: AgentUpdateConfig,
*,
git_versions: dict[str, str] | None = None,
) -> str:
product = host.product or ""
if git_versions is None:
git_versions = get_git_release_versions(cfg, db=db).versions
git_target = recommended_from_git(cfg, git_versions, product)
if git_target:
return git_target
latest_map = latest_agent_versions_by_product(db)
return latest_map.get(product, "") or (host.product_version or "")
def host_version_status(
host: Host,
*,
cfg: AgentUpdateConfig,
latest_map: dict[str, str],
git_versions: dict[str, str] | None = None,
) -> str:
product = host.product or ""
if git_versions is None:
git_versions = get_git_release_versions(cfg).versions
outdated = host_version_outdated(
host.product_version,
recommended=recommended_from_git(cfg, git_versions, product),
fleet_latest=latest_map.get(product, ""),
min_version=cfg.min_for_product(product),
)
if outdated is None:
return "unknown"
return "outdated" if outdated else "ok"
def request_agent_update(db: Session, host: Host) -> Host:
cfg = get_effective_agent_update_config(db)
if not cfg.sac_managed:
raise AgentUpdateNotAllowedError(
"Agent updates from SAC are disabled (mode=gpo). Use GPO/manual update or switch to mode=sac."
)
if not host.agent_instance_id:
raise AgentUpdateNotAllowedError("Host has no agent_instance_id (agent never connected via SAC)")
target = resolve_target_version(db, host, cfg)
now = datetime.now(timezone.utc)
host.pending_agent_update = True
host.pending_update_requested_at = now
host.pending_update_target_version = target or None
host.agent_update_state = "requested"
host.agent_update_last_error = None
db.flush()
return host
def _clear_pending(host: Host) -> None:
host.pending_agent_update = False
host.pending_update_requested_at = None
host.pending_update_target_version = None
def process_agent_update_ingest(db: Session, host: Host, event_type: str, details: dict | None) -> None:
if event_type not in AGENT_UPDATE_TYPES:
return
now = datetime.now(timezone.utc)
host.agent_update_last_at = now
details = details if isinstance(details, dict) else {}
if event_type == AGENT_UPDATE_STARTED:
host.agent_update_state = "started"
return
if event_type == AGENT_UPDATE_SUCCESS:
host.agent_update_state = "success"
host.agent_update_last_error = None
_clear_pending(host)
version = details.get("product_version") or details.get("version")
if isinstance(version, str) and version.strip():
host.product_version = version.strip()
return
if event_type == AGENT_UPDATE_FAILED:
host.agent_update_state = "failed"
err = details.get("error") or details.get("message")
host.agent_update_last_error = str(err)[:2000] if err else "agent.update.failed"
_clear_pending(host)
def run_linux_agent_update_fallback(
host: Host,
cfg_linux,
*,
repo_url: str | None = None,
git_branch: str | None = None,
) -> AgentUpdateFallbackResult:
if not is_linux_host(host):
return AgentUpdateFallbackResult(ok=False, message="Host is not Linux", product_version=None)
if not cfg_linux.configured:
return AgentUpdateFallbackResult(
ok=False,
message="Linux SSH admin is not configured",
product_version=None,
)
last = AgentUpdateFallbackResult(ok=False, message="SSH fallback failed", product_version=None)
try:
targets = iter_ssh_targets(host)
except (HostNotLinuxError, HostTargetMissingError) as exc:
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
effective_repo = (repo_url or "").strip() or None
effective_branch = (git_branch or "main").strip() or "main"
for target in targets:
result = run_ssh_monitor_update(
target=target,
user=cfg_linux.user,
password=cfg_linux.password,
repo_url=effective_repo,
git_branch=effective_branch,
)
last = AgentUpdateFallbackResult(
ok=result.ok,
message=result.message,
product_version=result.agent_version,
target=result.target,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
)
if result.ok:
return last
return last
def run_windows_agent_update_fallback(
host: Host,
cfg_win,
script_path: str,
*,
repo_url: str = "",
git_branch: str = "main",
) -> AgentUpdateFallbackResult:
if not is_windows_host(host):
return AgentUpdateFallbackResult(ok=False, message="Host is not Windows", product_version=None)
if not cfg_win.configured:
return AgentUpdateFallbackResult(
ok=False,
message="Windows domain admin is not configured",
product_version=None,
)
try:
targets = iter_winrm_targets(host)
except (HostNotWindowsError, WinRmHostTargetMissingError) as exc:
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
result, attempts = run_winrm_on_host_targets(
host,
user=cfg_win.user,
password=cfg_win.password,
action=lambda target: run_winrm_rdp_monitor_update(
target=target,
user=cfg_win.user,
password=cfg_win.password,
script_path=script_path,
repo_url=repo_url,
git_branch=git_branch,
),
)
if result is None:
return AgentUpdateFallbackResult(ok=False, message="WinRM fallback failed", product_version=None)
message = result.message
if attempts:
message = f"{message} (пробовали: {', '.join(attempts)})"
version = None
if result.ok and result.stdout:
from app.services.ssh_connect import parse_ssh_monitor_version_text
version = parse_ssh_monitor_version_text(result.stdout)
return AgentUpdateFallbackResult(
ok=result.ok,
message=message,
product_version=version,
target=result.target,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
)
def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbackResult:
cfg = get_effective_agent_update_config(db)
product = (host.product or "").strip()
if product == PRODUCT_SSH or is_linux_host(host):
linux_cfg = get_effective_linux_admin_for_host(db, host)
agent_cfg = get_effective_agent_update_config(db)
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
branch = (agent_cfg.git_branch or "main").strip() or "main"
result = run_linux_agent_update_fallback(
host,
linux_cfg,
repo_url=repo_url,
git_branch=branch,
)
elif product == PRODUCT_RDP or is_windows_host(host):
win_cfg = get_effective_win_admin_for_host(db, host)
result = run_windows_agent_update_fallback(
host,
win_cfg,
cfg.win_agent_update_script,
repo_url=cfg.rdp_git_repo_url,
git_branch=cfg.git_branch,
)
else:
return AgentUpdateFallbackResult(
ok=False,
message=f"Unsupported product for fallback update: {product}",
product_version=None,
)
now = datetime.now(timezone.utc)
host.agent_update_last_at = now
if result.ok:
host.agent_update_state = "success"
host.agent_update_last_error = None
_clear_pending(host)
if result.product_version:
host.product_version = result.product_version
if is_linux_host(host):
host.ssh_admin_ok = True
host.ssh_admin_checked_at = now
else:
host.agent_update_state = "failed"
host.agent_update_last_error = result.message[:2000]
db.flush()
return result
def process_agent_update_fallbacks(db: Session) -> list[dict]:
"""Find stale pending updates and run SSH/WinRM fallback when enabled."""
cfg = get_effective_agent_update_config(db)
if not cfg.sac_managed or not cfg.fallback_enabled:
return []
cutoff = datetime.now(timezone.utc) - timedelta(minutes=cfg.fallback_after_minutes)
rows = db.scalars(
select(Host).where(
Host.pending_agent_update.is_(True),
Host.pending_update_requested_at.isnot(None),
Host.pending_update_requested_at <= cutoff,
Host.agent_update_state.in_(("requested", "started")),
)
).all()
results: list[dict] = []
for host in rows:
result = execute_agent_update_fallback(db, host)
results.append(
{
"host_id": host.id,
"hostname": host.hostname,
"ok": result.ok,
"message": result.message,
"product_version": result.product_version,
}
)
return results
@@ -0,0 +1,192 @@
"""Global agent update policy (DB overrides env)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
PRODUCT_RDP = "rdp-login-monitor"
PRODUCT_SSH = "ssh-monitor"
AGENT_UPDATE_MODES = frozenset({"gpo", "sac"})
@dataclass(frozen=True)
class AgentUpdateConfig:
mode: str
fallback_enabled: bool
fallback_after_minutes: int
recommended_rdp_version: str
recommended_ssh_version: str
min_rdp_version: str
min_ssh_version: str
win_agent_update_script: str
rdp_git_repo_url: str
ssh_git_repo_url: str
git_branch: str
source: str # env | db
@property
def sac_managed(self) -> bool:
return self.mode == "sac"
def recommended_for_product(self, product: str) -> str:
if product == PRODUCT_RDP:
return self.recommended_rdp_version
if product == PRODUCT_SSH:
return self.recommended_ssh_version
return ""
def min_for_product(self, product: str) -> str:
if product == PRODUCT_RDP:
return self.min_rdp_version
if product == PRODUCT_SSH:
return self.min_ssh_version
return ""
def _agent_update_from_env() -> AgentUpdateConfig:
settings = get_settings()
mode = (settings.sac_agent_update_mode or "gpo").strip().lower()
if mode not in AGENT_UPDATE_MODES:
mode = "gpo"
return AgentUpdateConfig(
mode=mode,
fallback_enabled=settings.sac_agent_update_fallback_enabled,
fallback_after_minutes=max(1, int(settings.sac_agent_update_fallback_minutes)),
recommended_rdp_version=(settings.sac_agent_recommended_rdp_version or "").strip(),
recommended_ssh_version=(settings.sac_agent_recommended_ssh_version or "").strip(),
min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(),
min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(),
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(),
ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(),
git_branch=(settings.sac_agent_git_branch or "main").strip() or "main",
source="env",
)
def _row_has_agent_update_values(row: UiSettings) -> bool:
return any(
[
(row.agent_update_mode or "").strip() not in ("", "gpo"),
row.agent_update_fallback_enabled is not None,
row.agent_update_fallback_minutes not in (None, 15),
bool((row.agent_recommended_rdp_version or "").strip()),
bool((row.agent_recommended_ssh_version or "").strip()),
bool((row.agent_min_rdp_version or "").strip()),
bool((row.agent_min_ssh_version or "").strip()),
bool((row.win_agent_update_script or "").strip()),
bool((row.agent_rdp_git_repo_url or "").strip()),
bool((row.agent_ssh_git_repo_url or "").strip()),
(row.agent_git_branch or "main").strip() not in ("", "main"),
]
)
def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_agent_update_config(session)
finally:
session.close()
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_cfg = _agent_update_from_env()
if row is None:
return env_cfg
mode = (row.agent_update_mode or env_cfg.mode).strip().lower()
if mode not in AGENT_UPDATE_MODES:
mode = env_cfg.mode
return AgentUpdateConfig(
mode=mode,
fallback_enabled=(
row.agent_update_fallback_enabled
if row.agent_update_fallback_enabled is not None
else env_cfg.fallback_enabled
),
fallback_after_minutes=max(
1,
int(row.agent_update_fallback_minutes or env_cfg.fallback_after_minutes),
),
recommended_rdp_version=(row.agent_recommended_rdp_version or "").strip()
or env_cfg.recommended_rdp_version,
recommended_ssh_version=(row.agent_recommended_ssh_version or "").strip()
or env_cfg.recommended_ssh_version,
min_rdp_version=(row.agent_min_rdp_version or "").strip() or env_cfg.min_rdp_version,
min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version,
win_agent_update_script=(row.win_agent_update_script or "").strip()
or env_cfg.win_agent_update_script,
rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url,
ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url,
git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch,
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
)
def upsert_agent_update_settings(
db: Session,
*,
mode: str | None = None,
fallback_enabled: bool | None = None,
fallback_after_minutes: int | None = None,
recommended_rdp_version: str | None = None,
recommended_ssh_version: str | None = None,
min_rdp_version: str | None = None,
min_ssh_version: str | None = None,
win_agent_update_script: str | None = None,
rdp_git_repo_url: str | None = None,
ssh_git_repo_url: str | None = None,
git_branch: str | None = None,
) -> AgentUpdateConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
if mode is not None:
normalized = mode.strip().lower()
if normalized not in AGENT_UPDATE_MODES:
raise ValueError(f"mode must be one of: {sorted(AGENT_UPDATE_MODES)}")
row.agent_update_mode = normalized
if fallback_enabled is not None:
row.agent_update_fallback_enabled = fallback_enabled
if fallback_after_minutes is not None:
row.agent_update_fallback_minutes = max(1, int(fallback_after_minutes))
if recommended_rdp_version is not None:
row.agent_recommended_rdp_version = recommended_rdp_version.strip() or None
if recommended_ssh_version is not None:
row.agent_recommended_ssh_version = recommended_ssh_version.strip() or None
if min_rdp_version is not None:
row.agent_min_rdp_version = min_rdp_version.strip() or None
if min_ssh_version is not None:
row.agent_min_ssh_version = min_ssh_version.strip() or None
if win_agent_update_script is not None:
row.win_agent_update_script = win_agent_update_script.strip() or None
if rdp_git_repo_url is not None:
row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None
row.agent_git_rdp_version = None
row.agent_git_fetched_at = None
if ssh_git_repo_url is not None:
row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None
row.agent_git_ssh_version = None
row.agent_git_fetched_at = None
if git_branch is not None:
branch = git_branch.strip() or "main"
if branch != (row.agent_git_branch or "main"):
row.agent_git_rdp_version = None
row.agent_git_ssh_version = None
row.agent_git_fetched_at = None
row.agent_git_branch = branch
db.commit()
db.refresh(row)
return get_effective_agent_update_config(db)
@@ -0,0 +1,16 @@
"""Agent update fallback result with remote command streams."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class AgentUpdateFallbackResult:
ok: bool
message: str
product_version: str | None
target: str = ""
stdout: str = ""
stderr: str = ""
exit_code: int | None = None
+69
View File
@@ -60,6 +60,75 @@ def is_agent_version_outdated(
return lag >= lag_threshold
def effective_reference_version(
*,
recommended: str = "",
fleet_latest: str = "",
min_version: str = "",
) -> str | None:
"""Единый эталон: max(recommended, min_version, max версия в fleet по продукту)."""
best: ParsedAgentVersion | None = None
best_raw: str | None = None
for raw in (recommended, min_version, fleet_latest):
text = (raw or "").strip()
parsed = parse_agent_version(text)
if parsed is None:
continue
if best is None or parsed > best:
best = parsed
best_raw = text
return best_raw
def reference_agent_versions_by_product(
cfg,
latest_map: dict[str, str],
*,
git_versions: dict[str, str] | None = None,
products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"),
) -> dict[str, str]:
from app.services.agent_git_release import recommended_from_git
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
git_versions = git_versions or {}
product_cfg = {
PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)),
PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)),
}
out: dict[str, str] = {}
for product in products:
recommended, min_version = product_cfg.get(product, ("", ""))
ref = effective_reference_version(
recommended=recommended,
fleet_latest=latest_map.get(product, ""),
min_version=min_version,
)
if ref:
out[product] = ref
return out
def host_version_outdated(
host_version: str | None,
*,
recommended: str = "",
fleet_latest: str = "",
min_version: str = "",
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
) -> bool | None:
"""True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста)."""
if not host_version or parse_agent_version(host_version) is None:
return None
reference = effective_reference_version(
recommended=recommended,
fleet_latest=fleet_latest,
min_version=min_version,
)
if not reference:
return None
return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold)
def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
rows = db.execute(
select(Host.product, Host.product_version).where(
+27
View File
@@ -0,0 +1,27 @@
"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for)."""
from __future__ import annotations
from fastapi import Request
def client_ip_from_request(request: Request) -> str:
"""Client IP for rate limiting.
nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for``
appends the real peer address as the *last* hop. Spoofed values in the
incoming header therefore cannot replace the actual client IP.
"""
if request.client and request.client.host:
direct = request.client.host.strip()
else:
direct = "unknown"
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
if not forwarded:
return direct[:64]
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if not parts:
return direct[:64]
return parts[-1][:64]
+68 -2
View File
@@ -16,6 +16,10 @@ SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:")
_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ")
_ACTIVE_USERS_EMPTY_LINE_RE = re.compile(
r"^\(?нет данных|нет активных пользователей",
re.IGNORECASE,
)
_AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE)
_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*")
_LEGACY_SAC_SOURCE_RE = re.compile(
@@ -130,10 +134,21 @@ def collapse_blank_lines(text: str, *, max_run: int = 1) -> str:
return "\n".join(out).strip()
def is_active_users_empty_line(text: str) -> bool:
"""Строка-заглушка вместо списка сессий (агент или SAC)."""
s = text.strip()
if not s:
return True
inner = s.lstrip("👤").strip()
if _ACTIVE_USERS_EMPTY_LINE_RE.search(inner):
return True
return False
def split_active_user_tokens(entry: str) -> list[str]:
"""Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента)."""
entry = entry.strip()
if not entry:
if not entry or is_active_users_empty_line(entry):
return []
if entry.count("👤") <= 1:
line = entry if entry.startswith("👤") else f"👤 {entry}"
@@ -231,21 +246,71 @@ def normalize_active_users_in_body(body: str) -> str:
break
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
break
if stripped.startswith("(нет данных"):
if is_active_users_empty_line(stripped):
out[-1] = _fix_active_users_header_count(out[-1], 0)
out.append(cur)
i += 1
break
user_lines.extend(split_active_user_tokens(cur))
i += 1
user_lines = [ln for ln in user_lines if not is_active_users_empty_line(ln.strip())]
if user_lines:
out[-1] = _fix_active_users_header_count(out[-1], len(user_lines))
out.extend(user_lines)
else:
out[-1] = _fix_active_users_header_count(out[-1], 0)
continue
out.append(line)
i += 1
return "\n".join(out)
def extract_active_users_from_report_body(body: str) -> list[str]:
lines = body.replace("\r\n", "\n").split("\n")
in_section = False
users: list[str] = []
for line in lines:
stripped = line.strip()
if _ACTIVE_USERS_HEADER_RE.match(stripped):
in_section = True
continue
if not in_section:
continue
if not stripped:
break
if is_active_users_empty_line(stripped):
break
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
break
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
break
users.extend(split_active_user_tokens(stripped))
return normalize_active_users_list(users)
def reconcile_stats_from_report_body(
stats: dict[str, Any],
body: str,
platform: Platform,
) -> dict[str, Any]:
"""Синхронизирует stats.active_users и счётчики с текстом отчёта после нормализации."""
out = dict(stats)
users = extract_active_users_from_report_body(body)
out["active_users"] = users
count = len(users)
if platform == "ssh":
out["active_sessions"] = count
else:
out["active_sessions_rdp"] = count
names: list[str] = []
for raw in users:
name = re.sub(r"^👤\s*", "", raw.strip()).strip()
if name and not is_active_users_empty_line(name):
names.append(name)
out["unique_users"] = sorted(set(names))
return out
def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str:
"""Приводит текст отчёта (агент/SAC) к единому компактному виду."""
text = collapse_blank_lines(body.replace("\r\n", "\n"))
@@ -422,5 +487,6 @@ def normalize_daily_report_details(
stats = out.get("stats")
if isinstance(stats, dict):
stats = enrich_stats_for_storage(platform, dict(stats))
stats = reconcile_stats_from_report_body(stats, normalized_body, platform)
out["stats"] = stats
return out
+1
View File
@@ -15,6 +15,7 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
"session.logind.failed",
"rdp.login.success",
"rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started",
"rdp.shadow.control.stopped",
"rdp.shadow.control.permission",
@@ -1,4 +1,4 @@
"""Per event-type severity overrides (admin Settings UI)."""
"""Per event-type severity overrides (admin Settings UI)."""
from __future__ import annotations
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
from app.models.event_severity_override import EventSeverityOverride
from app.services.event_type_visibility import get_visibility_map
from app.services.notification_settings import VALID_SEVERITIES
SOURCE_DB = "db"
@@ -19,6 +20,7 @@ class EventSeverityOverrideItem:
event_type: str
default_severity: str
override_severity: str | None
show_in_events: bool = True
def get_override_map(db: Session) -> dict[str, str]:
@@ -48,7 +50,8 @@ def apply_severity_override(payload: dict, db: Session) -> dict:
def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
override_map = get_override_map(db)
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map)
visibility_map = get_visibility_map(db)
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map) | set(visibility_map)
items: list[EventSeverityOverrideItem] = []
for event_type in sorted(types):
default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
@@ -57,6 +60,7 @@ def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]
event_type=event_type,
default_severity=default,
override_severity=override_map.get(event_type),
show_in_events=visibility_map.get(event_type, True),
)
)
return items
+37 -3
View File
@@ -1,10 +1,35 @@
from sqlalchemy.orm import Session
from app.models.event import Event
from app.schemas.list_models import EventSummary
from app.services.event_actor_user import extract_event_actor_user
from app.services.host_sessions import event_session_terminated
from app.services.rdg_display import build_rdg_display
from app.services.rdg_session_flap import resolve_rdg_flap_summary
from app.services.session_duration import extract_session_duration_sec
def event_to_summary(event: Event) -> EventSummary:
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
host = event.host
rdg_flap = False
rdg_flap_pair_event_id: int | None = None
rdg_flap_qwinsta_event_id: int | None = None
if db is not None:
rdg_flap, rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id = resolve_rdg_flap_summary(
db, event
)
title = event.title
summary = event.summary
rdg_access_path: str | None = None
rdg_qwinsta_enabled = False
rdg_display = build_rdg_display(event, db)
if rdg_display is not None:
title = rdg_display.title
summary = rdg_display.summary
rdg_access_path = rdg_display.access_path
rdg_qwinsta_enabled = rdg_display.qwinsta_enabled
return EventSummary(
id=event.id,
event_id=event.event_id,
@@ -17,7 +42,16 @@ def event_to_summary(event: Event) -> EventSummary:
category=event.category,
type=event.type,
severity=event.severity,
title=event.title,
summary=event.summary,
title=title,
summary=summary,
actor_user=extract_event_actor_user(event.type, event.details),
session_duration_sec=extract_session_duration_sec(
event.details if isinstance(event.details, dict) else None
),
rdg_flap=rdg_flap,
rdg_flap_pair_event_id=rdg_flap_pair_event_id,
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
rdg_access_path=rdg_access_path,
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
session_terminated=event_session_terminated(event, db=db),
)
@@ -0,0 +1,66 @@
"""Hide selected event types from SAC UI/API event lists."""
from __future__ import annotations
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from sqlalchemy.sql import ColumnElement
from app.models.event import Event
from app.models.event_type_visibility import EventTypeVisibility
def get_hidden_event_types(db: Session) -> frozenset[str]:
rows = db.scalars(
select(EventTypeVisibility.event_type).where(EventTypeVisibility.show_in_events.is_(False))
).all()
return frozenset(rows)
def get_visibility_map(db: Session) -> dict[str, bool]:
rows = db.scalars(select(EventTypeVisibility)).all()
return {row.event_type: row.show_in_events for row in rows}
def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool:
return event_type not in hidden
def event_type_notifications_enabled(event_type: str, db: Session | None) -> bool:
"""Outbound alerts (Telegram/push/email/webhook) only for types visible in events UI."""
if db is None:
return True
hidden = get_hidden_event_types(db)
return show_in_events_for(event_type, hidden=hidden)
def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None:
if not hidden:
return None
return Event.type.not_in(tuple(sorted(hidden)))
def replace_event_visibility(db: Session, visibility: dict[str, bool | None]) -> dict[str, bool]:
stored = get_visibility_map(db)
for event_type, show in visibility.items():
normalized_type = event_type.strip()
if not normalized_type:
raise ValueError("event_type is required")
if show is None or show is True:
db.execute(
delete(EventTypeVisibility).where(EventTypeVisibility.event_type == normalized_type)
)
stored.pop(normalized_type, None)
continue
if show is False:
row = db.get(EventTypeVisibility, normalized_type)
if row is None:
row = EventTypeVisibility(event_type=normalized_type, show_in_events=False)
db.add(row)
else:
row.show_in_events = False
stored[normalized_type] = False
else:
raise ValueError("show_in_events must be true, false, or null")
db.flush()
return stored
+132
View File
@@ -0,0 +1,132 @@
"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB)."""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass
from app.config import get_settings
logger = logging.getLogger(__name__)
_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
@dataclass(frozen=True)
class Fail2banActionResult:
ok: bool
message: str
def _ssh_jail() -> str:
settings = get_settings()
jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip()
return jail or "sshd"
def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["fail2ban-client", *args],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def fail2ban_available() -> bool:
try:
proc = _run_fail2ban(["ping"], timeout=5)
return proc.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def list_ssh_banned_ips() -> list[str]:
if not fail2ban_available():
return []
jail = _ssh_jail()
try:
proc = _run_fail2ban(["status", jail])
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("fail2ban status failed: %s", exc)
return []
if proc.returncode != 0:
return []
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
ips: list[str] = []
capture = False
for line in text.splitlines():
lower = line.strip().lower()
if "banned ip list" in lower:
capture = True
tail = line.split(":", 1)[-1]
ips.extend(_BANNED_IP_RE.findall(tail))
continue
if capture:
if not line.strip():
break
ips.extend(_BANNED_IP_RE.findall(line))
# preserve order, unique
seen: set[str] = set()
out: list[str] = []
for ip in ips:
if ip not in seen:
seen.add(ip)
out.append(ip)
return out
def unban_ssh_ip(ip_address: str) -> Fail2banActionResult:
ip = (ip_address or "").strip()
if not ip:
return Fail2banActionResult(ok=False, message="empty ip")
if not fail2ban_available():
return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC")
jail = _ssh_jail()
try:
proc = _run_fail2ban(["set", jail, "unbanip", ip])
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=str(exc))
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "unban failed").strip()
return Fail2banActionResult(ok=False, message=err[:500])
return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})")
def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult:
settings = get_settings()
if not getattr(settings, "sac_fail2ban_sync_enabled", False):
return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)")
path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip()
if not path:
return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)")
jail = _ssh_jail()
base_ignore = "127.0.0.1/8 ::1"
extra = " ".join(ip for ip in ip_addresses if ip)
ignore_line = f"{base_ignore} {extra}".strip()
content = (
f"# Managed by SAC — login IP whitelist for fail2ban\n"
f"[{jail}]\n"
f"ignoreip = {ignore_line}\n"
)
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as exc:
return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}")
if not fail2ban_available():
return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен")
try:
proc = _run_fail2ban(["reload"], timeout=30)
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}")
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "reload failed").strip()
return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}")
return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})")
+127
View File
@@ -0,0 +1,127 @@
"""Probe remote host and register in SAC before agent deploy (WinRM / SSH)."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Host
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import test_winrm_connection
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
class ManualHostAddError(Exception):
pass
def validate_windows_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите имя Windows-ПК")
if _IPV4_RE.match(text):
raise ManualHostAddError(
"Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен"
)
short = text.split(".")[0].split("\\")[-1].strip()
if not short or " " in short:
raise ManualHostAddError("Некорректное имя хоста")
return short
def validate_linux_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите IP или hostname Linux-хоста")
return text
def probe_windows_host(db: Session, hostname: str) -> str:
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Windows domain admin не настроен в SAC")
probe = test_winrm_connection(
target=hostname,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
return (probe.hostname or hostname).strip()
def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]:
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Linux SSH admin не настроен в SAC")
probe = test_ssh_connection(
target=target,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
hostname = _ssh_output_hostname(probe.stdout) or target
ipv4 = target if _is_ipv4(target) else None
return hostname, ipv4
def get_or_create_manual_host(
db: Session,
*,
hostname: str,
product: str,
os_family: str,
ipv4: str | None = None,
) -> Host:
host = db.scalar(
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
)
now = datetime.now(timezone.utc)
if host is None:
host = Host(
hostname=hostname,
os_family=os_family,
product=product,
ipv4=ipv4,
last_seen_at=now,
)
db.add(host)
else:
host.os_family = os_family
if ipv4:
host.ipv4 = ipv4
host.last_seen_at = now
db.flush()
return host
def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host:
platform_norm = (platform or "").strip().lower()
if platform_norm == "windows":
win_target = validate_windows_target(target)
hostname = probe_windows_host(db, win_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_RDP,
os_family="windows",
)
if platform_norm == "linux":
linux_target = validate_linux_target(target)
hostname, ipv4 = probe_linux_host(db, linux_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_SSH,
os_family="linux",
ipv4=ipv4,
)
raise ManualHostAddError("platform должен быть windows или linux")
@@ -0,0 +1,116 @@
"""Per-host management credentials (override global Win/Linux admin)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.host import Host
from app.services.linux_admin_settings import (
LinuxAdminConfig,
get_effective_linux_admin_for_host,
)
from app.services.win_admin_settings import (
WinAdminConfig,
get_effective_win_admin_for_host,
normalize_win_admin_user,
)
def mask_secret(value: str | None) -> str | None:
if not value:
return None
text = value.strip()
if not text:
return None
if len(text) <= 4:
return "****"
return f"{text[:2]}{text[-2:]}"
@dataclass(frozen=True)
class HostMgmtAccessView:
host_id: int
has_override: bool
user: str | None
password_set: bool
password_hint: str | None
effective_source: str
effective_configured: bool
effective_user: str | None
def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig:
os_family = (host.os_family or "").strip().lower()
if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}:
return get_effective_win_admin_for_host(db, host)
if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}:
return get_effective_linux_admin_for_host(db, host)
# Fallback: try host override first via win helper (same columns), then global win, then linux.
win_cfg = get_effective_win_admin_for_host(db, host)
if win_cfg.configured:
return win_cfg
return get_effective_linux_admin_for_host(db, host)
def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView:
host_user = (host.mgmt_user or "").strip() or None
host_password = (host.mgmt_password or "").strip() or None
has_override = bool(host_user or host_password)
effective = _effective_for_host(db, host)
return HostMgmtAccessView(
host_id=host.id,
has_override=has_override,
user=host_user,
password_set=bool(host_password),
password_hint=mask_secret(host_password),
effective_source=effective.source,
effective_configured=effective.configured,
effective_user=effective.user or None,
)
def upsert_host_mgmt_credentials(
db: Session,
host: Host,
*,
user: str | None = None,
password: str | None = None,
clear: bool = False,
) -> HostMgmtAccessView:
"""Update per-host credentials.
- clear=True wipe host override (use global Settings).
- user="" clear username.
- password omitted (None) keep existing password.
- password="" clear password.
"""
if clear:
host.mgmt_user = None
host.mgmt_password = None
db.commit()
db.refresh(host)
return get_host_mgmt_access_view(db, host)
if user is not None:
cleaned = user.strip()
if not cleaned:
host.mgmt_user = None
else:
# Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip.
os_family = (host.os_family or "").strip().lower()
if os_family == "windows" or "\\" in cleaned or "@" in cleaned:
host.mgmt_user = normalize_win_admin_user(cleaned)
else:
host.mgmt_user = cleaned
if password is not None:
if not password.strip():
host.mgmt_password = None
else:
host.mgmt_password = password
db.commit()
db.refresh(host)
return get_host_mgmt_access_view(db, host)
+449
View File
@@ -0,0 +1,449 @@
"""Background SSH/WinRM host actions (survives UI navigation and multi-worker API)."""
from __future__ import annotations
import logging
import os
import threading
from datetime import datetime, timezone
from typing import Any, Callable
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models import Host
from app.services.agent_update import execute_agent_update_fallback
from app.services.agent_update_types import AgentUpdateFallbackResult
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
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,
tail_ssh_monitor_update_log,
)
logger = logging.getLogger(__name__)
ActionRunner = Callable[[Session, Host], AgentUpdateFallbackResult | SshCommandResult]
_lock = threading.Lock()
_running: set[int] = set()
class RemoteActionAlreadyRunningError(Exception):
pass
def _run_inline() -> bool:
return os.environ.get("SAC_REMOTE_ACTION_INLINE", "").strip().lower() in ("1", "true", "yes")
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _format_output(result: AgentUpdateFallbackResult | SshCommandResult) -> str:
parts: list[str] = []
stdout = (getattr(result, "stdout", None) or "").strip()
stderr = (getattr(result, "stderr", None) or "").strip()
if stdout:
parts.append(stdout)
if stderr:
parts.append(stderr)
exit_code = getattr(result, "exit_code", None)
if exit_code is not None:
parts.append(f"exit code: {exit_code}")
return "\n\n".join(parts)
def _result_payload(
result: AgentUpdateFallbackResult | SshCommandResult,
*,
title: str,
started_at: str,
) -> dict[str, Any]:
finished = _utcnow().isoformat()
product_version = getattr(result, "product_version", None) or getattr(result, "agent_version", None)
return {
"title": title,
"status": "success" if result.ok else "failed",
"message": result.message,
"stdout": getattr(result, "stdout", None) or "",
"stderr": getattr(result, "stderr", None) or "",
"output": _format_output(result),
"ok": result.ok,
"exit_code": getattr(result, "exit_code", None),
"product_version": product_version,
"target": getattr(result, "target", None) or "",
"started_at": started_at,
"finished_at": finished,
}
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_for_host(db, host)
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:
started_at = _utcnow().isoformat()
host.agent_update_state = "running"
host.agent_update_last_error = None
host.remote_action = {
"title": title,
"status": "running",
"message": "Подключение к хосту и выполнение команды…",
"stdout": "",
"stderr": "",
"output": "",
"ok": None,
"exit_code": None,
"product_version": None,
"target": host.hostname,
"started_at": started_at,
"finished_at": None,
}
db.flush()
return started_at
def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult) -> None:
now = _utcnow()
host.agent_update_last_at = now
if result.ok:
host.agent_update_state = "success"
host.agent_update_last_error = None
if result.agent_version:
host.product_version = result.agent_version
host.ssh_admin_ok = True
host.ssh_admin_checked_at = now
else:
host.agent_update_state = "failed"
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_for_host(session, row)
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(
db: Session,
host: Host,
*,
title: str,
runner: ActionRunner,
poll_remote_log: bool = False,
) -> dict[str, Any]:
host_id = int(host.id)
with _lock:
if host_id in _running or host.agent_update_state == "running":
raise RemoteActionAlreadyRunningError(
f"Remote action already running for host {host.hostname}"
)
_running.add(host_id)
started_at = _mark_running(db, host, title=title)
db.commit()
def _worker() -> None:
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:
row = session.get(Host, host_id)
if row is None:
return
previous_output = (row.remote_action or {}).get("output") or ""
result = runner(session, row)
started = (row.remote_action or {}).get("started_at") or started_at
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):
_apply_ssh_update_result(session, row, result)
session.commit()
except Exception:
logger.exception("Background remote action failed for host_id=%s", host_id)
session.rollback()
row = session.get(Host, host_id)
if row is not None:
started = (row.remote_action or {}).get("started_at") or started_at
row.agent_update_state = "failed"
row.agent_update_last_error = "Internal error during remote action"
row.remote_action = {
"title": title,
"status": "failed",
"message": "Внутренняя ошибка SAC при выполнении действия",
"stdout": "",
"stderr": "",
"output": "",
"ok": False,
"exit_code": None,
"product_version": None,
"target": row.hostname,
"started_at": started,
"finished_at": _utcnow().isoformat(),
}
session.commit()
finally:
stop_poll.set()
if poll_thread is not None:
poll_thread.join(timeout=5.0)
session.close()
with _lock:
_running.discard(host_id)
if _run_inline():
_worker()
else:
threading.Thread(target=_worker, name=f"sac-remote-action-{host_id}", daemon=True).start()
return dict(host.remote_action or {})
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
return SshCommandResult(
ok=False,
message="Linux SSH admin is not configured",
target=host.hostname or "",
)
agent_cfg = get_effective_agent_update_config(db)
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
branch = (agent_cfg.git_branch or "main").strip() or "main"
try:
targets = iter_ssh_targets(host)
except Exception as exc:
return SshCommandResult(
ok=False,
message=str(exc),
target=host.hostname or "",
)
last: SshCommandResult | None = None
for target in targets:
last = run_ssh_monitor_update(
target=target,
user=cfg.user,
password=cfg.password,
repo_url=repo_url,
git_branch=branch,
)
if last.ok:
return last
return last or SshCommandResult(
ok=False,
message="SSH update failed",
target=host.hostname or "",
)
def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackResult:
return execute_agent_update_fallback(db, host)
def clear_stale_running_remote_actions(
db: Session,
*,
reason: str = "Прервано перезапуском SAC (фоновый worker не завершил job)",
host_ids: list[int] | None = None,
) -> list[str]:
"""Сбросить зависшие running после restart sac-api (daemon thread умер, запись в БД осталась)."""
from sqlalchemy import select
stmt = select(Host).where(Host.agent_update_state == "running")
if host_ids is not None:
stmt = stmt.where(Host.id.in_(host_ids))
rows = list(db.scalars(stmt).all())
if not rows:
return []
finished = _utcnow().isoformat()
hostnames: list[str] = []
for host in rows:
started = (host.remote_action or {}).get("started_at")
payload = dict(host.remote_action or {})
payload.update(
{
"title": payload.get("title") or "Remote action",
"status": "failed",
"message": reason,
"ok": False,
"finished_at": finished,
"started_at": started,
"target": payload.get("target") or host.hostname,
}
)
host.remote_action = payload
host.agent_update_state = "failed"
host.agent_update_last_error = reason[:2000]
hostnames.append(host.hostname or str(host.id))
db.commit()
with _lock:
for host in rows:
_running.discard(int(host.id))
return hostnames
def cancel_host_remote_action(db: Session, host: Host, *, reason: str | None = None) -> bool:
"""Отменить зависший running job вручную (admin)."""
if host.agent_update_state != "running":
return False
msg = reason or "Отменено администратором SAC"
cleared = clear_stale_running_remote_actions(db, reason=msg, host_ids=[int(host.id)])
return bool(cleared)
def get_remote_action_status(host: Host) -> dict[str, Any]:
payload = dict(host.remote_action or {})
if not payload:
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"
active = status == "running"
return {
"active": active,
"host_id": host.id,
"hostname": host.hostname,
"status": status,
"title": payload.get("title") or "",
"message": payload.get("message") or "",
"stdout": payload.get("stdout") or "",
"stderr": payload.get("stderr") or "",
"output": payload.get("output") or "",
"ok": payload.get("ok"),
"exit_code": payload.get("exit_code"),
"product_version": payload.get("product_version") or host.product_version,
"target": payload.get("target") or host.hostname,
"agent_update_state": host.agent_update_state,
"started_at": payload.get("started_at"),
"finished_at": payload.get("finished_at"),
}
+578
View File
@@ -0,0 +1,578 @@
"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.models import Event, Host
from app.services.event_actor_user import extract_event_actor_user
from app.services.linux_admin_settings import LinuxAdminConfig
from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError,
SshCommandResult,
is_linux_host,
iter_ssh_targets,
run_ssh_command,
)
from app.services.win_admin_settings import WinAdminConfig
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
WinRmCmdResult,
is_windows_host,
run_winrm_logoff,
run_winrm_on_host_targets,
run_winrm_qwinsta,
)
SESSION_EVENT_TYPES_LINUX = frozenset(
{
"session.logind.new",
"ssh.login.success",
"privilege.sudo.command",
}
)
SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"})
SESSION_TERMINATED_AT_KEY = "session_terminated_at"
SESSION_TERMINATED_BY_KEY = "session_terminated_by"
_LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE)
_LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE)
_LOGIND_STATES = frozenset(
{
"active",
"online",
"closing",
"opening",
"degraded",
"lingering",
"preparing",
"unknown",
}
)
_LOGIND_TTY_RE = re.compile(r"^(pts|tty)/", re.IGNORECASE)
_NO_SESSION_MARKERS = ("no session", "unknown session", "does not exist")
@dataclass(frozen=True)
class HostSessionRow:
session_id: str
user: str
tty: str | None = None
state: str | None = None
source_ip: str | None = None
session_name: str | None = None
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _event_login_user(event: Event) -> str:
actor = extract_event_actor_user(event.type, event.details)
if actor:
return actor.strip()
return str(_details_dict(event).get("user") or "").strip()
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 (
event_closed_by_rdg,
resolve_workstation_login_closed,
)
details = _details_dict(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(event):
return True
if event_closed_by_logoff(event):
return True
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 False
def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None:
details = dict(_details_dict(event))
details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat()
if by_username and by_username.strip():
details[SESSION_TERMINATED_BY_KEY] = by_username.strip()
event.details = details
def event_session_id(event: Event) -> str | None:
details = _details_dict(event)
for key in ("session_id", "sid"):
val = details.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return None
def event_supports_session_terminate(event: Event) -> bool:
if event.type in SESSION_EVENT_TYPES_LINUX:
return is_linux_host_event(event)
if event.type in SESSION_EVENT_TYPES_WINDOWS:
return is_windows_host_event(event)
return False
def is_linux_host_event(event: Event) -> bool:
host = event.host
return host is not None and is_linux_host(host)
def is_windows_host_event(event: Event) -> bool:
host = event.host
return host is not None and is_windows_host(host)
def _normalize_logind_tty(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text == "-":
return None
return text
def _logind_row_priority(row: HostSessionRow) -> tuple[int, int]:
"""Prefer interactive TTY sessions over ephemeral scope/SSH-exec rows (tty «-»)."""
has_tty = 1 if row.tty and _LOGIND_TTY_RE.match(row.tty) else 0
try:
sid_num = int(row.session_id)
except ValueError:
sid_num = 0
return (has_tty, sid_num)
def filter_logind_session_rows(rows: list[HostSessionRow]) -> list[HostSessionRow]:
"""Drop no-TTY duplicates when the same user already has a pts/tty session."""
if len(rows) < 2:
return rows
by_user: dict[str, list[HostSessionRow]] = {}
for row in rows:
key = row.user.casefold()
by_user.setdefault(key, []).append(row)
out: list[HostSessionRow] = []
for group in by_user.values():
if len(group) == 1:
out.append(group[0])
continue
with_tty = [r for r in group if r.tty and _LOGIND_TTY_RE.match(r.tty)]
if with_tty:
out.append(max(with_tty, key=_logind_row_priority))
continue
out.append(max(group, key=_logind_row_priority))
out.sort(key=lambda r: (r.user.casefold(), -_logind_row_priority(r)[1]))
return out
def parse_loginctl_sessions_json(stdout: str) -> list[HostSessionRow]:
text = stdout.strip()
if not text:
return []
try:
payload = json.loads(text)
except json.JSONDecodeError:
return []
if not isinstance(payload, list):
return []
rows: list[HostSessionRow] = []
for item in payload:
if not isinstance(item, dict):
continue
sid = str(item.get("session") or "").strip()
user = str(item.get("user") or "").strip()
if not sid or not user:
continue
if not _LOGIND_SESSION_ID_RE.match(sid):
continue
if not _LOGIND_USER_RE.match(user):
continue
state = str(item.get("state") or "").strip().lower() or None
if state and state not in _LOGIND_STATES:
continue
rows.append(
HostSessionRow(
session_id=sid,
user=user,
tty=_normalize_logind_tty(item.get("tty")),
state=state,
)
)
return filter_logind_session_rows(rows)
def _looks_like_loginctl_row(parts: list[str]) -> bool:
if len(parts) < 6:
return False
sid, uid, user, state = parts[0], parts[1], parts[2], parts[5].lower()
if not _LOGIND_SESSION_ID_RE.match(sid):
return False
if not uid.isdigit():
return False
if not _LOGIND_USER_RE.match(user):
return False
return state in _LOGIND_STATES
def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]:
rows: list[HostSessionRow] = []
for line in stdout.splitlines():
text = line.strip()
if not text or text.startswith("SESSION"):
continue
parts = text.split()
if not _looks_like_loginctl_row(parts):
continue
sid, user = parts[0], parts[2]
tty = parts[4] if len(parts) > 4 and parts[4] != "-" else None
state = parts[5] if len(parts) > 5 else None
rows.append(
HostSessionRow(
session_id=sid,
user=user,
tty=_normalize_logind_tty(tty),
state=state,
)
)
return filter_logind_session_rows(rows)
def _shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _linux_session_exists_message(stderr: str, stdout: str) -> bool:
blob = f"{stderr}\n{stdout}".casefold()
return any(marker in blob for marker in _NO_SESSION_MARKERS)
def _linux_show_session_user(host: Host, cfg: LinuxAdminConfig, session_id: str) -> str | None:
sid = session_id.strip()
if not sid:
return None
remote_cmd = f"loginctl show-session {_shell_quote(sid)} -p Name --value"
targets = iter_ssh_targets(host)
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=remote_cmd,
connect_timeout_sec=15,
command_timeout_sec=30,
need_root=True,
login_shell=False,
)
if not result.ok:
continue
user = result.stdout.strip().splitlines()[-1].strip() if result.stdout.strip() else ""
if user and _LOGIND_USER_RE.match(user):
return user
return None
def list_linux_sessions(host: Host, cfg: LinuxAdminConfig) -> tuple[list[HostSessionRow], SshCommandResult]:
if not is_linux_host(host):
raise SshHostNotLinuxError("Host is not Linux")
targets = iter_ssh_targets(host)
json_cmd = "loginctl list-sessions --output=json --no-legend"
text_cmd = "loginctl list-sessions --no-legend --no-pager"
last: SshCommandResult | None = None
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=json_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok and result.stdout.strip():
rows = parse_loginctl_sessions_json(result.stdout)
if rows:
return rows, result
rows = parse_loginctl_sessions(result.stdout)
if rows:
return rows, result
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=text_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok and result.stdout.strip():
return parse_loginctl_sessions(result.stdout), result
assert last is not None
return [], last
def terminate_linux_session(
host: Host,
cfg: LinuxAdminConfig,
session_id: str,
) -> SshCommandResult:
sid = session_id.strip()
if not sid:
return SshCommandResult(ok=False, message="session_id is required", target="")
if not is_linux_host(host):
raise SshHostNotLinuxError("Host is not Linux")
targets = iter_ssh_targets(host)
remote_cmd = f"loginctl terminate-session {_shell_quote(sid)}"
last: SshCommandResult | None = None
session_user = _linux_show_session_user(host, cfg, sid)
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=remote_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok:
return result
if session_user:
return _terminate_linux_user_sessions(host, cfg, session_user)
if last is not None and _linux_session_exists_message(last.stderr, last.stdout):
return SshCommandResult(
ok=False,
message=(
f"Сессия {sid} не найдена (устарела). Обновите список и повторите "
"или завершите все сессии пользователя."
),
target=last.target,
stdout=last.stdout,
stderr=last.stderr,
exit_code=last.exit_code,
)
assert last is not None
return last
def _normalize_sam_account(user: str) -> str:
text = (user or "").strip()
if "\\" in text:
return text.split("\\")[-1].strip().casefold()
if "@" in text:
return text.split("@")[0].strip().casefold()
return text.casefold()
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():
text = line.strip()
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
continue
parts = text.split()
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
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
if not state or state.casefold().startswith("listen"):
continue
before = parts[:id_idx]
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
rows.append(
HostSessionRow(
session_id=str(sid),
user=user_name,
session_name=session_name,
state=state,
)
)
if rows or not filter_sam:
return rows
return parse_qwinsta_sessions(stdout, filter_user=None)
def list_windows_sessions(host: Host, cfg: WinAdminConfig) -> tuple[list[HostSessionRow], WinRmCmdResult | None]:
if not is_windows_host(host):
raise HostNotWindowsError("Host is not Windows")
def action(target: str) -> WinRmCmdResult:
return run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password)
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
if result is None or not result.ok:
return [], result
return parse_qwinsta_sessions(result.stdout), result
def terminate_windows_session(
host: Host,
cfg: WinAdminConfig,
session_id: str,
) -> WinRmCmdResult | None:
try:
sid = int(session_id.strip())
except ValueError:
return WinRmCmdResult(ok=False, message="Invalid Windows session_id", target="")
def action(target: str) -> WinRmCmdResult:
return run_winrm_logoff(
target=target,
user=cfg.user,
password=cfg.password,
session_id=sid,
)
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
return result
def terminate_session_for_event(
event: Event,
*,
linux_cfg: LinuxAdminConfig,
win_cfg: WinAdminConfig,
session_id: str | None = None,
) -> SshCommandResult | WinRmCmdResult:
host = event.host
if host is None:
raise ValueError("Event has no host")
sid = (session_id or event_session_id(event) or "").strip()
if is_linux_host(host):
if not sid:
user = _event_login_user(event)
if user:
return _terminate_linux_user_sessions(host, linux_cfg, user)
raise ValueError("session_id is required for this event")
return terminate_linux_session(host, linux_cfg, sid)
if is_windows_host(host):
if not sid:
user = _event_login_user(event)
sessions, qwinsta = list_windows_sessions(host, win_cfg)
if not qwinsta or not qwinsta.ok:
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
matched = filter_windows_sessions_for_user(sessions, user) if user else sessions
if len(matched) == 1:
sid = matched[0].session_id
elif not matched:
# Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
return WinRmCmdResult(
ok=True,
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
target=qwinsta.target,
stdout=qwinsta.stdout,
)
else:
raise ValueError("Multiple sessions; specify session_id")
result = terminate_windows_session(host, win_cfg, sid)
if result is None:
raise ValueError("WinRM logoff failed")
return result
raise ValueError("Unsupported host OS for session terminate")
def _terminate_linux_user_sessions(host: Host, cfg: LinuxAdminConfig, user: str) -> SshCommandResult:
safe_user = _shell_quote(user.strip())
remote_cmd = f"loginctl terminate-user {safe_user}"
targets = iter_ssh_targets(host)
last: SshCommandResult | None = None
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=remote_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok:
return result
assert last is not None
return last
+136 -28
View File
@@ -6,16 +6,19 @@ import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy import func, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Host, Problem
from app.services.problem_rules import RuleMatch, build_fingerprint, host_silence_match_for_host
from app.services.problems import _correlation_cutoff
from app.services.problem_rules import RULE_HOST_SILENCE, RuleMatch, build_fingerprint, host_silence_match_for_host
logger = logging.getLogger(__name__)
ACTIVE_HOST_SILENCE_STATUSES = ("open", "acknowledged")
_HOST_SILENCE_SCAN_LOCK_KEY = 915_001_001
@dataclass(frozen=True)
class HostSilenceScanResult:
@@ -25,45 +28,110 @@ class HostSilenceScanResult:
created: bool
def _host_silence_scan_lock(db: Session) -> bool:
"""Один scan за раз (uvicorn workers + systemd timer)."""
bind = db.get_bind()
if bind.dialect.name != "postgresql":
return True
acquired = db.execute(
text("SELECT pg_try_advisory_xact_lock(:key)"),
{"key": _HOST_SILENCE_SCAN_LOCK_KEY},
).scalar()
return bool(acquired)
def _find_active_host_silence_problem(
db: Session,
*,
host_id: int,
hostname: str | None,
) -> Problem | None:
active = db.scalar(
select(Problem)
.where(
Problem.host_id == host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
)
.order_by(Problem.last_seen_at.desc())
.limit(1)
)
if active is not None:
return active
name = (hostname or "").strip()
if not name:
return None
sibling = db.scalar(
select(Problem)
.join(Host, Problem.host_id == Host.id)
.where(
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
func.lower(Host.hostname) == name.lower(),
)
.order_by(Problem.last_seen_at.desc())
.limit(1)
)
if sibling is not None and sibling.host_id != host_id:
sibling.host_id = host_id
db.flush()
return sibling
def _apply_host_silence_refresh(
problem: Problem,
*,
host_id: int,
match: RuleMatch,
fingerprint: str,
ref: datetime,
) -> None:
problem.host_id = host_id
problem.summary = match.summary
problem.title = match.title
problem.fingerprint = fingerprint
problem.last_seen_at = ref
problem.updated_at = ref
def open_or_refresh_host_silence_problem(
db: Session,
host_id: int,
match: RuleMatch,
*,
now: datetime | None = None,
hostname: str | None = None,
) -> tuple[Problem | None, bool]:
"""
Открыть или обновить open Problem host_silence без ingest-события.
Открыть или обновить open/ack Problem host_silence без ingest-события.
Returns (problem, created). problem is None when suppressed by manual-resolve cooldown.
"""
ref = now or datetime.now(timezone.utc)
if hostname is None:
host = db.get(Host, host_id)
hostname = host.hostname if host is not None else None
fingerprint = build_fingerprint(
host_id,
match.correlation_type,
match.rule_id,
match.fingerprint_suffix,
)
cutoff = _correlation_cutoff(ref)
open_problem = db.scalar(
select(Problem)
.where(
Problem.status == "open",
Problem.fingerprint == fingerprint,
Problem.host_id == host_id,
Problem.last_seen_at >= cutoff,
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
if active_problem is not None:
_apply_host_silence_refresh(
active_problem,
host_id=host_id,
match=match,
fingerprint=fingerprint,
ref=ref,
)
.order_by(Problem.last_seen_at.desc())
.limit(1)
)
if open_problem:
open_problem.summary = match.summary
open_problem.title = match.title
open_problem.last_seen_at = ref
open_problem.updated_at = ref
db.flush()
return open_problem, False
return active_problem, False
settings = get_settings()
cooldown_hours = settings.sac_host_silence_manual_resolve_cooldown_hours
@@ -105,12 +173,27 @@ def open_or_refresh_host_silence_problem(
.limit(1)
)
if manual_expired is not None:
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
if active_problem is not None:
_apply_host_silence_refresh(
active_problem,
host_id=host_id,
match=match,
fingerprint=fingerprint,
ref=ref,
)
db.flush()
return active_problem, False
manual_expired.status = "open"
manual_expired.resolved_by = None
manual_expired.summary = match.summary
manual_expired.title = match.title
manual_expired.last_seen_at = ref
manual_expired.updated_at = ref
_apply_host_silence_refresh(
manual_expired,
host_id=host_id,
match=match,
fingerprint=fingerprint,
ref=ref,
)
db.flush()
return manual_expired, True
@@ -125,13 +208,32 @@ def open_or_refresh_host_silence_problem(
event_count=0,
last_seen_at=ref,
)
db.add(problem)
db.flush()
try:
with db.begin_nested():
db.add(problem)
db.flush()
except IntegrityError:
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
if active_problem is None:
raise
_apply_host_silence_refresh(
active_problem,
host_id=host_id,
match=match,
fingerprint=fingerprint,
ref=ref,
)
db.flush()
return active_problem, False
return problem, True
def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]:
"""Найти stale-хосты и создать/обновить Problems rule:host_silence."""
if not _host_silence_scan_lock(db):
logger.debug("host_silence scan skipped (another runner holds advisory lock)")
return []
ref = now or datetime.now(timezone.utc)
hosts = db.scalars(select(Host).order_by(Host.id)).all()
results: list[HostSilenceScanResult] = []
@@ -140,7 +242,13 @@ def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[H
match = host_silence_match_for_host(db, host.id, hostname=host.hostname, now=ref)
if match is None:
continue
problem, created = open_or_refresh_host_silence_problem(db, host.id, match, now=ref)
problem, created = open_or_refresh_host_silence_problem(
db,
host.id,
match,
now=ref,
hostname=host.hostname,
)
if problem is None:
continue
results.append(
+16
View File
@@ -5,9 +5,12 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models import Event, Host
from app.services.agent_update import process_agent_update_ingest
from app.services.daily_report_format import normalize_daily_report_details
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.rdp_session_logoff import close_workstation_session_for_rdp_logoff
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
@@ -123,4 +126,17 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
if raced is not None:
return raced, False
raise
process_agent_update_ingest(db, host, payload.get("type", ""), details)
from app.services.rdg_workstation_session import (
enrich_empty_login_from_rdg_success,
enrich_workstation_login_user_from_rdg,
)
if event.host is None:
event.host = host
enrich_workstation_login_user_from_rdg(db, event)
enrich_empty_login_from_rdg_success(db, event)
close_workstation_session_for_rdg_end(db, event)
close_workstation_session_for_rdp_logoff(db, event)
return event, True
@@ -0,0 +1,96 @@
"""Effective Linux SSH admin credentials (host → DB → env)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.host import Host
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@dataclass(frozen=True)
class LinuxAdminConfig:
user: str
password: str
source: str # host | env | db
@property
def configured(self) -> bool:
return bool(self.user.strip() and self.password.strip())
def _linux_admin_from_env() -> LinuxAdminConfig:
settings = get_settings()
return LinuxAdminConfig(
user=settings.sac_linux_admin_user.strip(),
password=settings.sac_linux_admin_password,
source="env",
)
def get_effective_linux_admin_config(db: Session | None = None) -> LinuxAdminConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_linux_admin_config(session)
finally:
session.close()
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_cfg = _linux_admin_from_env()
if row is None:
return env_cfg
user = (row.linux_admin_user or "").strip() or env_cfg.user
password = (row.linux_admin_password or "") or env_cfg.password
has_db_values = bool((row.linux_admin_user or "").strip() or (row.linux_admin_password or "").strip())
return LinuxAdminConfig(
user=user,
password=password,
source="db" if has_db_values else env_cfg.source,
)
def upsert_linux_admin_settings(
db: Session,
*,
user: str | None = None,
password: str | None = None,
) -> LinuxAdminConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_cfg = _linux_admin_from_env()
if row is None:
row = UiSettings(
id=UI_SETTINGS_ROW_ID,
show_sidebar_system_stats=True,
linux_admin_user=env_cfg.user or None,
linux_admin_password=env_cfg.password or None,
)
db.add(row)
if user is not None and user.strip():
row.linux_admin_user = user.strip()
if password is not None and password.strip():
row.linux_admin_password = password
db.commit()
db.refresh(row)
return get_effective_linux_admin_config(db)
def get_effective_linux_admin_for_host(db: Session, host: Host) -> LinuxAdminConfig:
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
host_user = (host.mgmt_user or "").strip()
host_password = (host.mgmt_password or "").strip()
if host_user and host_password:
return LinuxAdminConfig(user=host_user, password=host_password, source="host")
global_cfg = get_effective_linux_admin_config(db)
if host_user and global_cfg.password.strip():
return LinuxAdminConfig(user=host_user, password=global_cfg.password, source="host")
return global_cfg
+12 -10
View File
@@ -1,4 +1,4 @@
"""Rate limit failed SAC UI logins + optional Telegram alert."""
"""Rate limit failed SAC UI logins + optional Telegram alert."""
from __future__ import annotations
@@ -12,6 +12,11 @@ from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.login_attempt import LoginAttempt
from app.services.client_ip import client_ip_from_request
from app.services.login_security_settings import (
get_effective_login_security_config,
is_ip_login_whitelisted,
)
from app.services.telegram_notify import send_telegram_text
logger = logging.getLogger(__name__)
@@ -33,15 +38,6 @@ def get_login_rate_limit_config() -> LoginRateLimitConfig:
)
def client_ip_from_request(request: Request) -> str:
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
if forwarded:
return forwarded.split(",")[0].strip()[:64]
if request.client and request.client.host:
return request.client.host[:64]
return "unknown"
def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
return int(
db.scalar(
@@ -91,6 +87,9 @@ def ensure_login_allowed(db: Session, request: Request) -> str:
"""Raise 429 if IP exceeded failed login threshold. Returns client IP."""
cfg = get_login_rate_limit_config()
ip_address = client_ip_from_request(request)
sec = get_effective_login_security_config(db)
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
return ip_address
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
failures = _failure_count(db, ip_address, since=since)
if failures >= cfg.max_failures:
@@ -119,6 +118,9 @@ def record_login_failure(
request: Request | None = None,
) -> None:
del request # reserved
sec = get_effective_login_security_config(db)
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
return
cfg = get_login_rate_limit_config()
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
prior_failures = _failure_count(db, ip_address, since=since)
@@ -0,0 +1,254 @@
"""SAC UI login security: IP whitelist, blocked list, unblock."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.login_attempt import LoginAttempt
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.fail2ban_sync import (
Fail2banActionResult,
list_ssh_banned_ips,
sync_fail2ban_ignore_ips,
unban_ssh_ip,
)
_IP_RE = re.compile(
r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$"
)
@dataclass(frozen=True)
class LoginSecurityConfig:
ip_whitelist: tuple[str, ...]
sync_fail2ban: bool
max_failures: int
window_minutes: int
source: str
@dataclass(frozen=True)
class LoginBlockEntry:
ip_address: str
scope: str
failure_count: int | None
usernames: tuple[str, ...]
blocked_until: datetime | None
reason: str
def normalize_ip_token(raw: str) -> str | None:
text = (raw or "").strip()
if not text or text.startswith("#"):
return None
if _IP_RE.match(text):
return text
return None
def parse_ip_whitelist_text(text: str) -> list[str]:
if not text:
return []
tokens = re.split(r"[\s,;]+", text.replace("\n", " "))
out: list[str] = []
seen: set[str] = set()
for token in tokens:
ip = normalize_ip_token(token)
if ip and ip not in seen:
seen.add(ip)
out.append(ip)
return out
def _env_whitelist() -> list[str]:
settings = get_settings()
raw = (getattr(settings, "sac_login_ip_whitelist", None) or "").strip()
return parse_ip_whitelist_text(raw.replace(",", " "))
def _db_whitelist(row: UiSettings | None) -> list[str]:
if row is None:
return []
return parse_ip_whitelist_text(row.login_ip_whitelist or "")
def merge_whitelist(*parts: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for part in parts:
for ip in part:
if ip not in seen:
seen.add(ip)
out.append(ip)
return out
def get_effective_login_security_config(db: Session) -> LoginSecurityConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_ips = _env_whitelist()
db_ips = _db_whitelist(row)
merged = merge_whitelist(env_ips, db_ips)
settings = get_settings()
max_failures = max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3))
window_minutes = max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15))
sources: list[str] = []
if env_ips:
sources.append("env")
if db_ips:
sources.append("db")
source = "+".join(sources) if sources else "default"
sync_fb = bool(row.login_sync_fail2ban) if row is not None else False
return LoginSecurityConfig(
ip_whitelist=tuple(merged),
sync_fail2ban=sync_fb,
max_failures=max_failures,
window_minutes=window_minutes,
source=source,
)
def is_ip_login_whitelisted(ip_address: str, whitelist: tuple[str, ...] | list[str]) -> bool:
ip = (ip_address or "").strip()
if not ip:
return False
return ip in whitelist
def upsert_login_security_settings(
db: Session,
*,
ip_whitelist_text: str,
sync_fail2ban: bool,
) -> LoginSecurityConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
row.login_ip_whitelist = ip_whitelist_text.strip() or None
row.login_sync_fail2ban = bool(sync_fail2ban)
db.commit()
db.refresh(row)
cfg = get_effective_login_security_config(db)
if cfg.sync_fail2ban:
sync_fail2ban_ignore_ips(list(cfg.ip_whitelist))
return cfg
def _window_since(cfg: LoginSecurityConfig) -> datetime:
return datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
def list_web_login_blocks(db: Session) -> list[LoginBlockEntry]:
cfg = get_effective_login_security_config(db)
since = _window_since(cfg)
rows = db.execute(
select(
LoginAttempt.ip_address,
func.count().label("cnt"),
func.max(LoginAttempt.created_at).label("last_at"),
)
.where(
LoginAttempt.success.is_(False),
LoginAttempt.username != "__alert_sent__",
LoginAttempt.created_at >= since,
)
.group_by(LoginAttempt.ip_address)
.having(func.count() >= cfg.max_failures)
).all()
blocks: list[LoginBlockEntry] = []
for ip_address, cnt, last_at in rows:
ip = str(ip_address)
if is_ip_login_whitelisted(ip, cfg.ip_whitelist):
continue
user_rows = db.scalars(
select(LoginAttempt.username)
.where(
LoginAttempt.ip_address == ip,
LoginAttempt.success.is_(False),
LoginAttempt.username.isnot(None),
LoginAttempt.username != "__alert_sent__",
LoginAttempt.created_at >= since,
)
.distinct()
.limit(10)
).all()
usernames = tuple(sorted({u for u in user_rows if u}))
blocked_until = (last_at + timedelta(minutes=cfg.window_minutes)) if last_at else None
blocks.append(
LoginBlockEntry(
ip_address=ip,
scope="web",
failure_count=int(cnt or 0),
usernames=usernames,
blocked_until=blocked_until,
reason=f"{cfg.max_failures} неудачных входов в UI за {cfg.window_minutes} мин",
)
)
return blocks
def list_ssh_login_blocks() -> list[LoginBlockEntry]:
banned = list_ssh_banned_ips()
return [
LoginBlockEntry(
ip_address=ip,
scope="ssh",
failure_count=None,
usernames=(),
blocked_until=None,
reason="fail2ban (sshd)",
)
for ip in banned
]
def list_all_login_blocks(db: Session) -> list[LoginBlockEntry]:
web = {b.ip_address: b for b in list_web_login_blocks(db)}
ssh = list_ssh_login_blocks()
out = list(web.values())
for entry in ssh:
if entry.ip_address in web:
existing = web[entry.ip_address]
out[out.index(existing)] = LoginBlockEntry(
ip_address=entry.ip_address,
scope="web+ssh",
failure_count=existing.failure_count,
usernames=existing.usernames,
blocked_until=existing.blocked_until,
reason=f"{existing.reason}; {entry.reason}",
)
else:
out.append(entry)
out.sort(key=lambda e: e.ip_address)
return out
def clear_web_login_block(db: Session, ip_address: str) -> int:
ip = (ip_address or "").strip()
if not ip:
return 0
result = db.execute(delete(LoginAttempt).where(LoginAttempt.ip_address == ip))
db.commit()
return int(result.rowcount or 0)
def unblock_login_ip(db: Session, ip_address: str, *, scope: str = "both") -> list[str]:
ip = (ip_address or "").strip()
if not ip:
return ["empty ip"]
messages: list[str] = []
scope_norm = (scope or "both").strip().lower()
if scope_norm in ("web", "both"):
deleted = clear_web_login_block(db, ip)
messages.append(f"Web UI: удалено записей login_attempts: {deleted}")
if scope_norm in ("ssh", "both"):
res: Fail2banActionResult = unban_ssh_ip(ip)
messages.append(res.message)
return messages
+2 -2
View File
@@ -16,6 +16,7 @@ from app.models import Event, Problem
from app.models.mobile_device import MobileDevice
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_policy import get_effective_notification_policy
from app.services.telegram_templates import format_event_mobile_push
logger = logging.getLogger(__name__)
@@ -170,8 +171,7 @@ def _send_fcm_data(
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
title = f"[{event.severity}] {event.title}"
body = (event.summary or "")[:200]
title, body = format_event_mobile_push(event)
data = {
"kind": "event",
"id": str(event.id),
+124 -1
View File
@@ -4,16 +4,25 @@ import logging
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.notification_cooldown import should_notify_event, should_notify_problem
from app.services.notification_policy import get_effective_notification_policy
from app.services.event_type_visibility import event_type_notifications_enabled
from app.services.host_health import HEARTBEAT_TYPE
from app.services.notification_severity import severity_meets_minimum
logger = logging.getLogger(__name__)
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"})
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
RDG_CONNECTION_TYPES = frozenset({
@@ -29,6 +38,17 @@ def _event_telegram_via_agent(event: Event) -> bool:
return via == "agent"
def _skip_notifications_for_hidden_event(event: Event, db: Session | None) -> bool:
if event_type_notifications_enabled(event.type, db):
return False
logger.info(
"notify skipped hidden event type=%s event_id=%s",
event.type,
event.event_id,
)
return True
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
if policy.use_telegram:
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
@@ -55,6 +75,10 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
if event.type == HEARTBEAT_TYPE:
return
if _should_suppress_sudo_notify(event, db=db):
return
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(event.severity, policy.min_severity):
return
@@ -89,14 +113,78 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) SAC не дублирует.
"""
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_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:
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
if _lifecycle_suppress_notifications(event, db=db):
return
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
@@ -108,6 +196,8 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) SAC не дублирует.
"""
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
@@ -116,7 +206,40 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
"""RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login)."""
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_dispatch_lifecycle_channels(event, db=db, policy=policy)
def schedule_notify_daily_report(event_db_id: int) -> None:
"""Отложенное оповещение по суточному отчёту (после 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
db = SessionLocal()
try:
event = db.get(Event, event_db_id)
if event is None:
logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
return
handler(event, db=db)
except Exception:
logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
finally:
db.close()
+5 -1
View File
@@ -18,6 +18,7 @@ PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
RULE_BRUTE_FORCE = "rule:brute_force_burst"
RULE_PRIVILEGE_SPIKE = "rule:privilege_spike"
RULE_HOST_SILENCE = "rule:host_silence"
RULE_RDG_SESSION_FLAP = "rule:rdg_session_flap"
@dataclass(frozen=True)
@@ -204,8 +205,11 @@ def build_fingerprint(host_id: int, correlation_type: str, rule_id: str, suffix:
def pick_rule_match(db: Session, event: Event) -> RuleMatch | None:
"""Первое сработавшее правило (приоритет: burst → spike → silence → immediate)."""
"""Первое сработавшее правило (приоритет: rdg flap → burst → spike → silence → immediate)."""
from app.services.rdg_session_flap import evaluate_rdg_session_flap
for evaluator in (
evaluate_rdg_session_flap,
evaluate_brute_force_burst,
evaluate_privilege_spike,
evaluate_host_silence,
+7 -5
View File
@@ -1,6 +1,6 @@
"""Auto-create Problems from ingested events (rules + correlation)."""
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -11,6 +11,7 @@ from app.services.host_health import HEARTBEAT_TYPE
from app.services.problem_rules import (
RuleMatch,
RULE_HOST_SILENCE,
RULE_RDG_SESSION_FLAP,
build_fingerprint,
pick_rule_match,
resolve_host_silence_problems,
@@ -18,8 +19,6 @@ from app.services.problem_rules import (
def _correlation_cutoff(now: datetime) -> datetime:
from datetime import timedelta
minutes = get_settings().sac_problem_correlation_window_minutes
return now - timedelta(minutes=minutes)
@@ -52,7 +51,7 @@ def open_or_append_problem(
if ref.tzinfo is None:
ref = ref.replace(tzinfo=timezone.utc)
problem, created = open_or_refresh_host_silence_problem(
db, event.host_id, match, now=ref
db, event.host_id, match, now=ref, hostname=event.host.hostname if event.host else None
)
if problem is None:
return None, False
@@ -66,7 +65,10 @@ def open_or_append_problem(
match.fingerprint_suffix,
)
now = datetime.now(timezone.utc)
cutoff = _correlation_cutoff(now)
if match.rule_id == RULE_RDG_SESSION_FLAP:
cutoff = now - timedelta(seconds=get_settings().sac_rdg_flap_dedup_sec)
else:
cutoff = _correlation_cutoff(now)
open_problem = db.scalar(
select(Problem)
+50
View File
@@ -0,0 +1,50 @@
"""Resolve RDG client workstation (session host) from event internal_ip."""
from __future__ import annotations
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.models import Event, Host
from app.services.rdg_session_flap import event_internal_ip
from app.services.winrm_connect import is_windows_host
class ClientWorkstationNotFoundError(Exception):
def __init__(self, internal_ip: str) -> None:
self.internal_ip = internal_ip
super().__init__(
f"Client workstation not found in SAC hosts for internal_ip={internal_ip}. "
"Ensure the PC is registered with matching ipv4."
)
def find_windows_host_by_ipv4(db: Session, ipv4: str) -> Host | None:
ip = (ipv4 or "").strip()
if not ip:
return None
rows = db.scalars(
select(Host)
.where(
Host.ipv4 == ip,
or_(
Host.os_family.ilike("windows"),
Host.product == "rdp-login-monitor",
),
)
.order_by(Host.last_seen_at.desc())
).all()
for host in rows:
if is_windows_host(host):
return host
return None
def resolve_client_workstation(db: Session, event: Event) -> Host:
internal_ip = event_internal_ip(event)
if not internal_ip:
raise ClientWorkstationNotFoundError("")
host = find_windows_host_by_ipv4(db, internal_ip)
if host is None:
raise ClientWorkstationNotFoundError(internal_ip)
return host
+132
View File
@@ -0,0 +1,132 @@
"""RD Gateway event labels (access path, UI title/summary)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event
from app.services.rdg_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import event_internal_ip, resolve_rdg_qwinsta_enabled
RDG_TYPES = frozenset(
{
"rdg.connection.success",
"rdg.connection.disconnected",
"rdg.connection.failed",
}
)
ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp"
ACCESS_PATH_DIRECT = "RDG-Comp"
@dataclass(frozen=True)
class RdgDisplayInfo:
title: str
summary: str
access_path: str | None
internal_ip: str | None
qwinsta_enabled: bool
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _event_external_ip(event: Event) -> str:
details = _details_dict(event)
for key in ("external_ip", "source_ip", "ip_address"):
val = details.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return ""
def _parse_haproxy_ips() -> frozenset[str]:
raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip()
if not raw:
return frozenset()
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
return frozenset(p for p in parts if p)
def classify_rdg_access_path(external_ip: str) -> str | None:
ip = (external_ip or "").strip()
if not ip or ip in ("-", "N/A"):
return None
if ip in _parse_haproxy_ips():
return ACCESS_PATH_HAPROXY
return ACCESS_PATH_DIRECT
def _windows_event_label(event: Event) -> str:
details = _details_dict(event)
win_id = details.get("event_id_windows")
if win_id is None:
return ""
text = str(win_id).strip()
return f"event {text}" if text else ""
def _action_label(event: Event) -> str:
if event.type == "rdg.connection.success":
return "подключение"
if event.type == "rdg.connection.disconnected":
return "отключение"
return "ошибка"
def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None:
if event.type not in RDG_TYPES:
return None
details = _details_dict(event)
internal_ip = event_internal_ip(event)
external_ip = _event_external_ip(event)
access_path = classify_rdg_access_path(external_ip)
user = str(details.get("user") or "").strip()
gateway = event.host.hostname if event.host else ""
client_label = internal_ip or ""
if db and internal_ip:
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is not None:
client_label = f"{client_host.hostname} ({internal_ip})"
path_note = f" ({access_path})" if access_path else ""
win_note = _windows_event_label(event)
action = _action_label(event)
title = f"RDS {action}{client_label}{path_note}"
if win_note:
title = f"{title} · {win_note}"
summary_parts: list[str] = []
if user:
summary_parts.append(user)
if external_ip:
summary_parts.append(f"внешний {external_ip}")
summary_parts.append(f"шлюз {gateway}")
if win_note:
summary_parts.append(win_note)
summary = " · ".join(summary_parts)
qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event)
return RdgDisplayInfo(
title=title,
summary=summary,
access_path=access_path,
internal_ip=internal_ip or None,
qwinsta_enabled=qwinsta_enabled,
)
def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool:
from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled
return resolve_rdg_qwinsta_enabled(db, event)
+311
View File
@@ -0,0 +1,311 @@
"""RDG 302→303 session flap: detect, flag event, build Problem match."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.config import get_settings
from app.models import Event
from app.services.problem_rules import RULE_RDG_SESSION_FLAP, RuleMatch
RDG_SUCCESS_TYPE = "rdg.connection.success"
RDG_END_TYPES = frozenset({"rdg.connection.disconnected", "rdg.connection.failed"})
def _event_user(event: Event) -> str:
details = event.details if isinstance(event.details, dict) else {}
user = details.get("user")
return str(user).strip() if user else ""
def _event_internal_ip(event: Event) -> str:
details = event.details if isinstance(event.details, dict) else {}
for key in ("internal_ip", "client_ip", "ip_address"):
val = details.get(key)
if val:
return str(val).strip()
return ""
def event_internal_ip(event: Event) -> str:
return _event_internal_ip(event)
def _users_match(end_event: Event, success_event: Event) -> bool:
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
end_ip = _event_internal_ip(end_event)
success_ip = _event_internal_ip(success_event)
if not end_ip or not success_ip:
return True
return end_ip == success_ip
def _internal_ips_match_strict(end_event: Event, success_event: Event) -> bool:
"""Для «сессия завершена» — только при совпадении целевого ПК (оба IP заданы)."""
end_ip = _event_internal_ip(end_event)
success_ip = _event_internal_ip(success_event)
if not end_ip or not success_ip:
return False
return end_ip == success_ip
def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def find_rdg_success_before_end(db: Session, end_event: Event) -> Event | None:
if end_event.type not in RDG_END_TYPES:
return None
settings = get_settings()
min_sec = settings.sac_rdg_flap_window_min_sec
max_sec = settings.sac_rdg_flap_window_max_sec
end_at = _as_utc(end_event.occurred_at)
window_start = end_at - timedelta(seconds=max_sec)
window_end = end_at - timedelta(seconds=min_sec)
candidates = db.scalars(
select(Event)
.where(
Event.host_id == end_event.host_id,
Event.type == RDG_SUCCESS_TYPE,
Event.occurred_at >= window_start,
Event.occurred_at <= window_end,
Event.id != end_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
for prior in candidates:
if not _users_match(end_event, prior):
continue
if not _internal_ips_compatible(end_event, prior):
continue
prior_at = _as_utc(prior.occurred_at)
delta = (end_at - prior_at).total_seconds()
if min_sec <= delta <= max_sec:
return prior
return None
def mark_rdg_flap(event: Event, *, pair_event: Event) -> None:
details = dict(event.details) if isinstance(event.details, dict) else {}
details["rdg_flap"] = True
details["rdg_flap_pair_event_id"] = pair_event.id
event.details = details
flag_modified(event, "details")
def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None:
prior = find_rdg_success_before_end(db, event)
if prior is None:
return None
mark_rdg_flap(event, pair_event=prior)
user = _event_user(event)
internal_ip = _event_internal_ip(event)
ip_note = f", client {internal_ip}" if internal_ip else ""
delta_sec = int((_as_utc(event.occurred_at) - _as_utc(prior.occurred_at)).total_seconds())
return RuleMatch(
rule_id=RULE_RDG_SESSION_FLAP,
correlation_type="rdg.session.flap",
fingerprint_suffix=f"u{user}:ip{internal_ip or 'any'}",
title=f"RDG session flap: {user}",
summary=(
f"302→303 за {delta_sec} с "
f"({user}{ip_note}). Возможна зависшая сессия на ПК пользователя — qwinsta/logoff."
),
severity="warning",
)
def event_has_rdg_flap(event: Event) -> bool:
details = event.details if isinstance(event.details, dict) else {}
return details.get("rdg_flap") is True
def _stored_flap_pair_id(event: Event) -> int | None:
details = event.details if isinstance(event.details, dict) else {}
raw = details.get("rdg_flap_pair_event_id")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
"""303 с rdg_flap, у которого пара — этот 302 (или вычисляется по окну)."""
if success_event.type != RDG_SUCCESS_TYPE:
return None
settings = get_settings()
min_sec = settings.sac_rdg_flap_window_min_sec
max_sec = settings.sac_rdg_flap_window_max_sec
start_at = _as_utc(success_event.occurred_at)
window_start = start_at + timedelta(seconds=min_sec)
window_end = start_at + timedelta(seconds=max_sec)
candidates = db.scalars(
select(Event)
.where(
Event.host_id == success_event.host_id,
Event.type.in_(RDG_END_TYPES),
Event.occurred_at >= window_start,
Event.occurred_at <= window_end,
Event.id != success_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for end in candidates:
if not _users_match(end, success_event):
continue
if not _internal_ips_compatible(end, success_event):
continue
pair_id = _stored_flap_pair_id(end)
if pair_id == success_event.id:
return end
prior = find_rdg_success_before_end(db, end)
if prior is not None and prior.id == success_event.id:
return end
return None
def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
"""303 после 302 с паузой больше flap-окна — штатное завершение сессии."""
if success_event.type != RDG_SUCCESS_TYPE:
return None
settings = get_settings()
max_sec = settings.sac_rdg_flap_window_max_sec
start_at = _as_utc(success_event.occurred_at)
after_flap = start_at + timedelta(seconds=max_sec)
candidates = db.scalars(
select(Event)
.where(
Event.host_id == success_event.host_id,
Event.type.in_(RDG_END_TYPES),
Event.occurred_at > after_flap,
Event.id != success_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for end in candidates:
if not _users_match(end, success_event):
continue
if not _internal_ips_match_strict(end, success_event):
continue
return end
return None
def _users_and_ip_match(left: Event, right: Event) -> bool:
return _users_match(left, right) and _internal_ips_match_strict(left, right)
def find_later_rdg_success_after(
db: Session,
*,
anchor: Event,
after: datetime,
) -> Event | None:
"""Поздний 302 на том же шлюзе, user и client PC — пользователь снова зашёл через RDG."""
if anchor.type != RDG_SUCCESS_TYPE:
return None
after_at = _as_utc(after)
candidates = db.scalars(
select(Event)
.where(
Event.host_id == anchor.host_id,
Event.type == RDG_SUCCESS_TYPE,
Event.occurred_at > after_at,
Event.id != anchor.id,
)
.order_by(Event.occurred_at.asc())
).all()
for later in candidates:
if not _users_and_ip_match(later, anchor):
continue
return later
return None
def _flap_auto_disconnect_succeeded(flap_end: Event) -> bool:
details = flap_end.details if isinstance(flap_end.details, dict) else {}
block = details.get("rdp_flap_auto_disconnect")
if not isinstance(block, dict):
return False
return block.get("ok") is True
def _flap_workstation_session_closed(db: Session, flap_end: Event) -> bool:
from app.services.host_sessions import event_session_terminated
from app.services.rdg_workstation_session import find_workstation_login_for_rdg_end
login = find_workstation_login_for_rdg_end(db, flap_end)
if login is None:
return False
return event_session_terminated(login, db=db)
def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool:
"""Кнопка qwinsta/logoff только на 302, пока сессия может быть активна (или RDG flap)."""
if event.type in RDG_END_TYPES:
return False
if event.type != RDG_SUCCESS_TYPE:
return False
if not _event_internal_ip(event):
return False
if db is None:
return True
flap_end = find_rdg_end_after_success(db, event)
if flap_end is not None:
if _flap_auto_disconnect_succeeded(flap_end):
return False
if _flap_workstation_session_closed(db, flap_end):
return False
if find_later_rdg_success_after(db, anchor=event, after=flap_end.occurred_at) is not None:
return False
return True
if find_normal_rdg_end_after_success(db, event) is not None:
return False
return True
def resolve_rdg_flap_summary(
db: Session, event: Event
) -> tuple[bool, int | None, int | None]:
"""
(rdg_flap, pair_event_id, qwinsta_event_id).
qwinsta_event_id id события 302 для qwinsta (на 303 кнопку не показываем).
"""
if event_has_rdg_flap(event):
pair_id = _stored_flap_pair_id(event)
if event.type == RDG_SUCCESS_TYPE:
return True, pair_id, event.id
return True, pair_id, pair_id
if event.type in RDG_END_TYPES:
prior = find_rdg_success_before_end(db, event)
if prior is not None:
return True, prior.id, prior.id
if event.type == RDG_SUCCESS_TYPE:
end = find_rdg_end_after_success(db, event)
if end is not None:
return True, end.id, event.id
return False, None, None
+182
View File
@@ -0,0 +1,182 @@
"""RDG qwinsta/logoff via WinRM on client workstation (variant B)."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models import AgentCommand, Event, Host
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
from app.services.rdg_display import event_supports_rdg_client_qwinsta
from app.services.rdg_session_flap import event_internal_ip
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import (
WinRmCmdResult,
run_winrm_logoff,
run_winrm_on_host_targets,
run_winrm_qwinsta,
)
def _require_win_admin(db: Session, host: Host | None = None):
if host is not None:
cfg = get_effective_win_admin_for_host(db, host)
else:
from app.services.win_admin_settings import get_effective_win_admin_config
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=503,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
return cfg
def _require_rdg_client_qwinsta(db: Session, event: Event) -> None:
if event_supports_rdg_client_qwinsta(event, db):
return
raise HTTPException(
status_code=400,
detail="Event is not an RD Gateway connection with internal_ip (client workstation)",
)
def _resolve_client(db: Session, event: Event) -> Host:
try:
return resolve_client_workstation(db, event)
except ClientWorkstationNotFoundError as exc:
if not exc.internal_ip:
raise HTTPException(
status_code=400,
detail="Event has no internal_ip (client workstation address)",
) from exc
raise HTTPException(status_code=404, detail=str(exc)) from exc
def _format_result_message(result: WinRmCmdResult, *, attempts: list[str]) -> str:
message = result.message
if attempts:
message = f"{message} (пробовали: {', '.join(attempts)})"
return message
def _persist_command(
db: Session,
*,
client_host: Host,
event: Event,
command_type: str,
params: dict,
requested_by: str,
result: WinRmCmdResult,
attempts: list[str],
) -> AgentCommand:
now = datetime.now(timezone.utc)
cmd = AgentCommand(
command_uuid=str(uuid.uuid4()),
host_id=client_host.id,
event_id=event.id,
command_type=command_type,
params=params,
status="completed" if result.ok else "failed",
result_stdout=result.stdout or None,
result_stderr=(result.stderr or _format_result_message(result, attempts=attempts) or None)
if not result.ok
else None,
requested_by=requested_by,
created_at=now,
completed_at=now,
)
db.add(cmd)
db.flush()
return cmd
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
_require_rdg_client_qwinsta(db, event)
client_host = _resolve_client(db, event)
cfg = _require_win_admin(db, client_host)
details = event.details if isinstance(event.details, dict) else {}
user = details.get("user")
internal_ip = event_internal_ip(event)
result, attempts = run_winrm_on_host_targets(
client_host,
user=cfg.user,
password=cfg.password,
action=lambda target: run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password),
)
assert result is not None
params = {
"execution": "winrm",
"user": user,
"internal_ip": internal_ip,
"client_hostname": client_host.hostname,
"client_host_id": client_host.id,
"winrm_target": result.target,
}
return _persist_command(
db,
client_host=client_host,
event=event,
command_type="qwinsta",
params=params,
requested_by=requested_by,
result=result,
attempts=attempts,
)
def execute_logoff_via_winrm(
db: Session,
event: Event,
*,
session_id: int,
requested_by: str,
) -> AgentCommand:
_require_rdg_client_qwinsta(db, event)
client_host = _resolve_client(db, event)
cfg = _require_win_admin(db, client_host)
details = event.details if isinstance(event.details, dict) else {}
user = details.get("user")
internal_ip = event_internal_ip(event)
result, attempts = run_winrm_on_host_targets(
client_host,
user=cfg.user,
password=cfg.password,
action=lambda target: run_winrm_logoff(
target=target,
user=cfg.user,
password=cfg.password,
session_id=session_id,
),
)
assert result is not None
params = {
"execution": "winrm",
"user": user,
"internal_ip": internal_ip,
"client_hostname": client_host.hostname,
"client_host_id": client_host.id,
"session_id": session_id,
"winrm_target": result.target,
}
return _persist_command(
db,
client_host=client_host,
event=event,
command_type="logoff",
params=params,
requested_by=requested_by,
result=result,
attempts=attempts,
)
@@ -0,0 +1,290 @@
"""Correlate RDG 303/303-failed with workstation rdp.login.success (1149)."""
from __future__ import annotations
from datetime import timedelta, timezone
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_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import (
RDG_END_TYPES,
RDG_SUCCESS_TYPE,
_event_user,
event_internal_ip,
find_rdg_success_before_end,
)
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id"
USER_ENRICHED_FROM_RDG_EVENT_ID_KEY = "user_enriched_from_rdg_event_id"
# RCM 1149 via RD Gateway often has empty Param1/Param2; RDG 302 has the account.
RDG_LOGIN_USER_ENRICH_WINDOW = timedelta(minutes=5)
WORKSTATION_LOGIN_TYPE = "rdp.login.success"
def _as_utc(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def normalize_sam_account(user: str) -> str:
text = (user or "").strip()
if "\\" in text:
return text.split("\\")[-1].strip().casefold()
if "@" in text:
return text.split("@")[0].strip().casefold()
return text.casefold()
def users_match_rdg(login_user: str, rdg_user: str) -> bool:
left = normalize_sam_account(login_user)
right = normalize_sam_account(rdg_user)
return bool(left and right and left == right)
def _login_user_missing(event: Event) -> bool:
details = _details_dict(event)
for key in ("user", "username"):
val = details.get(key)
if val is not None and str(val).strip() not in ("", "-"):
return False
return True
def _apply_rdg_user_to_login(login_event: Event, *, rdg_event: Event, rdg_user: str) -> None:
details = dict(_details_dict(login_event))
details["user"] = rdg_user
details[USER_ENRICHED_FROM_RDG_EVENT_ID_KEY] = rdg_event.id
login_event.details = details
flag_modified(login_event, "details")
summary = (login_event.summary or "").strip()
if summary.startswith("RCM 1149") and rdg_user not in summary:
rest = summary[len("RCM 1149") :].strip()
login_event.summary = f"RCM 1149 {rdg_user} {rest}".strip()
def find_rdg_success_for_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Nearest RDG 302 for this workstation IP within the enrich window."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
host = login_event.host
if host is None or not (host.ipv4 or "").strip():
return None
workstation_ip = host.ipv4.strip()
login_at = _as_utc(login_event.occurred_at)
window_start = login_at - RDG_LOGIN_USER_ENRICH_WINDOW
window_end = login_at + RDG_LOGIN_USER_ENRICH_WINDOW
candidates = db.scalars(
select(Event)
.where(
Event.type == RDG_SUCCESS_TYPE,
Event.occurred_at >= window_start,
Event.occurred_at <= window_end,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
best: Event | None = None
best_delta: timedelta | None = None
for rdg in candidates:
if event_internal_ip(rdg) != workstation_ip:
continue
if not _event_user(rdg):
continue
delta = abs(_as_utc(rdg.occurred_at) - login_at)
if best is None or best_delta is None or delta < best_delta:
best = rdg
best_delta = delta
return best
def enrich_workstation_login_user_from_rdg(db: Session, login_event: Event) -> Event | None:
"""Fill details.user when RCM 1149 EventLog left Param1/Param2 empty (seen on some Win10 Pro)."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if not _login_user_missing(login_event):
return None
rdg = find_rdg_success_for_workstation_login(db, login_event)
if rdg is None:
return None
rdg_user = _event_user(rdg)
if not rdg_user:
return None
_apply_rdg_user_to_login(login_event, rdg_event=rdg, rdg_user=rdg_user)
return login_event
def enrich_empty_login_from_rdg_success(db: Session, rdg_success_event: Event) -> Event | None:
"""Backfill empty workstation 1149 when RDG 302 is ingested after it."""
if rdg_success_event.type != RDG_SUCCESS_TYPE:
return None
internal_ip = event_internal_ip(rdg_success_event)
rdg_user = _event_user(rdg_success_event)
if not internal_ip or not rdg_user:
return None
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is None:
return None
rdg_at = _as_utc(rdg_success_event.occurred_at)
window_start = rdg_at - RDG_LOGIN_USER_ENRICH_WINDOW
window_end = rdg_at + RDG_LOGIN_USER_ENRICH_WINDOW
candidates = db.scalars(
select(Event)
.where(
Event.host_id == client_host.id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at >= window_start,
Event.occurred_at <= window_end,
Event.id != rdg_success_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
for login in candidates:
if not _login_user_missing(login):
continue
_apply_rdg_user_to_login(login, rdg_event=rdg_success_event, rdg_user=rdg_user)
return login
return None
def event_closed_by_rdg(event: Event) -> bool:
details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> None:
details = dict(_details_dict(login_event))
details[SESSION_CLOSED_BY_RDG_AT_KEY] = rdg_end_event.occurred_at.isoformat()
details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] = rdg_end_event.id
login_event.details = details
flag_modified(login_event, "details")
def _login_already_closed(login_event: Event) -> bool:
from app.services.rdp_session_logoff import event_closed_by_logoff
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_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
if rdg_end_event.type not in RDG_END_TYPES:
return None
internal_ip = event_internal_ip(rdg_end_event)
if not internal_ip:
return None
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is None:
return None
rdg_user = _event_user(rdg_end_event)
if not rdg_user:
return None
end_at = rdg_end_event.occurred_at
candidates = db.scalars(
select(Event)
.where(
Event.host_id == client_host.id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at <= end_at,
Event.id != rdg_end_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
for login in candidates:
if _login_already_closed(login):
continue
login_user = (_event_login_user(login) or "").strip()
if login_user in ("", "-"):
login_user = ""
# RCM 1149 may lack user (empty Param1); still close by workstation IP + open session.
if login_user and not users_match_rdg(login_user, rdg_user):
continue
return login
return None
def find_rdg_end_after_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Runtime lookup for historical events without persisted close flag."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if _login_already_closed(login_event):
return None
host = login_event.host
if host is None or not host.ipv4:
return None
workstation_ip = host.ipv4.strip()
login_user = _event_login_user(login_event)
if not login_user:
return None
login_at = login_event.occurred_at
candidates = db.scalars(
select(Event)
.where(
Event.type.in_(RDG_END_TYPES),
Event.occurred_at >= login_at,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for end in candidates:
if event_internal_ip(end) != workstation_ip:
continue
if not users_match_rdg(_event_user(end), login_user):
continue
if find_rdg_success_before_end(db, end) is not None:
continue
return end
return None
def resolve_workstation_login_closed(db: Session, login_event: Event) -> bool:
if event_closed_by_rdg(login_event):
return True
return find_rdg_end_after_workstation_login(db, login_event) is not None
def close_workstation_session_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
"""On RDG disconnect, mark matching workstation login as session-closed."""
if rdg_end_event.type not in RDG_END_TYPES:
return None
if find_rdg_success_before_end(db, rdg_end_event) is not None:
return None
login = find_workstation_login_for_rdg_end(db, rdg_end_event)
if login is None:
return None
mark_login_closed_by_rdg(login, rdg_end_event=rdg_end_event)
return login
@@ -0,0 +1,69 @@
"""Short-lived RDP agent bundles for WinRM clients to download."""
from __future__ import annotations
import io
import re
import secrets
import time
import zipfile
from pathlib import Path
_UTF8_BOM = b"\xef\xbb\xbf"
_BOM_SUFFIXES = {".ps1", ".txt"}
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
TTL_SECONDS = 600
BUNDLE_DIR = Path("/tmp/sac-rdp-bundles")
def _bundle_file_bytes(path: Path) -> bytes:
data = path.read_bytes()
if path.suffix.lower() in _BOM_SUFFIXES and not data.startswith(_UTF8_BOM):
return _UTF8_BOM + data
return data
def build_rdp_bundle_zip(repo_dir: Path, filenames: tuple[str, ...]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for name in filenames:
path = repo_dir / name
if path.is_file():
info = zipfile.ZipInfo(name)
info.compress_type = zipfile.ZIP_DEFLATED
info.flag_bits |= 0x800
archive.writestr(info, _bundle_file_bytes(path))
return buffer.getvalue()
def _prune_expired_bundles(now: float | None = None) -> None:
if not BUNDLE_DIR.is_dir():
return
cutoff = (now or time.time()) - TTL_SECONDS
for path in BUNDLE_DIR.glob("*.zip"):
try:
if path.stat().st_mtime < cutoff:
path.unlink(missing_ok=True)
except OSError:
continue
def register_rdp_bundle_zip(zip_bytes: bytes) -> str:
token = secrets.token_urlsafe(32)
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
_prune_expired_bundles()
(BUNDLE_DIR / f"{token}.zip").write_bytes(zip_bytes)
return token
def get_rdp_bundle_zip(token: str) -> bytes | None:
text = (token or "").strip()
if not _TOKEN_RE.fullmatch(text):
return None
path = BUNDLE_DIR / f"{text}.zip"
if not path.is_file():
return None
if time.time() - path.stat().st_mtime > TTL_SECONDS:
path.unlink(missing_ok=True)
return None
return path.read_bytes()
@@ -0,0 +1,324 @@
"""Auto logoff stuck RDP sessions when RDG flap (or direct login failure) is detected."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event, Host
from app.services.host_sessions import (
list_windows_sessions,
mark_event_session_terminated,
parse_qwinsta_sessions,
terminate_windows_session,
)
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
from app.services.rdg_session_flap import (
RDG_END_TYPES,
RDG_SUCCESS_TYPE,
_event_user,
_stored_flap_pair_id,
event_has_rdg_flap,
find_rdg_success_before_end,
)
from app.services.rdg_winrm_actions import execute_logoff_via_winrm
from app.services.rdg_workstation_session import (
WORKSTATION_LOGIN_TYPE,
_event_login_user,
_login_already_closed,
find_workstation_login_for_rdg_end,
users_match_rdg,
)
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
from app.services.win_admin_settings import get_effective_win_admin_for_host
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
AUTO_DISCONNECT_BY = "auto:rdp_flap"
RDP_LOGIN_FAILED = "rdp.login.failed"
AUTO_DISCONNECT_DETAILS_KEY = "rdp_flap_auto_disconnect"
@dataclass(frozen=True)
class AutoDisconnectResult:
ok: bool
message: str
trigger_event_id: int
workstation_host_id: int | None = None
login_event_id: int | None = None
session_ids: tuple[int, ...] = ()
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _already_auto_disconnected(event: Event) -> bool:
details = _details_dict(event)
block = details.get(AUTO_DISCONNECT_DETAILS_KEY)
if not isinstance(block, dict):
return False
return block.get("ok") is True
def _mark_auto_disconnect(event: Event, *, result: AutoDisconnectResult) -> None:
details = dict(_details_dict(event))
details[AUTO_DISCONNECT_DETAILS_KEY] = {
"ok": result.ok,
"message": result.message,
"workstation_host_id": result.workstation_host_id,
"login_event_id": result.login_event_id,
"session_ids": list(result.session_ids),
"at": datetime.now(timezone.utc).isoformat(),
}
event.details = details
flag_modified(event, "details")
def _norm_user_filter(user: str) -> str:
text = (user or "").strip()
if "\\" in text:
return text.split("\\")[-1].strip().lower()
if "@" in text:
return text.split("@")[0].strip().lower()
return text.lower()
def _sessions_for_user(sessions, user: str):
needle = _norm_user_filter(user)
if not needle:
return []
matched = []
for row in sessions:
if needle in _norm_user_filter(row.user):
matched.append(row)
return matched
def find_open_workstation_login(db: Session, *, host_id: int, user: str) -> Event | None:
if not user.strip():
return None
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == WORKSTATION_LOGIN_TYPE,
)
.order_by(Event.occurred_at.desc())
).all()
for login in candidates:
if _login_already_closed(login):
continue
if users_match_rdg(_event_login_user(login), user):
return login
return None
def _rdg_pair_success_event(db: Session, event: Event) -> Event | None:
if event.type == RDG_SUCCESS_TYPE and event_has_rdg_flap(event):
pair_id = _stored_flap_pair_id(event)
if pair_id is not None:
return db.get(Event, pair_id)
return event
if event.type in RDG_END_TYPES:
pair_id = _stored_flap_pair_id(event)
if pair_id is not None:
return db.get(Event, pair_id)
return find_rdg_success_before_end(db, event)
return None
def _disconnect_on_workstation(
db: Session,
*,
trigger_event: Event,
workstation: Host,
rdg_event: Event | None,
user: str,
login_event: Event | None,
) -> AutoDisconnectResult:
win_cfg = get_effective_win_admin_for_host(db, workstation)
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
if qwinsta is None or not qwinsta.ok:
message = qwinsta.message if qwinsta else "qwinsta failed"
return AutoDisconnectResult(
ok=False,
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
)
parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
matched = _sessions_for_user(parsed, user)
if not matched and qwinsta.stdout.strip():
parsed = parse_qwinsta_sessions(qwinsta.stdout)
matched = _sessions_for_user(parsed, user)
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(
ok=False,
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
)
logged_off: list[int] = []
errors: list[str] = []
for row in matched:
if rdg_event is not None:
cmd = execute_logoff_via_winrm(
db,
rdg_event,
session_id=int(row.session_id),
requested_by=AUTO_DISCONNECT_BY,
)
if cmd.status == "completed":
logged_off.append(int(row.session_id))
else:
errors.append(cmd.result_stderr or cmd.result_stdout or f"logoff {row.session_id} failed")
else:
result = terminate_windows_session(workstation, win_cfg, row.session_id)
if result is not None and result.ok:
logged_off.append(int(row.session_id))
else:
errors.append(result.message if result else f"logoff {row.session_id} failed")
if login_event is not None and logged_off:
mark_event_session_terminated(login_event, by_username=AUTO_DISCONNECT_BY)
if logged_off:
message = f"Auto logoff session(s) {', '.join(str(s) for s in logged_off)} on {workstation.hostname}"
ok = True
else:
ok = False
message = "; ".join(errors) if errors else "logoff failed"
return AutoDisconnectResult(
ok=ok,
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
session_ids=tuple(logged_off),
)
def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult | None:
if not event_has_rdg_flap(event):
return None
if _already_auto_disconnected(event):
return None
rdg_success = _rdg_pair_success_event(db, event)
if rdg_success is None:
return None
user = _event_user(event) or _event_user(rdg_success)
if not user:
return None
try:
workstation = resolve_client_workstation(db, rdg_success)
except ClientWorkstationNotFoundError as exc:
result = AutoDisconnectResult(
ok=False,
message=str(exc),
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)
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
result = _disconnect_on_workstation(
db,
trigger_event=event,
workstation=workstation,
rdg_event=rdg_success,
user=user,
login_event=login_event,
)
_mark_auto_disconnect(event, result=result)
return result
def _auto_disconnect_direct_rdp_failed(db: Session, event: Event) -> AutoDisconnectResult | None:
if event.type != RDP_LOGIN_FAILED:
return None
if _already_auto_disconnected(event):
return None
host = event.host
if host is None:
return None
user = _event_login_user(event)
if not user:
return None
login_event = find_open_workstation_login(db, host_id=host.id, user=user)
if login_event is None:
return None
result = _disconnect_on_workstation(
db,
trigger_event=event,
workstation=host,
rdg_event=None,
user=user,
login_event=login_event,
)
_mark_auto_disconnect(event, result=result)
return result
def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDisconnectResult | None:
"""Log off stuck user session when auto-disconnect is enabled."""
if not get_effective_rdp_flap_settings(db).auto_disconnect:
return None
result = _auto_disconnect_rdg_flap(db, event)
if result is not None:
if result.ok:
logger.info(
"auto rdp flap disconnect ok event_id=%s sessions=%s",
event.event_id,
result.session_ids,
)
else:
logger.warning(
"auto rdp flap disconnect failed event_id=%s: %s",
event.event_id,
result.message,
)
return result
result = _auto_disconnect_direct_rdp_failed(db, event)
if result is not None:
if result.ok:
logger.info(
"auto direct rdp disconnect ok event_id=%s sessions=%s",
event.event_id,
result.session_ids,
)
else:
logger.warning(
"auto direct rdp disconnect failed event_id=%s: %s",
event.event_id,
result.message,
)
return result
+33
View File
@@ -0,0 +1,33 @@
"""RDP / RDG flap auto-disconnect settings (ui_settings singleton)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@dataclass(frozen=True)
class RdpFlapSettings:
auto_disconnect: bool
source: str = "db"
def get_effective_rdp_flap_settings(db: Session) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
return RdpFlapSettings(auto_disconnect=False, source="default")
return RdpFlapSettings(auto_disconnect=bool(row.auto_rdp_flap_disconnect), source="db")
def upsert_rdp_flap_settings(db: Session, *, auto_disconnect: bool) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
row.auto_rdp_flap_disconnect = bool(auto_disconnect)
db.commit()
db.refresh(row)
return get_effective_rdp_flap_settings(db)
+148
View File
@@ -0,0 +1,148 @@
"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event
from app.services.host_sessions import (
SESSION_TERMINATED_AT_KEY,
_details_dict,
_event_login_user,
)
from app.services.rdg_workstation_session import (
WORKSTATION_LOGIN_TYPE,
event_closed_by_rdg,
users_match_rdg,
)
SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at"
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id"
LOGOFF_EVENT_TYPE = "rdp.session.logoff"
LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE})
def event_closed_by_logoff(event: Event) -> bool:
details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None:
details = dict(_details_dict(login_event))
details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat()
details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id
login_event.details = details
flag_modified(login_event, "details")
def _login_already_closed(login_event: Event) -> bool:
details = _details_dict(login_event)
if details.get("session_terminated") is True:
return True
at = details.get(SESSION_TERMINATED_AT_KEY)
if at is not None and str(at).strip() != "":
return True
if event_closed_by_rdg(login_event):
return True
return event_closed_by_logoff(login_event)
def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None:
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
logoff_user = _event_login_user(logoff_event)
if not logoff_user:
return None
logoff_at = logoff_event.occurred_at
host_id = logoff_event.host_id
if host_id is None:
return None
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at <= logoff_at,
Event.id != logoff_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
logoff_details = _details_dict(logoff_event)
logoff_ip = str(logoff_details.get("ip_address") or "").strip()
for login in candidates:
if _login_already_closed(login):
continue
login_user = _event_login_user(login)
if not users_match_rdg(login_user, logoff_user):
continue
if logoff_ip and logoff_ip not in ("", "-"):
login_ip = str(_details_dict(login).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip:
continue
return login
return None
def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Runtime lookup for historical logins without persisted close flag."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if _login_already_closed(login_event):
return None
host_id = login_event.host_id
if host_id is None:
return None
login_user = _event_login_user(login_event)
if not login_user:
return None
login_at = login_event.occurred_at
login_ip = str(_details_dict(login_event).get("ip_address") or "").strip()
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == LOGOFF_EVENT_TYPE,
Event.occurred_at >= login_at,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for logoff in candidates:
if not users_match_rdg(_event_login_user(logoff), login_user):
continue
logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"):
if login_ip != logoff_ip:
continue
return logoff
return None
def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool:
if event_closed_by_logoff(login_event):
return True
return find_logoff_after_workstation_login(db, login_event) is not None
def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None:
"""On direct RDP logoff, mark matching open workstation login as session-closed."""
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
login = find_workstation_login_for_logoff(db, logoff_event)
if login is None:
return None
mark_login_closed_by_logoff(login, logoff_event=logoff_event)
return login
+37
View File
@@ -0,0 +1,37 @@
"""Format and extract RDP/RDG session_duration_sec for UI."""
from __future__ import annotations
from typing import Any
def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None:
if not isinstance(details, dict):
return None
raw = details.get("session_duration_sec")
if raw is None or raw == "":
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
if value < 0:
return None
return value
def format_session_duration(seconds: int) -> str:
"""
Human-readable session length for lists:
- under 24h HH:MM:SS (05:05:24)
- 24h+ HH:MM:SS (1д 00:01:25)
"""
if seconds < 0:
return ""
days, rem = divmod(int(seconds), 86_400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
clock = f"{hours:02d}:{minutes:02d}:{secs:02d}"
if days:
return f"{days}д {clock}"
return clock
+588
View File
@@ -0,0 +1,588 @@
"""SSH connectivity and remote commands for Linux hosts."""
from __future__ import annotations
import re
import socket
from dataclasses import dataclass
from app.config import get_settings
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_DIR = "/opt/scripts/update"
SSH_MONITOR_REPO_NAME = "ssh-monitor"
SSH_MONITOR_UPDATE_REPO_URL = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
SSH_MONITOR_VERSION_CMD = (
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
"| sed -E 's/^SSH_MONITOR_VERSION=[\"'\\'']*([^\"'\\'']+)[\"'\\'']*$/\\1/'"
)
SSH_OUTPUT_MAX_LEN = 12_000
_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE)
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
_SSH_TRANSIENT_ERROR_MARKERS = (
"no existing session",
"error reading ssh protocol banner",
"connection reset",
"connection lost",
"session not active",
"eof",
"socket is closed",
"connection timed out",
)
_SSH_CONNECT_ATTEMPTS = 2
class LinuxAdminNotConfiguredError(Exception):
pass
class HostNotLinuxError(Exception):
pass
class HostTargetMissingError(Exception):
pass
@dataclass(frozen=True)
class SshCommandResult:
ok: bool
message: str
target: str
stdout: str = ""
stderr: str = ""
exit_code: int | None = None
agent_version: str | None = None
def is_linux_host(host: Host) -> bool:
if (host.os_family or "").strip().lower() == "linux":
return True
return (host.product or "").strip() == "ssh-monitor"
def _is_ipv4(value: str) -> bool:
return bool(_IPV4_RE.match(value.strip()))
def _looks_like_ssh_hostname(value: str) -> bool:
"""Короткое имя или FQDN; не человекочитаемый display_name вроде «Ubabuba Kalina (10.10.36.9)»."""
text = value.strip()
if not text or " " in text or "(" in text or ")" in text:
return False
if _is_ipv4(text):
return True
if len(text) > 253:
return False
return all(ch.isalnum() or ch in ".-_" for ch in text)
def _ssh_name_resolves(name: str) -> bool:
if _is_ipv4(name):
return True
try:
socket.getaddrinfo(name.strip(), 22, type=socket.SOCK_STREAM)
return True
except OSError:
return False
def iter_ssh_targets(host: Host) -> list[str]:
if not is_linux_host(host):
raise HostNotLinuxError("Host is not Linux")
seen: set[str] = set()
ordered: list[str] = []
def add(value: str | None) -> None:
text = (value or "").strip()
if not text or text.casefold() in seen:
return
if not _looks_like_ssh_hostname(text):
return
if not _ssh_name_resolves(text):
return
seen.add(text.casefold())
ordered.append(text)
add(host.hostname)
inventory = host.inventory if isinstance(host.inventory, dict) else {}
computer_name = inventory.get("computer_name")
if isinstance(computer_name, str):
add(computer_name)
if host.display_name and _looks_like_ssh_hostname(host.display_name):
add(host.display_name)
add(host.ipv4)
if not ordered:
raise HostTargetMissingError("Host has no resolvable hostname or IPv4 for SSH")
return ordered
def _truncate_output(text: str) -> str:
if len(text) <= SSH_OUTPUT_MAX_LEN:
return text
return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)"
def _is_transient_ssh_error(exc: BaseException) -> bool:
text = str(exc).casefold()
return any(marker in text for marker in _SSH_TRANSIENT_ERROR_MARKERS)
def _ssh_connect_hint(exc: BaseException) -> str:
text = str(exc).casefold()
if "no existing session" in text:
return (
" Сервер закрыл SSH-сессию во время подключения или auth "
"(проверьте пароль admin, лимиты sshd MaxSessions/MaxStartups, "
"доступ SAC→хост по 22/tcp; на SAC не должен мешать ssh-agent)."
)
return ""
def _connect_ssh_client(
client,
*,
target: str,
user: str,
password: str,
connect_timeout_sec: int,
) -> None:
client.connect(
hostname=target,
username=user,
password=password,
timeout=connect_timeout_sec,
banner_timeout=connect_timeout_sec,
auth_timeout=connect_timeout_sec,
allow_agent=False,
look_for_keys=False,
compress=False,
)
def _close_ssh_client(client) -> None:
if client is None:
return
try:
transport = client.get_transport()
if transport is not None and transport.is_active():
transport.close()
except Exception:
pass
try:
client.close()
except Exception:
pass
def _shell_single_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _remote_shell_command(
user: str,
remote_cmd: str,
*,
need_root: bool = False,
login_shell: bool = True,
) -> tuple[str, bool]:
"""Build remote shell command. Returns (command, needs_sudo_password_on_stdin)."""
safe_cmd = _shell_single_quote(remote_cmd)
bash_flag = "lc" if login_shell else "c"
if user == "root" or not need_root:
return f"bash -{bash_flag} {safe_cmd}", False
return f"sudo -S -p '' bash -{bash_flag} {safe_cmd}", True
def _configure_ssh_client(client, target: str) -> None:
import paramiko
settings = get_settings()
if settings.sac_ssh_auto_add_host_key:
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
return
client.set_missing_host_key_policy(paramiko.RejectPolicy())
client.load_system_host_keys()
known_hosts = (settings.sac_ssh_known_hosts_file or "").strip()
if known_hosts:
try:
client.load_host_keys(known_hosts)
except OSError:
pass
def _exec_remote_command(
client,
*,
shell_cmd: str,
password: str,
needs_sudo_password: bool,
command_timeout_sec: int,
) -> tuple[int, str, str]:
if needs_sudo_password:
stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec, get_pty=True)
stdin.write(f"{password}\n")
stdin.flush()
stdin.channel.shutdown_write()
else:
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
exit_code = stdout.channel.recv_exit_status()
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
return exit_code, out_text, err_text
def run_ssh_command(
*,
target: str,
user: str,
password: str,
remote_cmd: str,
connect_timeout_sec: int = 15,
command_timeout_sec: int = 600,
need_root: bool = False,
login_shell: bool = True,
) -> SshCommandResult:
target = target.strip()
user = user.strip()
if not target or not user or not password:
raise LinuxAdminNotConfiguredError("Linux SSH admin credentials or target missing")
try:
import paramiko
except ImportError as exc:
raise RuntimeError("paramiko is not installed on SAC server") from exc
shell_cmd, needs_sudo_password = _remote_shell_command(
user,
remote_cmd,
need_root=need_root,
login_shell=login_shell,
)
exit_code: int | None = None
out_text = ""
err_text = ""
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
client = paramiko.SSHClient()
try:
_configure_ssh_client(client, target)
_connect_ssh_client(
client,
target=target,
user=user,
password=password,
connect_timeout_sec=connect_timeout_sec,
)
exit_code, out_text, err_text = _exec_remote_command(
client,
shell_cmd=shell_cmd,
password=password,
needs_sudo_password=needs_sudo_password,
command_timeout_sec=command_timeout_sec,
)
break
except paramiko.AuthenticationException:
return SshCommandResult(
ok=False,
message=f"SSH auth failed for {user}@{target}",
target=target,
)
except (socket.timeout, TimeoutError):
return SshCommandResult(
ok=False,
message=f"SSH timeout ({connect_timeout_sec}s connect / {command_timeout_sec}s cmd) to {target}",
target=target,
)
except Exception as exc:
if attempt < _SSH_CONNECT_ATTEMPTS and _is_transient_ssh_error(exc):
continue
hint = _ssh_connect_hint(exc)
return SshCommandResult(
ok=False,
message=f"SSH error ({target}): {exc}{hint}",
target=target,
)
finally:
_close_ssh_client(client)
else:
return SshCommandResult(
ok=False,
message=f"SSH error ({target}): repeated transient connection failures",
target=target,
)
ok = exit_code == 0
if ok:
message = f"SSH OK ({target}), exit 0"
if out_text.strip():
message = f"{message}\n{out_text.strip().splitlines()[0][:200]}"
else:
detail = err_text.strip() or out_text.strip() or f"exit code {exit_code}"
message = f"SSH command failed ({target}): {detail[:500]}"
return SshCommandResult(
ok=ok,
message=message,
target=target,
stdout=out_text,
stderr=err_text,
exit_code=exit_code,
)
def _ssh_output_hostname(stdout: str) -> str:
"""Last line that looks like a hostname (MOTD/login banners may precede command output)."""
candidates = [ln.strip() for ln in stdout.splitlines() if ln.strip()]
for line in reversed(candidates):
if re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}$", line):
return line
return candidates[-1] if candidates else ""
def test_ssh_connection(
*,
target: str,
user: str,
password: str,
timeout_sec: int = 15,
) -> SshCommandResult:
result = run_ssh_command(
target=target,
user=user,
password=password,
remote_cmd="hostname",
connect_timeout_sec=timeout_sec,
command_timeout_sec=timeout_sec,
login_shell=False,
)
if result.ok and result.stdout.strip():
host = _ssh_output_hostname(result.stdout)
if not host:
return result
return SshCommandResult(
ok=True,
message=f"SSH OK, hostname={host}",
target=target,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
)
return result
def parse_ssh_monitor_version_text(text: str) -> str | None:
if not text:
return None
match = _AGENT_VERSION_RE.search(text)
if not match:
return None
return match.group(1)
def probe_ssh_monitor_version(
*,
target: str,
user: str,
password: str,
) -> str | None:
result = run_ssh_command(
target=target,
user=user,
password=password,
remote_cmd=SSH_MONITOR_VERSION_CMD,
command_timeout_sec=30,
login_shell=False,
)
if result.ok and result.stdout.strip():
version = parse_ssh_monitor_version_text(result.stdout)
if version:
return version
if result.stdout or result.stderr:
return parse_ssh_monitor_version_text(f"{result.stdout}\n{result.stderr}")
return None
def _ssh_monitor_preflight_git_remote(repo_url: str) -> str:
"""Align clone remotes with SAC-trusted URL (works even before updater script is refreshed)."""
safe_repo = _shell_single_quote(repo_url.strip())
repo_dir = _shell_single_quote(f"{SSH_MONITOR_UPDATE_DIR}/{SSH_MONITOR_REPO_NAME}")
return (
f"if [ -d {repo_dir}/.git ]; then "
f"git -C {repo_dir} remote add kalinamall {safe_repo} 2>/dev/null || "
f"git -C {repo_dir} remote set-url kalinamall {safe_repo}; "
f"if git -C {repo_dir} remote get-url origin >/dev/null 2>&1; then "
f"git -C {repo_dir} remote set-url origin {safe_repo}; "
f"fi; fi"
)
def _ssh_monitor_update_invoke_command(
repo_url: str,
*,
git_branch: str = "main",
) -> str:
"""Run installed updater with SAC-trusted git URL (overrides script default / stale origin)."""
safe_repo = _shell_single_quote(repo_url.strip())
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
preflight = _ssh_monitor_preflight_git_remote(repo_url)
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:
"""Clone ssh-monitor repo and install updater when /opt/scripts/update_ssh_monitor.sh is absent."""
safe_repo = repo_url.replace("\\", "\\\\").replace('"', '\\"')
safe_branch = (git_branch or "main").replace("\\", "\\\\").replace('"', '\\"')
return (
f'UPDATE_DIR="{SSH_MONITOR_UPDATE_DIR}";'
f'REPO_URL="{safe_repo}";'
f'GIT_BRANCH="{safe_branch}";'
f'SCRIPT_NAME="{SSH_MONITOR_REPO_NAME}";'
f'INSTALL="{SSH_MONITOR_UPDATE_SCRIPT}";'
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
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'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; "
)
def run_ssh_monitor_update(
*,
target: str,
user: str,
password: str,
repo_url: str | None = None,
git_branch: str | None = None,
) -> SshCommandResult:
script = SSH_MONITOR_UPDATE_SCRIPT
effective_repo = (repo_url or SSH_MONITOR_UPDATE_REPO_URL).strip()
effective_branch = (git_branch or "main").strip() or "main"
update_cmd = _ssh_monitor_update_invoke_command(
effective_repo,
git_branch=effective_branch,
)
check_cmd = f"test -x {script} && echo ready || echo missing"
probe = run_ssh_command(
target=target,
user=user,
password=password,
remote_cmd=check_cmd,
command_timeout_sec=30,
login_shell=False,
)
if not probe.ok:
return SshCommandResult(
ok=False,
message=f"SSH probe failed on {target}: {probe.message}",
target=target,
stdout=probe.stdout,
stderr=probe.stderr,
exit_code=probe.exit_code,
)
bootstrapped = False
if "missing" in probe.stdout:
bootstrap_cmd = _ssh_monitor_bootstrap_command(
effective_repo,
git_branch=effective_branch,
)
updated = run_ssh_command(
target=target,
user=user,
password=password,
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
command_timeout_sec=900,
need_root=True,
login_shell=False,
)
bootstrapped = True
else:
updated = run_ssh_command(
target=target,
user=user,
password=password,
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
command_timeout_sec=900,
need_root=True,
login_shell=False,
)
if not updated.ok:
prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed"
return SshCommandResult(
ok=False,
message=f"{prefix} ({target}): {updated.message}",
target=updated.target,
stdout=updated.stdout,
stderr=updated.stderr,
exit_code=updated.exit_code,
)
version = probe_ssh_monitor_version(target=target, user=user, password=password)
if not version:
version = parse_ssh_monitor_version_text(updated.stdout)
if version:
prefix = "Bootstrap + update OK" if bootstrapped else updated.message
message = f"{prefix}\nagent version: {version}"
return SshCommandResult(
ok=True,
message=message,
target=updated.target,
stdout=updated.stdout,
stderr=updated.stderr,
exit_code=updated.exit_code,
agent_version=version,
)
if bootstrapped:
return SshCommandResult(
ok=True,
message=f"Bootstrap + update OK ({target}), exit 0",
target=updated.target,
stdout=updated.stdout,
stderr=updated.stderr,
exit_code=updated.exit_code,
)
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)
+56 -12
View File
@@ -506,25 +506,69 @@ def format_event_telegram_html(event: Event) -> str:
return _append_event_source(_format_event_body_html(event), event)
def _format_rdg_html(event: Event) -> str:
details = _details_dict(event)
if event.type.endswith(".success"):
header = "✅ RD Gateway: подключение"
elif event.type.endswith(".disconnected"):
header = "️ RD Gateway: отключение"
_TELEGRAM_INLINE_TAGS = re.compile(r"</?(?:b|i|u|s|code|pre|tg-spoiler)(\s[^>]*)?>", re.I)
_TELEGRAM_ANCHOR_OPEN = re.compile(r"<a\b[^>]*>", re.I)
_TELEGRAM_ANCHOR_CLOSE = re.compile(r"</a>", re.I)
_MOBILE_PUSH_BODY_MAX = 3500
def telegram_html_to_plaintext(text: str) -> str:
"""Plain text for Seaca push from the same HTML templates as Telegram."""
out = sanitize_telegram_html(text)
out = _TELEGRAM_INLINE_TAGS.sub("", out)
out = _TELEGRAM_ANCHOR_OPEN.sub("", out)
out = _TELEGRAM_ANCHOR_CLOSE.sub("", out)
out = html.unescape(out)
out = re.sub(r"\n{3,}", "\n\n", out)
return out.strip()
def format_event_mobile_push(event: Event) -> tuple[str, str]:
"""Title + body for FCM data payload (mirrors Telegram wording)."""
plain = telegram_html_to_plaintext(format_event_telegram_html(event))
lines = [line.strip() for line in plain.split("\n") if line.strip()]
if lines:
title = lines[0]
body = "\n".join(lines[1:]).strip()
if not body:
body = (event.summary or "").strip()
else:
header = "❌ RD Gateway: ошибка"
title = f"[{event.severity}] {event.title}"
body = (event.summary or "").strip()
if len(title) > 120:
title = title[:117] + ""
if len(body) > _MOBILE_PUSH_BODY_MAX:
body = body[: _MOBILE_PUSH_BODY_MAX - 1] + ""
return title, body
def _format_rdg_html(event: Event) -> str:
from app.services.rdg_display import build_rdg_display
details = _details_dict(event)
display = build_rdg_display(event)
if event.type.endswith(".success"):
header = "✅ RDS через RD Gateway"
elif event.type.endswith(".disconnected"):
header = "ℹ️ RDS отключение (RD Gateway)"
else:
header = "❌ RDS ошибка (RD Gateway)"
msg = f"<b>{header}</b>\n"
if display is not None and display.access_path:
msg += _line("🔀", "Путь", html_escape(display.access_path))
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
msg += _line("🏢", "Хост", host_label(event.host))
msg += _line("🚪", "Шлюз RDG", host_label(event.host))
msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address")))
msg += _line("🏠", "Внутренний IP", html_escape(_detail(details, "internal_ip", default="-")))
msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-")))
err = _detail(details, "gateway_error_code", "error_code", default="")
if err != "-":
msg += _line("⚠️", "Код ошибки", html_escape(err))
dur = _detail(details, "session_duration_sec", default="")
if dur not in ("-", "0", ""):
msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с")
from app.services.session_duration import extract_session_duration_sec, format_session_duration
dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None)
if dur_sec is not None and dur_sec > 0:
msg += _line("⏱️", "Длительность", html_escape(format_session_duration(dur_sec)))
msg += _line("🕐", "Время", format_time(event.occurred_at))
win_id = _detail(details, "event_id_windows", default="")
if win_id != "-":
+109
View File
@@ -0,0 +1,109 @@
"""Effective Windows domain admin credentials (host → DB → env)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.host import Host
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@dataclass(frozen=True)
class WinAdminConfig:
user: str
password: str
source: str # host | env | db
@property
def configured(self) -> bool:
return bool(self.user.strip() and self.password.strip())
def normalize_win_admin_user(user: str) -> str:
"""DOMAIN\\user — один обратный слэш; убрать лишнее экранирование из env/UI."""
text = user.strip()
if not text:
return ""
text = text.replace("\\\\", "\\")
if "\\" not in text and "@" not in text and "/" not in text:
# NETBIOS\user без слэша — не трогаем (оператор допишет в UI)
return text
return text
def _win_admin_from_env() -> WinAdminConfig:
settings = get_settings()
return WinAdminConfig(
user=normalize_win_admin_user(settings.sac_win_admin_user),
password=settings.sac_win_admin_password,
source="env",
)
def get_effective_win_admin_config(db: Session | None = None) -> WinAdminConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_win_admin_config(session)
finally:
session.close()
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_cfg = _win_admin_from_env()
if row is None:
return env_cfg
user = normalize_win_admin_user((row.win_admin_user or "").strip() or env_cfg.user)
password = (row.win_admin_password or "") or env_cfg.password
has_db_values = bool((row.win_admin_user or "").strip() or (row.win_admin_password or "").strip())
return WinAdminConfig(
user=user,
password=password,
source="db" if has_db_values else env_cfg.source,
)
def upsert_win_admin_settings(
db: Session,
*,
user: str | None = None,
password: str | None = None,
) -> WinAdminConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_cfg = _win_admin_from_env()
if row is None:
row = UiSettings(
id=UI_SETTINGS_ROW_ID,
show_sidebar_system_stats=True,
win_admin_user=env_cfg.user or None,
win_admin_password=env_cfg.password or None,
)
db.add(row)
if user is not None and user.strip():
row.win_admin_user = normalize_win_admin_user(user)
if password is not None and password.strip():
row.win_admin_password = password
db.commit()
db.refresh(row)
return get_effective_win_admin_config(db)
def get_effective_win_admin_for_host(db: Session, host: Host) -> WinAdminConfig:
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
host_user = normalize_win_admin_user((host.mgmt_user or "").strip())
host_password = (host.mgmt_password or "").strip()
if host_user and host_password:
return WinAdminConfig(user=host_user, password=host_password, source="host")
global_cfg = get_effective_win_admin_config(db)
# Allow host to override only the username, still using global password (rare).
if host_user and global_cfg.password.strip():
return WinAdminConfig(user=host_user, password=global_cfg.password, source="host")
return global_cfg
+692
View File
@@ -0,0 +1,692 @@
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
from __future__ import annotations
import re
import socket
from dataclasses import dataclass
from pathlib import Path
from app.config import get_settings
from app.models import Host
from app.services.agent_git_release import _cache_dir, clone_or_update_repo
from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, register_rdp_bundle_zip
_CLIXML_PROGRESS_NOISE = frozenset(
{
"Preparing modules for first use.",
"Preparing modules fo",
}
)
_CLIXML_MARKER = "#< CLIXML"
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]")
RDP_REMOTE_STAGING_DIRNAME = "sac-rdp-staging"
RDP_LEGACY_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging"
RDP_BUNDLE_FILES = (
"Login_Monitor.ps1",
"Sac-Client.ps1",
"RdpMonitor-TaskQuery.ps1",
"version.txt",
"Deploy-LoginMonitor.ps1",
"Restart-RdpLoginMonitor.ps1",
"login_monitor.settings.example.ps1",
)
RDP_BUNDLE_REQUIRED = frozenset(
{
"Login_Monitor.ps1",
"Sac-Client.ps1",
"version.txt",
"Deploy-LoginMonitor.ps1",
}
)
class WinAdminNotConfiguredError(Exception):
pass
class HostNotWindowsError(Exception):
pass
class HostTargetMissingError(Exception):
pass
@dataclass(frozen=True)
class WinRmTestResult:
ok: bool
message: str
target: str
hostname: str | None = None
@dataclass(frozen=True)
class WinRmCmdResult:
ok: bool
message: str
target: str
stdout: str = ""
stderr: str = ""
exit_code: int | None = None
def _winrm_session(
target: str,
user: str,
password: str,
*,
timeout_sec: int = 15,
):
from app.services.win_admin_settings import normalize_win_admin_user
user = normalize_win_admin_user(user.strip())
target = target.strip()
if not target or not user or not password:
raise WinAdminNotConfiguredError("Windows admin credentials or target missing")
try:
import winrm
except ImportError as exc:
raise RuntimeError("pywinrm is not installed on SAC server") from exc
operation_timeout_sec = max(5, timeout_sec)
read_timeout_sec = operation_timeout_sec + 15
settings = get_settings()
scheme = "https" if settings.sac_winrm_use_https else "http"
port = 5986 if settings.sac_winrm_use_https else 5985
endpoint = f"{scheme}://{target}:{port}/wsman"
cert_validation = (settings.sac_winrm_server_cert_validation or "validate").strip().lower()
if cert_validation not in {"validate", "ignore"}:
cert_validation = "validate"
session = winrm.Session(
endpoint,
auth=(user, password),
transport="ntlm",
read_timeout_sec=read_timeout_sec,
operation_timeout_sec=operation_timeout_sec,
server_cert_validation=cert_validation,
)
return session, winrm
def _clixml_to_plain(text: str) -> str:
"""Turn WinRM/PowerShell CLIXML blobs into readable text; drop progress noise."""
raw = text or ""
if _CLIXML_MARKER not in raw:
return raw.strip()
messages = re.findall(r'<S N="Message">([^<]*)</S>', raw, flags=re.IGNORECASE)
messages = [
msg.strip()
for msg in messages
if msg.strip() and msg.strip() not in _CLIXML_PROGRESS_NOISE
]
to_strings = re.findall(r"<ToString>([^<]*)</ToString>", raw, flags=re.IGNORECASE)
to_strings = [
text.strip()
for text in to_strings
if text.strip() and "Preparing modules" not in text
]
parts = [*messages, *to_strings]
if parts:
return "\n".join(parts)
without = re.sub(r"#< CLIXML.*", "", raw, flags=re.DOTALL).strip()
return without
def _decode_text_score(text: str) -> int:
cyrillic = len(_CYRILLIC_RE.findall(text))
suspicious = len(re.findall(r"\?{3,}", text))
return cyrillic * 5 - text.count("\ufffd") * 20 - suspicious * 15 - max(0, text.count("?") - cyrillic) * 2
def _decode_winrm_bytes(data: bytes) -> str:
if not data:
return ""
for encoding in ("utf-8-sig", "utf-8"):
try:
text = data.decode(encoding)
except UnicodeDecodeError:
break
score = _decode_text_score(text)
if text.isascii() or score > 0:
return text
best_score = -10_000
best_text = ""
for encoding in ("cp866", "cp1251"):
try:
text = data.decode(encoding)
except UnicodeDecodeError:
continue
score = _decode_text_score(text)
if score > best_score:
best_score = score
best_text = text
if best_text:
return best_text
return data.decode("utf-8", errors="replace")
def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str:
stdout_plain = _clixml_to_plain(stdout)
stderr_plain = _clixml_to_plain(stderr)
for text in (stderr_plain, stdout_plain):
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("ERROR:"):
return stripped[6:].strip() or stripped
for candidate in (stderr_plain, stdout_plain):
if candidate and _CLIXML_MARKER not in candidate:
lines = [line.strip() for line in candidate.splitlines() if line.strip()]
if lines:
return lines[-1][:2000]
if stdout_plain:
return stdout_plain[:2000]
if stderr_plain:
return stderr_plain[:2000]
return (
f"PowerShell exit code {exit_code} "
"(CLIXML без текста ошибки — проверьте Deploy-LoginMonitor.ps1 на ПК)"
)
def _sanitize_winrm_streams(stdout: str, stderr: str) -> tuple[str, str]:
return _clixml_to_plain(stdout), _clixml_to_plain(stderr)
def _finalize_winrm_run(
*,
target: str,
stdout: str,
stderr: str,
exit_code: int,
) -> WinRmCmdResult:
ok = exit_code == 0
if ok:
message = f"WinRM OK ({target}), exit 0"
if stdout.strip():
message = f"{message}\n{stdout.strip().splitlines()[0][:200]}"
else:
detail = _winrm_failure_detail(stdout, stderr, exit_code)
message = f"WinRM command failed ({target}): {detail}"
return WinRmCmdResult(
ok=ok,
message=message,
target=target,
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
)
_SYSTEM32_CMDS = {
"hostname": "hostname.exe",
"qwinsta": "qwinsta.exe",
"logoff": "logoff.exe",
}
def _resolve_winrm_cmd(remote_cmd: str) -> str:
"""WinRM cmd shell may have minimal PATH — use explicit System32 for built-ins."""
text = remote_cmd.strip()
if not text:
return text
parts = text.split(None, 1)
name = parts[0].lower()
rest = parts[1] if len(parts) > 1 else ""
exe = _SYSTEM32_CMDS.get(name)
if exe:
suffix = f" {rest}" if rest else ""
return f"%SystemRoot%\\System32\\{exe}{suffix}"
return text
def run_winrm_ps(
*,
target: str,
user: str,
password: str,
script: str,
timeout_sec: int = 30,
) -> WinRmCmdResult:
target = target.strip()
try:
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
result = session.run_ps(script)
except winrm.exceptions.WinRMTransportError as exc:
detail = str(exc)
hint = ""
if "credentials were rejected" in detail.lower():
hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК."
return WinRmCmdResult(
ok=False,
message=f"WinRM transport error ({target}): {detail}{hint}",
target=target,
)
except (socket.timeout, TimeoutError):
return WinRmCmdResult(
ok=False,
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
target=target,
)
except WinAdminNotConfiguredError as exc:
return WinRmCmdResult(ok=False, message=str(exc), target=target)
except RuntimeError as exc:
return WinRmCmdResult(ok=False, message=str(exc), target=target)
except Exception as exc:
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
stdout = _decode_winrm_bytes(result.std_out or b"")
stderr = _decode_winrm_bytes(result.std_err or b"")
exit_code = int(result.status_code)
stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
return _finalize_winrm_run(
target=target,
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
)
def run_winrm_cmd(
*,
target: str,
user: str,
password: str,
remote_cmd: str,
timeout_sec: int = 30,
) -> WinRmCmdResult:
target = target.strip()
try:
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
result = session.run_cmd(_resolve_winrm_cmd(remote_cmd))
except winrm.exceptions.WinRMTransportError as exc:
detail = str(exc)
hint = ""
if "credentials were rejected" in detail.lower():
hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК."
return WinRmCmdResult(
ok=False,
message=f"WinRM transport error ({target}): {detail}{hint}",
target=target,
)
except (socket.timeout, TimeoutError):
return WinRmCmdResult(
ok=False,
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
target=target,
)
except WinAdminNotConfiguredError as exc:
return WinRmCmdResult(ok=False, message=str(exc), target=target)
except RuntimeError as exc:
return WinRmCmdResult(ok=False, message=str(exc), target=target)
except Exception as exc:
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
stdout = _decode_winrm_bytes(result.std_out or b"")
stderr = _decode_winrm_bytes(result.std_err or b"")
exit_code = int(result.status_code)
stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
return _finalize_winrm_run(
target=target,
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
)
def run_winrm_qwinsta(*, target: str, user: str, password: str) -> WinRmCmdResult:
return run_winrm_cmd(target=target, user=user, password=password, remote_cmd="qwinsta", timeout_sec=45)
def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) -> WinRmCmdResult:
if session_id < 0:
return WinRmCmdResult(ok=False, message="Invalid session_id", target=target)
return run_winrm_cmd(
target=target,
user=user,
password=password,
remote_cmd=f"logoff {session_id} /v",
timeout_sec=45,
)
def _powershell_script_prelude() -> str:
return (
"$ProgressPreference = 'SilentlyContinue'\n"
"$InformationPreference = 'SilentlyContinue'\n"
"$utf8NoBom = New-Object System.Text.UTF8Encoding $false\n"
"[Console]::OutputEncoding = $utf8NoBom\n"
"$OutputEncoding = $utf8NoBom\n"
)
def _powershell_literal_path(path: str) -> str:
return path.replace("'", "''")
def _wrap_powershell_body(body: str) -> str:
indented = "\n".join(f" {line}" if line else "" for line in body.splitlines())
return (
_powershell_script_prelude()
+ "try {\n"
+ indented
+ "\n} catch {\n"
+ " Write-Output ('ERROR: ' + $_.Exception.Message)\n"
+ " exit 1\n"
+ "}\n"
)
def _custom_deploy_body(script_path: str) -> str:
literal = _powershell_literal_path(script_path.strip())
return (
f"$p = '{literal}'\n"
"if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n"
"Write-Output \"Running: $p\"\n"
"& $p"
)
def _rdp_staging_setup_ps() -> str:
dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME)
legacy = _powershell_literal_path(RDP_LEGACY_STAGING)
return (
f"$staging = Join-Path $env:TEMP '{dirname}'\n"
f"$legacyStaging = '{legacy}'\n"
"if (Test-Path -LiteralPath $legacyStaging) {\n"
" Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\n"
"}\n"
)
def _prepare_staging_body() -> str:
return (
_rdp_staging_setup_ps()
+ "if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n"
+ "New-Item -ItemType Directory -Path $staging -Force | Out-Null\n"
+ "Write-Output \"Staging ready: $staging\"\n"
)
def _deploy_from_staging_body() -> str:
return (
_rdp_staging_setup_ps()
+ "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
+ "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n"
+ "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n"
+ "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
+ "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n"
+ "if (Test-Path -LiteralPath $logPath) {\n"
+ " Write-Output ''\n"
+ " Write-Output '--- deploy.log ---'\n"
+ " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n"
+ "}\n"
)
def _bundle_download_url(token: str) -> str:
settings = get_settings()
base = (settings.sac_agent_bundle_base_url or settings.sac_public_url).rstrip("/")
return f"{base}/api/v1/agent/rdp-bundle/{token}"
def _download_bundle_body(bundle_url: str) -> str:
url = _powershell_literal_path(bundle_url)
return (
_rdp_staging_setup_ps()
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
+ f"$url = '{url}'\n"
+ "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n"
+ "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
+ "Write-Output \"Downloading bundle: $url\"\n"
+ "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
+ "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n"
+ "$zipSize = (Get-Item -LiteralPath $zip).Length\n"
+ "if ($zipSize -lt 100) { throw \"Bundle too small ($zipSize bytes)\" }\n"
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem\n"
+ "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n"
+ "Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue\n"
+ "Write-Output \"Bundle extracted to $staging\"\n"
)
def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
settings = get_settings()
return clone_or_update_repo(
repo_url,
git_branch,
_cache_dir(),
token=(settings.sac_agent_git_token or "").strip(),
)
def run_winrm_rdp_monitor_update(
*,
target: str,
user: str,
password: str,
script_path: str = "",
repo_url: str = "",
git_branch: str = "main",
) -> WinRmCmdResult:
target = target.strip()
if script_path.strip():
return run_winrm_ps(
target=target,
user=user,
password=password,
script=_wrap_powershell_body(_custom_deploy_body(script_path)),
timeout_sec=900,
)
effective_repo = (
repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
).strip()
effective_branch = (git_branch or "main").strip() or "main"
if not effective_repo:
return WinRmCmdResult(
ok=False,
message="rdp_git_repo_url is not configured in SAC",
target=target,
)
try:
repo_dir = _fetch_rdp_bundle_dir(effective_repo, effective_branch)
except Exception as exc:
return WinRmCmdResult(
ok=False,
message=f"SAC failed to fetch RDP bundle from git: {exc}",
target=target,
)
missing = [name for name in RDP_BUNDLE_REQUIRED if not (repo_dir / name).is_file()]
if missing:
return WinRmCmdResult(
ok=False,
message=f"RDP bundle incomplete on SAC: missing {', '.join(missing)}",
target=target,
)
zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES)
if not zip_bytes:
return WinRmCmdResult(
ok=False,
message="RDP bundle zip is empty on SAC",
target=target,
)
bundle_token = register_rdp_bundle_zip(zip_bytes)
bundle_url = _bundle_download_url(bundle_token)
staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}"
prep = run_winrm_ps(
target=target,
user=user,
password=password,
script=_wrap_powershell_body(_prepare_staging_body()),
timeout_sec=120,
)
if not prep.ok:
return prep
download = run_winrm_ps(
target=target,
user=user,
password=password,
script=_wrap_powershell_body(_download_bundle_body(bundle_url)),
timeout_sec=300,
)
if not download.ok:
return WinRmCmdResult(
ok=False,
message=(
f"Failed to download RDP bundle on client: {download.message} "
"(bundle URL omitted from logs)"
),
target=target,
stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
stderr=download.stderr,
exit_code=download.exit_code,
)
deploy = run_winrm_ps(
target=target,
user=user,
password=password,
script=_wrap_powershell_body(_deploy_from_staging_body()),
timeout_sec=900,
)
header = f"SAC served bundle from git; client staging {staging_label}"
stdout = "\n\n".join(
part.strip()
for part in [header, prep.stdout, download.stdout, deploy.stdout]
if part.strip()
)
return WinRmCmdResult(
ok=deploy.ok,
message=deploy.message,
target=target,
stdout=stdout,
stderr=deploy.stderr,
exit_code=deploy.exit_code,
)
def run_winrm_on_host_targets(
host: Host,
*,
user: str,
password: str,
action,
) -> tuple[WinRmCmdResult | None, list[str]]:
"""Try WinRM targets in hostname-first order; action(target) -> WinRmCmdResult."""
attempts: list[str] = []
last: WinRmCmdResult | None = None
for target in iter_winrm_targets(host):
result = action(target=target)
last = result
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
if result.ok:
return result, attempts
return last, attempts
def resolve_windows_host_target(host: Host) -> str:
targets = iter_winrm_targets(host)
if not targets:
raise HostTargetMissingError("Host has no hostname or IPv4 for WinRM test")
return targets[0]
def _netbios_name_variants(name: str | None) -> list[str]:
"""Короткое имя ПК (NetBIOS), как Enter-PSSession -ComputerName Andrisonova-PC."""
text = (name or "").strip()
if not text:
return []
short = text.split(".")[0].split("\\")[0].strip()
if not short:
return []
if short.casefold() == text.casefold():
return [short]
return [short, text]
def _looks_like_computer_name(value: str) -> bool:
text = value.strip()
if not text or " " in text:
return False
return len(text) <= 63
def iter_winrm_targets(host: Host) -> list[str]:
"""WinRM + NTLM: сначала имя хоста (как Enter-PSSession -ComputerName), IP — последним."""
if not is_windows_host(host):
raise HostNotWindowsError("Host is not Windows")
seen: set[str] = set()
ordered: list[str] = []
def add(value: str | None) -> None:
text = (value or "").strip()
if not text or text.casefold() in seen:
return
seen.add(text.casefold())
ordered.append(text)
for variant in _netbios_name_variants(host.hostname):
add(variant)
inventory = host.inventory if isinstance(host.inventory, dict) else {}
computer_name = inventory.get("computer_name")
if isinstance(computer_name, str):
for variant in _netbios_name_variants(computer_name):
add(variant)
if host.display_name and _looks_like_computer_name(host.display_name):
for variant in _netbios_name_variants(host.display_name):
add(variant)
add(host.ipv4)
return ordered
def is_windows_host(host: Host) -> bool:
if (host.os_family or "").strip().lower() == "windows":
return True
return (host.product or "").strip() == "rdp-login-monitor"
def test_winrm_connection(
*,
target: str,
user: str,
password: str,
timeout_sec: int = 15,
) -> WinRmTestResult:
result = run_winrm_cmd(
target=target,
user=user,
password=password,
remote_cmd="hostname",
timeout_sec=timeout_sec,
)
if result.ok and result.stdout.strip():
host = result.stdout.strip().splitlines()[0]
return WinRmTestResult(
ok=True,
message=f"WinRM OK, hostname={host}",
target=result.target,
hostname=host,
)
return WinRmTestResult(
ok=result.ok,
message=result.message,
target=result.target,
hostname=None,
)
+8
View File
@@ -0,0 +1,8 @@
"""Escape user input for SQL ILIKE patterns."""
def escape_ilike_pattern(value: str) -> str:
text = (value or "").strip()
if not text:
return text
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.9.10"
APP_VERSION = "0.5.18"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2
View File
@@ -13,6 +13,8 @@ httpx>=0.28.0,<1
google-auth>=2.36.0,<3
requests>=2.32.0,<3
psutil>=6.1.0,<8
pywinrm>=0.5.0,<1
paramiko>=3.5.0,<4
# dev
pytest>=8.3.0,<9
+49 -4
View File
@@ -1,10 +1,14 @@
"""SQLite in-memory fixtures for API tests."""
"""SQLite in-memory fixtures for API tests."""
import os
# Must be set before app.database imports create_engine
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SAC_BOOTSTRAP_API_KEY", "sac_test_key_for_pytest_only")
os.environ.setdefault("SAC_SECURITY_ENFORCE", "false")
os.environ.setdefault("JWT_SECRET", "pytest-jwt-secret-not-for-production-use")
os.environ.setdefault("SAC_SSH_AUTO_ADD_HOST_KEY", "true")
os.environ.setdefault("SAC_HOST_SILENCE_SCAN_ENABLED", "false")
import pytest
from fastapi.testclient import TestClient
@@ -80,16 +84,57 @@ def db_session(db_engine):
session.close()
@pytest.fixture(autouse=True)
def mock_agent_git_release(monkeypatch):
"""API tests must not clone real git repos."""
from app.services.agent_git_release import GitReleaseVersions
empty = GitReleaseVersions(versions={}, fetched_at=None, from_cache=True)
monkeypatch.setattr(
"app.services.agent_git_release.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.api.v1.hosts.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.api.v1.settings.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.services.agent_update.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
@pytest.fixture(autouse=True)
def reset_remote_action_state():
from app.services import host_remote_actions
with host_remote_actions._lock:
host_remote_actions._running.clear()
yield
with host_remote_actions._lock:
host_remote_actions._running.clear()
@pytest.fixture
def client(db_session, monkeypatch):
def client(db_session, db_engine, monkeypatch):
monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1")
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
monkeypatch.setattr("app.main.bootstrap_stale_remote_actions", lambda: None)
test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local)
def override_get_db():
session = test_session_local()
try:
yield db_session
yield session
finally:
pass
session.close()
fastapi_app.dependency_overrides[get_db] = override_get_db
with TestClient(fastapi_app) as c:
+86
View File
@@ -0,0 +1,86 @@
"""Tests for git-based agent release version resolution."""
from pathlib import Path
from app.services.agent_git_release import (
normalize_git_repo_url,
parse_product_version_from_dir,
parse_rdp_version_from_files,
parse_ssh_version_from_files,
recommended_from_git,
)
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
from app.services.agent_version import reference_agent_versions_by_product
def test_normalize_git_repo_url():
assert normalize_git_repo_url("git.kalinamall.ru/PapaTramp/ssh-monitor").endswith(
"ssh-monitor.git"
)
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
def test_parse_rdp_version_from_files():
assert parse_rdp_version_from_files("2.1.2-SAC\n", None) == "2.1.2-SAC"
assert (
parse_rdp_version_from_files(None, '$ScriptVersion = "2.0.35-SAC"')
== "2.0.35-SAC"
)
def test_parse_ssh_version_from_files():
assert parse_ssh_version_from_files('SSH_MONITOR_VERSION="2.1.0-SAC"\n', None) == "2.1.0-SAC"
assert parse_ssh_version_from_files(None, "2.0.6-SAC") == "2.0.6-SAC"
def test_parse_product_version_from_dir(tmp_path: Path):
(tmp_path / "version.txt").write_text("2.1.2-SAC\n", encoding="utf-8")
assert parse_product_version_from_dir(tmp_path, PRODUCT_RDP) == "2.1.2-SAC"
ssh_dir = tmp_path / "ssh"
ssh_dir.mkdir()
(ssh_dir / "ssh-monitor").write_text('SSH_MONITOR_VERSION="2.1.0-SAC"\n', encoding="utf-8")
assert parse_product_version_from_dir(ssh_dir, PRODUCT_SSH) == "2.1.0-SAC"
def test_recommended_from_git_prefers_git():
cfg = AgentUpdateConfig(
mode="sac",
fallback_enabled=True,
fallback_after_minutes=15,
recommended_rdp_version="2.0.0-SAC",
recommended_ssh_version="",
min_rdp_version="",
min_ssh_version="",
win_agent_update_script="",
rdp_git_repo_url="https://git.example/rdp.git",
ssh_git_repo_url="https://git.example/ssh.git",
git_branch="main",
source="env",
)
git_versions = {PRODUCT_SSH: "2.1.0-SAC"}
assert recommended_from_git(cfg, git_versions, PRODUCT_SSH) == "2.1.0-SAC"
assert recommended_from_git(cfg, git_versions, PRODUCT_RDP) == "2.0.0-SAC"
def test_reference_agent_versions_uses_git():
cfg = AgentUpdateConfig(
mode="gpo",
fallback_enabled=True,
fallback_after_minutes=15,
recommended_rdp_version="",
recommended_ssh_version="",
min_rdp_version="",
min_ssh_version="",
win_agent_update_script="",
rdp_git_repo_url="",
ssh_git_repo_url="",
git_branch="main",
source="env",
)
refs = reference_agent_versions_by_product(
cfg,
{"ssh-monitor": "2.0.6-SAC"},
git_versions={"ssh-monitor": "2.1.0-SAC"},
)
assert refs["ssh-monitor"] == "2.1.0-SAC"
+292
View File
@@ -0,0 +1,292 @@
"""Tests for agent update settings, poll, request and ingest lifecycle."""
import time
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from app.models import Host
from app.services.agent_poll import build_agent_poll_payload
from app.services.agent_update import (
AGENT_UPDATE_SUCCESS,
AgentUpdateNotAllowedError,
process_agent_update_ingest,
request_agent_update,
)
from app.services.agent_update_settings import upsert_agent_update_settings
from app.services.ingest import ingest_event
from tests.test_ingest import VALID_EVENT
def wait_remote_job(client, host_id, headers, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
response = client.get(
f"/api/v1/hosts/{host_id}/actions/remote-job",
headers=headers,
)
assert response.status_code == 200
body = response.json()
if not body.get("active") and body.get("status") != "running":
return body
time.sleep(0.05)
raise AssertionError("remote job did not finish in time")
def test_agent_update_settings_sac_mode(db_session):
cfg = upsert_agent_update_settings(
db_session,
mode="sac",
recommended_ssh_version="2.1.0-SAC",
fallback_after_minutes=10,
)
assert cfg.mode == "sac"
assert cfg.recommended_ssh_version == "2.1.0-SAC"
assert cfg.fallback_after_minutes == 10
def test_request_agent_update_sets_pending(db_session):
upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC")
host = Host(
hostname="linux-1",
os_family="linux",
product="ssh-monitor",
product_version="2.0.0-SAC",
agent_instance_id="agent-uuid-1",
)
db_session.add(host)
db_session.commit()
request_agent_update(db_session, host)
assert host.pending_agent_update is True
assert host.pending_update_target_version == "2.1.0-SAC"
assert host.agent_update_state == "requested"
def test_request_agent_update_blocked_in_gpo_mode(db_session):
upsert_agent_update_settings(db_session, mode="gpo")
host = Host(
hostname="linux-2",
os_family="linux",
product="ssh-monitor",
agent_instance_id="agent-uuid-2",
)
db_session.add(host)
db_session.commit()
try:
request_agent_update(db_session, host)
raise AssertionError("expected AgentUpdateNotAllowedError")
except AgentUpdateNotAllowedError:
pass
def test_poll_payload_includes_update_and_config(db_session):
upsert_agent_update_settings(db_session, mode="sac")
host = Host(
hostname="win-1",
os_family="windows",
product="rdp-login-monitor",
agent_instance_id="agent-uuid-3",
pending_agent_update=True,
pending_update_target_version="2.1.0-SAC",
agent_config={"ServerDisplayName": "Test PC"},
agent_config_revision=3,
)
db_session.add(host)
db_session.commit()
payload = build_agent_poll_payload(db_session, host)
assert payload["config_revision"] == 3
assert payload["config"]["settings"]["ServerDisplayName"] == "Test PC"
assert payload["update"]["requested"] is True
assert payload["update"]["target_version"] == "2.1.0-SAC"
assert payload["update"]["source"] == "sac"
def test_ingest_agent_update_success_clears_pending(db_session):
host = Host(
hostname="linux-3",
os_family="linux",
product="ssh-monitor",
agent_instance_id="agent-uuid-4",
pending_agent_update=True,
pending_update_target_version="2.1.0-SAC",
)
db_session.add(host)
db_session.commit()
process_agent_update_ingest(
db_session,
host,
AGENT_UPDATE_SUCCESS,
{"product_version": "2.1.0-SAC"},
)
assert host.pending_agent_update is False
assert host.product_version == "2.1.0-SAC"
assert host.agent_update_state == "success"
def test_api_request_agent_update(jwt_headers, client, db_session):
upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC")
host = Host(
hostname="api-linux",
os_family="linux",
product="ssh-monitor",
agent_instance_id="agent-api-1",
)
db_session.add(host)
db_session.commit()
response = client.post(f"/api/v1/hosts/{host.id}/actions/request-agent-update", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["pending_agent_update"] is True
assert body["target_version"] == "2.1.0-SAC"
def test_api_agent_poll_extended(auth_headers, client, db_session):
upsert_agent_update_settings(db_session, mode="sac")
host = Host(
hostname="poll-host",
os_family="linux",
product="ssh-monitor",
agent_instance_id="poll-agent-id",
pending_agent_update=True,
pending_update_target_version="2.1.0-SAC",
)
db_session.add(host)
db_session.commit()
response = client.get(
"/api/v1/agent/commands",
params={"agent_instance_id": "poll-agent-id"},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["update"]["requested"] is True
assert body["commands"] == []
def test_api_patch_agent_config(jwt_headers, client, db_session):
host = Host(
hostname="cfg-host",
os_family="windows",
product="rdp-login-monitor",
)
db_session.add(host)
db_session.commit()
response = client.patch(
f"/api/v1/hosts/{host.id}/agent-config",
json={"settings": {"ServerDisplayName": "UNMS Kalina", "GetInventory": True}},
headers=jwt_headers,
)
assert response.status_code == 200
body = response.json()
assert body["config_revision"] == 1
assert body["settings"]["ServerDisplayName"] == "UNMS Kalina"
def test_api_agent_update_fallback_ssh(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
get_settings.cache_clear()
host = Host(
hostname="fb-linux",
os_family="linux",
product="ssh-monitor",
ipv4="10.10.36.9",
)
db_session.add(host)
db_session.commit()
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
from app.services.ssh_connect import SshCommandResult
mock_update.return_value = SshCommandResult(
ok=True,
message="updated",
target="fb-linux",
agent_version="2.1.0-SAC",
)
response = client.post(
f"/api/v1/hosts/{host.id}/actions/agent-update-fallback",
headers=jwt_headers,
)
assert response.status_code == 202
assert response.json()["status"] == "running"
job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True
assert job["product_version"] == "2.1.0-SAC"
def test_clear_stale_running_remote_actions(db_session):
from app.services.host_remote_actions import clear_stale_running_remote_actions
host = Host(
hostname="stale-win",
os_family="windows",
product="rdp-login-monitor",
agent_update_state="running",
remote_action={"status": "running", "message": "Подключение…"},
)
db_session.add(host)
db_session.commit()
cleared = clear_stale_running_remote_actions(db_session, reason="test reset")
assert cleared == ["stale-win"]
db_session.refresh(host)
assert host.agent_update_state == "failed"
assert host.remote_action["status"] == "failed"
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):
host = Host(
hostname="cancel-me",
os_family="windows",
product="rdp-login-monitor",
agent_update_state="running",
remote_action={"status": "running", "title": "Обновление через WinRM"},
)
db_session.add(host)
db_session.commit()
response = client.post(
f"/api/v1/hosts/{host.id}/actions/remote-job/cancel",
headers=jwt_headers,
)
assert response.status_code == 200
body = response.json()
assert body["active"] is False
assert body["status"] == "failed"
+62 -1
View File
@@ -1,6 +1,12 @@
"""Agent version parse/compare for outdated highlighting."""
from app.services.agent_version import is_agent_version_outdated, latest_agent_versions_by_product, parse_agent_version
from app.services.agent_version import (
effective_reference_version,
host_version_outdated,
is_agent_version_outdated,
latest_agent_versions_by_product,
parse_agent_version,
)
from app.models import Host
from datetime import datetime, timezone
@@ -27,6 +33,61 @@ def test_outdated_different_minor_or_major():
assert is_agent_version_outdated("2.1.0-SAC", latest) is False
def test_effective_reference_version_picks_max():
assert (
effective_reference_version(
recommended="2.1.0-SAC",
fleet_latest="2.0.6-SAC",
min_version="",
)
== "2.1.0-SAC"
)
assert (
effective_reference_version(
recommended="",
fleet_latest="2.0.6-SAC",
min_version="2.1.0-SAC",
)
== "2.1.0-SAC"
)
assert (
effective_reference_version(
recommended="",
fleet_latest="2.0.35-SAC",
min_version="",
)
== "2.0.35-SAC"
)
def test_host_version_outdated_ssh_vs_git():
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="2.1.0-SAC",
fleet_latest="2.0.6-SAC",
)
is True
)
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="",
fleet_latest="2.0.6-SAC",
)
is False
)
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="",
fleet_latest="2.0.6-SAC",
min_version="2.1.0-SAC",
)
is True
)
def test_latest_agent_versions_by_product(db_session):
now = datetime.now(timezone.utc)
db_session.add_all(
+26
View File
@@ -0,0 +1,26 @@
"""Tests for client IP resolution behind reverse proxy."""
from types import SimpleNamespace
from app.services.client_ip import client_ip_from_request
def _request(*, client_host: str = "10.0.0.5", xff: str | None = None):
headers = {}
if xff is not None:
headers["x-forwarded-for"] = xff
return SimpleNamespace(client=SimpleNamespace(host=client_host), headers=headers)
def test_client_ip_without_forwarded_header():
assert client_ip_from_request(_request(client_host="203.0.113.10")) == "203.0.113.10"
def test_client_ip_uses_last_forwarded_hop():
req = _request(client_host="127.0.0.1", xff="203.0.113.99, 198.51.100.20")
assert client_ip_from_request(req) == "198.51.100.20"
def test_client_ip_ignores_spoofed_first_hop():
req = _request(client_host="127.0.0.1", xff="1.2.3.4, 203.0.113.50")
assert client_ip_from_request(req) == "203.0.113.50"
+56
View File
@@ -134,3 +134,59 @@ def test_normalize_report_body_adds_server_and_collapses_blanks():
user_lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")]
assert len(user_lines) == 2
assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body
def test_normalize_ssh_active_users_mismatch_count_and_empty_body():
host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.1", product="ssh-monitor")
raw = "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2):",
" (нет данных)",
]
)
body = normalize_report_body(raw, host, "ssh")
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body
assert "(нет данных)" in body
assert not any(ln.strip().startswith("👤") for ln in body.split("\n"))
def test_normalize_rdp_empty_active_users_placeholder():
host = Host(hostname="gw", display_name="K6A-DC3", ipv4="192.168.160.40", product="rdp-login-monitor")
raw = "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0):",
" (нет активных пользователей / RDP-сессий)",
]
)
body = normalize_report_body(raw, host, "windows")
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body
assert "нет активных пользователей" in body
assert "👤 (нет активных" not in body
def test_normalize_daily_report_reconciles_stats_from_body():
from app.services.daily_report_format import normalize_daily_report_details
host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.2", product="ssh-monitor")
details = {
"report_body": "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (9):",
" (нет данных)",
]
),
"stats": {
"successful_ssh": 1,
"active_sessions": 9,
"active_users": [],
"generated_by": "agent",
},
}
out = normalize_daily_report_details(details, host, "report.daily.ssh")
assert out is not None
assert out["stats"]["active_sessions"] == 0
assert out["stats"]["active_users"] == []
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in out["report_body"]
+117
View File
@@ -0,0 +1,117 @@
"""Tests for hiding event types from SAC UI."""
import uuid
from app.models.event_type_visibility import EventTypeVisibility
VALID_EVENT = {
"schema_version": "1.0",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "test-host", "os_family": "windows"},
"category": "auth",
"type": "agent.heartbeat",
"severity": "info",
"title": "Heartbeat",
"summary": "pytest",
}
LOGIN_EVENT = {
**VALID_EVENT,
"type": "rdp.login.success",
"title": "RDP login",
"summary": "login pytest",
}
def _ingest(client, auth_headers, payload):
event_id = str(uuid.uuid4())
r = client.post("/api/v1/events", json={**payload, "event_id": event_id}, headers=auth_headers)
assert r.status_code == 201
return event_id
def test_hidden_event_type_excluded_from_list(client, auth_headers, jwt_headers, db_session):
db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False))
db_session.commit()
hb_id = _ingest(client, auth_headers, VALID_EVENT)
login_id = _ingest(client, auth_headers, LOGIN_EVENT)
listed = client.get("/api/v1/events", headers=jwt_headers)
assert listed.status_code == 200
ids = {item["event_id"] for item in listed.json()["items"]}
assert hb_id not in ids
assert login_id in ids
def test_hidden_event_type_returns_404_on_detail(client, auth_headers, jwt_headers, db_session):
db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False))
db_session.commit()
_ingest(client, auth_headers, VALID_EVENT)
listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.heartbeat"})
assert listed.json()["total"] == 0
def test_hidden_event_type_visible_on_host_detail_list(client, auth_headers, jwt_headers, db_session):
from app.models import Host
host = Host(hostname="inv-pc", os_family="windows", product="rdp-login-monitor")
db_session.add(host)
db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False))
db_session.commit()
payload = {
**VALID_EVENT,
"type": "agent.inventory",
"category": "agent",
"title": "Inventory",
"summary": "hw",
"host": {"hostname": "inv-pc", "os_family": "windows"},
"details": {"inventory": {"memory_gb": 16}},
}
_ingest(client, auth_headers, payload)
global_list = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.inventory"})
assert global_list.json()["total"] == 0
host_list = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"host_id": host.id, "include_hidden": "true"},
)
assert host_list.status_code == 200
assert host_list.json()["total"] == 1
event_db_id = host_list.json()["items"][0]["id"]
detail = client.get(f"/api/v1/events/{event_db_id}", headers=jwt_headers)
assert detail.status_code == 200
assert detail.json()["type"] == "agent.inventory"
def test_include_hidden_requires_host_id(client, jwt_headers):
r = client.get("/api/v1/events", headers=jwt_headers, params={"include_hidden": "true"})
assert r.status_code == 400
def test_visibility_settings_api(client, jwt_headers, db_session):
put = client.put(
"/api/v1/settings/notifications/severity-overrides",
headers=jwt_headers,
json={"visibility": {"agent.heartbeat": False}},
)
assert put.status_code == 200
items = {i["event_type"]: i for i in put.json()["items"]}
assert items["agent.heartbeat"]["show_in_events"] is False
assert items["rdp.login.success"]["show_in_events"] is True
reset = client.put(
"/api/v1/settings/notifications/severity-overrides",
headers=jwt_headers,
json={"visibility": {"agent.heartbeat": None}},
)
assert reset.status_code == 200
items = {i["event_type"]: i for i in reset.json()["items"]}
assert items["agent.heartbeat"]["show_in_events"] is True
+54
View File
@@ -0,0 +1,54 @@
"""Tests for GET /api/v1/events filters."""
import uuid
VALID_RDG = {
"schema_version": "1.0",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "gw-filter-test", "os_family": "windows"},
"category": "auth",
"severity": "info",
"title": "RDG",
"summary": "pytest",
}
def _ingest(client, auth_headers, *, event_type: str) -> str:
event_id = str(uuid.uuid4())
payload = {**VALID_RDG, "event_id": event_id, "type": event_type}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
return event_id
def test_events_type_filter_prefix(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
other_id = _ingest(client, auth_headers, event_type="rdp.login.success")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
body = listed.json()
ids = {row["event_id"] for row in body["items"]}
assert success_id in ids
assert disconnected_id in ids
assert other_id not in ids
def test_events_type_filter_exact_still_works(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection.success", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
ids = {row["event_id"] for row in listed.json()["items"]}
assert success_id in ids
assert disconnected_id not in ids
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
assert APP_VERSION == "0.9.10"
assert APP_VERSION == "0.5.0"
assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.10"
assert APP_VERSION_LABEL == "Security Alert Center v.0.5.0"
+141
View File
@@ -0,0 +1,141 @@
"""Manual host add: validation, probe, deploy kick-off."""
from unittest.mock import patch
import pytest
from app.models import Host
from app.services.host_manual_add import (
ManualHostAddError,
prepare_manual_host_add,
validate_linux_target,
validate_windows_target,
)
from app.services.winrm_connect import WinRmTestResult
from tests.test_agent_update import wait_remote_job
def test_validate_windows_rejects_ip():
with pytest.raises(ManualHostAddError, match="не IP"):
validate_windows_target("10.10.36.9")
def test_validate_windows_accepts_hostname():
assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01"
assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01"
def test_validate_linux_accepts_ip():
assert validate_linux_target("10.10.36.9") == "10.10.36.9"
def test_prepare_manual_host_windows(db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=WORKSTATION-01",
target="WORKSTATION-01",
hostname="WORKSTATION-01",
)
host = prepare_manual_host_add(
db_session,
platform="windows",
target="WORKSTATION-01",
)
assert host.hostname == "WORKSTATION-01"
assert host.product == "rdp-login-monitor"
assert host.os_family == "windows"
def test_api_manual_add_windows(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=NEW-PC",
target="NEW-PC",
hostname="NEW-PC",
)
with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy:
from app.services.winrm_connect import WinRmCmdResult
mock_deploy.return_value = WinRmCmdResult(
ok=True,
message="deploy ok",
target="NEW-PC",
stdout="2.1.8-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "NEW-PC"},
headers=jwt_headers,
)
assert response.status_code == 202
body = response.json()
assert body["status"] == "running"
host_id = body["host_id"]
host = db_session.get(Host, host_id)
assert host is not None
assert host.hostname == "NEW-PC"
job = wait_remote_job(client, host_id, jwt_headers)
assert job["ok"] is True
def test_api_manual_add_rejects_windows_ip(jwt_headers, client):
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "192.168.1.10"},
headers=jwt_headers,
)
assert response.status_code == 400
assert "не IP" in response.json()["detail"]
def test_api_manual_add_linux(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
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh:
from app.services.ssh_connect import SshCommandResult
mock_ssh.return_value = SshCommandResult(
ok=True,
message="SSH OK, hostname=linux-srv",
target="10.10.36.9",
stdout="linux-srv\n",
)
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
mock_update.return_value = SshCommandResult(
ok=True,
message="updated",
target="10.10.36.9",
agent_version="2.1.5-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "linux", "target": "10.10.36.9"},
headers=jwt_headers,
)
assert response.status_code == 202
host_id = response.json()["host_id"]
host = db_session.get(Host, host_id)
assert host.hostname == "linux-srv"
assert host.ipv4 == "10.10.36.9"
job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0)
assert job["ok"] is True
@@ -0,0 +1,64 @@
"""Per-host management credential override."""
from app.models.host import Host
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.host_mgmt_credentials import get_host_mgmt_access_view, upsert_host_mgmt_credentials
def test_win_admin_for_host_prefers_override(db_session):
host = Host(
hostname="HOME-PC",
os_family="windows",
product="rdp-login-monitor",
mgmt_user=".\\Admin",
mgmt_password="local-secret",
)
db_session.add(host)
db_session.commit()
db_session.refresh(host)
cfg = get_effective_win_admin_for_host(db_session, host)
assert cfg.configured is True
assert cfg.source == "host"
assert cfg.user == ".\\Admin"
assert cfg.password == "local-secret"
def test_linux_admin_for_host_prefers_override(db_session):
host = Host(
hostname="homeserver",
os_family="linux",
product="ssh-monitor",
mgmt_user="deploy",
mgmt_password="ssh-pass",
)
db_session.add(host)
db_session.commit()
db_session.refresh(host)
cfg = get_effective_linux_admin_for_host(db_session, host)
assert cfg.source == "host"
assert cfg.user == "deploy"
def test_upsert_and_clear_host_access(db_session):
host = Host(hostname="box", os_family="windows", product="rdp-login-monitor")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
view = upsert_host_mgmt_credentials(
db_session,
host,
user="HOME\\user",
password="secret123",
)
assert view.has_override is True
assert view.password_set is True
assert view.user == "HOME\\user"
cleared = upsert_host_mgmt_credentials(db_session, host, clear=True)
assert cleared.has_override is False
assert cleared.password_set is False
assert get_host_mgmt_access_view(db_session, host).user is None
+218
View File
@@ -0,0 +1,218 @@
"""Tests for host session list/parse helpers."""
from types import SimpleNamespace
from app.services.host_sessions import (
HostSessionRow,
_event_login_user,
event_session_terminated,
event_supports_session_terminate,
filter_logind_session_rows,
filter_windows_sessions_for_user,
mark_event_session_terminated,
parse_loginctl_sessions,
parse_loginctl_sessions_json,
parse_qwinsta_sessions,
terminate_session_for_event,
)
from app.services.linux_admin_settings import LinuxAdminConfig
from app.services.win_admin_settings import WinAdminConfig
from app.services.winrm_connect import WinRmCmdResult
def test_parse_loginctl_sessions():
stdout = "c1 1000 alice seat0 pts/0 active -\n 2 1001 bob - tty2 active -\n"
rows = parse_loginctl_sessions(stdout)
assert len(rows) == 2
assert rows[0].session_id == "c1"
assert rows[0].user == "alice"
assert rows[0].tty == "pts/0"
def test_parse_loginctl_sessions_prefers_pts_over_ephemeral_duplicate():
stdout = """21166 1000 papatramp - pts/0 active no -
21169 1000 papatramp - - active no -
"""
rows = parse_loginctl_sessions(stdout)
assert len(rows) == 1
assert rows[0].session_id == "21166"
assert rows[0].tty == "pts/0"
def test_parse_loginctl_sessions_json():
stdout = """[
{"session":"21166","uid":1000,"user":"papatramp","seat":null,"tty":"pts/0","state":"active","idle":false,"since":null},
{"session":"21169","uid":1000,"user":"papatramp","seat":null,"tty":null,"state":"active","idle":false,"since":null}
]"""
rows = parse_loginctl_sessions_json(stdout)
assert len(rows) == 1
assert rows[0].session_id == "21166"
assert rows[0].tty == "pts/0"
def test_filter_logind_session_rows_keeps_distinct_users():
from app.services.host_sessions import HostSessionRow
rows = filter_logind_session_rows(
[
HostSessionRow(session_id="1", user="alice", tty="pts/0", state="active"),
HostSessionRow(session_id="2", user="bob", tty=None, state="active"),
]
)
assert len(rows) == 2
def test_parse_qwinsta_sessions_filters_user():
stdout = """SESSIONNAME USERNAME ID STATE
console Administrator 1 Active
rdp-tcp#0 B26\\alice 2 Active
"""
all_rows = parse_qwinsta_sessions(stdout)
assert len(all_rows) == 2
filtered = parse_qwinsta_sessions(stdout, filter_user="alice")
assert len(filtered) == 1
assert filtered[0].session_id == "2"
def test_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():
class HostStub:
os_family = "linux"
product = "ssh-monitor"
class EventStub:
def __init__(self, event_type: str):
self.type = event_type
self.host = HostStub()
assert event_supports_session_terminate(EventStub("ssh.login.success")) is True
def test_event_login_user_without_orm_actor_user_attr():
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "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):
host = SimpleNamespace(
os_family="windows",
product="rdp-login-monitor",
hostname="srv01",
ipv4="10.0.0.1",
display_name=None,
inventory={},
)
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
host=host,
)
rows = [HostSessionRow(session_id="2", user="B26\\papatramp", state="Active")]
def fake_list(_host, _cfg):
return rows, WinRmCmdResult(ok=True, message="ok", target="srv01")
def fake_term(_host, _cfg, sid):
assert sid == "2"
return WinRmCmdResult(ok=True, message="logged off", target="srv01")
monkeypatch.setattr(
"app.services.host_sessions.list_windows_sessions",
fake_list,
)
monkeypatch.setattr(
"app.services.host_sessions.terminate_windows_session",
fake_term,
)
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
def test_event_session_terminated_flag():
event = SimpleNamespace(
type="rdp.login.success",
details={"session_terminated_at": "2026-06-24T10:00:00+00:00"},
)
assert event_session_terminated(event) is True
fresh = SimpleNamespace(type="rdp.login.success", details={"user": "alice"})
assert event_session_terminated(fresh) is False
mark_event_session_terminated(fresh, by_username="admin")
assert event_session_terminated(fresh) is True
assert fresh.details["session_terminated_by"] == "admin"
+154
View File
@@ -82,6 +82,43 @@ def test_scan_does_not_duplicate_open_problem(db_session, scan_settings):
assert len(problems) == 1
def test_scan_does_not_duplicate_after_correlation_window(db_session, scan_settings, monkeypatch):
"""host_silence — одна open-проблема на хост, даже если прошло > correlation window."""
monkeypatch.setenv("SAC_PROBLEM_CORRELATION_WINDOW_MINUTES", "60")
get_settings.cache_clear()
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
t0 = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=t0)
assert first[0].created is True
first[0].problem.last_seen_at = t0 - timedelta(hours=2)
db_session.flush()
later = run_host_silence_scan(db_session, now=t0 + timedelta(hours=3))
assert len(later) == 1
assert later[0].created is False
assert later[0].problem.id == first[0].problem.id
problems = db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
).all()
assert len(problems) == 1
def test_scan_skips_host_without_heartbeat(db_session, scan_settings):
_ingest(
db_session,
@@ -174,6 +211,123 @@ def test_scan_reopens_after_manual_cooldown_expires(db_session, scan_settings, m
assert later[0].problem.resolved_by is None
def test_scan_does_not_duplicate_acknowledged_problem(db_session, scan_settings):
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
now = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=now)
assert first[0].created is True
first[0].problem.status = "acknowledged"
db_session.flush()
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5))
assert len(second) == 1
assert second[0].created is False
assert second[0].problem.id == first[0].problem.id
problems = db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(("open", "acknowledged")),
)
).all()
assert len(problems) == 1
def test_scan_dedupes_by_hostname_across_duplicate_hosts(db_session, scan_settings):
from app.models import Host
now = datetime.now(timezone.utc)
host_a = Host(
hostname="COMM-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.162.33",
)
host_b = Host(
hostname="COMM-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.162.33",
)
db_session.add_all([host_a, host_b])
db_session.flush()
hb = _ingest(
db_session,
host={"hostname": host_a.hostname, "os_family": "windows", "ipv4": host_a.ipv4},
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.host_id = host_a.id
hb.received_at = now - timedelta(hours=2)
db_session.flush()
first = run_host_silence_scan(db_session, now=now)
assert len(first) == 1
assert first[0].created is True
hb_b = _ingest(
db_session,
event_id=str(uuid.uuid4()),
host={"hostname": host_b.hostname, "os_family": "windows", "ipv4": host_b.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.0.0-SAC"},
type="agent.heartbeat",
category="agent",
severity="info",
title="hb2",
summary="heartbeat",
)
hb_b.host_id = host_b.id
hb_b.received_at = now - timedelta(hours=2)
db_session.flush()
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5))
assert len(second) == 2
assert all(not item.created for item in second)
assert {item.problem.id for item in second} == {first[0].problem.id}
active = db_session.scalars(
select(Problem).where(
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(("open", "acknowledged")),
)
).all()
assert len(active) == 1
def test_scan_skipped_when_advisory_lock_not_acquired(db_session, scan_settings, monkeypatch):
monkeypatch.setattr(
"app.services.host_silence_scan._host_silence_scan_lock",
lambda _db: False,
)
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
assert run_host_silence_scan(db_session) == []
def test_scan_notifies_only_on_create(db_session, scan_settings):
hb = _ingest(
db_session,
+4 -2
View File
@@ -4,6 +4,7 @@ import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.models import Event, Host, Problem
@@ -138,13 +139,14 @@ def test_delete_host_removes_events_and_problems(client, db_session, jwt_headers
)
db_session.commit()
r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_headers)
host_id = h.id
r = client.delete(f"/api/v1/hosts/{host_id}", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["hostname"] == "test-host"
assert body["deleted_events"] == 1
assert body["deleted_problems"] == 1
assert db_session.get(Host, h.id) is None
assert db_session.scalar(select(Host.id).where(Host.id == host_id)) is None
def test_delete_host_404(client, jwt_headers):
+46 -1
View File
@@ -126,10 +126,55 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
"summary": "stats",
"details": {"generated_by": "agent", "report_body": "line1"},
}
with patch.object(events_api, "notify_daily_report") as mock_daily:
with patch.object(events_api, "schedule_notify_daily_report") as mock_daily:
with patch.object(events_api, "notify_event") as mock_event:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_daily.assert_called_once()
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()

Some files were not shown because too many files have changed in this diff Show More