Compare commits

..

223 Commits

Author SHA1 Message Date
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp b47cbf03c5 ui: remove duplicate SAC version from overview page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:35:45 +10:00
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 4eb41608ea chore: release SAC 0.3.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:07:54 +10:00
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 75eec35365 fix(frontend): auto-close WinRM update success panel after 30s (0.20.24) 2026-06-21 20:41:57 +10:00
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 87ac1e09b0 fix: widen remote action log panel and wrap long WinRM output (0.20.22) 2026-06-21 11:30:37 +10:00
PapaTramp 8adaf3bfd3 fix: WinRM update panel no longer blocks SAC navigation (0.20.21) 2026-06-21 11:07:28 +10:00
PapaTramp 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
PapaTramp 5635be1322 feat: background remote agent updates survive SAC navigation (0.20.19) 2026-06-21 11:01:26 +10:00
PapaTramp 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
PapaTramp f3a8952947 fix: friendly 502 handling, 2 uvicorn workers, file bundle cache (0.20.18) 2026-06-20 20:01:46 +10:00
PapaTramp 4d9a9e7e2b fix: decode WinRM Cyrillic from CP866 and show deploy.log UTF-8 (0.20.17) 2026-06-20 19:58:54 +10:00
PapaTramp 9484160045 fix: UTF-8 BOM in RDP bundle zip for Russian Windows PowerShell (0.20.16) 2026-06-20 19:54:12 +10:00
PapaTramp 6acbfe22de fix: deliver RDP bundle via SAC HTTP zip download, not WinRM chunks (0.20.15) 2026-06-20 19:51:45 +10:00
PapaTramp ca90f5c296 fix: SAC pushes RDP bundle from git via WinRM, no client git (0.20.14) 2026-06-20 19:49:20 +10:00
PapaTramp 191fb4e2cf fix: WinRM RDP update only via git clone on client (0.20.13) 2026-06-20 19:46:22 +10:00
PapaTramp 8c1a8fddb1 fix: WinRM RDP update via NETLOGON Deploy, git fallback (0.20.12) 2026-06-20 19:43:34 +10:00
PapaTramp 05408e17c3 fix: WinRM RDP update via run_ps, strip CLIXML noise (0.20.11) 2026-06-20 19:41:05 +10:00
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp cc8f50de89 feat: Linux SSH admin and remote ssh-monitor update (0.11.0) 2026-06-20 00:25:35 +10:00
PapaTramp 279ea925a8 fix: WinRM try host.hostname first like Enter-PSSession (0.10.4) 2026-06-20 00:13:02 +10:00
PapaTramp c8e3eef9d7 fix: WinRM NTLM username normalize and hostname-first targets (0.10.3) 2026-06-20 00:10:52 +10:00
PapaTramp 2fe492b06b fix: WinRM read_timeout must exceed operation_timeout (0.10.2) 2026-06-20 00:06:26 +10:00
PapaTramp 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
PapaTramp 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
PapaTramp db95be39f3 docs: agent poll contract for RDP 2.0.39-SAC 2026-06-19 23:38:02 +10:00
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 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
PapaTramp 8a97bbfb43 merge: sync local backlog with kalinamall production URLs
Integrate diverged kalinamall/main; keep v0.7 agent-update docs and
kalinamall.ru paths in deploy/docs; mirror scripts remain removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:13:35 +10:00
PapaTramp 2069e57f14 docs: backlog for agent version compliance and remote updates
Add agent-update-backlog.md and roadmap v0.7 outline for SAC-driven
agent version tracking and remote update initiation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:07:12 +10:00
PapaTramp 6b288bfa49 chore(github): generic example.com in docs, remove mirror scripts
Replace sac.kalinamall.ru with sac.example.com in public docs/deploy.
Remove Push-Mirror and Rewrite-GitHostUrls scripts from repo.
Simplify workspace-three-repos.md for GitHub-only workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:57:02 +10:00
PapaTramp 6bff073108 chore(docs): sync repository URLs for kalinamall mirror 2026-06-16 11:41:07 +10:00
PapaTramp 06c6748274 fix(scripts): force-with-lease on mirror push
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:40:59 +10:00
PapaTramp eac1e0b59e fix(scripts): Push-Mirror ignores untracked files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:40:15 +10:00
PapaTramp 36ccfc9471 chore(docs): GitHub URLs in README/docs, remove .cursor from repo
Replace git.kalinamall.ru links with github.com/PTah in public docs.
Add Rewrite-GitHostUrls.ps1 and Push-Mirror.ps1 for kalinamall mirror pushes.
Add .cursor/ to .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:37:20 +10:00
PapaTramp 5b2aeb0183 fix(frontend): убрать BOM из package.json — ломал vite build на SAC. 2026-06-13 16:36:34 +10:00
PapaTramp 3d36faa49d fix(mobile): повторный enroll и удаление устройств в UI (0.9.10).
При повторной привязке Seaca переиспользуется запись device_uuid вместо INSERT.
Добавлены POST /devices/{id}/revoke и DELETE для полного удаления записи в настройках SAC.
2026-06-13 16:36:07 +10:00
PapaTramp e022f2f213 docs: sac-api.kalinamall.ru и схема HAProxy для Seaca.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 12:01:06 +10:00
PapaTramp f0bd31c544 fix(nginx): отклонять чужие Host на SAC (default_server 444)
Только sac.kalinamall.ru; ext.kalinamall.ru и прочие имена на IP SAC не отдают UI. ssl_reject_handshake на :443.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 11:24:21 +10:00
PapaTramp 87549731c8 chore: note frontend version.ts in SAC version bump rule
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 10:35:18 +10:00
PapaTramp 7df718eed3 fix: 12h cooldown before reopening manually closed host_silence (0.9.9)
Track resolved_by on problems; suppress scan/ingest recreate after manual
resolve; reopen same problem after cooldown; auto-close via heartbeat unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 10:33:55 +10:00
PapaTramp befaf86bf4 feat: readable report and host detail layout in web UI (0.9.8)
Field grid for event/host cards, human-readable daily report labels,
and normalized plain-text report bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 10:25:53 +10:00
PapaTramp fed21c536b fix: exclude resolved problems from new-in-24h metrics (0.9.7)
Dashboard and created_within_hours list only show open/acknowledged problems
created in the last 24 hours; closed items appear under resolved only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 10:24:12 +10:00
PapaTramp 3f671b9899 fix: FCM data-only push so Seaca controls sound (0.9.6)
Send title and body in the data payload instead of notification block so
onMessageReceived always runs and the app can honour silent mode in background.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 10:03:28 +10:00
PapaTramp 3f7095ed03 fix: host_silence proactive notify when event is None
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 09:26:03 +10:00
PapaTramp 37bffe0e3f fix: SAC version from /health in sidebar and dashboard (0.9.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:38:54 +10:00
PapaTramp 0a414a0904 chore: sync frontend version label to 0.9.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:37:09 +10:00
PapaTramp e38bd8a52a fix: FCM must not break ingest; add requests dependency (0.9.3)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:30:32 +10:00
PapaTramp f9baf96d88 fix: test push errors, skip heartbeat notify, stale 5h (0.9.2)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:25:16 +10:00
PapaTramp 5e9f0f8110 fix: show copyable enrollment code after issue in Settings
Исправлен v-if, скрывавший весь раздел при success; баннер с полным
кодом, копированием и sessionStorage до Скрыть. Версия 0.9.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:07:49 +10:00
PapaTramp e2b5fb7604 docs: Seaca mobile operator guide and FCM setup
Документация для админа и runbook: коды регистрации, устройства,
настройка SAC_FCM на сервере.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:34:34 +10:00
PapaTramp 563b836acc feat: Seaca mobile API, enrollment, FCM push and admin UI (0.9.0)
Adds mobile device registration by admin codes, refresh tokens, push channel
in notification policy, and Settings section for managing Seaca clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:19:14 +10:00
PapaTramp 86c602cf07 docs: add v0.6 Mobile (Seaca) to roadmap and README
Links the new Android client repo and documents server-side mobile API work.
2026-06-10 12:45:52 +10:00
PapaTramp 77e167b6e3 feat: add actor user column to events and dashboard tables
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 10:11:04 +10:00
PapaTramp f9d56621f6 feat: drill-down filters on overview and outdated agent version highlight
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 09:21:21 +10:00
PapaTramp e65daa8c3b chore: sync frontend version.ts with API 0.8.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 09:11:41 +10:00
PapaTramp aafb80fa31 feat: proactive host_silence scan for stale agent.heartbeat (0.8.3)
Background scan every 5 min opens rule:host_silence and notifies without
waiting for the next ingest event. CLI job + optional systemd timer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 09:07:32 +10:00
PapaTramp 2a0eb6e90c chore: exclude Cursor workspace file from git (local IDE only)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 14:41:03 +10:00
PapaTramp 459b2babd1 docs: split README (RU) and README_en (EN) for GitHub
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 14:38:53 +10:00
PapaTramp 95c5b9f25a fix(ui): table actions column layout and resolved label
Move flex off td.actions to inner wrapper; show Закрыта for resolved problems. SAC 0.8.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 14:10:56 +10:00
PapaTramp 02200f1a80 docs: note Exchange WinRM strict mode in agent integration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:07:46 +10:00
PapaTramp 502971367e feat(ui): live host row update via SSE without full reload
On new ingest, patch visible host from GET /hosts/{id}; SAC 0.8.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:47:21 +10:00
PapaTramp d9893497ce fix(ui): full-row host click, agent version label on host card
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:20:32 +10:00
PapaTramp adfc69c656 fix(ui): host name nav styled like sidebar, plain hostname column
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:10:10 +10:00
PapaTramp 7699d8690a fix: visible host card links in Hosts table; bump SAC to 0.8.0
Hostname and display name always link to /hosts/:id with sac-row-link styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:00:11 +10:00
PapaTramp 54337a5917 feat: host inventory (agent.inventory), host detail page, UI columns
Store hardware/software snapshots on hosts; warn on hardware changes. Host card from Hosts list. Add agent version column to Events/Overview; rename Hosts table columns and sort by status.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:53:00 +10:00
PapaTramp 5d3cda2ab6 docs: expand README highlights (5140 admin share, agent versions)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:17:11 +10:00
PapaTramp 57793d24ba feat: SAC support smb.admin_share.access events from RDP agent
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:14:14 +10:00
PapaTramp d324e41eba docs: README highlights list monitored sources (SSH, RDP, WinRM, sudo)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 16:10:49 +10:00
PapaTramp 1580982d88 fix: rename README screenshots to bust stale CDN/browser cache
Same user-provided PNGs (no blur); new filenames avoid serving old
Gaussian-blurred assets cached at dashboard.png / events.png paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 14:18:07 +10:00
PapaTramp d8ba864e48 docs: drop redundant note about blurred screenshots in README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 14:14:33 +10:00
PapaTramp 7ab497b300 docs: restore original README screenshots without extra blur
Use user-provided redacted images as-is for dashboard, events, and problems.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 14:13:47 +10:00
PapaTramp 2d39943020 docs: add sanitized UI screenshots to README (0.7.4)
Dashboard, events, and problems screenshots with hostnames and IPs blurred.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 14:11:14 +10:00
PapaTramp 79185d9186 fix: show RD Gateway session duration in Telegram when present
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:07:51 +10:00
PapaTramp 47d08b54d2 chore: sync Cursor rules from CursorRules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:31:57 +10:00
PapaTramp b565a259ea chore: add Cursor rule for mandatory agent script version bumps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:29:23 +10:00
PapaTramp 0d4a50cd35 fix: dedupe daily report Telegram in dual mode and show host in summary fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:15:21 +10:00
PapaTramp 3053058cdf feat: SAC 0.7.4 sidebar system stats block with UI toggle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:15:15 +10:00
PapaTramp cf5a6f995c docs: update README with current SAC and agent status
Reflect SAC 0.7.3 capabilities, security hardening highlights, and current RDP monitor integration state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:25:16 +10:00
PapaTramp 141fc44099 feat: SAC 0.7.3 custom error pages 401/403/404/429
Centered logo pages with event explanation and allowed actions; router
and apiFetch redirect to them on auth, permission, not-found, and rate-limit errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:06:21 +10:00
PapaTramp d3a337992c feat: SAC 0.7.2 security hardening (rate limit, JWT DB check, DOMPurify)
Path traversal fix in SPA fallback, admin-only host delete, login rate limit
with 3 attempts and Telegram alert, JWT validated against active users in DB,
and DOMPurify for agent report HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:04:14 +10:00
PapaTramp 06a8ed8614 feat: events date filter and preserve list page in URL (SAC 0.7.1)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:32:24 +10:00
PapaTramp de29270a25 feat: SAC 0.7.0 display_name columns and event severity overrides
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:21:43 +10:00
PapaTramp 8de5d14cfb feat: SAC 0.6.0 user edit UI, login to dashboard, daily report timer sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:55:12 +10:00
PapaTramp 56c469ddbd feat: multi-user UI login with admin and monitor roles
Add sac_users table, JWT roles, settings/users API guarded for admin, Users page in UI, and sac_manage_user.py CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:27:14 +10:00
PapaTramp bc77f82307 feat: notify RD Gateway events in Telegram outside min_severity
Route rdg.connection.* through notify_rdg_connection and add disconnected type.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:14:37 +10:00
PapaTramp 7e13be3c38 fix: hide redundant RDP workstation line when it matches server host
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 18:55:11 +10:00
PapaTramp fbef1e2f4b feat: always send RDP/SSH login success to Telegram via SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 18:47:39 +10:00
PapaTramp 4baa2b426f feat: render full agent lifecycle text from notification_body in Telegram
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 18:11:17 +10:00
PapaTramp 9dacbd7944 fix: Telegram footer shows SAC as sender when SAC delivers the message
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:41:23 +10:00
PapaTramp 366a9c83f7 feat: always notify agent.lifecycle via SAC with telegram_via routing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:25:48 +10:00
PapaTramp 3d4d1f3c76 feat: show notification source (agent vs SAC) in alerts and reports
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:51:53 +10:00
PapaTramp a45b641b8c feat: daily report agent version line and fix active users layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:28:30 +10:00
PapaTramp 799f8668e6 feat: Telegram templates for RDS shadow and WinRM session events
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:20:57 +10:00
PapaTramp 2501ecc4fe fix: normalize daily report layout for Telegram and UI
Ensure server line, compact spacing, and one user per row when ingesting
agent reports or sending SAC-generated daily reports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:52:09 +10:00
PapaTramp eba2f0d15c docs: agent daily report toggles and unified template versions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:18:03 +10:00
PapaTramp 7ebbb9a9d1 feat: unify SSH and Windows daily report layout in SAC and UI
Agent-style report body for SAC aggregation; shared stats cards; RDP ban note in docs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:03:29 +10:00
PapaTramp 11195ae64f feat: delete host from UI with events and problems cleanup
DELETE /api/v1/hosts/{id} (JWT); host reappears on next agent ingest

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:52:05 +10:00
PapaTramp 2670c8cb46 fix: sanitize daily report HTML for Telegram parse_mode
Strip div/br tags unsupported by Telegram API; reports still store UI HTML in details

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:35:04 +10:00
PapaTramp 0baeacf808 docs: mark notification epic complete in work-plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:31:18 +10:00
PapaTramp 42f31b3bb5 fix: dashboard and hosts count SSH and RDP daily reports
analytics-a4: daily_reports_24h uses report.daily.*; sac-deploy enables timers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:31:09 +10:00
PapaTramp 415d863b3b feat: SAC daily reports from DB aggregation (notif-32)
- Aggregate report.daily.ssh/rdp from 24h ingest; job and systemd timer

- notify_daily_report bypasses NOTIFY_MIN_SEVERITY; ingest routes agent reports

- HTML templates, env SAC_DAILY_REPORT_*, docs and tests (56 cases)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:30:07 +10:00
PapaTramp 4167687dec feat: notification cooldown dedup for events and problems (notif-31)
Store last notify time by dedup_key/fingerprint, gate dispatch before
channels; config SAC_NOTIFY_*_COOLDOWN_SEC, migration 008.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:24:01 +10:00
PapaTramp 1a70980920 feat: HTML Telegram templates for RDP/SSH events (notif-30)
Agent-style messages from event details, parse_mode HTML, templates for
login/sudo/RDG and problems; unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:21:40 +10:00
PapaTramp ebb450be92 feat: global notification policy severity to channels (notif-22)
Singleton notification_policy table, NOTIFY_MIN_SEVERITY/CHANNELS env,
central dispatch gate, Settings policy UI, migration 007.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:19:26 +10:00
PapaTramp 9b177c145b feat: SMTP email notification channel with UI and ingest
Migration 006, email config DB/env, smtplib sender, settings API,
Settings UI section, and dispatch on event/problem ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:12:55 +10:00
PapaTramp 20fa7e2c27 feat: webhook notification channel with UI and ingest dispatch
Add webhook config (DB/env), JSON POST on events/problems, settings API,
Settings UI section, and notify_dispatch for multi-channel ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:09:40 +10:00
PapaTramp f7b5fff04e docs: E2E checklist for SAC Telegram severity (notif-02)
Add synthetic ingest curl steps, update prod next steps for migration 004.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:06:58 +10:00
PapaTramp 79a78dc7c9 feat: Telegram settings in DB with UI edit and test send
Store notification_channels (migration 004), effective config DB over env,
PUT/test API endpoints, Settings form, and pass db session into ingest notify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:05:32 +10:00
PapaTramp e0ba384705 feat: SAC Telegram settings view, notify_problem severity gate, exclusive docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:58:08 +10:00
PapaTramp 117390cbca docs: add 2026-05-29 todo for SAC notifications and Settings UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 15:39:21 +10:00
PapaTramp a42a63b8fa docs: add analytics backlog for deferred SAC development
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 15:17:08 +10:00
PapaTramp daec333dbe feat: add SAC app icon to favicon, sidebar, and login page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 14:39:57 +10:00
PapaTramp ac3c20b7db feat: persistent Zabbix-style sidebar on all pages, rename Dashboard to Обзор
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 13:54:41 +10:00
PapaTramp dce5b88b2f feat: move navigation to dashboard sidebar and bump SAC to 0.5.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 13:45:25 +10:00
PapaTramp 81cf9c46b3 feat: improve SAC UI layout, reports state, and hosts sorting
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 13:17:37 +10:00
PapaTramp d4bf6e536c feat: host display_name в ingest, поиск хостов, UI и docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 11:49:10 +10:00
PapaTramp 108e1756c4 feat: live последние события на Dashboard; retention, health, runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 11:29:31 +10:00
PapaTramp 9ed0670863 docs: RDP SAC 1.2.12 null+JSON fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:44:23 +10:00
PapaTramp fcf3da223c test/docs: RDP agent.lifecycle ingest and 422 notes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:34:23 +10:00
PapaTramp f322a46f98 docs: RDP SAC 409 spool loop fix 1.2.10-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:27:25 +10:00
PapaTramp a218ff2581 docs: RDP SAC spool 422 and 1.2.9-SAC notes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:20:31 +10:00
PapaTramp 98055dfdf8 docs: RDP SAC UTF-8 fix note for report.daily.rdp mojibake
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:05:49 +10:00
PapaTramp c8f357007f feat: dashboard top hosts/types, problems 24h, drill-down (d2-2)
- Extend /dashboards/summary; Events filters from query (hostname, severity)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:01:07 +10:00
PapaTramp 249894d8e1 feat: UI Problems list filters, detail card and event timeline (d2-1)
- Filters status/severity/hostname; /problems/:id with Ack/Resolve and timeline

- Dashboard drill-down to open problems

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:55:28 +10:00
PapaTramp 64b5ef297a feat: problem rules v1 brute-force, privilege spike, host silence (d1-3)
- Threshold rules on ingest; heartbeat auto-resolves host silence

- Config thresholds in sac-api.env; unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:51:05 +10:00
PapaTramp 83f48dce97 fix: alembic 003 avoid :r bind param in fingerprint backfill SQL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:47:35 +10:00
PapaTramp a4a224b284 feat: Problems correlation fingerprint, count, last_seen (d1-2)
- Migration 003; windowed host+type+rule correlation on ingest

- GET /problems filters; GET /problems/{id} with event timeline; ack/resolve 409 guard

- Tests and SAC_PROBLEM_CORRELATION_WINDOW_MINUTES config

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:46:12 +10:00
PapaTramp 8eb40cf75d docs: agent version bumps and deploy triggers (1.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:34:22 +10:00
PapaTramp 4d603637e5 fix(deploy): sync systemd unit, preflight DB check, health retry
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:28:01 +10:00
PapaTramp e0b066f5e5 fix(deploy): recover from diverging git after force-push on kalinamall
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:26:06 +10:00
PapaTramp 25a485fabb docs: git policy — kalinamall only, push after each feature
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:23:10 +10:00
PapaTramp 2b76ae8497 feat: ingest HTTP 201/409 for event_id idempotency
- Validate UUID format; log created/duplicate/rejected

- Tests for 201, 409, 422; update agent-integration and work-plan

- Docs: neutral IDE wording (no product-specific editor names)
2026-05-28 09:13:12 +10:00
PapaTramp 5cf76f492c docs: add display_name tasks for RDP, ssh, and SAC hosts
Document ServerDisplayName parity with ssh SERVER_DISPLAY_NAME and
agent-display-* checklist items for SAC Hosts UI.
2026-05-27 17:02:23 +10:00
PapaTramp d1b89c75dd docs: add 2-day delivery and hourly checklist
Add execution checklist for next two development days and a follow-up week of testing in work-plan.md.
2026-05-27 16:12:56 +10:00
PapaTramp 721de21d5c docs: update agent integration readiness checklist
Mark completed SAC config, check-sac, and spool items for ssh and RDP agents.
2026-05-27 15:33:42 +10:00
PapaTramp 8995a34037 fix: faster sac-api restart (TimeoutStopSec, SSE cancel on shutdown) 2026-05-27 14:53:54 +10:00
PapaTramp 61c9f9b673 feat: reports page with formatted daily report cards 2026-05-27 14:15:15 +10:00
PapaTramp 04301ab359 feat: host heartbeat status and dashboard metrics (2.2)
Track agent.heartbeat and report.daily.ssh per host; show online/stale
on Hosts UI; dashboard counters and SAC_HEARTBEAT_STALE_MINUTES config.
2026-05-27 13:36:17 +10:00
PapaTramp 9ba3c4b63b docs: pilot 2.1 exclusive (Linux) runbook 2026-05-27 13:25:47 +10:00
PapaTramp b404747f8d test: version constants without DB import 2026-05-27 13:21:02 +10:00
PapaTramp c657a65970 feat: v0.2.0 branding, healthz version, SSE dashboard live
Centralize APP_VERSION 0.2.0; /health and /healthz return version;
startup log line; UI title Security Alert Center v.0.2.0;
SSE /api/v1/stream/events for live dashboard counters.
2026-05-27 13:20:39 +10:00
PapaTramp ea221db3c7 feat: Dashboard MVP with summary API and UI
Add GET /api/v1/dashboards/summary (24h events, open problems,
hosts, severity breakdown, recent events) and Vue /dashboard page.
2026-05-27 12:37:06 +10:00
PapaTramp eeba3a704d feat: Telegram notifications from SAC on ingest
Send Telegram alerts for high/critical events and new Problems.
Configurable via TELEGRAM_* env vars; ingest never fails on send errors.
2026-05-27 12:21:03 +10:00
PapaTramp 5fce00faae feat: Problems API, ingest hook, and UI 2026-05-27 11:24:19 +10:00
PapaTramp b19cdf2b47 fix: SPA fallback для /events при F5 (index.html) 2026-05-27 11:01:47 +10:00
PapaTramp 947fc4ab98 docs: тестовый агент ubabuba; gitignore deploy helpers 2026-05-27 10:47:54 +10:00
PapaTramp 4265d58c4b chore: gitignore local SSH deploy helper scripts 2026-05-26 22:46:15 +10:00
PapaTramp a4bbc14448 docs: prod status, sac-deploy.sh; update install and deployment guides 2026-05-26 22:03:10 +10:00
PapaTramp bdfc7016e6 feat: phase 1C JWT UI, events/hosts API, Vue frontend 2026-05-26 21:46:34 +10:00
PapaTramp d8a8329397 docs: права /etc/ssl/sac — root:ssl-cert, www-data в группе 2026-05-26 21:37:56 +10:00
PapaTramp a9bea3d276 docs: PFX -legacy на Ubuntu 24.04, scp с другого Linux 2026-05-26 21:27:52 +10:00
PapaTramp 0c5d2537db docs: подробное руководство по копированию wildcard TLS на сервер 2026-05-26 21:15:38 +10:00
PapaTramp 21578b27c9 docs: nginx TLS с корпоративным wildcard VeriSign 2026-05-26 21:14:22 +10:00
PapaTramp 2c12f161a1 fix(nginx): только HTTP до certbot, без ssl без сертификата 2026-05-26 21:12:48 +10:00
PapaTramp 934a5a0c9c fix: загрузка sac-api.env через pydantic, не EnvironmentFile systemd 2026-05-26 21:07:51 +10:00
PapaTramp 539126b75e fix(deploy): конфиг в /opt/security-alert-center/config (sac:sac)
Убирает ошибку Permission denied при каталоге /etc root:root.
2026-05-26 20:59:44 +10:00
PapaTramp 38c137cbd3 fix(docs): chmod 750 на каталог /etc/security-alert-center для группы sac 2026-05-26 20:56:38 +10:00
PapaTramp 6d33669ac2 fix(docs): sac-api.env chmod 640 root:sac для пользователя sac 2026-05-26 20:53:10 +10:00
PapaTramp 69547d83bc docs: URL sac.kalinamall.ru во всех руководствах и примерах конфигов 2026-05-26 20:30:10 +10:00
PapaTramp 9203af90d5 docs: native Ubuntu install as primary; systemd and nginx
- install-ubuntu-24.04-native.md (production) and -docker.md (alternative)

- deploy/systemd/sac-api.service, nginx/sac.conf.example, env.native.example

- deployment, README, INDEX, work-plan, TZ updated
2026-05-26 20:28:05 +10:00
PapaTramp 69a232d08a feat: backend ingest API, Docker Compose, Ubuntu install guide
- FastAPI: POST /api/v1/events, GET /health, JSON Schema validation

- PostgreSQL models, Alembic migration, bootstrap API key

- deploy/docker-compose.yml, .env.example

- docs/install-ubuntu-24.04.md, updated work-plan (agents first)
2026-05-26 20:21:31 +10:00
PapaTramp bf1c1c9fe9 docs: обновить пример путей в workspace 2026-05-26 19:59:51 +10:00
PapaTramp 2f82c7c658 chore: пути репозиториев D:\Soft\Git 2026-05-26 19:58:59 +10:00
PapaTramp 4f3adc9429 docs: ТЗ v1.0, планы и схема SAC (фаза 0)
Security Alert Center — документация без кода приложения.

- TZ, архитектура, интеграция агентов (UseSAC)

- JSON Schema событий v1, deployment Ubuntu 24.04

- План работ, roadmap, multi-root workspace
2026-05-26 19:54:47 +10:00
76 changed files with 195 additions and 2568 deletions
-2
View File
@@ -1,2 +0,0 @@
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
*.sh text eol=lf
+6 -6
View File
@@ -1,4 +1,4 @@
# Security Alert Center (SAC)
# Security Alert Center (SAC)
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
@@ -8,12 +8,12 @@
| Репозиторий | Назначение |
|-------------|------------|
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux-агент |
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
| [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.papatramp.ru/PapaTramp/seaca) | Android-клиент |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
**Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh`
**Версия:** `0.5.0` · **Деплой:** `sudo /opt/sac-deploy.sh`
## Возможности
@@ -40,7 +40,7 @@
## Быстрый старт
```bash
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# 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
+6 -6
View File
@@ -1,4 +1,4 @@
# Security Alert Center (SAC)
# Security Alert Center (SAC)
Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management.
@@ -8,12 +8,12 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
| Repository | Role |
|------------|------|
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux agent |
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows agent |
| [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.papatramp.ru/PapaTramp/seaca) | Android client |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
**Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh`
**Version:** `0.5.0` · **Deploy:** `sudo /opt/sac-deploy.sh`
## Features
@@ -39,7 +39,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
## Quick start
```bash
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
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
@@ -1,25 +0,0 @@
"""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")
+6 -16
View File
@@ -28,12 +28,12 @@ from app.services.host_sessions import (
mark_event_session_terminated,
terminate_session_for_event,
)
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.linux_admin_settings import get_effective_linux_admin_config
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.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
from app.services.ingest import ingest_event
from app.services.event_summary import event_to_summary
@@ -52,9 +52,7 @@ 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"])
@@ -102,17 +100,15 @@ def post_event(
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:
deferred_daily_report_id = event.id
elif event.type == LIFECYCLE_EVENT_TYPE:
deferred_lifecycle_id = event.id
notify_lifecycle(event, db=db)
elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
deferred_auth_login_id = event.id
notify_auth_login(event, db=db)
elif event.type in RDG_CONNECTION_TYPES:
notify_rdg_connection(event, db=db)
else:
@@ -126,10 +122,6 @@ def post_event(
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:
@@ -284,10 +276,8 @@ def post_event_terminate_session(
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)
linux_cfg = get_effective_linux_admin_config(db)
win_cfg = get_effective_win_admin_config(db)
sid = (body.session_id if body else None) or event_session_id(event)
try:
+16 -111
View File
@@ -46,12 +46,8 @@ from app.services.host_sessions import (
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.linux_admin_settings import get_effective_linux_admin_config
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
@@ -303,23 +299,6 @@ class HostAgentConfigResponse(BaseModel):
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
@@ -490,12 +469,9 @@ def test_host_winrm(
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_win_admin_for_host(db, host)
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
targets = iter_winrm_targets(host)
@@ -556,12 +532,9 @@ def test_host_ssh(
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_for_host(db, host)
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
_set_ssh_admin_status(host, response.ok)
@@ -583,12 +556,9 @@ def update_host_agent_via_ssh(
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_for_host(db, host)
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
title = "Обновление ssh-monitor (SSH)"
try:
@@ -597,7 +567,6 @@ def update_host_agent_via_ssh(
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
@@ -683,7 +652,6 @@ def get_host_remote_job(
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))
@@ -716,12 +684,9 @@ def list_host_sessions(
from app.services.winrm_connect import is_windows_host
if is_linux_host(host):
cfg = get_effective_linux_admin_for_host(db, host)
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
try:
sessions, result = list_linux_sessions(host, cfg)
except SshHostNotLinuxError as exc:
@@ -736,12 +701,9 @@ def list_host_sessions(
)
if is_windows_host(host):
cfg = get_effective_win_admin_for_host(db, host)
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
sessions, result = list_windows_sessions(host, cfg)
except HostNotWindowsError as exc:
@@ -779,12 +741,9 @@ def terminate_host_session(
raise HTTPException(status_code=422, detail="session_id is required")
if is_linux_host(host):
cfg = get_effective_linux_admin_for_host(db, host)
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
try:
result = terminate_linux_session(host, cfg, sid)
except SshHostNotLinuxError as exc:
@@ -800,12 +759,9 @@ def terminate_host_session(
)
if is_windows_host(host):
cfg = get_effective_win_admin_for_host(db, host)
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
result = terminate_windows_session(host, cfg, sid)
except HostNotWindowsError as exc:
@@ -845,57 +801,6 @@ def patch_host_agent_config(
)
@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,
+3 -4
View File
@@ -1,4 +1,4 @@
import os
import os
from functools import lru_cache
from pathlib import Path
@@ -36,7 +36,6 @@ class Settings(BaseSettings):
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 = ""
@@ -138,8 +137,8 @@ class Settings(BaseSettings):
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.papatramp.ru/PapaTramp/RDP-login-monitor.git"
sac_agent_ssh_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
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
-1
View File
@@ -23,7 +23,6 @@ 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",
-3
View File
@@ -26,9 +26,6 @@ class Host(Base):
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))
-1
View File
@@ -78,7 +78,6 @@ 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
+6 -17
View File
@@ -7,22 +7,17 @@ from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import AgentCommand, Host
from app.services.win_admin_settings import (
get_effective_win_admin_config,
get_effective_win_admin_for_host,
)
from app.services.win_admin_settings import get_effective_win_admin_config
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)
def win_admin_run_as() -> dict[str, str] | None:
cfg = get_effective_win_admin_config()
if not cfg.configured:
return None
return {
@@ -31,13 +26,7 @@ def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dic
}
def command_to_dict(
cmd: AgentCommand,
*,
include_run_as: bool = False,
db: Session | None = None,
host: Host | None = None,
) -> dict:
def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
out = {
"id": cmd.command_uuid,
"type": cmd.command_type,
@@ -49,7 +38,7 @@ def command_to_dict(
"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)
run_as = win_admin_run_as()
if run_as:
out["run_as"] = run_as
return out
+1 -1
View File
@@ -27,5 +27,5 @@ def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
"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],
"commands": [command_to_dict(c, include_run_as=True) for c in pending],
}
+4 -4
View File
@@ -20,7 +20,7 @@ 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.linux_admin_settings import get_effective_linux_admin_config
from app.services.ssh_connect import (
HostNotLinuxError,
HostTargetMissingError,
@@ -28,7 +28,7 @@ from app.services.ssh_connect import (
iter_ssh_targets,
run_ssh_monitor_update,
)
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError as WinRmHostTargetMissingError,
@@ -249,7 +249,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
product = (host.product or "").strip()
if product == PRODUCT_SSH or is_linux_host(host):
linux_cfg = get_effective_linux_admin_for_host(db, host)
linux_cfg = get_effective_linux_admin_config(db)
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"
@@ -260,7 +260,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
git_branch=branch,
)
elif product == PRODUCT_RDP or is_windows_host(host):
win_cfg = get_effective_win_admin_for_host(db, host)
win_cfg = get_effective_win_admin_config(db)
result = run_windows_agent_update_fallback(
host,
win_cfg,
-1
View File
@@ -15,7 +15,6 @@ 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",
-4
View File
@@ -6,7 +6,6 @@ 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, db: Session | None = None) -> EventSummary:
@@ -45,9 +44,6 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
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,
@@ -1,116 +0,0 @@
"""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)
+5 -156
View File
@@ -14,14 +14,9 @@ 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.linux_admin_settings import get_effective_linux_admin_config
from app.services.agent_update_settings import get_effective_agent_update_config
from app.services.ssh_connect import (
iter_ssh_targets,
run_ssh_monitor_update,
SshCommandResult,
tail_ssh_monitor_update_log,
)
from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult
logger = logging.getLogger(__name__)
@@ -81,61 +76,6 @@ def _result_payload(
}
def _completion_title(base_title: str, result: SshCommandResult) -> str:
if result.ok:
return f"{base_title} — готово"
return f"{base_title} — ошибка"
def _completion_message(result: SshCommandResult) -> str:
if result.ok:
version = (result.agent_version or "").strip()
if version:
return f"Обновление завершено успешно. Версия агента: {version}"
return "Обновление завершено успешно"
return (result.message or "Обновление не удалось").strip()
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
cfg = get_effective_linux_admin_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"
@@ -173,70 +113,12 @@ def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult)
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:
@@ -251,35 +133,13 @@ def start_host_remote_action(
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
row.remote_action = _result_payload(result, title=title, started_at=started)
if isinstance(result, SshCommandResult):
_apply_ssh_update_result(session, row, result)
session.commit()
@@ -307,9 +167,6 @@ def start_host_remote_action(
}
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)
@@ -322,7 +179,7 @@ def start_host_remote_action(
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
cfg = get_effective_linux_admin_for_host(db, host)
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
return SshCommandResult(
ok=False,
@@ -419,16 +276,8 @@ 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"
active = status == "running" or host.agent_update_state == "running"
return {
"active": active,
"host_id": host.id,
+15 -80
View File
@@ -84,10 +84,6 @@ def _event_login_user(event: Event) -> str:
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,
@@ -101,11 +97,7 @@ def event_session_terminated(event: Event, db: Session | None = None) -> bool:
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
@@ -384,79 +376,28 @@ def terminate_linux_session(
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"})
norm_filter = (filter_user or "").strip().lower()
def norm_user(value: str) -> str:
return value.replace("B26\\", "").replace("b26\\", "").lower()
for line in stdout.splitlines():
text = line.strip()
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
continue
parts = text.split()
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:
if len(parts) < 4:
continue
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
if not state or state.casefold().startswith("listen"):
session_name = parts[0].lstrip(">")
user_name = parts[1]
try:
sid = int(parts[2])
except ValueError:
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):
state = " ".join(parts[3:])
if norm_filter and norm_filter not in norm_user(user_name):
continue
rows.append(
HostSessionRow(
@@ -467,7 +408,7 @@ def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> li
)
)
if rows or not filter_sam:
if rows or not norm_filter:
return rows
return parse_qwinsta_sessions(stdout, filter_user=None)
@@ -534,17 +475,11 @@ def terminate_session_for_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
matched = [s for s in sessions if user.lower() in s.user.lower()] if user else sessions
if len(matched) == 1:
sid = matched[0].session_id
elif not matched:
# Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
return WinRmCmdResult(
ok=True,
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
target=qwinsta.target,
stdout=qwinsta.stdout,
)
raise ValueError("No matching Windows session for user")
else:
raise ValueError("Multiple sessions; specify session_id")
result = terminate_windows_session(host, win_cfg, sid)
-11
View File
@@ -9,7 +9,6 @@ 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"})
@@ -128,15 +127,5 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
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
+2 -16
View File
@@ -1,4 +1,4 @@
"""Effective Linux SSH admin credentials (host → DB → env)."""
"""Effective Linux SSH admin credentials (DB overrides env)."""
from __future__ import annotations
@@ -7,7 +7,6 @@ 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
@@ -15,7 +14,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
class LinuxAdminConfig:
user: str
password: str
source: str # host | env | db
source: str # env | db
@property
def configured(self) -> bool:
@@ -81,16 +80,3 @@ def upsert_linux_admin_settings(
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
+4 -88
View File
@@ -4,7 +4,7 @@ import logging
from sqlalchemy.orm import Session
from app.models import Event, Host, Problem
from app.models import Event, 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
@@ -15,14 +15,6 @@ 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({
@@ -75,8 +67,6 @@ 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)
@@ -121,68 +111,8 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
_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)
@@ -216,30 +146,16 @@ def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
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)
logger.warning("deferred daily report notify: event id=%s not found", event_db_id)
return
handler(event, db=db)
notify_daily_report(event, db=db)
except Exception:
logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
logger.exception("deferred daily report notify failed event_db_id=%s", event_db_id)
finally:
db.close()
+5 -10
View File
@@ -12,7 +12,7 @@ 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.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import (
WinRmCmdResult,
run_winrm_logoff,
@@ -21,17 +21,12 @@ from app.services.winrm_connect import (
)
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
def _require_win_admin(db: Session):
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)",
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
)
return cfg
@@ -98,8 +93,8 @@ def _persist_command(
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
_require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db)
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")
@@ -141,8 +136,8 @@ def execute_logoff_via_winrm(
requested_by: str,
) -> AgentCommand:
_require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db)
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")
+3 -134
View File
@@ -2,8 +2,6 @@
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
@@ -17,7 +15,6 @@ from app.services.host_sessions import (
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,
@@ -25,22 +22,10 @@ from app.services.rdg_session_flap import (
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:
@@ -56,115 +41,6 @@ def users_match_rdg(login_user: str, rdg_user: str) -> bool:
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)
@@ -180,17 +56,13 @@ def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> Non
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)
return event_closed_by_rdg(login_event)
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
@@ -222,11 +94,8 @@ def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Eve
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):
login_user = _event_login_user(login)
if not users_match_rdg(login_user, rdg_user):
continue
return login
return None
@@ -35,7 +35,7 @@ from app.services.rdg_workstation_session import (
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
from app.services.win_admin_settings import get_effective_win_admin_config
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
@@ -143,7 +143,7 @@ def _disconnect_on_workstation(
user: str,
login_event: Event | None,
) -> AutoDisconnectResult:
win_cfg = get_effective_win_admin_for_host(db, workstation)
win_cfg = get_effective_win_admin_config(db)
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
if qwinsta is None or not qwinsta.ok:
message = qwinsta.message if qwinsta else "qwinsta failed"
@@ -155,23 +155,13 @@ def _disconnect_on_workstation(
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)
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user)
if not matched and qwinsta.stdout.strip():
parsed = parse_qwinsta_sessions(qwinsta.stdout)
matched = _sessions_for_user(parsed, user)
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), 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,
message="No matching Windows session for user",
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
@@ -234,13 +224,11 @@ def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult
try:
workstation = resolve_client_workstation(db, rdg_success)
except ClientWorkstationNotFoundError as exc:
result = AutoDisconnectResult(
return 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
@@ -291,6 +279,14 @@ def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDi
if not get_effective_rdp_flap_settings(db).auto_disconnect:
return None
win_cfg = get_effective_win_admin_config(db)
if not win_cfg.configured:
logger.warning(
"auto rdp flap disconnect skipped: win admin not configured (event_id=%s)",
event.event_id,
)
return None
result = _auto_disconnect_rdg_flap(db, event)
if result is not None:
if result.ok:
-148
View File
@@ -1,148 +0,0 @@
"""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
@@ -1,37 +0,0 @@
"""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+ → Nд 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
+6 -43
View File
@@ -1,4 +1,4 @@
"""SSH connectivity and remote commands for Linux hosts."""
"""SSH connectivity and remote commands for Linux hosts."""
from __future__ import annotations
@@ -9,12 +9,10 @@ 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.papatramp.ru/PapaTramp/ssh-monitor.git"
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 "
@@ -435,7 +433,7 @@ def _ssh_monitor_update_invoke_command(
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}"
return f"{preflight}; 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:
@@ -451,16 +449,7 @@ def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -
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; "
f'chmod 750 "$INSTALL" && REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL"'
)
@@ -508,7 +497,7 @@ def run_ssh_monitor_update(
target=target,
user=user,
password=password,
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
remote_cmd=bootstrap_cmd,
command_timeout_sec=900,
need_root=True,
login_shell=False,
@@ -519,7 +508,7 @@ def run_ssh_monitor_update(
target=target,
user=user,
password=password,
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
remote_cmd=update_cmd,
command_timeout_sec=900,
need_root=True,
login_shell=False,
@@ -560,29 +549,3 @@ def run_ssh_monitor_update(
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)
+3 -5
View File
@@ -564,11 +564,9 @@ def _format_rdg_html(event: Event) -> str:
err = _detail(details, "gateway_error_code", "error_code", default="")
if err != "-":
msg += _line("⚠️", "Код ошибки", html_escape(err))
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)))
dur = _detail(details, "session_duration_sec", default="")
if dur not in ("-", "0", ""):
msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с")
msg += _line("🕐", "Время", format_time(event.occurred_at))
win_id = _detail(details, "event_id_windows", default="")
if win_id != "-":
+2 -17
View File
@@ -1,4 +1,4 @@
"""Effective Windows domain admin credentials (host → DB → env)."""
"""Effective Windows domain admin credentials (DB overrides env)."""
from __future__ import annotations
@@ -7,7 +7,6 @@ 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
@@ -15,7 +14,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
class WinAdminConfig:
user: str
password: str
source: str # host | env | db
source: str # env | db
@property
def configured(self) -> bool:
@@ -93,17 +92,3 @@ def upsert_win_admin_settings(
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
+2 -2
View File
@@ -1,4 +1,4 @@
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
from __future__ import annotations
@@ -487,7 +487,7 @@ def run_winrm_rdp_monitor_update(
)
effective_repo = (
repo_url or "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
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:
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.5.17"
APP_VERSION = "0.5.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+1 -1
View File
@@ -14,7 +14,7 @@ from app.services.agent_version import reference_agent_versions_by_product
def test_normalize_git_repo_url():
assert normalize_git_repo_url("git.papatramp.ru/PapaTramp/ssh-monitor").endswith(
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"
-24
View File
@@ -247,30 +247,6 @@ def test_clear_stale_running_remote_actions(db_session):
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",
@@ -1,64 +0,0 @@
"""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
-69
View File
@@ -8,7 +8,6 @@ from app.services.host_sessions import (
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,
@@ -74,32 +73,6 @@ def test_parse_qwinsta_sessions_filters_user():
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"
@@ -121,48 +94,6 @@ def test_event_login_user_without_orm_actor_user_attr():
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",
-45
View File
@@ -133,48 +133,3 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
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()
@@ -174,51 +174,3 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
assert job["target"] == "ubabuba"
if "product_version" in job:
assert job["product_version"] is None or isinstance(job["product_version"], str)
def test_host_agent_update_job_shows_log_tail_and_completion_title(
jwt_headers, client, db_session, monkeypatch
):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
from app.models import Host
from app.services.ssh_connect import SshCommandResult
from tests.test_agent_update import wait_remote_job
get_settings.cache_clear()
host = Host(hostname="router", os_family="linux", product="ssh-monitor", ipv4="10.0.0.1")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
log_tail = (
"2026-07-08 14:18:15 INFO: === Script update completed successfully ===\n"
"2026-07-08 14:18:15 INFO: завершено успешно (код 0). Итог см. выше\n"
)
with (
patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update,
patch("app.services.host_remote_actions.tail_ssh_monitor_update_log") as mock_tail,
):
mock_update.return_value = SshCommandResult(
ok=True,
message="SSH OK (router), exit 0",
target="router",
stdout="",
exit_code=0,
agent_version="2.3.2-SAC",
)
mock_tail.return_value = log_tail
response = client.post(
f"/api/v1/hosts/{host.id}/actions/agent-update",
headers=jwt_headers,
)
assert response.status_code == 202
job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True
assert "готово" in (job.get("title") or "")
assert "2.3.2-SAC" in (job.get("message") or "")
assert "Script update completed successfully" in (job.get("output") or "")
mock_tail.assert_called()
@@ -266,113 +266,3 @@ def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False
def test_empty_rcm1149_user_enriched_from_prior_rdg302(db_session, rdg_settings, rdg_hosts):
"""COMM-PC class: EventLog 1149 has empty Param1; RDG 302 already has the account."""
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.success",
category="auth",
severity="warning",
title="RDG 302",
summary="302",
details={"user": user, "internal_ip": internal_ip},
)
login = _ingest(
db_session,
t0 + timedelta(seconds=3),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="warning",
title="RDP connection (RCM 1149)",
summary="RCM 1149 - 192.168.160.40",
details={"user": "-", "ip_address": "192.168.160.40", "event_id_windows": 1149},
)
db_session.refresh(login)
assert login.details["user"] == user
assert login.details.get("user_enriched_from_rdg_event_id")
summary = event_to_summary(login, db_session)
assert summary.actor_user == user
def test_empty_rcm1149_backfilled_when_rdg302_arrives_later(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP connection (RCM 1149)",
summary="RCM 1149 - 1.2.3.4",
details={"user": "-", "event_id_windows": 1149},
)
assert login.details["user"] == "-"
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.success",
category="auth",
severity="info",
title="RDG 302",
summary="302",
details={"user": user, "internal_ip": internal_ip},
)
db_session.refresh(login)
assert login.details["user"] == user
def test_empty_user_login_still_closed_by_rdg303(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0,
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP connection (RCM 1149)",
summary="RCM 1149",
details={"user": "-", "event_id_windows": 1149},
)
end = _ingest(
db_session,
t0 + timedelta(minutes=5),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": user, "internal_ip": internal_ip},
)
db_session.refresh(login)
assert login.details.get(SESSION_CLOSED_BY_RDG_AT_KEY) == end.occurred_at.isoformat()
assert event_session_terminated(login, db=db_session) is True
-229
View File
@@ -1,229 +0,0 @@
"""Tests for direct RDP logoff → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
from app.services.event_summary import event_to_summary
from app.services.host_sessions import event_session_terminated
from app.services.ingest import ingest_event
from app.services.rdp_session_logoff import (
SESSION_CLOSED_BY_LOGOFF_AT_KEY,
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY,
close_workstation_session_for_rdp_logoff,
find_logoff_after_workstation_login,
resolve_workstation_login_closed_by_logoff,
)
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
def test_logoff_marks_workstation_login_closed_on_ingest(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={
"user": user,
"ip_address": "192.168.160.3",
"logon_type": 10,
"event_id_windows": 4634,
},
)
assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id
assert event_session_terminated(login, db=db_session) is True
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\Alice"},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_runtime_resolve_for_historical_login_without_flag(db_session):
from app.models import Event, Host
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = Host(
hostname="BIV-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.165.39",
)
db_session.add(host)
db_session.commit()
login = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="4624",
payload={},
details={"user": "papatramp", "ip_address": "192.168.160.3"},
)
logoff = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdp.session.logoff",
severity="info",
title="4634",
summary="4634",
payload={},
details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634},
)
db_session.add_all([login, logoff])
db_session.commit()
assert resolve_workstation_login_closed_by_logoff(db_session, login) is True
assert find_logoff_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_sam_domain_user_match_on_logoff(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\papatramp", "logon_type": 10},
)
_ingest(
db_session,
t0 + timedelta(minutes=30),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4647",
details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647},
)
db_session.refresh(login)
assert event_session_terminated(login, db=db_session) is True
-20
View File
@@ -1,20 +0,0 @@
"""Tests for session_duration_sec extract/format."""
from app.services.session_duration import extract_session_duration_sec, format_session_duration
def test_extract_session_duration_sec():
assert extract_session_duration_sec({"session_duration_sec": 18324}) == 18324
assert extract_session_duration_sec({"session_duration_sec": "42"}) == 42
assert extract_session_duration_sec({"session_duration_sec": ""}) is None
assert extract_session_duration_sec({}) is None
assert extract_session_duration_sec(None) is None
assert extract_session_duration_sec({"session_duration_sec": -1}) is None
def test_format_session_duration():
assert format_session_duration(0) == "00:00:00"
assert format_session_duration(18324) == "05:05:24"
assert format_session_duration(2428) == "00:40:28"
assert format_session_duration(86400 + 85) == "1д 00:01:25"
assert format_session_duration(2 * 86400 + 3661) == "2д 01:01:01"
+2 -2
View File
@@ -1,4 +1,4 @@
"""SSH connect service tests (paramiko mocked via sys.modules)."""
"""SSH connect service tests (paramiko mocked via sys.modules)."""
import sys
from unittest.mock import MagicMock
@@ -323,7 +323,7 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
target="ubabuba",
user="root",
password="pw",
repo_url="https://git.papatramp.ru/PapaTramp/ssh-monitor.git",
repo_url="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git",
)
assert result.ok is True
assert result.agent_version == "2.1.0-SAC"
-3
View File
@@ -7,8 +7,6 @@ DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:543
# SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00.
SAC_DB_POOL_SIZE=15
SAC_DB_MAX_OVERFLOW=25
# Uvicorn workers (ingest burst). Читает deploy/systemd/sac-api-start.sh; при >1 отключите in-process host_silence scan ниже.
SAC_UVICORN_WORKERS=4
SAC_PUBLIC_URL=https://sac.kalinamall.ru
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
@@ -130,7 +128,6 @@ SAC_MOBILE_REFRESH_EXPIRE_DAYS=90
CORS_ORIGINS=https://sac.kalinamall.ru
# SSH: verify host keys (RejectPolicy). Add keys to file before remote actions.
# Комментарий — только отдельной строкой с #. Нельзя: SAC_SSH_AUTO_ADD_HOST_KEY=false ← текст
# SAC_SSH_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts
# SAC_SSH_AUTO_ADD_HOST_KEY=false
-13
View File
@@ -44,19 +44,6 @@ server {
proxy_read_timeout 960s;
}
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / {
proxy_pass http://sac_api;
proxy_http_version 1.1;
-13
View File
@@ -69,19 +69,6 @@ server {
proxy_read_timeout 960s;
}
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / {
proxy_pass http://sac_api;
proxy_http_version 1.1;
+9 -27
View File
@@ -56,16 +56,12 @@ fi
log "pip install -r requirements.txt"
sudo -u "${APP_USER}" "${VENV}/bin/pip" install -q -r "${APP_ROOT}/backend/requirements.txt"
log "Проверка ${CONFIG_FILE} (pydantic; без bash source — inline-комментарии в значениях ломают deploy)"
sudo -u "${APP_USER}" bash -c "
export SAC_CONFIG_FILE='${CONFIG_FILE}'
cd '${APP_ROOT}/backend'
'${VENV}/bin/python' -c 'from app.config import get_settings; get_settings(); print(\"config: OK\")'
" || die "Неверный ${CONFIG_FILE}: одна переменная = одна строка, комментарии только отдельной строкой с # (не «false ← …» после значения)"
log "alembic upgrade head"
sudo -u "${APP_USER}" bash -c "
export SAC_CONFIG_FILE='${CONFIG_FILE}'
set -a
# shellcheck source=/dev/null
source '${CONFIG_FILE}'
set +a
cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head
"
@@ -93,16 +89,6 @@ if [ -f "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" ]; then
systemctl daemon-reload
fi
fi
START_SH="${APP_ROOT}/deploy/systemd/sac-api-start.sh"
if [ -f "${START_SH}" ]; then
sed -i 's/\r$//' "${START_SH}"
chmod 755 "${START_SH}"
fi
for _sh in "${APP_ROOT}"/deploy/sac-deploy.sh "${APP_ROOT}"/deploy/systemd/*.sh; do
[ -f "${_sh}" ] || continue
sed -i 's/\r$//' "${_sh}"
chmod 755 "${_sh}" 2>/dev/null || true
done
log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)"
sudo -u "${APP_USER}" bash -c "
@@ -121,7 +107,10 @@ print('db: OK')
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
log "Сброс зависших remote_action (running без worker после restart)"
sudo -u "${APP_USER}" bash -c "
export SAC_CONFIG_FILE='${CONFIG_FILE}'
set -a
# shellcheck source=/dev/null
source '${CONFIG_FILE}'
set +a
cd '${APP_ROOT}/backend'
'${VENV}/bin/python' -c \"
from app.database import SessionLocal
@@ -136,14 +125,7 @@ finally:
\"
"
systemctl restart "${SERVICE_NAME}"
sleep 2
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if systemctl is-active --quiet "${SERVICE_NAME}"; then
break
fi
sleep 1
done
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active — journalctl -u ${SERVICE_NAME} -n 40 --no-pager"
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active"
HEALTH_OK=0
for _ in 1 2 3 4 5 6; do
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# Uvicorn launcher: SAC_UVICORN_WORKERS из sac-api.env (без systemd EnvironmentFile).
set -euo pipefail
APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}"
CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}"
VENV="${APP_ROOT}/backend/.venv"
HOST="127.0.0.1"
PORT="8000"
WORKERS=4
_read_env_int() {
local key="$1" default="$2" line val
[ -f "$CONFIG_FILE" ] || {
printf '%s\n' "$default"
return 0
}
line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" 2>/dev/null | tail -1)" || {
printf '%s\n' "$default"
return 0
}
val="${line#*=}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val#\"}"
val="${val%\"}"
val="${val#\'}"
val="${val%\'}"
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
printf '%s\n' "$val"
else
printf '%s\n' "$default"
fi
}
WORKERS="$(_read_env_int SAC_UVICORN_WORKERS 4)"
exec "${VENV}/bin/uvicorn" app.main:app \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--timeout-graceful-shutdown 30
+2 -2
View File
@@ -1,6 +1,6 @@
[Unit]
Description=Security Alert Center API (FastAPI)
Documentation=https://git.papatramp.ru/PapaTramp/security-alert-center
Documentation=https://git.kalinamall.ru/PapaTramp/security-alert-center
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
@@ -13,7 +13,7 @@ WorkingDirectory=/opt/security-alert-center/backend
# Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д.
Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env
Environment=PYTHONPATH=/opt/security-alert-center/backend
ExecStart=/usr/bin/bash /opt/security-alert-center/deploy/systemd/sac-api-start.sh
ExecStart=/opt/security-alert-center/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 --timeout-graceful-shutdown 30
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
+5 -7
View File
@@ -183,13 +183,11 @@ sequenceDiagram
## 5. П.2C — Доступ SAC к хостам
### 5.1. Windows — WinRM / учётки
### 5.1. Windows — WinRM
- **Глобальный доменный admin** в **Настройки → Windows**: `DOMAIN\user` + password (encrypted). Аналогично **Linux admin** для SSH.
- **Override на карточке хоста** (**Хосты → WinRM/SSH / доступ к хосту**): `COMPUTER\user` / `.\Administrator` + password (encrypted в `hosts.mgmt_*`). Если пусто — берутся глобальные.
- Effective creds: `get_effective_win_admin_for_host` / `get_effective_linux_admin_for_host` (source: `host` | `db` | `env`).
- API: `GET` / `PUT /api/v1/hosts/{id}/access` (`user`, `password`, `clear`).
- Используются для WinRM/SSH-test, fallback-update, qwinsta/logoff, remote ops; для run_as через агента — те же creds в command poll (TLS + API key, не пишутся на диск агента).
- **Один доменный admin** в **Настройки → Управление хостами → Windows**: `DOMAIN\user` + password (encrypted).
- Override на карточке хоста — опционально позже.
- Используется для fallback-обновления и (при необходимости) remote ops; для qwinsta/logoff MVP — **через агента** с теми же creds, переданными в command poll (TLS + API key, не пишутся на диск агента).
### 5.2. Linux — bootstrap password → SSH key → удалить password
@@ -268,7 +266,7 @@ Authorization: Bearer sac_xxx
| POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` |
| GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат |
| PATCH | `/api/v1/hosts/{id}/config` | Desired config |
| GET/PUT | `/api/v1/hosts/{id}/access` | Per-host WinRM/SSH override (mgmt_user/password) |
| PATCH | `/api/v1/hosts/{id}/access` | Bootstrap / WinRM creds |
| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники |
| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows |
-1
View File
@@ -185,7 +185,6 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|---------|--------|------------|
| 4624 успех | `rdp.login.success` | info |
| 4625 неудача | `rdp.login.failed` | warning |
| 4634 / 4647 выход (прямой RDP, **только рабочая станция**) | `rdp.session.logoff` | info |
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
| RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** |
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
+1 -1
View File
@@ -115,7 +115,7 @@ sudo /opt/sac-deploy.sh
Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу.
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **4 worker** uvicorn по умолчанию (`SAC_UVICORN_WORKERS`, `deploy/systemd/sac-api-start.sh`).
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **2 worker** uvicorn (`deploy/systemd/sac-api.service`).
Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh`
+1 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
"$id": "https://git.kalinamall.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
"title": "Security Alert Center Event v1",
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
"type": "object",
@@ -94,7 +94,6 @@
"session.logind.new",
"rdp.login.success",
"rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started",
"rdp.shadow.control.stopped",
"rdp.shadow.control.permission",
-50
View File
@@ -1,50 +0,0 @@
# Backlog: ingest и массовое обновление агентов
**Статус:** ToDo (не в текущем релизе).
**Контекст:** массовый SSH-update через SAC (10+ хостов) + `sac-deploy` в одно окно → часть POST в ingest «теряется» с точки зрения агента (`SAC POST HTTP :`), flood в Telegram (fallback + watchdog).
**Связано:** [runbook-ops.md](runbook-ops.md), [agent-integration.md](agent-integration.md), ssh-monitor [security-roadmap.ru.md](https://git.papatramp.ru/PapaTramp/ssh-monitor/src/branch/main/docs/security-roadmap.ru.md).
Увеличение `SAC_DB_POOL_SIZE` / defer daily в **0.5.0** лечит штурм суточных отчётов и пул БД; **не** снимает узкие места ниже при одновременном restart многих агентов.
---
## Операционно (сразу, без кода)
- [ ] **Не совмещать** `sudo /opt/sac-deploy.sh` и массовое «Обновить ssh-monitor (SSH)» по многим хостам — сначала deploy SAC, потом агенты (или наоборот).
- [ ] Обновлять Linux-хосты **пачками по 23**, не все подряд.
- [ ] На зрелых хостах перевести **`UseSAC=exclusive`** (нет дубля в Telegram с агента при fallback); watchdog по-прежнему шлёт в Telegram сам.
- [ ] Перед первым SSH-update с SAC: **`ssh-keyscan`** в `config/ssh_known_hosts` (см. runbook).
- [ ] В `sac-api.env`: только `KEY=value` на строку, комментарии отдельной строкой с `#`.
---
## SAC (backend / deploy)
- [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*``schedule_notify_daily_report`), чтобы ingest не ждал Telegram API — **0.5.5**
- [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh` — **0.5.5**
- [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` — **0.5.5**
- [ ] Опционально: метрики/лог длительности ingest и очереди при burst.
- [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками») — **0.5.5**
---
## ssh-monitor (агент)
- [x] При **shutdown** / `SIGTERM` не увеличивать `sac-fail.count` — **2.3.2-SAC**
- [x] После успешного heartbeat сбрасывать fail counter (успешный POST ingest) — уже было
- [x] Watchdog: не слать Telegram при штатном restart во время SAC-update (state file `/var/lib/ssh-monitor/agent-update-in-progress` от updater) — **2.2.1-SAC**
- [x] Sudo bootstrap/update через SAC: не слать Telegram (`ssh_monitor_sudo_is_sac_maintenance`, state-file раньше) — **2.2.3-SAC**
- [x] SAC: suppress `privilege.sudo.command` при `agent_update_state=running` / maintenance command — **0.5.3**
- [x] Документировать рекомендуемый `SAC_TIMEOUT_SEC` при тяжёлом ingest — **2.3.2-SAC** (`docs/sac-ingest.ru.md`)
---
## Принято / сделано
- [x] Pool БД 15+25, defer daily push — SAC **0.5.0**
- [x] `sac-deploy.sh` без `source` конфига, preflight pydantic — SAC `fa1bd41`
- [x] Watchdog: строка `🖥️ Сервер:` в Telegram — ssh-monitor **2.1.9-SAC**
---
*После реализации пунктов SAC — bump `APP_VERSION` и runbook.*
+1 -1
View File
@@ -24,7 +24,7 @@ Docker удобен для локальной отладки или изолир
```bash
sudo apt install -y git
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
sudo chown -R "$USER:$USER" /opt/security-alert-center
```
+1 -1
View File
@@ -71,7 +71,7 @@ nginx -v
```bash
sudo mkdir -p /opt
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
```
---
+1 -1
View File
@@ -14,7 +14,7 @@
```bash
# см. полный чеклист в native-руководстве
sudo apt update && sudo apt install -y git postgresql nginx python3.12 python3.12-venv
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# … PostgreSQL, venv, config/sac-api.env, systemd, nginx
```
+1 -1
View File
@@ -39,7 +39,7 @@ sudo /opt/sac-deploy.sh
| Параметр | Значение |
|----------|----------|
| Хост | `ubabuba` / `10.10.36.9` |
| Репозиторий | `git.papatramp.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) |
| Репозиторий | `git.kalinamall.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) |
| `UseSAC` | пилот **exclusive** — см. [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) |
| Сервис | `ssh-monitor.service`**active** |
| `--check-sac` | OK (health + ingest `agent.test` 202) |
+3 -3
View File
@@ -66,7 +66,7 @@
**Цель:** нативный Android-клиент и push по тем же правилам оповещений, что Telegram/email/webhook.
Репозиторий клиента: [seaca](https://git.papatramp.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM).
Репозиторий клиента: [seaca](https://git.kalinamall.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM).
**Сервер SAC:**
@@ -77,7 +77,7 @@
**Не в мобильном клиенте:** пользователи SAC, каналы Telegram/SMTP/webhook, админ-настройки.
Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.papatramp.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md).
Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.kalinamall.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md).
---
@@ -112,7 +112,7 @@
| v0.1 | — (не требуется) | — |
| v0.2 | TBD (тег с UseSAC) | TBD |
| v0.3+ | совместимость schema 1.0 | совместимость schema 1.0 |
| v0.6 | — | — (мобильный клиент [seaca](https://git.papatramp.ru/PapaTramp/seaca), не агент) |
| v0.6 | — | — (мобильный клиент [seaca](https://git.kalinamall.ru/PapaTramp/seaca), не агент) |
При breaking change схемы — `schema_version: 1.1` с поддержкой 1.0 на ingest.
-26
View File
@@ -18,32 +18,6 @@ curl -sS https://sac.kalinamall.ru/health | jq .
Ожидается `status: ok`, `database: ok`. При устаревших heartbeat агентов — `status: degraded`, поле `hosts_stale` > 0.
### `sac-api.env`: формат строк
Файл читается **pydantic** (`SAC_CONFIG_FILE`), не как произвольный bash-скрипт.
- Одна переменная — одна строка: `SAC_SSH_AUTO_ADD_HOST_KEY=false`
- Комментарии — **отдельной** строкой с `#` в начале
- **Нельзя** inline после значения: `false ← так и оставляем` — deploy упадёт на `alembic` с `bool_parsing`
Проверка без деплоя:
```bash
sudo -u sac bash -c 'export SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env; cd /opt/security-alert-center/backend && .venv/bin/python -c "from app.config import get_settings; get_settings(); print(\"OK\")"'
```
### Linux SSH update: `known_hosts`
Перед «Обновить ssh-monitor (SSH)» ключ хоста должен быть в файле (по умолчанию `config/ssh_known_hosts`):
```bash
ssh-keyscan -H 10.10.7.2 | sudo tee -a /opt/security-alert-center/config/ssh_known_hosts
sudo chown sac:sac /opt/security-alert-center/config/ssh_known_hosts
sudo chmod 600 /opt/security-alert-center/config/ssh_known_hosts
```
Иначе SAC: `Server '…' not found in known_hosts`. `SAC_SSH_AUTO_ADD_HOST_KEY=true` для prod не рекомендуется.
## Мобильные устройства (Seaca)
См. [seaca-mobile.md](seaca-mobile.md) и [seaca-fcm.md](seaca-fcm.md).
+2 -2
View File
@@ -1,6 +1,6 @@
# Seaca — мобильный клиент SAC
Репозиторий приложения: [seaca](https://git.papatramp.ru/PapaTramp/seaca).
Репозиторий приложения: [seaca](https://git.kalinamall.ru/PapaTramp/seaca).
Требуется SAC **≥ 0.9.0** с применённой миграцией `014`.
---
@@ -84,7 +84,7 @@ SAC_FCM_SERVICE_ACCOUNT_JSON=/etc/security-alert-center/fcm-service-account.json
| `sac.kalinamall.ru` | Web UI | yes (`git-sac-allowed`) |
| `sac-api.kalinamall.ru` | Seaca, `/api/v1/mobile/*` | no (enroll + JWT) |
Both resolve to the same SAC server (**192.168.160.145**). nginx must accept both names in `server_name`. See [reverse-proxy/docs/sac-access.md](https://git.papatramp.ru/PapaTramp/reverse-proxy/src/branch/main/docs/sac-access.md).
Both resolve to the same SAC server (**192.168.160.145**). nginx must accept both names in `server_name`. See [reverse-proxy/docs/sac-access.md](https://git.kalinamall.ru/PapaTramp/reverse-proxy/src/branch/main/docs/sac-access.md).
---
+6 -6
View File
@@ -8,9 +8,9 @@
| Имя в workspace | Путь (пример Windows) | Remote |
|-----------------|----------------------|--------|
| `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.papatramp.ru/PapaTramp/ssh-monitor |
| `rdp-monitor` | `D:\Soft\Git\RDP-login-monitor` | git.papatramp.ru/PapaTramp/RDP-login-monitor |
| `security-alert-center` | `D:\Soft\Git\security-alert-center` | git.papatramp.ru/PapaTramp/security-alert-center |
| `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.kalinamall.ru/PapaTramp/ssh-monitor |
| `rdp-monitor` | `D:\Soft\Git\RDP-login-monitor` | git.kalinamall.ru/PapaTramp/RDP-login-monitor |
| `security-alert-center` | `D:\Soft\Git\security-alert-center` | git.kalinamall.ru/PapaTramp/security-alert-center |
Пути подставьте свои.
@@ -65,9 +65,9 @@ git push kalinamall main
| Проект | URL |
|--------|-----|
| security-alert-center | `https://git.papatramp.ru/PapaTramp/security-alert-center.git` |
| ssh-monitor | `https://git.papatramp.ru/PapaTramp/ssh-monitor.git` |
| RDP-login-monitor | `https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git` |
| security-alert-center | `https://git.kalinamall.ru/PapaTramp/security-alert-center.git` |
| ssh-monitor | `https://git.kalinamall.ru/PapaTramp/ssh-monitor.git` |
| RDP-login-monitor | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` |
---
+1 -29
View File
@@ -192,7 +192,6 @@ export interface EventSummary {
title: string;
summary: string;
actor_user?: string | null;
session_duration_sec?: number | null;
rdg_flap?: boolean;
rdg_flap_pair_event_id?: number | null;
rdg_flap_qwinsta_event_id?: number | null;
@@ -532,31 +531,6 @@ export function updateLinuxAdminSettings(payload: {
});
}
export interface HostMgmtAccess {
host_id: number;
has_override: boolean;
user: string | null;
password_set: boolean;
password_hint: string | null;
effective_source: string;
effective_configured: boolean;
effective_user: string | null;
}
export function fetchHostAccess(hostId: number): Promise<HostMgmtAccess> {
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`);
}
export function updateHostAccess(
hostId: number,
payload: { user?: string | null; password?: string | null; clear?: boolean },
): Promise<HostMgmtAccess> {
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface HostSshActionResult {
ok: boolean;
message: string;
@@ -639,9 +613,7 @@ export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteA
}
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
return apiFetch<HostRemoteActionJobStatus>(
`/api/v1/hosts/${hostId}/actions/remote-job?_ts=${Date.now()}`,
);
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
}
export interface HostSessionItem {
+16 -269
View File
@@ -1,60 +1,31 @@
<template>
<div v-if="open" class="host-action-log-dock" aria-live="polite">
<div
class="host-action-log-panel"
:class="{ 'host-action-log-panel-done': !isLoading && ok === true, 'host-action-log-panel-fail': !isLoading && ok === false }"
role="dialog"
:aria-label="displayTitle"
>
<div class="host-action-log-panel" role="dialog" :aria-label="title">
<div class="host-action-log-header">
<h3>
<span v-if="!isLoading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true"></span>
<span v-else-if="!isLoading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true"></span>
{{ displayTitle }}
</h3>
<h3>{{ title }}</h3>
<button
type="button"
class="host-action-log-icon-btn"
:title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
:aria-label="isLoading ? 'Свернуть' : 'Закрыть'"
:title="loading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
:aria-label="loading ? 'Свернуть' : 'Закрыть'"
@click="emit('close')"
>
×
</button>
</div>
<p v-if="isLoading" class="host-action-log-status">
<p v-if="loading" class="host-action-log-status">
<span class="host-action-log-spinner" aria-hidden="true" />
Выполняется на удалённом хосте Можно запустить обновление других хостов.
</p>
<p
v-else-if="ok === true"
class="host-action-log-message success host-action-log-result"
>
{{ displayMessage }}
</p>
<p
v-else-if="ok === false"
class="host-action-log-message error host-action-log-result"
>
{{ displayMessage }}
</p>
<p v-else-if="displayMessage && isLoading" class="muted host-action-log-sub host-action-log-message">
{{ displayMessage }}
</p>
<p v-if="!isLoading && ok === true && autoCloseSec > 0" class="muted host-action-log-sub">
Окно закроется автоматически через {{ autoCloseSec }} с
</p>
<pre
v-show="logVisible"
ref="logPreRef"
class="host-action-log-output"
>{{ displayOutput }}</pre>
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
<pre v-if="output" class="host-action-log-output">{{ output }}</pre>
<div class="host-action-log-actions">
<button type="button" class="secondary" @click="emit('toggle-log')">
{{ logVisible ? "Скрыть лог" : "Показать лог" }}
<button v-if="loading" type="button" class="secondary" @click="emit('close')">
Свернуть
</button>
<button type="button" class="secondary" @click="emit('close')">
{{ isLoading ? "Свернуть" : "Закрыть" }}
<button v-else type="button" class="secondary" @click="emit('close')">
Закрыть
</button>
</div>
</div>
@@ -62,210 +33,24 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { fetchHostRemoteJob, type HostRemoteActionJobStatus } from "../api";
const LOG_POLL_MS = 1500;
const AUTO_CLOSE_MS = 30_000;
import { computed } from "vue";
const props = defineProps<{
hostId: number;
open: boolean;
title: string;
loading: boolean;
ok: boolean | null;
message: string;
output: string;
logPlaceholder: string;
logVisible: boolean;
sessionStartedAt: string;
}>();
const emit = defineEmits<{
close: [];
"toggle-log": [];
finished: [job: HostRemoteActionJobStatus];
}>();
const logPreRef = ref<HTMLElement | null>(null);
const polledOutput = ref("");
const polledMessage = ref("");
const isLoading = ref(true);
const ok = ref<boolean | null>(null);
const localTitle = ref("");
const localMessage = ref("");
const autoCloseSec = ref(0);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let finishedEmitted = false;
let sawRunning = false;
function isTerminalJob(job: HostRemoteActionJobStatus): boolean {
const st = (job.status || "").toLowerCase();
return st === "success" || st === "failed";
}
function jobStartedAfterSession(job: HostRemoteActionJobStatus): boolean {
const started = job.started_at;
if (!started) return false;
const jobMs = Date.parse(started);
const sessionMs = Date.parse(props.sessionStartedAt);
if (Number.isNaN(jobMs) || Number.isNaN(sessionMs)) return false;
return jobMs >= sessionMs - 3000;
}
function isJobRunning(job: HostRemoteActionJobStatus): boolean {
if (job.active) return true;
const st = (job.status || "").toLowerCase();
if (st === "running") return true;
return (job.agent_update_state || "").toLowerCase() === "running";
}
const displayTitle = computed(() => {
if (localTitle.value) return localTitle.value;
return props.title;
});
const displayMessage = computed(() => {
if (localMessage.value) return localMessage.value;
if (polledMessage.value) return polledMessage.value;
return props.message;
});
const displayOutput = computed(() => {
const text = (polledOutput.value || props.output).trim();
return text || props.logPlaceholder;
});
function stopPoll() {
if (pollTimer != null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function stopCountdown() {
if (countdownTimer != null) {
clearInterval(countdownTimer);
countdownTimer = null;
}
autoCloseSec.value = 0;
}
function startCountdown() {
stopCountdown();
autoCloseSec.value = Math.ceil(AUTO_CLOSE_MS / 1000);
countdownTimer = setInterval(() => {
if (autoCloseSec.value <= 1) {
stopCountdown();
return;
}
autoCloseSec.value -= 1;
}, 1000);
}
function applyFinishedJob(job: HostRemoteActionJobStatus) {
isLoading.value = false;
ok.value = job.ok ?? job.status === "success";
if (job.title) localTitle.value = job.title;
if (job.message) localMessage.value = job.message;
const out = (job.output || job.stdout || "").trim();
if (out) polledOutput.value = out;
if (ok.value) startCountdown();
}
async function pollJobOnce() {
try {
const job = await fetchHostRemoteJob(props.hostId);
const out = (job.output || job.stdout || "").trim();
if (out) polledOutput.value = out;
if (job.message && isLoading.value) polledMessage.value = job.message;
if (isJobRunning(job)) {
sawRunning = true;
return;
}
const canFinish =
isTerminalJob(job) && (sawRunning || jobStartedAfterSession(job));
if (canFinish) {
if (!finishedEmitted) {
finishedEmitted = true;
applyFinishedJob(job);
emit("finished", job);
}
stopPoll();
}
} catch {
/* следующий poll */
}
}
function startPoll() {
stopPoll();
void pollJobOnce();
pollTimer = setInterval(() => void pollJobOnce(), LOG_POLL_MS);
}
function resetLocalState() {
finishedEmitted = false;
sawRunning = false;
isLoading.value = true;
ok.value = null;
localTitle.value = "";
localMessage.value = "";
polledOutput.value = props.output.trim();
polledMessage.value = "";
stopCountdown();
}
watch(
() => props.open,
(open) => {
if (open && isLoading.value) {
startPoll();
} else {
stopPoll();
}
},
{ immediate: true },
);
watch(
() => props.loading,
(loading) => {
if (loading) {
resetLocalState();
if (props.open) startPoll();
return;
}
if (!finishedEmitted && props.ok != null) {
finishedEmitted = true;
isLoading.value = false;
ok.value = props.ok;
localTitle.value = props.title;
localMessage.value = props.message;
if (props.output.trim()) polledOutput.value = props.output.trim();
if (props.ok) startCountdown();
stopPoll();
}
},
{ immediate: true },
);
watch(
() => [displayOutput.value, props.logVisible] as const,
async () => {
if (!props.logVisible) return;
await nextTick();
const el = logPreRef.value;
if (el) el.scrollTop = el.scrollHeight;
},
);
onUnmounted(() => {
stopPoll();
stopCountdown();
const messageClass = computed(() => {
if (props.loading || props.ok == null) return "muted";
return props.ok ? "success" : "error";
});
</script>
@@ -286,14 +71,6 @@ onUnmounted(() => {
padding: 0.85rem 1rem 1rem;
}
.host-action-log-panel-done {
border-color: #3fb950;
}
.host-action-log-panel-fail {
border-color: #f85149;
}
.host-action-log-header {
display: flex;
align-items: flex-start;
@@ -305,22 +82,6 @@ onUnmounted(() => {
margin: 0;
font-size: 1rem;
line-height: 1.3;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.host-action-log-mark {
flex-shrink: 0;
font-weight: 700;
}
.host-action-log-mark-ok {
color: #3fb950;
}
.host-action-log-mark-fail {
color: #f85149;
}
.host-action-log-icon-btn {
@@ -361,20 +122,6 @@ onUnmounted(() => {
word-break: break-word;
}
.host-action-log-result {
margin: 0.65rem 0 0;
font-size: 0.95rem;
font-weight: 500;
}
.host-action-log-message.success {
color: #3fb950;
}
.host-action-log-message.error {
color: #f85149;
}
.host-action-log-spinner {
width: 1rem;
height: 1rem;
+4 -13
View File
@@ -1,23 +1,16 @@
<template>
<div v-if="hasOpenLogs" class="host-action-log-stack" aria-live="polite">
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
<div v-if="openLogs.length" class="host-action-log-stack" aria-live="polite">
<HostActionLogModal
v-if="entry.open"
:host-id="entry.hostId"
v-for="entry in openLogs"
:key="entry.id"
:open="true"
:title="entry.title"
:loading="entry.loading"
:ok="entry.ok"
:message="entry.message"
:output="entry.output"
:log-placeholder="entry.logPlaceholder"
:log-visible="entry.logVisible"
:session-started-at="entry.sessionStartedAt"
@close="closeHostRemoteActionLog(entry.id)"
@toggle-log="toggleHostRemoteActionLog(entry.id)"
@finished="syncHostRemoteActionJobFinished(entry.id, $event)"
/>
</template>
</div>
</template>
@@ -27,11 +20,9 @@ import HostActionLogModal from "./HostActionLogModal.vue";
import {
closeHostRemoteActionLog,
hostRemoteActionLogs,
syncHostRemoteActionJobFinished,
toggleHostRemoteActionLog,
} from "../composables/useHostRemoteAction";
const hasOpenLogs = computed(() => hostRemoteActionLogs.some((e) => e.open));
const openLogs = computed(() => hostRemoteActionLogs.filter((e) => e.open));
</script>
<style scoped>
@@ -16,16 +16,11 @@ export type HostRemoteActionLogEntry = {
id: string;
hostId: number;
open: boolean;
logVisible: boolean;
loading: boolean;
ok: boolean | null;
title: string;
message: string;
output: string;
/** Текст в окне лога, пока output с сервера ещё пустой */
logPlaceholder: string;
/** ISO-время открытия окна — отсекаем stale success от прошлого job */
sessionStartedAt: string;
};
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
@@ -86,22 +81,12 @@ function scheduleSuccessAutoClose(entryId: string) {
}
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
if (job.title) {
entry.title = job.title;
}
if (job.message) {
entry.message = job.message;
}
const nextOutput = (job.output || job.stdout || "").trim();
if (nextOutput) {
entry.title = job.title || entry.title;
entry.message = job.message || entry.message;
entry.output = job.output || job.stdout || "";
}
}
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
if (!entry.loading && entry.ok != null) {
return;
}
entry.loading = false;
entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job);
@@ -111,16 +96,6 @@ function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobSt
}
}
export function syncHostRemoteActionJobFinished(
entryId: string,
job: HostRemoteActionJobStatus,
): void {
const entry = findLogById(entryId);
if (!entry) return;
finishEntry(entry, job);
void patchHostFromJob(entry.hostId, job);
}
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
for (;;) {
const job = await fetchHostRemoteJob(hostId);
@@ -191,7 +166,6 @@ async function executeRemoteAction(
export async function pollHostRemoteAction(
hostId: number,
title: string,
logPlaceholder?: string,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
@@ -206,14 +180,11 @@ export async function pollHostRemoteAction(
id: newLogId(),
hostId,
open: true,
logVisible: false,
loading: true,
ok: null,
title,
message: "Выполняется на удалённом хосте…",
output: "",
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
sessionStartedAt: new Date().toISOString(),
};
hostRemoteActionLogs.push(entry);
@@ -244,7 +215,6 @@ export async function runHostRemoteAction(
hostId: number,
title: string,
kind: HostRemoteActionKind,
logPlaceholder?: string,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
@@ -259,14 +229,11 @@ export async function runHostRemoteAction(
id: newLogId(),
hostId,
open: true,
logVisible: false,
loading: true,
ok: null,
title,
message: "Запуск на сервере SAC…",
output: "",
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
sessionStartedAt: new Date().toISOString(),
};
hostRemoteActionLogs.push(entry);
@@ -279,13 +246,6 @@ export async function runHostRemoteAction(
}
}
export function toggleHostRemoteActionLog(entryId: string) {
const entry = findLogById(entryId);
if (!entry) return;
entry.logVisible = !entry.logVisible;
entry.open = true;
}
export function closeHostRemoteActionLog(entryId: string) {
clearAutoCloseTimer(entryId);
const entry = findLogById(entryId);
-34
View File
@@ -58,37 +58,3 @@ export function remoteActionTitleForHost(host: HostSummary): string {
}
return `Обновление ssh-monitor (SSH): ${label}`;
}
const LOG_PLACEHOLDER_SSH_UPDATE =
"Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
const LOG_PLACEHOLDER_WINRM =
"Ожидание вывода с хоста…\n(WinRM: Deploy-LoginMonitor.ps1)";
const LOG_PLACEHOLDER_GENERIC = "Ожидание лога с хоста…";
export function remoteActionLogPlaceholder(
host: Pick<HostSummary, "os_family" | "product"> | null | undefined,
kind: HostRemoteActionKind | null,
): string {
if (kind === "ssh-update") {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
if (host && isWindowsAgentHost(host)) {
return LOG_PLACEHOLDER_WINRM;
}
if (kind === "fallback") {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
return LOG_PLACEHOLDER_GENERIC;
}
export function remoteActionLogPlaceholderFromTitle(title: string): string {
if (/winrm/i.test(title)) {
return LOG_PLACEHOLDER_WINRM;
}
if (/ssh-monitor|\/var\/log\/update_script/i.test(title)) {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
return LOG_PLACEHOLDER_GENERIC;
}
-17
View File
@@ -1,17 +0,0 @@
/** Human-readable session_duration_sec for event tables. */
export function formatSessionDuration(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) {
return "—";
}
const total = Math.floor(seconds);
const days = Math.floor(total / 86_400);
const hours = Math.floor((total % 86_400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
if (days > 0) {
return `${days}д ${clock}`;
}
return clock;
}
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.5.17";
export const APP_VERSION = "0.5.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
-5
View File
@@ -238,8 +238,6 @@
<th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th>
<th>Severity</th>
@@ -278,8 +276,6 @@
<td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
@@ -358,7 +354,6 @@ import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { formatServerName } from "../utils/hostDisplay";
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
-3
View File
@@ -34,7 +34,6 @@
<th>Хост</th>
<th>Имя сервера</th>
<th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th>
<th>Severity</th>
<th>Type</th>
@@ -51,7 +50,6 @@
<td>{{ e.hostname }}</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
@@ -122,7 +120,6 @@ import {
type EventsListState,
} from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const route = useRoute();
+1 -184
View File
@@ -69,52 +69,6 @@
<dd>{{ host.event_count ?? 0 }}</dd>
</div>
</dl>
<section v-if="showAgentConfig" class="host-mgmt-access">
<h3>{{ isWindowsHost ? "WinRM / доступ к хосту" : "SSH / доступ к хосту" }}</h3>
<p class="muted">
Переопределение для этого ПК. Если пусто используются глобальные учётные данные из
<RouterLink to="/settings">Настройки</RouterLink>
({{ isWindowsHost ? "Windows admin" : "Linux admin" }}).
Сейчас effective:
<strong>{{ accessEffectiveLabel }}</strong>
</p>
<form class="agent-config-form" @submit.prevent="saveHostAccess">
<label class="config-field">
Логин
<input
v-model="accessForm.user"
type="text"
:placeholder="isWindowsHost ? 'COMPUTER\\user или .\\Administrator' : 'root'"
autocomplete="off"
/>
</label>
<label class="config-field">
Пароль
<input
v-model="accessForm.password"
type="password"
:placeholder="accessForm.passwordSet ? '•••• (оставьте пустым, чтобы не менять)' : ''"
autocomplete="new-password"
/>
</label>
<div class="host-actions-row">
<button type="submit" :disabled="savingAccess">
{{ savingAccess ? "Сохранение…" : "Сохранить доступ" }}
</button>
<button
type="button"
class="secondary"
:disabled="savingAccess || !accessForm.hasOverride"
@click="clearHostAccess"
>
Сбросить (глобальные)
</button>
</div>
<p v-if="accessMessage" :class="accessOk ? 'success' : 'error'">{{ accessMessage }}</p>
</form>
</section>
<div v-if="isWindowsHost" class="host-actions">
<button
type="button"
@@ -342,8 +296,6 @@ import {
import {
apiFetch,
fetchHost,
fetchHostAccess,
updateHostAccess,
fetchRecentEvents,
testHostWinRm,
testHostSsh,
@@ -351,7 +303,6 @@ import {
patchHostAgentConfig,
type EventListResponse,
type HostDetail,
type HostMgmtAccess,
} from "../api";
import { emitHostPatch } from "../utils/hostPatchBus";
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
@@ -364,7 +315,6 @@ import {
import {
isLinuxAgentHost,
remoteActionKindForHost,
remoteActionLogPlaceholder,
remoteActionTitleForHost,
type AgentGitVersions,
} from "../utils/hostAgentUpgrade";
@@ -403,32 +353,6 @@ const configForm = reactive({
serverDisplayName: "",
getInventory: true,
});
const savingAccess = ref(false);
const accessMessage = ref("");
const accessOk = ref(false);
const accessForm = reactive({
user: "",
password: "",
passwordSet: false,
hasOverride: false,
effectiveSource: "",
effectiveUser: "",
effectiveConfigured: false,
});
const accessEffectiveLabel = computed(() => {
if (!accessForm.effectiveConfigured) {
return "не настроено";
}
const src =
accessForm.effectiveSource === "host"
? "override хоста"
: accessForm.effectiveSource === "db"
? "глобальные (Settings)"
: accessForm.effectiveSource === "env"
? "env"
: accessForm.effectiveSource;
return `${accessForm.effectiveUser || "—"} · ${src}`;
});
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let sshProbeSeq = 0;
@@ -714,7 +638,7 @@ async function runRequestAgentUpdate() {
async function runAgentFallback() {
agentControlMessage.value = "";
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
const job = await runHostRemoteAction(hostId.value, title, "fallback", remoteActionLogPlaceholder(host.value, "fallback"));
const job = await runHostRemoteAction(hostId.value, title, "fallback");
if (job) {
agentControlOk.value = job.ok ?? false;
if (job.ok) {
@@ -753,67 +677,6 @@ async function saveAgentConfig() {
}
}
async function applyAccessView(view: HostMgmtAccess) {
accessForm.user = view.user || "";
accessForm.password = "";
accessForm.passwordSet = view.password_set;
accessForm.hasOverride = view.has_override;
accessForm.effectiveSource = view.effective_source;
accessForm.effectiveUser = view.effective_user || "";
accessForm.effectiveConfigured = view.effective_configured;
}
async function loadHostAccess() {
accessMessage.value = "";
try {
const view = await fetchHostAccess(hostId.value);
applyAccessView(view);
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Не удалось загрузить доступ хоста";
}
}
async function saveHostAccess() {
savingAccess.value = true;
accessMessage.value = "";
try {
const payload: { user?: string | null; password?: string | null } = {
user: accessForm.user,
};
if (accessForm.password.trim()) {
payload.password = accessForm.password;
}
const view = await updateHostAccess(hostId.value, payload);
applyAccessView(view);
accessOk.value = true;
accessMessage.value = view.has_override
? "Сохранено (override хоста)"
: "Сохранено (логин очищен — глобальные настройки)";
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Ошибка сохранения доступа";
} finally {
savingAccess.value = false;
}
}
async function clearHostAccess() {
savingAccess.value = true;
accessMessage.value = "";
try {
const view = await updateHostAccess(hostId.value, { clear: true });
applyAccessView(view);
accessOk.value = true;
accessMessage.value = "Override сброшен — используются глобальные настройки";
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Ошибка сброса доступа";
} finally {
savingAccess.value = false;
}
}
async function loadHost() {
loading.value = true;
error.value = "";
@@ -828,7 +691,6 @@ async function loadHost() {
try {
const detail = await fetchHost(hostId.value);
applyHostDetail(detail);
void loadHostAccess();
if (isLinuxHostDetail(detail)) {
void probeSshOnOpen();
}
@@ -844,7 +706,6 @@ async function runAgentUpdate() {
hostId.value,
"Обновление ssh-monitor (SSH)",
"ssh-update",
remoteActionLogPlaceholder(host.value, "ssh-update"),
);
if (job?.ok) {
const detail = await fetchHost(hostId.value);
@@ -867,7 +728,6 @@ async function runAgentUpgradeFromCell() {
hostId.value,
remoteActionTitleForHost(host.value),
kind,
remoteActionLogPlaceholder(host.value, kind),
);
if (job?.ok) {
const detail = await fetchHost(hostId.value);
@@ -985,49 +845,6 @@ watch(
color: #9aa4b2;
}
.host-mgmt-access {
margin: 1rem 0 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid #2a3340;
border-radius: 8px;
background: #141a22;
}
.host-mgmt-access h3 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.host-mgmt-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.75rem;
max-width: 28rem;
}
.host-mgmt-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
.host-mgmt-field input {
padding: 0.4rem 0.55rem;
border-radius: 6px;
border: 1px solid #3a4554;
background: #0f1419;
color: #e8eef5;
}
.host-mgmt-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.host-actions {
margin-top: 1rem;
display: flex;
+2 -48
View File
@@ -135,12 +135,9 @@ import { isAgentVersionOutdated } from "../utils/agentVersion";
import {
isGitAgentUpgradeAvailable,
remoteActionKindForHost,
remoteActionLogPlaceholder,
remoteActionLogPlaceholderFromTitle,
remoteActionTitleForHost,
} from "../utils/hostAgentUpgrade";
import {
hostRemoteActionLogs,
isHostRemoteActionActive,
pollHostRemoteAction,
runHostRemoteAction,
@@ -247,37 +244,6 @@ function isVersionOutdated(h: HostSummary): boolean {
return isAgentVersionOutdated(h.product_version, reference);
}
function countOutdatedUpgradeableHosts(): number {
return (data.value?.items ?? []).filter(
(h) => isGitAgentUpgradeAvailable(h, data.value) && isVersionOutdated(h),
).length;
}
function countActiveRemoteUpgrades(): number {
return hostRemoteActionLogs.filter((e) => e.loading).length;
}
function confirmMassUpgradeIfNeeded(hostname: string): boolean {
const active = countActiveRemoteUpgrades();
const outdated = countOutdatedUpgradeableHosts();
if (active >= 2) {
return window.confirm(
`Сейчас обновляется ${active} хост(ов). Рекомендуется пачками по 2–3. Продолжить обновление «${hostname}»?`,
);
}
if (outdated > 3 && active >= 1) {
return window.confirm(
`Устарело агентов на странице: ${outdated}, уже идёт обновление. Продолжить «${hostname}»?`,
);
}
if (outdated > 3) {
return window.confirm(
`Устарело агентов на странице: ${outdated}. Рекомендуется обновлять пачками по 2–3, не все сразу. Продолжить «${hostname}»?`,
);
}
return true;
}
async function startAgentUpgrade(h: HostSummary) {
if (!isGitAgentUpgradeAvailable(h, data.value)) {
return;
@@ -286,17 +252,9 @@ async function startAgentUpgrade(h: HostSummary) {
showHostRemoteActionLog(h.id);
return;
}
if (!confirmMassUpgradeIfNeeded(h.hostname)) {
return;
}
const kind = remoteActionKindForHost(h);
if (!kind) return;
void runHostRemoteAction(
h.id,
remoteActionTitleForHost(h),
kind,
remoteActionLogPlaceholder(h, kind),
);
void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind);
}
function openHost(id: number) {
@@ -457,11 +415,7 @@ interface HostDeleteResponse {
async function onManualAddStarted(payload: { hostId: number; title: string }) {
await loadHosts();
void pollHostRemoteAction(
payload.hostId,
payload.title,
remoteActionLogPlaceholderFromTitle(payload.title),
);
void pollHostRemoteAction(payload.hostId, payload.title);
}
async function confirmDelete(h: HostSummary) {
+5 -7
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="settings-page">
<h1>Настройки</h1>
<p class="settings-intro">
@@ -126,9 +126,8 @@
<section class="card settings-card settings-policy">
<h2>Windows (qwinsta / logoff)</h2>
<p class="settings-hint settings-hint-top">
Глобальный (доменный) admin для удалённых команд на Windows-хостах (qwinsta, logoff, WinRM-update).
Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент).
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
Для отдельного ПК (home/workgroup) задайте override в карточке <strong>Хосты доступ к хосту</strong>.
Пока запись не создана в UI используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
</p>
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
@@ -187,9 +186,8 @@
<section class="card settings-card settings-policy">
<h2>Linux (SSH / обновление ssh-monitor)</h2>
<p class="settings-hint settings-hint-top">
Глобальная SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
Для отдельного сервера задайте override в карточке <strong>Хосты доступ к хосту</strong>.
Пока запись не создана в UI используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
</p>
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
@@ -253,7 +251,7 @@
<input
v-model="agentUpdateForm.rdp_git_repo_url"
type="text"
placeholder="https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
placeholder="https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
/>
</label>
<label class="settings-field">
@@ -261,7 +259,7 @@
<input
v-model="agentUpdateForm.ssh_git_repo_url"
type="text"
placeholder="https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
placeholder="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
/>
</label>
<label class="settings-field">
+1 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
"$id": "https://git.kalinamall.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
"title": "Security Alert Center Event v1",
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
"type": "object",
@@ -94,7 +94,6 @@
"session.logind.new",
"rdp.login.success",
"rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started",
"rdp.shadow.control.stopped",
"rdp.shadow.control.permission",
Regular → Executable
View File
+2 -2
View File
@@ -7,8 +7,8 @@ REMOTE_CMD = (
"set -e\n"
"TMP=$(mktemp -d)\n"
'trap "rm -rf \\"$TMP\\"" EXIT\n'
"git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git \"$TMP/rdp\"\n"
"git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n"
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git \"$TMP/rdp\"\n"
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n"
"echo RDP_version_txt:\n"
"cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n"
"echo RDP_script:\n"
+2 -2
View File
@@ -2,8 +2,8 @@
set -e
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
echo RDP_version_txt:
cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing
echo RDP_script: