From ed4e78f6c3145b59fd88b082a967f0e026dc2071 Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 14 Jul 2026 20:43:52 +1000 Subject: [PATCH] chore(home): mirror from kalinamall (9883e6a) with papatramp URLs --- .gitattributes | 2 + .gitignore | 47 + README.md | 51 + README_en.md | 50 + backend/Dockerfile | 21 + backend/README.md | 42 + backend/alembic.ini | 42 + backend/alembic/env.py | 46 + backend/alembic/script.py.mako | 25 + backend/alembic/versions/001_initial.py | 90 + backend/alembic/versions/002_problems.py | 48 + .../versions/003_problem_correlation.py | 52 + .../versions/004_notification_channels.py | 37 + .../versions/005_notification_webhook.py | 30 + .../alembic/versions/006_notification_smtp.py | 43 + .../versions/007_notification_policy.py | 37 + .../versions/008_notification_cooldown.py | 31 + backend/alembic/versions/009_sac_users.py | 35 + .../versions/010_event_severity_overrides.py | 29 + .../alembic/versions/011_login_attempts.py | 35 + backend/alembic/versions/012_ui_settings.py | 31 + .../alembic/versions/013_host_inventory.py | 26 + backend/alembic/versions/014_mobile_seaca.py | 105 + .../versions/015_problem_resolved_by.py | 26 + .../alembic/versions/016_agent_commands.py | 46 + .../versions/017_ui_settings_win_admin.py | 25 + .../versions/018_ui_settings_linux_admin.py | 25 + .../versions/019_host_ssh_admin_status.py | 25 + .../versions/020_agent_control_plane.py | 98 + .../alembic/versions/021_agent_git_release.py | 51 + .../versions/022_host_remote_action.py | 27 + .../versions/023_event_type_visibility.py | 34 + .../alembic/versions/024_login_security.py | 28 + .../versions/025_auto_rdp_flap_disconnect.py | 26 + .../versions/026_host_silence_dedupe.py | 74 + .../versions/027_host_mgmt_credentials.py | 25 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 0 backend/app/api/v1/agent.py | 76 + backend/app/api/v1/auth.py | 90 + backend/app/api/v1/dashboards.py | 167 ++ backend/app/api/v1/events.py | 390 ++++ backend/app/api/v1/health.py | 53 + backend/app/api/v1/hosts.py | 932 ++++++++ backend/app/api/v1/mobile.py | 378 ++++ backend/app/api/v1/problems.py | 376 ++++ backend/app/api/v1/router.py | 17 + backend/app/api/v1/settings.py | 860 ++++++++ backend/app/api/v1/stream.py | 88 + backend/app/api/v1/system.py | 30 + backend/app/api/v1/users.py | 144 ++ backend/app/auth/api_key.py | 55 + backend/app/auth/jwt_auth.py | 352 +++ backend/app/auth/stream_cookie.py | 37 + backend/app/config.py | 170 ++ backend/app/constants/event_types.py | 40 + backend/app/database.py | 26 + backend/app/jobs/__init__.py | 1 + backend/app/jobs/daily_report.py | 45 + backend/app/jobs/host_silence_background.py | 60 + backend/app/jobs/host_silence_scan.py | 38 + backend/app/jobs/retention.py | 36 + backend/app/main.py | 163 ++ backend/app/middleware/ingest_body_limit.py | 28 + backend/app/models/__init__.py | 38 + backend/app/models/agent_command.py | 27 + backend/app/models/api_key.py | 17 + backend/app/models/event.py | 30 + backend/app/models/event_severity_override.py | 19 + backend/app/models/event_type_visibility.py | 21 + backend/app/models/host.py | 45 + backend/app/models/login_attempt.py | 21 + backend/app/models/mobile_device.py | 25 + backend/app/models/mobile_enrollment_code.py | 23 + backend/app/models/mobile_refresh_token.py | 17 + backend/app/models/mobile_settings.py | 21 + backend/app/models/notification_channel.py | 30 + backend/app/models/notification_cooldown.py | 16 + backend/app/models/notification_policy.py | 19 + backend/app/models/problem.py | 39 + backend/app/models/ui_settings.py | 40 + backend/app/models/user.py | 21 + backend/app/schemas/list_models.py | 104 + backend/app/security_bootstrap.py | 52 + backend/app/services/agent_commands.py | 109 + backend/app/services/agent_git_release.py | 308 +++ backend/app/services/agent_host_config.py | 74 + backend/app/services/agent_poll.py | 31 + backend/app/services/agent_update.py | 325 +++ backend/app/services/agent_update_settings.py | 192 ++ backend/app/services/agent_update_types.py | 16 + backend/app/services/agent_version.py | 149 ++ backend/app/services/client_ip.py | 27 + backend/app/services/daily_report.py | 368 ++++ backend/app/services/daily_report_format.py | 492 +++++ backend/app/services/email_notify.py | 131 ++ backend/app/services/event_actor_user.py | 50 + .../app/services/event_severity_overrides.py | 88 + backend/app/services/event_summary.py | 57 + backend/app/services/event_type_visibility.py | 66 + backend/app/services/fail2ban_sync.py | 132 ++ backend/app/services/host_delete.py | 51 + backend/app/services/host_health.py | 100 + backend/app/services/host_inventory.py | 162 ++ backend/app/services/host_manual_add.py | 127 ++ backend/app/services/host_mgmt_credentials.py | 116 + backend/app/services/host_remote_actions.py | 449 ++++ backend/app/services/host_sessions.py | 578 +++++ backend/app/services/host_silence_scan.py | 290 +++ backend/app/services/ingest.py | 142 ++ backend/app/services/linux_admin_settings.py | 96 + backend/app/services/login_rate_limit.py | 146 ++ .../app/services/login_security_settings.py | 254 +++ backend/app/services/mobile_devices.py | 104 + backend/app/services/mobile_enrollment.py | 296 +++ backend/app/services/mobile_notify.py | 244 +++ backend/app/services/mobile_settings.py | 58 + backend/app/services/mobile_tokens.py | 68 + backend/app/services/notification_cooldown.py | 100 + backend/app/services/notification_policy.py | 140 ++ backend/app/services/notification_settings.py | 355 +++ backend/app/services/notification_severity.py | 11 + backend/app/services/notify_dispatch.py | 245 +++ backend/app/services/problem_rules.py | 220 ++ backend/app/services/problems.py | 119 ++ backend/app/services/rdg_client_host.py | 50 + backend/app/services/rdg_display.py | 132 ++ backend/app/services/rdg_session_flap.py | 311 +++ backend/app/services/rdg_winrm_actions.py | 182 ++ .../app/services/rdg_workstation_session.py | 290 +++ backend/app/services/rdp_bundle_delivery.py | 69 + .../app/services/rdp_flap_auto_disconnect.py | 324 +++ backend/app/services/rdp_flap_settings.py | 33 + backend/app/services/rdp_session_logoff.py | 148 ++ backend/app/services/retention.py | 39 + backend/app/services/schema_validate.py | 24 + backend/app/services/session_duration.py | 37 + backend/app/services/ssh_connect.py | 588 +++++ backend/app/services/system_stats.py | 115 + backend/app/services/telegram_notify.py | 105 + backend/app/services/telegram_templates.py | 604 ++++++ backend/app/services/ui_settings.py | 30 + backend/app/services/user_auth.py | 96 + backend/app/services/webhook_notify.py | 136 ++ backend/app/services/win_admin_settings.py | 109 + backend/app/services/winrm_connect.py | 692 ++++++ backend/app/utils/sql_like.py | 8 + backend/app/version.py | 5 + backend/requirements.txt | 21 + backend/scripts/sac_manage_user.py | 80 + backend/tests/conftest.py | 159 ++ backend/tests/test_agent_git_release.py | 86 + backend/tests/test_agent_update.py | 292 +++ backend/tests/test_agent_version.py | 129 ++ backend/tests/test_client_ip.py | 26 + backend/tests/test_daily_report.py | 258 +++ backend/tests/test_daily_report_format.py | 192 ++ backend/tests/test_dashboard.py | 122 ++ backend/tests/test_email_notify.py | 71 + backend/tests/test_event_actor_user.py | 67 + .../tests/test_event_severity_overrides.py | 53 + backend/tests/test_event_type_visibility.py | 117 + backend/tests/test_events_list_filter.py | 54 + backend/tests/test_health.py | 9 + backend/tests/test_health_ops.py | 11 + backend/tests/test_host_inventory.py | 132 ++ backend/tests/test_host_manual_add.py | 141 ++ backend/tests/test_host_mgmt_credentials.py | 64 + backend/tests/test_host_sessions.py | 218 ++ backend/tests/test_host_silence_scan.py | 355 +++ backend/tests/test_hosts.py | 183 ++ backend/tests/test_ingest.py | 180 ++ backend/tests/test_ingest_body_limit.py | 23 + backend/tests/test_linux_admin_settings.py | 224 ++ backend/tests/test_login_rate_limit.py | 65 + backend/tests/test_login_security_settings.py | 88 + backend/tests/test_mobile_api.py | 227 ++ backend/tests/test_mobile_notify.py | 99 + backend/tests/test_notification_cooldown.py | 85 + backend/tests/test_notification_policy.py | 65 + backend/tests/test_notify_dispatch.py | 366 ++++ backend/tests/test_problem_rules.py | 280 +++ backend/tests/test_problems.py | 200 ++ backend/tests/test_rdg_client_host.py | 55 + backend/tests/test_rdg_display.py | 136 ++ backend/tests/test_rdg_session_flap.py | 339 +++ backend/tests/test_rdg_winrm_actions.py | 223 ++ backend/tests/test_rdg_workstation_session.py | 378 ++++ .../tests/test_rdp_flap_auto_disconnect.py | 242 +++ backend/tests/test_rdp_session_logoff.py | 229 ++ backend/tests/test_retention.py | 78 + backend/tests/test_security_hardening.py | 52 + backend/tests/test_session_duration.py | 20 + backend/tests/test_settings_api.py | 197 ++ backend/tests/test_ssh_connect.py | 336 +++ backend/tests/test_system_stats.py | 30 + backend/tests/test_telegram_notify.py | 76 + backend/tests/test_telegram_templates.py | 418 ++++ backend/tests/test_ui_settings.py | 32 + backend/tests/test_users_auth.py | 136 ++ backend/tests/test_webhook_notify.py | 55 + backend/tests/test_win_admin_settings.py | 115 + backend/tests/test_winrm_connect.py | 170 ++ deploy/.env.example | 30 + deploy/README.md | 30 + deploy/docker-compose.yml | 51 + deploy/env.native.example | 139 ++ deploy/nginx/sac.conf.example | 69 + deploy/nginx/sac.conf.tls.example | 94 + deploy/sac-deploy.sh | 198 ++ .../systemd/render-sac-daily-report-timer.sh | 44 + deploy/systemd/sac-api-start.sh | 42 + deploy/systemd/sac-api.service | 24 + deploy/systemd/sac-daily-report.service | 16 + deploy/systemd/sac-daily-report.timer | 12 + deploy/systemd/sac-host-silence-scan.service | 16 + deploy/systemd/sac-host-silence-scan.timer | 11 + deploy/systemd/sac-retention.service | 16 + deploy/systemd/sac-retention.timer | 10 + docs/INDEX.md | 29 + docs/TZ.md | 310 +++ docs/agent-control-plane.md | 329 +++ docs/agent-integration.md | 306 +++ docs/agent-update-backlog.md | 58 + docs/analytics-backlog.md | 41 + docs/architecture.md | 163 ++ docs/deployment.md | 138 ++ docs/diagrams/data-flow.md | 45 + docs/event-schema-v1.json | 174 ++ docs/ingest-mass-update-backlog.ru.md | 50 + docs/install-ubuntu-24.04-docker.md | 105 + docs/install-ubuntu-24.04-native.md | 465 ++++ docs/install-ubuntu-24.04.md | 24 + docs/operations-prod-status.md | 66 + docs/pilot-2.1-exclusive.md | 92 + docs/pilot-2.2-heartbeat-reports.md | 93 + docs/roadmap.md | 132 ++ docs/runbook-ops.md | 130 ++ docs/screenshots/ui-events.png | Bin 0 -> 90246 bytes docs/screenshots/ui-overview.png | Bin 0 -> 64842 bytes docs/screenshots/ui-problems.png | Bin 0 -> 19592 bytes docs/seaca-fcm.md | 49 + docs/seaca-mobile.md | 113 + docs/ssl-certificate.md | 313 +++ docs/testing-e2e-checklist.md | 109 + docs/todo-2026-05-29.md | 89 + docs/work-plan.md | 171 ++ docs/workspace-three-repos.md | 76 + frontend/README.md | 9 + frontend/index.html | 16 + frontend/package-lock.json | 1578 ++++++++++++++ frontend/package.json | 22 + frontend/public/favicon.png | Bin 0 -> 1635 bytes frontend/public/favicon.svg | 11 + frontend/public/sac-icon.png | Bin 0 -> 14259 bytes frontend/src/App.vue | 28 + frontend/src/api.ts | 825 +++++++ frontend/src/components/AppSidebar.vue | 237 ++ .../src/components/HostActionLogModal.vue | 415 ++++ .../src/components/HostActionLogStack.vue | 58 + .../components/HostAgentVersionUpgrade.vue | 135 ++ .../src/components/HostManualAddModal.vue | 157 ++ frontend/src/components/HostSessionsPanel.vue | 209 ++ frontend/src/components/RdgAccessBadge.vue | 40 + frontend/src/components/RdgFlapBadge.vue | 54 + frontend/src/components/RdgQwinstaModal.vue | 199 ++ frontend/src/components/ReportBodyCard.vue | 58 + .../src/components/SidebarSystemStats.vue | 191 ++ .../src/composables/useHostRemoteAction.ts | 329 +++ frontend/src/composables/useRdgQwinsta.ts | 188 ++ .../src/composables/useSacLiveEventStream.ts | 73 + frontend/src/composables/useSacVersion.ts | 57 + frontend/src/errorPages.ts | 53 + frontend/src/main.ts | 6 + frontend/src/router.ts | 72 + frontend/src/style.css | 465 ++++ frontend/src/utils/agentVersion.ts | 66 + frontend/src/utils/eventsListQuery.ts | 80 + frontend/src/utils/hostAgentUpgrade.ts | 94 + frontend/src/utils/hostDetailLive.ts | 37 + frontend/src/utils/hostDisplay.ts | 5 + frontend/src/utils/hostPatchBus.ts | 16 + frontend/src/utils/hostsLiveUpdate.ts | 30 + frontend/src/utils/rdgFlap.ts | 32 + frontend/src/utils/reportDisplay.ts | 191 ++ frontend/src/utils/sessionActions.ts | 32 + frontend/src/utils/sessionDuration.ts | 17 + frontend/src/version.ts | 4 + frontend/src/views/DashboardView.vue | 664 ++++++ frontend/src/views/ErrorPageView.vue | 124 ++ frontend/src/views/EventDetailView.vue | 162 ++ frontend/src/views/EventsView.vue | 303 +++ frontend/src/views/HostDetailView.vue | 1131 ++++++++++ frontend/src/views/HostsView.vue | 557 +++++ frontend/src/views/LoginView.vue | 83 + frontend/src/views/ProblemDetailView.vue | 136 ++ frontend/src/views/ProblemsView.vue | 228 ++ frontend/src/views/ReportsView.vue | 147 ++ frontend/src/views/SettingsView.vue | 1899 +++++++++++++++++ frontend/src/views/UsersView.vue | 320 +++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 17 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 18 + schemas/event-schema-v1.json | 179 ++ tools/Invoke-SacRemoteSudo.ps1 | 38 + tools/patch-nginx-sac-long-timeout.sh | 53 + tools/probe_git_release.py | 21 + tools/push-github-snapshot.sh | 82 + tools/test_sac_agent_git.py | 30 + tools/test_sac_agent_git.sh | 12 + 312 files changed, 42790 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README_en.md create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial.py create mode 100644 backend/alembic/versions/002_problems.py create mode 100644 backend/alembic/versions/003_problem_correlation.py create mode 100644 backend/alembic/versions/004_notification_channels.py create mode 100644 backend/alembic/versions/005_notification_webhook.py create mode 100644 backend/alembic/versions/006_notification_smtp.py create mode 100644 backend/alembic/versions/007_notification_policy.py create mode 100644 backend/alembic/versions/008_notification_cooldown.py create mode 100644 backend/alembic/versions/009_sac_users.py create mode 100644 backend/alembic/versions/010_event_severity_overrides.py create mode 100644 backend/alembic/versions/011_login_attempts.py create mode 100644 backend/alembic/versions/012_ui_settings.py create mode 100644 backend/alembic/versions/013_host_inventory.py create mode 100644 backend/alembic/versions/014_mobile_seaca.py create mode 100644 backend/alembic/versions/015_problem_resolved_by.py create mode 100644 backend/alembic/versions/016_agent_commands.py create mode 100644 backend/alembic/versions/017_ui_settings_win_admin.py create mode 100644 backend/alembic/versions/018_ui_settings_linux_admin.py create mode 100644 backend/alembic/versions/019_host_ssh_admin_status.py create mode 100644 backend/alembic/versions/020_agent_control_plane.py create mode 100644 backend/alembic/versions/021_agent_git_release.py create mode 100644 backend/alembic/versions/022_host_remote_action.py create mode 100644 backend/alembic/versions/023_event_type_visibility.py create mode 100644 backend/alembic/versions/024_login_security.py create mode 100644 backend/alembic/versions/025_auto_rdp_flap_disconnect.py create mode 100644 backend/alembic/versions/026_host_silence_dedupe.py create mode 100644 backend/alembic/versions/027_host_mgmt_credentials.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/agent.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/dashboards.py create mode 100644 backend/app/api/v1/events.py create mode 100644 backend/app/api/v1/health.py create mode 100644 backend/app/api/v1/hosts.py create mode 100644 backend/app/api/v1/mobile.py create mode 100644 backend/app/api/v1/problems.py create mode 100644 backend/app/api/v1/router.py create mode 100644 backend/app/api/v1/settings.py create mode 100644 backend/app/api/v1/stream.py create mode 100644 backend/app/api/v1/system.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/auth/api_key.py create mode 100644 backend/app/auth/jwt_auth.py create mode 100644 backend/app/auth/stream_cookie.py create mode 100644 backend/app/config.py create mode 100644 backend/app/constants/event_types.py create mode 100644 backend/app/database.py create mode 100644 backend/app/jobs/__init__.py create mode 100644 backend/app/jobs/daily_report.py create mode 100644 backend/app/jobs/host_silence_background.py create mode 100644 backend/app/jobs/host_silence_scan.py create mode 100644 backend/app/jobs/retention.py create mode 100644 backend/app/main.py create mode 100644 backend/app/middleware/ingest_body_limit.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/agent_command.py create mode 100644 backend/app/models/api_key.py create mode 100644 backend/app/models/event.py create mode 100644 backend/app/models/event_severity_override.py create mode 100644 backend/app/models/event_type_visibility.py create mode 100644 backend/app/models/host.py create mode 100644 backend/app/models/login_attempt.py create mode 100644 backend/app/models/mobile_device.py create mode 100644 backend/app/models/mobile_enrollment_code.py create mode 100644 backend/app/models/mobile_refresh_token.py create mode 100644 backend/app/models/mobile_settings.py create mode 100644 backend/app/models/notification_channel.py create mode 100644 backend/app/models/notification_cooldown.py create mode 100644 backend/app/models/notification_policy.py create mode 100644 backend/app/models/problem.py create mode 100644 backend/app/models/ui_settings.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/list_models.py create mode 100644 backend/app/security_bootstrap.py create mode 100644 backend/app/services/agent_commands.py create mode 100644 backend/app/services/agent_git_release.py create mode 100644 backend/app/services/agent_host_config.py create mode 100644 backend/app/services/agent_poll.py create mode 100644 backend/app/services/agent_update.py create mode 100644 backend/app/services/agent_update_settings.py create mode 100644 backend/app/services/agent_update_types.py create mode 100644 backend/app/services/agent_version.py create mode 100644 backend/app/services/client_ip.py create mode 100644 backend/app/services/daily_report.py create mode 100644 backend/app/services/daily_report_format.py create mode 100644 backend/app/services/email_notify.py create mode 100644 backend/app/services/event_actor_user.py create mode 100644 backend/app/services/event_severity_overrides.py create mode 100644 backend/app/services/event_summary.py create mode 100644 backend/app/services/event_type_visibility.py create mode 100644 backend/app/services/fail2ban_sync.py create mode 100644 backend/app/services/host_delete.py create mode 100644 backend/app/services/host_health.py create mode 100644 backend/app/services/host_inventory.py create mode 100644 backend/app/services/host_manual_add.py create mode 100644 backend/app/services/host_mgmt_credentials.py create mode 100644 backend/app/services/host_remote_actions.py create mode 100644 backend/app/services/host_sessions.py create mode 100644 backend/app/services/host_silence_scan.py create mode 100644 backend/app/services/ingest.py create mode 100644 backend/app/services/linux_admin_settings.py create mode 100644 backend/app/services/login_rate_limit.py create mode 100644 backend/app/services/login_security_settings.py create mode 100644 backend/app/services/mobile_devices.py create mode 100644 backend/app/services/mobile_enrollment.py create mode 100644 backend/app/services/mobile_notify.py create mode 100644 backend/app/services/mobile_settings.py create mode 100644 backend/app/services/mobile_tokens.py create mode 100644 backend/app/services/notification_cooldown.py create mode 100644 backend/app/services/notification_policy.py create mode 100644 backend/app/services/notification_settings.py create mode 100644 backend/app/services/notification_severity.py create mode 100644 backend/app/services/notify_dispatch.py create mode 100644 backend/app/services/problem_rules.py create mode 100644 backend/app/services/problems.py create mode 100644 backend/app/services/rdg_client_host.py create mode 100644 backend/app/services/rdg_display.py create mode 100644 backend/app/services/rdg_session_flap.py create mode 100644 backend/app/services/rdg_winrm_actions.py create mode 100644 backend/app/services/rdg_workstation_session.py create mode 100644 backend/app/services/rdp_bundle_delivery.py create mode 100644 backend/app/services/rdp_flap_auto_disconnect.py create mode 100644 backend/app/services/rdp_flap_settings.py create mode 100644 backend/app/services/rdp_session_logoff.py create mode 100644 backend/app/services/retention.py create mode 100644 backend/app/services/schema_validate.py create mode 100644 backend/app/services/session_duration.py create mode 100644 backend/app/services/ssh_connect.py create mode 100644 backend/app/services/system_stats.py create mode 100644 backend/app/services/telegram_notify.py create mode 100644 backend/app/services/telegram_templates.py create mode 100644 backend/app/services/ui_settings.py create mode 100644 backend/app/services/user_auth.py create mode 100644 backend/app/services/webhook_notify.py create mode 100644 backend/app/services/win_admin_settings.py create mode 100644 backend/app/services/winrm_connect.py create mode 100644 backend/app/utils/sql_like.py create mode 100644 backend/app/version.py create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/sac_manage_user.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_agent_git_release.py create mode 100644 backend/tests/test_agent_update.py create mode 100644 backend/tests/test_agent_version.py create mode 100644 backend/tests/test_client_ip.py create mode 100644 backend/tests/test_daily_report.py create mode 100644 backend/tests/test_daily_report_format.py create mode 100644 backend/tests/test_dashboard.py create mode 100644 backend/tests/test_email_notify.py create mode 100644 backend/tests/test_event_actor_user.py create mode 100644 backend/tests/test_event_severity_overrides.py create mode 100644 backend/tests/test_event_type_visibility.py create mode 100644 backend/tests/test_events_list_filter.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_health_ops.py create mode 100644 backend/tests/test_host_inventory.py create mode 100644 backend/tests/test_host_manual_add.py create mode 100644 backend/tests/test_host_mgmt_credentials.py create mode 100644 backend/tests/test_host_sessions.py create mode 100644 backend/tests/test_host_silence_scan.py create mode 100644 backend/tests/test_hosts.py create mode 100644 backend/tests/test_ingest.py create mode 100644 backend/tests/test_ingest_body_limit.py create mode 100644 backend/tests/test_linux_admin_settings.py create mode 100644 backend/tests/test_login_rate_limit.py create mode 100644 backend/tests/test_login_security_settings.py create mode 100644 backend/tests/test_mobile_api.py create mode 100644 backend/tests/test_mobile_notify.py create mode 100644 backend/tests/test_notification_cooldown.py create mode 100644 backend/tests/test_notification_policy.py create mode 100644 backend/tests/test_notify_dispatch.py create mode 100644 backend/tests/test_problem_rules.py create mode 100644 backend/tests/test_problems.py create mode 100644 backend/tests/test_rdg_client_host.py create mode 100644 backend/tests/test_rdg_display.py create mode 100644 backend/tests/test_rdg_session_flap.py create mode 100644 backend/tests/test_rdg_winrm_actions.py create mode 100644 backend/tests/test_rdg_workstation_session.py create mode 100644 backend/tests/test_rdp_flap_auto_disconnect.py create mode 100644 backend/tests/test_rdp_session_logoff.py create mode 100644 backend/tests/test_retention.py create mode 100644 backend/tests/test_security_hardening.py create mode 100644 backend/tests/test_session_duration.py create mode 100644 backend/tests/test_settings_api.py create mode 100644 backend/tests/test_ssh_connect.py create mode 100644 backend/tests/test_system_stats.py create mode 100644 backend/tests/test_telegram_notify.py create mode 100644 backend/tests/test_telegram_templates.py create mode 100644 backend/tests/test_ui_settings.py create mode 100644 backend/tests/test_users_auth.py create mode 100644 backend/tests/test_webhook_notify.py create mode 100644 backend/tests/test_win_admin_settings.py create mode 100644 backend/tests/test_winrm_connect.py create mode 100644 deploy/.env.example create mode 100644 deploy/README.md create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/env.native.example create mode 100644 deploy/nginx/sac.conf.example create mode 100644 deploy/nginx/sac.conf.tls.example create mode 100644 deploy/sac-deploy.sh create mode 100644 deploy/systemd/render-sac-daily-report-timer.sh create mode 100644 deploy/systemd/sac-api-start.sh create mode 100644 deploy/systemd/sac-api.service create mode 100644 deploy/systemd/sac-daily-report.service create mode 100644 deploy/systemd/sac-daily-report.timer create mode 100644 deploy/systemd/sac-host-silence-scan.service create mode 100644 deploy/systemd/sac-host-silence-scan.timer create mode 100644 deploy/systemd/sac-retention.service create mode 100644 deploy/systemd/sac-retention.timer create mode 100644 docs/INDEX.md create mode 100644 docs/TZ.md create mode 100644 docs/agent-control-plane.md create mode 100644 docs/agent-integration.md create mode 100644 docs/agent-update-backlog.md create mode 100644 docs/analytics-backlog.md create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/diagrams/data-flow.md create mode 100644 docs/event-schema-v1.json create mode 100644 docs/ingest-mass-update-backlog.ru.md create mode 100644 docs/install-ubuntu-24.04-docker.md create mode 100644 docs/install-ubuntu-24.04-native.md create mode 100644 docs/install-ubuntu-24.04.md create mode 100644 docs/operations-prod-status.md create mode 100644 docs/pilot-2.1-exclusive.md create mode 100644 docs/pilot-2.2-heartbeat-reports.md create mode 100644 docs/roadmap.md create mode 100644 docs/runbook-ops.md create mode 100644 docs/screenshots/ui-events.png create mode 100644 docs/screenshots/ui-overview.png create mode 100644 docs/screenshots/ui-problems.png create mode 100644 docs/seaca-fcm.md create mode 100644 docs/seaca-mobile.md create mode 100644 docs/ssl-certificate.md create mode 100644 docs/testing-e2e-checklist.md create mode 100644 docs/todo-2026-05-29.md create mode 100644 docs/work-plan.md create mode 100644 docs/workspace-three-repos.md create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.png create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/sac-icon.png create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AppSidebar.vue create mode 100644 frontend/src/components/HostActionLogModal.vue create mode 100644 frontend/src/components/HostActionLogStack.vue create mode 100644 frontend/src/components/HostAgentVersionUpgrade.vue create mode 100644 frontend/src/components/HostManualAddModal.vue create mode 100644 frontend/src/components/HostSessionsPanel.vue create mode 100644 frontend/src/components/RdgAccessBadge.vue create mode 100644 frontend/src/components/RdgFlapBadge.vue create mode 100644 frontend/src/components/RdgQwinstaModal.vue create mode 100644 frontend/src/components/ReportBodyCard.vue create mode 100644 frontend/src/components/SidebarSystemStats.vue create mode 100644 frontend/src/composables/useHostRemoteAction.ts create mode 100644 frontend/src/composables/useRdgQwinsta.ts create mode 100644 frontend/src/composables/useSacLiveEventStream.ts create mode 100644 frontend/src/composables/useSacVersion.ts create mode 100644 frontend/src/errorPages.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/utils/agentVersion.ts create mode 100644 frontend/src/utils/eventsListQuery.ts create mode 100644 frontend/src/utils/hostAgentUpgrade.ts create mode 100644 frontend/src/utils/hostDetailLive.ts create mode 100644 frontend/src/utils/hostDisplay.ts create mode 100644 frontend/src/utils/hostPatchBus.ts create mode 100644 frontend/src/utils/hostsLiveUpdate.ts create mode 100644 frontend/src/utils/rdgFlap.ts create mode 100644 frontend/src/utils/reportDisplay.ts create mode 100644 frontend/src/utils/sessionActions.ts create mode 100644 frontend/src/utils/sessionDuration.ts create mode 100644 frontend/src/version.ts create mode 100644 frontend/src/views/DashboardView.vue create mode 100644 frontend/src/views/ErrorPageView.vue create mode 100644 frontend/src/views/EventDetailView.vue create mode 100644 frontend/src/views/EventsView.vue create mode 100644 frontend/src/views/HostDetailView.vue create mode 100644 frontend/src/views/HostsView.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/ProblemDetailView.vue create mode 100644 frontend/src/views/ProblemsView.vue create mode 100644 frontend/src/views/ReportsView.vue create mode 100644 frontend/src/views/SettingsView.vue create mode 100644 frontend/src/views/UsersView.vue create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 schemas/event-schema-v1.json create mode 100644 tools/Invoke-SacRemoteSudo.ps1 create mode 100644 tools/patch-nginx-sac-long-timeout.sh create mode 100644 tools/probe_git_release.py create mode 100644 tools/push-github-snapshot.sh create mode 100644 tools/test_sac_agent_git.py create mode 100644 tools/test_sac_agent_git.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cba3316 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC). +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cb943a --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +.env.* +!.env.example +deploy/.env + +# Node / frontend +frontend/node_modules/ +frontend/dist/ +node_modules/ +dist/ +.nuxt/ +.output/ + +# IDE +.idea/ +.vscode/* +!.vscode/extensions.json +*.code-workspace +.cursor/ + +# OS +.DS_Store +Thumbs.db + +# One-off remote deploy helper (never commit credentials) +deploy/_remote_sac_deploy_once.py +deploy/_remote_sac_verify.py +deploy/_ssh_test_*.py + +# Runtime / deploy secrets +/config/local.yaml +/deploy/*.local.* +*.pem +*.key + +# Data +*.db +pgdata/ + +# Logs +*.log +.cursorignore diff --git a/README.md b/README.md new file mode 100644 index 0000000..9931fe0 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Security Alert Center (SAC) + +Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами. + +**English:** [README_en.md](README_en.md) + +## Репозитории + +| Репозиторий | Назначение | +|-------------|------------| +| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux-агент | +| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows-агент | +| **security-alert-center** | Сервер SAC (Ubuntu 24.04) | +| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android-клиент | + +**Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh` + +## Возможности + +- **Ingest** событий от агентов ([контракт](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, отчёты, inventory +- **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 1–10 с) +- **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown +- **UI** — события, хосты, problems, отчёты, обзор (SSE live), настройки (admin) +- **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках); опционально — автоотключение зависшей сессии при flap +- **Хосты** — статус агента, inventory, обновление ssh-monitor/RDP (SSH/WinRM), тест Git/SSH/WinRM; WinRM RDP: SAC тянет пакет с git, клиент скачивает zip с SAC, локальный `Deploy-LoginMonitor.ps1` (git на ПК не нужен) +- **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC +- **Роли** — `admin` / `monitor`; JWT, rate limit входа +- **Безопасность (0.5.0)** — проверка host key SSH, SSE через httpOnly-cookie, защита rate limit от спуфинга `X-Forwarded-For`, `SAC_SECURITY_ENFORCE`, HMAC для API-ключей + +## Документация + +| Документ | Описание | +|----------|----------| +| [docs/agent-integration.md](docs/agent-integration.md) | Агенты, `UseSAC`, типы событий | +| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, обновления агентов | +| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Установка (native) | +| [docs/deployment.md](docs/deployment.md) | Эксплуатация | +| [docs/seaca-mobile.md](docs/seaca-mobile.md) | Мобильные устройства | + +## Быстрый старт + +```bash +git clone https://git.papatramp.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 +``` + +## Лицензия + +MIT — [LICENSE](LICENSE). diff --git a/README_en.md b/README_en.md new file mode 100644 index 0000000..6cd801c --- /dev/null +++ b/README_en.md @@ -0,0 +1,50 @@ +# Security Alert Center (SAC) + +Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management. + +**Русский:** [README.md](README.md) + +## Repositories + +| 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 | +| **security-alert-center** | SAC server (Ubuntu 24.04) | +| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android client | + +**Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh` + +## Features + +- **Ingest** from agents ([contract](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, reports, inventory +- **Problems** — auto-correlation (incl. RDG 302→303 flap within 1–10 s) +- **Notifications** — Telegram, email, webhook, FCM (Seaca) +- **Web UI** — events, hosts, problems, reports, dashboard (SSE), settings (admin) +- **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required); optional auto-disconnect of stuck sessions +- **Hosts** — agent status, inventory, agent updates (SSH/WinRM); WinRM RDP: SAC fetches from git, client downloads zip from SAC, runs local `Deploy-LoginMonitor.ps1` (no git on PC) +- **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API +- **Roles** — `admin` / `monitor` +- **Security (0.5.0)** — SSH host-key verification, SSE via httpOnly cookie, anti-spoof login rate limit, `SAC_SECURITY_ENFORCE`, HMAC API keys + +## Documentation + +| Document | Description | +|----------|-------------| +| [docs/agent-integration.md](docs/agent-integration.md) | Agents, `UseSAC`, event types | +| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, agent updates | +| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Native install | +| [docs/deployment.md](docs/deployment.md) | Operations | + +## Quick start + +```bash +git clone https://git.papatramp.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 +``` + +## License + +MIT — [LICENSE](LICENSE). diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0032ad3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/app ./app +COPY backend/alembic.ini . +COPY backend/alembic ./alembic +COPY schemas ./schemas + +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 +ENV EVENT_SCHEMA_PATH=/app/schemas/event-schema-v1.json + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..ebbd3c1 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,42 @@ +# Backend — Security Alert Center + +FastAPI + PostgreSQL + Alembic. + +## Production (native на Ubuntu 24.04) + +См. **[docs/install-ubuntu-24.04-native.md](../docs/install-ubuntu-24.04-native.md)**. + +Кратко: + +```bash +cd /opt/security-alert-center/backend +python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt +# /opt/security-alert-center/config/sac-api.env — см. deploy/env.native.example +alembic upgrade head +systemctl start sac-api +``` + +## Локальная разработка + +```bash +cd backend +python3.12 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +export DATABASE_URL=postgresql+psycopg2://sac:sac@localhost:5432/sac +export SAC_BOOTSTRAP_API_KEY=sac_dev_test_key +export EVENT_SCHEMA_PATH=../schemas/event-schema-v1.json +alembic upgrade head +uvicorn app.main:app --reload --port 8000 +``` + +## Docker (альтернатива) + +[docs/install-ubuntu-24.04-docker.md](../docs/install-ubuntu-24.04-docker.md) + +## API + +| Метод | Путь | Auth | +|-------|------|------| +| GET | `/health` | нет | +| POST | `/api/v1/events` | Bearer API key | diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cd64a16 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..e14364d --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,46 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import get_settings +from app.database import Base +from app.models import ApiKey, Event, Host # noqa: F401 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..14cb7eb --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -0,0 +1,90 @@ +"""initial hosts events api_keys + +Revision ID: 001 +Revises: +Create Date: 2026-05-26 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "hosts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("agent_instance_id", sa.String(length=128), nullable=True), + sa.Column("hostname", sa.String(length=255), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=True), + sa.Column("os_family", sa.String(length=32), nullable=False), + sa.Column("os_version", sa.String(length=128), nullable=True), + sa.Column("product", sa.String(length=64), nullable=False), + sa.Column("product_version", sa.String(length=64), nullable=True), + sa.Column("ipv4", sa.String(length=45), nullable=True), + sa.Column("ipv6", sa.String(length=45), nullable=True), + sa.Column("tags", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("use_sac_mode", sa.String(length=32), nullable=True), + sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("agent_instance_id"), + ) + op.create_index("ix_hosts_hostname", "hosts", ["hostname"]) + op.create_index("ix_hosts_product", "hosts", ["product"]) + + op.create_table( + "api_keys", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("key_prefix", sa.String(length=16), nullable=False), + sa.Column("key_hash", sa.String(length=128), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key_hash"), + ) + op.create_index("ix_api_keys_key_prefix", "api_keys", ["key_prefix"]) + + op.create_table( + "events", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("host_id", sa.Integer(), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("received_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("category", sa.String(length=64), nullable=False), + sa.Column("type", sa.String(length=128), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False), + sa.Column("title", sa.String(length=256), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("details", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("raw", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("dedup_key", sa.String(length=512), nullable=True), + sa.Column("correlation_id", sa.String(length=36), nullable=True), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.ForeignKeyConstraint(["host_id"], ["hosts.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("event_id", name="uq_events_event_id"), + ) + op.create_index("ix_events_event_id", "events", ["event_id"]) + op.create_index("ix_events_host_id", "events", ["host_id"]) + op.create_index("ix_events_occurred_at", "events", ["occurred_at"]) + op.create_index("ix_events_category", "events", ["category"]) + op.create_index("ix_events_type", "events", ["type"]) + op.create_index("ix_events_severity", "events", ["severity"]) + op.create_index("ix_events_dedup_key", "events", ["dedup_key"]) + + +def downgrade() -> None: + op.drop_table("events") + op.drop_table("api_keys") + op.drop_table("hosts") diff --git a/backend/alembic/versions/002_problems.py b/backend/alembic/versions/002_problems.py new file mode 100644 index 0000000..153cf9a --- /dev/null +++ b/backend/alembic/versions/002_problems.py @@ -0,0 +1,48 @@ +"""problems table + +Revision ID: 002 +Revises: 001 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "problems", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("host_id", sa.Integer(), nullable=True), + sa.Column("title", sa.String(length=256), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="open"), + sa.Column("rule_id", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["host_id"], ["hosts.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_problems_status", "problems", ["status"]) + op.create_index("ix_problems_severity", "problems", ["severity"]) + + op.create_table( + "problem_events", + sa.Column("problem_id", sa.Integer(), nullable=False), + sa.Column("event_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["problem_id"], ["problems.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("problem_id", "event_id"), + ) + + +def downgrade() -> None: + op.drop_table("problem_events") + op.drop_table("problems") diff --git a/backend/alembic/versions/003_problem_correlation.py b/backend/alembic/versions/003_problem_correlation.py new file mode 100644 index 0000000..c760f70 --- /dev/null +++ b/backend/alembic/versions/003_problem_correlation.py @@ -0,0 +1,52 @@ +"""problem correlation fields + +Revision ID: 003 +Revises: 002 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "003" +down_revision: Union[str, None] = "002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("problems", sa.Column("fingerprint", sa.String(length=128), nullable=True)) + op.add_column( + "problems", + sa.Column("event_count", sa.Integer(), nullable=False, server_default="1"), + ) + op.add_column("problems", sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_problems_fingerprint", "problems", ["fingerprint"]) + op.create_index("ix_problems_last_seen_at", "problems", ["last_seen_at"]) + + op.execute( + sa.text( + """ + UPDATE problems p SET + fingerprint = 'h' || COALESCE(p.host_id::text, '0') || chr(58) || 'r' + || COALESCE(p.rule_id, 'unknown'), + event_count = COALESCE( + (SELECT COUNT(*)::int FROM problem_events pe WHERE pe.problem_id = p.id), + 1 + ), + last_seen_at = COALESCE(p.updated_at, p.created_at) + """ + ) + ) + + op.alter_column("problems", "fingerprint", nullable=False) + op.alter_column("problems", "last_seen_at", nullable=False) + + +def downgrade() -> None: + op.drop_index("ix_problems_last_seen_at", table_name="problems") + op.drop_index("ix_problems_fingerprint", table_name="problems") + op.drop_column("problems", "last_seen_at") + op.drop_column("problems", "event_count") + op.drop_column("problems", "fingerprint") diff --git a/backend/alembic/versions/004_notification_channels.py b/backend/alembic/versions/004_notification_channels.py new file mode 100644 index 0000000..74e462a --- /dev/null +++ b/backend/alembic/versions/004_notification_channels.py @@ -0,0 +1,37 @@ +"""notification_channels for UI-managed Telegram settings + +Revision ID: 004 +Revises: 003 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "004" +down_revision: Union[str, None] = "003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notification_channels", + sa.Column("channel", sa.String(length=32), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("min_severity", sa.String(length=16), nullable=False, server_default="warning"), + sa.Column("bot_token", sa.Text(), nullable=True), + sa.Column("chat_id", sa.String(length=64), nullable=True), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("channel"), + ) + + +def downgrade() -> None: + op.drop_table("notification_channels") diff --git a/backend/alembic/versions/005_notification_webhook.py b/backend/alembic/versions/005_notification_webhook.py new file mode 100644 index 0000000..4f84675 --- /dev/null +++ b/backend/alembic/versions/005_notification_webhook.py @@ -0,0 +1,30 @@ +"""notification_channels: webhook columns + +Revision ID: 005 +Revises: 004 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "005" +down_revision: Union[str, None] = "004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("notification_channels", sa.Column("webhook_url", sa.Text(), nullable=True)) + op.add_column( + "notification_channels", + sa.Column("webhook_secret_header", sa.String(length=64), nullable=True), + ) + op.add_column("notification_channels", sa.Column("webhook_secret", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("notification_channels", "webhook_secret") + op.drop_column("notification_channels", "webhook_secret_header") + op.drop_column("notification_channels", "webhook_url") diff --git a/backend/alembic/versions/006_notification_smtp.py b/backend/alembic/versions/006_notification_smtp.py new file mode 100644 index 0000000..5ebecf8 --- /dev/null +++ b/backend/alembic/versions/006_notification_smtp.py @@ -0,0 +1,43 @@ +"""notification_channels: SMTP/email columns + +Revision ID: 006 +Revises: 005 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "006" +down_revision: Union[str, None] = "005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("notification_channels", sa.Column("smtp_host", sa.Text(), nullable=True)) + op.add_column("notification_channels", sa.Column("smtp_port", sa.Integer(), nullable=True)) + op.add_column("notification_channels", sa.Column("smtp_user", sa.String(length=128), nullable=True)) + op.add_column("notification_channels", sa.Column("smtp_password", sa.Text(), nullable=True)) + op.add_column("notification_channels", sa.Column("mail_from", sa.String(length=256), nullable=True)) + op.add_column("notification_channels", sa.Column("mail_to", sa.Text(), nullable=True)) + op.add_column( + "notification_channels", + sa.Column("smtp_starttls", sa.Boolean(), nullable=False, server_default=sa.text("true")), + ) + op.add_column( + "notification_channels", + sa.Column("smtp_ssl", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + +def downgrade() -> None: + op.drop_column("notification_channels", "smtp_ssl") + op.drop_column("notification_channels", "smtp_starttls") + op.drop_column("notification_channels", "mail_to") + op.drop_column("notification_channels", "mail_from") + op.drop_column("notification_channels", "smtp_password") + op.drop_column("notification_channels", "smtp_user") + op.drop_column("notification_channels", "smtp_port") + op.drop_column("notification_channels", "smtp_host") diff --git a/backend/alembic/versions/007_notification_policy.py b/backend/alembic/versions/007_notification_policy.py new file mode 100644 index 0000000..c200ce2 --- /dev/null +++ b/backend/alembic/versions/007_notification_policy.py @@ -0,0 +1,37 @@ +"""notification_policy singleton (global severity rule) + +Revision ID: 007 +Revises: 006 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "007" +down_revision: Union[str, None] = "006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notification_policy", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("min_severity", sa.String(length=16), nullable=False, server_default="warning"), + sa.Column("use_telegram", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("use_webhook", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("use_email", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.PrimaryKeyConstraint("id"), + ) + op.execute( + sa.text( + "INSERT INTO notification_policy (id, min_severity, use_telegram, use_webhook, use_email) " + "VALUES (1, 'warning', true, false, false)" + ) + ) + + +def downgrade() -> None: + op.drop_table("notification_policy") diff --git a/backend/alembic/versions/008_notification_cooldown.py b/backend/alembic/versions/008_notification_cooldown.py new file mode 100644 index 0000000..b6eeee6 --- /dev/null +++ b/backend/alembic/versions/008_notification_cooldown.py @@ -0,0 +1,31 @@ +"""notification_cooldown for SAC notify dedup (F-NOT-03) + +Revision ID: 008 +Revises: 007 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "008" +down_revision: Union[str, None] = "007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notification_cooldown", + sa.Column("cooldown_key", sa.String(length=512), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("last_notified_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("cooldown_key"), + ) + op.create_index("ix_notification_cooldown_kind", "notification_cooldown", ["kind"]) + + +def downgrade() -> None: + op.drop_index("ix_notification_cooldown_kind", table_name="notification_cooldown") + op.drop_table("notification_cooldown") diff --git a/backend/alembic/versions/009_sac_users.py b/backend/alembic/versions/009_sac_users.py new file mode 100644 index 0000000..dffcc4a --- /dev/null +++ b/backend/alembic/versions/009_sac_users.py @@ -0,0 +1,35 @@ +"""sac_users table for multi-user UI login + +Revision ID: 009 +Revises: 008 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "009" +down_revision: Union[str, None] = "008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "sac_users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("password_hash", sa.String(length=128), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False, server_default="monitor"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("username"), + ) + op.create_index("ix_sac_users_username", "sac_users", ["username"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_sac_users_username", table_name="sac_users") + op.drop_table("sac_users") diff --git a/backend/alembic/versions/010_event_severity_overrides.py b/backend/alembic/versions/010_event_severity_overrides.py new file mode 100644 index 0000000..00810ca --- /dev/null +++ b/backend/alembic/versions/010_event_severity_overrides.py @@ -0,0 +1,29 @@ +"""event_severity_overrides table + +Revision ID: 010 +Revises: 009 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "010" +down_revision: Union[str, None] = "009" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "event_severity_overrides", + sa.Column("event_type", sa.String(length=128), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("event_type"), + ) + + +def downgrade() -> None: + op.drop_table("event_severity_overrides") diff --git a/backend/alembic/versions/011_login_attempts.py b/backend/alembic/versions/011_login_attempts.py new file mode 100644 index 0000000..7a52f9f --- /dev/null +++ b/backend/alembic/versions/011_login_attempts.py @@ -0,0 +1,35 @@ +"""login_attempts for SAC UI login rate limiting + +Revision ID: 011 +Revises: 010 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "011" +down_revision: Union[str, None] = "010" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "login_attempts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("ip_address", sa.String(length=64), nullable=False), + sa.Column("username", sa.String(length=64), nullable=True), + sa.Column("success", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_login_attempts_ip_address", "login_attempts", ["ip_address"], unique=False) + op.create_index("ix_login_attempts_created_at", "login_attempts", ["created_at"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_login_attempts_created_at", table_name="login_attempts") + op.drop_index("ix_login_attempts_ip_address", table_name="login_attempts") + op.drop_table("login_attempts") diff --git a/backend/alembic/versions/012_ui_settings.py b/backend/alembic/versions/012_ui_settings.py new file mode 100644 index 0000000..421bd11 --- /dev/null +++ b/backend/alembic/versions/012_ui_settings.py @@ -0,0 +1,31 @@ +"""ui_settings singleton for SAC UI preferences + +Revision ID: 012 +Revises: 011 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "012" +down_revision: Union[str, None] = "011" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "ui_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("show_sidebar_system_stats", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.PrimaryKeyConstraint("id"), + ) + op.execute( + "INSERT INTO ui_settings (id, show_sidebar_system_stats) VALUES (1, true)" + ) + + +def downgrade() -> None: + op.drop_table("ui_settings") diff --git a/backend/alembic/versions/013_host_inventory.py b/backend/alembic/versions/013_host_inventory.py new file mode 100644 index 0000000..1b0069a --- /dev/null +++ b/backend/alembic/versions/013_host_inventory.py @@ -0,0 +1,26 @@ +"""host inventory JSONB + +Revision ID: 013 +Revises: 012 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision: str = "013" +down_revision: Union[str, None] = "012" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("hosts", sa.Column("inventory", JSONB, nullable=True)) + op.add_column("hosts", sa.Column("inventory_updated_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("hosts", "inventory_updated_at") + op.drop_column("hosts", "inventory") diff --git a/backend/alembic/versions/014_mobile_seaca.py b/backend/alembic/versions/014_mobile_seaca.py new file mode 100644 index 0000000..3294f6f --- /dev/null +++ b/backend/alembic/versions/014_mobile_seaca.py @@ -0,0 +1,105 @@ +"""mobile settings, enrollment codes, devices, refresh tokens; policy use_mobile + +Revision ID: 014 +Revises: 013 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "014" +down_revision: Union[str, None] = "013" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +MOBILE_SETTINGS_ROW_ID = 1 + + +def upgrade() -> None: + op.add_column( + "notification_policy", + sa.Column("use_mobile", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + op.create_table( + "mobile_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("devices_allowed", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("max_devices_per_user", sa.Integer(), nullable=False, server_default="3"), + sa.Column("min_app_version", sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.execute( + sa.text( + "INSERT INTO mobile_settings (id, devices_allowed, max_devices_per_user) " + f"VALUES ({MOBILE_SETTINGS_ROW_ID}, false, 3)" + ) + ) + + op.create_table( + "mobile_enrollment_codes", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("label", sa.String(length=128), nullable=False, server_default=""), + sa.Column("code_hash", sa.String(length=64), nullable=False), + sa.Column("code_prefix", sa.String(length=16), nullable=False), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("target_user_id", sa.Integer(), nullable=True), + sa.Column("login_mode", sa.String(length=16), nullable=False, server_default="password"), + sa.Column("max_uses", sa.Integer(), nullable=False, server_default="1"), + sa.Column("use_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["sac_users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["target_user_id"], ["sac_users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mobile_enrollment_codes_code_hash", "mobile_enrollment_codes", ["code_hash"], unique=True) + + op.create_table( + "mobile_devices", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("device_uuid", sa.String(length=64), nullable=False), + sa.Column("display_name", sa.String(length=128), nullable=False, server_default=""), + sa.Column("platform", sa.String(length=32), nullable=False, server_default="android"), + sa.Column("app_version", sa.String(length=32), nullable=True), + sa.Column("fcm_token", sa.String(length=512), nullable=True), + sa.Column("fcm_token_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("enrollment_code_id", sa.Integer(), nullable=True), + sa.Column("enrolled_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["sac_users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["enrollment_code_id"], ["mobile_enrollment_codes.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mobile_devices_device_uuid", "mobile_devices", ["device_uuid"], unique=True) + op.create_index("ix_mobile_devices_user_id", "mobile_devices", ["user_id"]) + + op.create_table( + "mobile_refresh_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("device_id", sa.Integer(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["device_id"], ["mobile_devices.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mobile_refresh_tokens_token_hash", "mobile_refresh_tokens", ["token_hash"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_mobile_refresh_tokens_token_hash", table_name="mobile_refresh_tokens") + op.drop_table("mobile_refresh_tokens") + op.drop_index("ix_mobile_devices_user_id", table_name="mobile_devices") + op.drop_index("ix_mobile_devices_device_uuid", table_name="mobile_devices") + op.drop_table("mobile_devices") + op.drop_index("ix_mobile_enrollment_codes_code_hash", table_name="mobile_enrollment_codes") + op.drop_table("mobile_enrollment_codes") + op.drop_table("mobile_settings") + op.drop_column("notification_policy", "use_mobile") diff --git a/backend/alembic/versions/015_problem_resolved_by.py b/backend/alembic/versions/015_problem_resolved_by.py new file mode 100644 index 0000000..0ce3aa6 --- /dev/null +++ b/backend/alembic/versions/015_problem_resolved_by.py @@ -0,0 +1,26 @@ +"""problems.resolved_by — manual vs auto close for host_silence cooldown + +Revision ID: 015 +Revises: 014 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "015" +down_revision: Union[str, None] = "014" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "problems", + sa.Column("resolved_by", sa.String(length=16), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("problems", "resolved_by") diff --git a/backend/alembic/versions/016_agent_commands.py b/backend/alembic/versions/016_agent_commands.py new file mode 100644 index 0000000..e98387e --- /dev/null +++ b/backend/alembic/versions/016_agent_commands.py @@ -0,0 +1,46 @@ +"""Revision ID: 016 +Revises: 015 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "016" +down_revision: Union[str, None] = "015" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "agent_commands", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("command_uuid", sa.String(length=36), nullable=False), + sa.Column("host_id", sa.Integer(), nullable=False), + sa.Column("event_id", sa.Integer(), nullable=True), + sa.Column("command_type", sa.String(length=32), nullable=False), + sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"), + sa.Column("result_stdout", sa.Text(), nullable=True), + sa.Column("result_stderr", sa.Text(), nullable=True), + sa.Column("requested_by", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("command_uuid"), + ) + op.create_index("ix_agent_commands_command_uuid", "agent_commands", ["command_uuid"]) + op.create_index("ix_agent_commands_host_id", "agent_commands", ["host_id"]) + op.create_index("ix_agent_commands_status", "agent_commands", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_agent_commands_status", table_name="agent_commands") + op.drop_index("ix_agent_commands_host_id", table_name="agent_commands") + op.drop_index("ix_agent_commands_command_uuid", table_name="agent_commands") + op.drop_table("agent_commands") diff --git a/backend/alembic/versions/017_ui_settings_win_admin.py b/backend/alembic/versions/017_ui_settings_win_admin.py new file mode 100644 index 0000000..70ff7f7 --- /dev/null +++ b/backend/alembic/versions/017_ui_settings_win_admin.py @@ -0,0 +1,25 @@ +"""ui_settings: Windows domain admin for agent commands + +Revision ID: 017 +Revises: 016 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "017" +down_revision: Union[str, None] = "016" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("ui_settings", sa.Column("win_admin_user", sa.String(256), nullable=True)) + op.add_column("ui_settings", sa.Column("win_admin_password", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ui_settings", "win_admin_password") + op.drop_column("ui_settings", "win_admin_user") diff --git a/backend/alembic/versions/018_ui_settings_linux_admin.py b/backend/alembic/versions/018_ui_settings_linux_admin.py new file mode 100644 index 0000000..362d572 --- /dev/null +++ b/backend/alembic/versions/018_ui_settings_linux_admin.py @@ -0,0 +1,25 @@ +"""ui_settings: Linux SSH admin for remote agent updates + +Revision ID: 018 +Revises: 017 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "018" +down_revision: Union[str, None] = "017" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("ui_settings", sa.Column("linux_admin_user", sa.String(128), nullable=True)) + op.add_column("ui_settings", sa.Column("linux_admin_password", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ui_settings", "linux_admin_password") + op.drop_column("ui_settings", "linux_admin_user") diff --git a/backend/alembic/versions/019_host_ssh_admin_status.py b/backend/alembic/versions/019_host_ssh_admin_status.py new file mode 100644 index 0000000..9bb6e5b --- /dev/null +++ b/backend/alembic/versions/019_host_ssh_admin_status.py @@ -0,0 +1,25 @@ +"""hosts: persist Linux SSH admin check status + +Revision ID: 019 +Revises: 018 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "019" +down_revision: Union[str, None] = "018" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("hosts", sa.Column("ssh_admin_ok", sa.Boolean(), nullable=True)) + op.add_column("hosts", sa.Column("ssh_admin_checked_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("hosts", "ssh_admin_checked_at") + op.drop_column("hosts", "ssh_admin_ok") diff --git a/backend/alembic/versions/020_agent_control_plane.py b/backend/alembic/versions/020_agent_control_plane.py new file mode 100644 index 0000000..42d5f42 --- /dev/null +++ b/backend/alembic/versions/020_agent_control_plane.py @@ -0,0 +1,98 @@ +"""agent control plane: host update/config + ui agent-update settings + +Revision ID: 020 +Revises: 019 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision: str = "020" +down_revision: Union[str, None] = "019" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "hosts", + sa.Column("pending_agent_update", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + "hosts", + sa.Column("pending_update_requested_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "hosts", + sa.Column("pending_update_target_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "hosts", + sa.Column("agent_config", JSONB, nullable=True), + ) + op.add_column( + "hosts", + sa.Column("agent_config_revision", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column( + "hosts", + sa.Column("agent_update_state", sa.String(length=32), nullable=True), + ) + op.add_column( + "hosts", + sa.Column("agent_update_last_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column("hosts", sa.Column("agent_update_last_error", sa.Text(), nullable=True)) + + op.add_column( + "ui_settings", + sa.Column("agent_update_mode", sa.String(length=16), nullable=False, server_default="gpo"), + ) + op.add_column( + "ui_settings", + sa.Column("agent_update_fallback_enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "ui_settings", + sa.Column("agent_update_fallback_minutes", sa.Integer(), nullable=False, server_default="15"), + ) + op.add_column( + "ui_settings", + sa.Column("agent_recommended_rdp_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_recommended_ssh_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_min_rdp_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_min_ssh_version", sa.String(length=64), nullable=True), + ) + op.add_column("ui_settings", sa.Column("win_agent_update_script", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ui_settings", "win_agent_update_script") + op.drop_column("ui_settings", "agent_min_ssh_version") + op.drop_column("ui_settings", "agent_min_rdp_version") + op.drop_column("ui_settings", "agent_recommended_ssh_version") + op.drop_column("ui_settings", "agent_recommended_rdp_version") + op.drop_column("ui_settings", "agent_update_fallback_minutes") + op.drop_column("ui_settings", "agent_update_fallback_enabled") + op.drop_column("ui_settings", "agent_update_mode") + + op.drop_column("hosts", "agent_update_last_error") + op.drop_column("hosts", "agent_update_last_at") + op.drop_column("hosts", "agent_update_state") + op.drop_column("hosts", "agent_config_revision") + op.drop_column("hosts", "agent_config") + op.drop_column("hosts", "pending_update_target_version") + op.drop_column("hosts", "pending_update_requested_at") + op.drop_column("hosts", "pending_agent_update") diff --git a/backend/alembic/versions/021_agent_git_release.py b/backend/alembic/versions/021_agent_git_release.py new file mode 100644 index 0000000..dededd1 --- /dev/null +++ b/backend/alembic/versions/021_agent_git_release.py @@ -0,0 +1,51 @@ +"""agent git release repos and version cache + +Revision ID: 021 +Revises: 020 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "021" +down_revision: Union[str, None] = "020" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "ui_settings", + sa.Column("agent_rdp_git_repo_url", sa.String(length=512), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_ssh_git_repo_url", sa.String(length=512), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_git_branch", sa.String(length=64), nullable=False, server_default="main"), + ) + op.add_column( + "ui_settings", + sa.Column("agent_git_rdp_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_git_ssh_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_git_fetched_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("ui_settings", "agent_git_fetched_at") + op.drop_column("ui_settings", "agent_git_ssh_version") + op.drop_column("ui_settings", "agent_git_rdp_version") + op.drop_column("ui_settings", "agent_git_branch") + op.drop_column("ui_settings", "agent_ssh_git_repo_url") + op.drop_column("ui_settings", "agent_rdp_git_repo_url") diff --git a/backend/alembic/versions/022_host_remote_action.py b/backend/alembic/versions/022_host_remote_action.py new file mode 100644 index 0000000..d269569 --- /dev/null +++ b/backend/alembic/versions/022_host_remote_action.py @@ -0,0 +1,27 @@ +"""host remote_action JSON for background SSH/WinRM jobs + +Revision ID: 022_host_remote_action +Revises: 021 +Create Date: 2026-06-21 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "022_host_remote_action" +down_revision = "021" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "hosts", + sa.Column("remote_action", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("hosts", "remote_action") diff --git a/backend/alembic/versions/023_event_type_visibility.py b/backend/alembic/versions/023_event_type_visibility.py new file mode 100644 index 0000000..fbb6015 --- /dev/null +++ b/backend/alembic/versions/023_event_type_visibility.py @@ -0,0 +1,34 @@ +"""event type visibility settings + +Revision ID: 023_event_type_visibility +Revises: 022_host_remote_action +Create Date: 2026-06-21 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "023_event_type_visibility" +down_revision = "022_host_remote_action" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "event_type_visibility", + sa.Column("event_type", sa.String(length=128), nullable=False), + sa.Column("show_in_events", sa.Boolean(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("event_type"), + ) + + +def downgrade() -> None: + op.drop_table("event_type_visibility") diff --git a/backend/alembic/versions/024_login_security.py b/backend/alembic/versions/024_login_security.py new file mode 100644 index 0000000..a3800a0 --- /dev/null +++ b/backend/alembic/versions/024_login_security.py @@ -0,0 +1,28 @@ +"""login security whitelist in ui_settings + +Revision ID: 024_login_security +Revises: 023_event_type_visibility +Create Date: 2026-06-25 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "024_login_security" +down_revision = "023_event_type_visibility" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("ui_settings", sa.Column("login_ip_whitelist", sa.Text(), nullable=True)) + op.add_column( + "ui_settings", + sa.Column("login_sync_fail2ban", sa.Boolean(), server_default="false", nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("ui_settings", "login_sync_fail2ban") + op.drop_column("ui_settings", "login_ip_whitelist") diff --git a/backend/alembic/versions/025_auto_rdp_flap_disconnect.py b/backend/alembic/versions/025_auto_rdp_flap_disconnect.py new file mode 100644 index 0000000..a6102cb --- /dev/null +++ b/backend/alembic/versions/025_auto_rdp_flap_disconnect.py @@ -0,0 +1,26 @@ +"""auto RDP flap disconnect setting in ui_settings + +Revision ID: 025_auto_rdp_flap_disconnect +Revises: 024_login_security +Create Date: 2026-07-02 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "025_auto_rdp_flap_disconnect" +down_revision = "024_login_security" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ui_settings", + sa.Column("auto_rdp_flap_disconnect", sa.Boolean(), server_default="false", nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("ui_settings", "auto_rdp_flap_disconnect") diff --git a/backend/alembic/versions/026_host_silence_dedupe.py b/backend/alembic/versions/026_host_silence_dedupe.py new file mode 100644 index 0000000..e965c74 --- /dev/null +++ b/backend/alembic/versions/026_host_silence_dedupe.py @@ -0,0 +1,74 @@ +"""dedupe host_silence problems + unique active index + +Revision ID: 026_host_silence_dedupe +Revises: 025_auto_rdp_flap_disconnect +Create Date: 2026-07-03 + +""" + +from alembic import op + +revision = "026_host_silence_dedupe" +down_revision = "025_auto_rdp_flap_disconnect" +branch_labels = None +depends_on = None + + +def _dedupe_host_silence_problems() -> None: + op.execute( + """ + WITH ranked AS ( + SELECT + p.id, + ROW_NUMBER() OVER ( + PARTITION BY p.host_id + ORDER BY p.last_seen_at DESC, p.id DESC + ) AS rn + FROM problems p + WHERE p.rule_id = 'rule:host_silence' + AND p.status IN ('open', 'acknowledged') + ) + UPDATE problems + SET status = 'resolved', + resolved_by = 'auto', + updated_at = NOW() + WHERE id IN (SELECT id FROM ranked WHERE rn > 1) + """ + ) + op.execute( + """ + WITH ranked AS ( + SELECT + p.id, + ROW_NUMBER() OVER ( + PARTITION BY lower(h.hostname) + ORDER BY p.last_seen_at DESC, p.id DESC + ) AS rn + FROM problems p + JOIN hosts h ON h.id = p.host_id + WHERE p.rule_id = 'rule:host_silence' + AND p.status IN ('open', 'acknowledged') + ) + UPDATE problems + SET status = 'resolved', + resolved_by = 'auto', + updated_at = NOW() + WHERE id IN (SELECT id FROM ranked WHERE rn > 1) + """ + ) + + +def upgrade() -> None: + _dedupe_host_silence_problems() + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_problems_host_silence_active + ON problems (host_id) + WHERE rule_id = 'rule:host_silence' + AND status IN ('open', 'acknowledged') + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS uq_problems_host_silence_active") diff --git a/backend/alembic/versions/027_host_mgmt_credentials.py b/backend/alembic/versions/027_host_mgmt_credentials.py new file mode 100644 index 0000000..ec16f33 --- /dev/null +++ b/backend/alembic/versions/027_host_mgmt_credentials.py @@ -0,0 +1,25 @@ +"""per-host management credentials (SSH / WinRM override) + +Revision ID: 027_host_mgmt_credentials +Revises: 026_host_silence_dedupe +Create Date: 2026-07-14 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "027_host_mgmt_credentials" +down_revision = "026_host_silence_dedupe" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True)) + op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("hosts", "mgmt_password") + op.drop_column("hosts", "mgmt_user") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/agent.py b/backend/app/api/v1/agent.py new file mode 100644 index 0000000..320ac37 --- /dev/null +++ b/backend/app/api/v1/agent.py @@ -0,0 +1,76 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.api_key import get_api_key_auth +from app.database import get_db +from app.services.agent_commands import ( + complete_command, + resolve_host_by_agent_instance_id, +) +from app.services.agent_poll import build_agent_poll_payload + +router = APIRouter(prefix="/agent", tags=["agent"]) + + +class AgentCommandResultBody(BaseModel): + status: str = Field(pattern="^(completed|failed)$") + stdout: str | None = None + stderr: str | None = None + + +class AgentCommandsPollResponse(BaseModel): + config_revision: int = 0 + config: dict[str, Any] = Field(default_factory=dict) + update: dict[str, Any] = Field(default_factory=dict) + commands: list[dict[str, Any]] = Field(default_factory=list) + + +@router.get("/commands", response_model=AgentCommandsPollResponse) +def poll_agent_commands( + agent_instance_id: str = Query(..., min_length=1), + db: Session = Depends(get_db), + _api_key: str = Depends(get_api_key_auth), +) -> AgentCommandsPollResponse: + host = resolve_host_by_agent_instance_id(db, agent_instance_id) + if host is None: + raise HTTPException(status_code=404, detail="Unknown agent_instance_id") + payload = build_agent_poll_payload(db, host) + return AgentCommandsPollResponse(**payload) + + +@router.post("/commands/{command_uuid}/result") +def post_agent_command_result( + command_uuid: str, + body: AgentCommandResultBody, + db: Session = Depends(get_db), + _api_key: str = Depends(get_api_key_auth), +) -> dict[str, str]: + cmd = complete_command( + db, + command_uuid, + status=body.status, + stdout=body.stdout, + stderr=body.stderr, + ) + if cmd is None: + raise HTTPException(status_code=404, detail="Command not found") + db.commit() + return {"status": cmd.status, "command_uuid": cmd.command_uuid} + + +@router.get("/rdp-bundle/{token}") +def download_rdp_bundle(token: str) -> Response: + from app.services.rdp_bundle_delivery import get_rdp_bundle_zip + + data = get_rdp_bundle_zip(token) + if not data: + raise HTTPException(status_code=404, detail="Bundle not found or expired") + return Response( + content=data, + media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="sac-rdp-bundle.zip"'}, + ) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..0f2cb36 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user +from app.auth.stream_cookie import clear_stream_auth_cookie, set_stream_auth_cookie +from app.config import get_settings +from app.database import get_db +from app.services.login_rate_limit import ( + ensure_login_allowed, + record_login_failure, + record_login_success, +) +from app.services.user_auth import authenticate_user + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + username: str + role: str + + +class MeResponse(BaseModel): + username: str + role: str + + +@router.post("/login", response_model=TokenResponse) +def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse: + settings = get_settings() + if not settings.sac_admin_password and not _has_any_user(db): + raise HTTPException( + status_code=503, + detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)", + ) + + ip_address = ensure_login_allowed(db, request) + + user = authenticate_user(db, body.username, body.password) + if user is None: + record_login_failure(db, ip_address=ip_address, username=body.username) + db.commit() + raise HTTPException(status_code=401, detail="Invalid username or password") + + record_login_success(db, ip_address=ip_address, username=user.username) + db.commit() + + token = create_access_token(user.username, user.role) + payload = TokenResponse( + access_token=token, + username=user.username, + role=user.role, + ) + response = JSONResponse(content=payload.model_dump()) + set_stream_auth_cookie(response, token, settings) + return response + + +@router.post("/logout", status_code=204) +def logout() -> Response: + settings = get_settings() + response = Response(status_code=204) + clear_stream_auth_cookie(response, settings) + return response + + +@router.get("/me", response_model=MeResponse) +def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse: + return MeResponse(username=current_user.username, role=current_user.role) + + +def _has_any_user(db: Session) -> bool: + from app.models.user import User + + try: + count = db.scalar(select(func.count()).select_from(User)) or 0 + return count > 0 + except Exception: + db.rollback() + return False diff --git a/backend/app/api/v1/dashboards.py b/backend/app/api/v1/dashboards.py new file mode 100644 index 0000000..0cdf049 --- /dev/null +++ b/backend/app/api/v1/dashboards.py @@ -0,0 +1,167 @@ +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload + +from app.auth.jwt_auth import get_current_user +from app.config import get_settings +from app.database import get_db +from app.models import Event, Host, Problem +from app.schemas.list_models import EventSummary +from app.services.event_summary import event_to_summary +from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter +from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts + +router = APIRouter(prefix="/dashboards", tags=["dashboards"]) + + +def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]: + """Последние события по времени приёма ingest (received_at).""" + hidden = get_hidden_event_types(db) + type_filter = visibility_type_filter(hidden) + stmt = ( + select(Event) + .join(Host) + .options(joinedload(Event.host)) + .order_by(Event.received_at.desc()) + .limit(limit) + ) + if type_filter is not None: + stmt = stmt.where(type_filter) + rows = db.scalars(stmt).all() + return [event_to_summary(e, db) for e in rows] + + +class TopHostItem(BaseModel): + host_id: int + hostname: str + display_name: str | None = None + count: int + + +class TopTypeItem(BaseModel): + type: str + count: int + + +class DashboardSummary(BaseModel): + events_last_24h: int + hosts_total: int + hosts_stale: int + heartbeats_24h: int + daily_reports_24h: int + problems_open: int + problems_opened_24h: int + problems_resolved_24h: int + severity_24h: dict[str, int] + top_hosts: list[TopHostItem] + top_event_types: list[TopTypeItem] + recent_events: list[EventSummary] + + +@router.get("/summary", response_model=DashboardSummary) +def dashboard_summary( + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> DashboardSummary: + since = datetime.now(timezone.utc) - timedelta(hours=24) + hidden = get_hidden_event_types(db) + type_filter = visibility_type_filter(hidden) + + events_24h_stmt = select(func.count()).select_from(Event).where(Event.received_at >= since) + if type_filter is not None: + events_24h_stmt = events_24h_stmt.where(type_filter) + events_24h = db.scalar(events_24h_stmt) or 0 + hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0 + settings = get_settings() + hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes) + heartbeats_24h = db.scalar( + select(func.count()) + .select_from(Event) + .where(Event.received_at >= since, Event.type == HEARTBEAT_TYPE) + ) or 0 + daily_reports_24h = db.scalar( + select(func.count()) + .select_from(Event) + .where(Event.received_at >= since, Event.type.in_(DAILY_REPORT_TYPES)) + ) or 0 + problems_open = ( + db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0 + ) + problems_opened_24h = ( + db.scalar( + select(func.count()).select_from(Problem).where( + Problem.created_at >= since, + Problem.status != "resolved", + ) + ) + or 0 + ) + problems_resolved_24h = ( + db.scalar( + select(func.count()).select_from(Problem).where( + Problem.status == "resolved", + Problem.updated_at >= since, + ) + ) + or 0 + ) + + top_host_stmt = ( + select(Host.id, Host.hostname, Host.display_name, func.count()) + .select_from(Event) + .join(Host, Event.host_id == Host.id) + .where(Event.received_at >= since) + ) + if type_filter is not None: + top_host_stmt = top_host_stmt.where(type_filter) + top_host_rows = db.execute( + top_host_stmt.group_by(Host.id, Host.hostname, Host.display_name) + .order_by(func.count().desc()) + .limit(10) + ).all() + top_hosts = [ + TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows + ] + + top_type_stmt = select(Event.type, func.count()).where(Event.received_at >= since) + if type_filter is not None: + top_type_stmt = top_type_stmt.where(type_filter) + top_type_rows = db.execute( + top_type_stmt.group_by(Event.type).order_by(func.count().desc()).limit(10) + ).all() + top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows] + + severity_stmt = select(Event.severity, func.count()).where(Event.received_at >= since) + if type_filter is not None: + severity_stmt = severity_stmt.where(type_filter) + severity_rows = db.execute(severity_stmt.group_by(Event.severity)).all() + severity_24h = {row[0]: row[1] for row in severity_rows} + + recent_events = fetch_recent_events(db, limit=8) + + return DashboardSummary( + events_last_24h=events_24h, + hosts_total=hosts_total, + hosts_stale=hosts_stale, + heartbeats_24h=heartbeats_24h, + daily_reports_24h=daily_reports_24h, + problems_open=problems_open, + problems_opened_24h=problems_opened_24h, + problems_resolved_24h=problems_resolved_24h, + severity_24h=severity_24h, + top_hosts=top_hosts, + top_event_types=top_event_types, + recent_events=recent_events, + ) + + +@router.get("/recent-events", response_model=list[EventSummary]) +def dashboard_recent_events( + limit: int = Query(8, ge=1, le=50), + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> list[EventSummary]: + return fetch_recent_events(db, limit=limit) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py new file mode 100644 index 0000000..dcdad07 --- /dev/null +++ b/backend/app/api/v1/events.py @@ -0,0 +1,390 @@ +import logging +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload + +from app.auth.api_key import get_api_key_auth +from app.auth.jwt_auth import get_current_user, CurrentUser +from app.config import get_settings +from app.database import get_db +from app.models import Event, Host +from app.schemas.list_models import EventDetail, EventListResponse, EventSummary +from app.utils.sql_like import escape_ilike_pattern +from app.services.agent_commands import ( + command_response_fields, + command_to_dict, + get_command_by_uuid, +) +from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm +from app.services.host_sessions import ( + event_session_id, + event_session_terminated, + event_supports_session_terminate, + mark_event_session_terminated, + terminate_session_for_event, +) +from app.services.linux_admin_settings import get_effective_linux_admin_for_host +from app.services.ssh_connect import ( + HostNotLinuxError as SshHostNotLinuxError, + HostTargetMissingError as SshHostTargetMissingError, +) +from app.services.win_admin_settings import get_effective_win_admin_for_host +from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError +from app.services.ingest import ingest_event +from app.services.event_summary import event_to_summary +from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter +from app.services.problems import maybe_create_problem +from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session +from app.services.schema_validate import validate_event_payload +from app.services.notify_dispatch import ( + AUTH_LOGIN_SUCCESS_TYPES, + DAILY_REPORT_EVENT_TYPES, + LIFECYCLE_EVENT_TYPE, + RDG_CONNECTION_TYPES, + notify_auth_login, + notify_daily_report, + notify_event, + notify_lifecycle, + notify_problem, + notify_rdg_connection, + schedule_notify_auth_login, + schedule_notify_daily_report, + schedule_notify_lifecycle, +) + +router = APIRouter(prefix="/events", tags=["events"]) +logger = logging.getLogger("sac.ingest") + + +class IngestResponse(BaseModel): + status: str + event_id: str + created: bool + sac_event_url: str + problem_id: int | None = None + + +def _ingest_response(event: Event, *, created: bool) -> IngestResponse: + settings = get_settings() + base = settings.sac_public_url.rstrip("/") + return IngestResponse( + status="created" if created else "duplicate", + event_id=event.event_id, + created=created, + sac_event_url=f"{base}/api/v1/events/{event.id}", + problem_id=None, + ) + + +@router.post("", response_model=IngestResponse) +def post_event( + background_tasks: BackgroundTasks, + payload: dict[str, Any] = Body(...), + db: Session = Depends(get_db), + _api_key: str = Depends(get_api_key_auth), +) -> JSONResponse: + errors = validate_event_payload(payload) + if errors: + event_id_hint = payload.get("event_id") if isinstance(payload.get("event_id"), str) else None + logger.warning( + "ingest rejected status=422 event_id=%s errors=%s", + event_id_hint, + errors[:5], + ) + raise HTTPException(status_code=422, detail={"schema_errors": errors[:20]}) + + event, created = ingest_event(db, payload) + problem = None + problem_created = False + deferred_daily_report_id: int | None = None + deferred_lifecycle_id: int | None = None + deferred_auth_login_id: int | None = None + if created: + problem, problem_created = maybe_create_problem(db, event) + maybe_auto_disconnect_stuck_rdp_session(db, event) + if event.type in DAILY_REPORT_EVENT_TYPES: + deferred_daily_report_id = event.id + elif event.type == LIFECYCLE_EVENT_TYPE: + deferred_lifecycle_id = event.id + elif event.type in AUTH_LOGIN_SUCCESS_TYPES: + deferred_auth_login_id = event.id + elif event.type in RDG_CONNECTION_TYPES: + notify_rdg_connection(event, db=db) + else: + notify_event(event, db=db) + if problem is not None and problem_created: + notify_problem(problem, event, db=db) + logger.info("ingest created event_id=%s type=%s host_id=%s", event.event_id, event.type, event.host_id) + else: + logger.info("ingest duplicate event_id=%s", event.event_id) + db.commit() + + if deferred_daily_report_id is not None: + background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id) + if deferred_lifecycle_id is not None: + background_tasks.add_task(schedule_notify_lifecycle, deferred_lifecycle_id) + if deferred_auth_login_id is not None: + background_tasks.add_task(schedule_notify_auth_login, deferred_auth_login_id) + + body = _ingest_response(event, created=created) + if problem is not None: + body.problem_id = problem.id + status_code = 201 if created else 409 + return JSONResponse(status_code=status_code, content=body.model_dump(mode="json")) + + +def _parse_optional_dt(value: str | None, *, end_of_day: bool = False) -> datetime | None: + if not value: + return None + raw = value.strip() + if len(raw) == 10 and raw[4] == "-" and raw[7] == "-": + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + if end_of_day: + return dt.replace(hour=23, minute=59, second=59, microsecond=999999) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + + +@router.get("", response_model=EventListResponse) +def list_events( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + severity: str | None = None, + type: str | None = None, + host_id: int | None = None, + hostname: str | None = None, + include_hidden: bool = False, + from_time: str | None = Query(None, alias="from"), + to_time: str | None = Query(None, alias="to"), + q: str | None = None, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> EventListResponse: + if include_hidden and host_id is None: + raise HTTPException( + status_code=400, + detail="include_hidden requires host_id", + ) + hidden = get_hidden_event_types(db) + type_filter = visibility_type_filter(hidden) + if include_hidden and host_id is not None: + type_filter = None + stmt = select(Event).join(Host).options(joinedload(Event.host)) + count_stmt = select(func.count()).select_from(Event).join(Host) + if type_filter is not None: + stmt = stmt.where(type_filter) + count_stmt = count_stmt.where(type_filter) + + if severity: + stmt = stmt.where(Event.severity == severity) + count_stmt = count_stmt.where(Event.severity == severity) + if type: + normalized = type.strip() + if normalized: + stmt = stmt.where(Event.type.ilike(f"{normalized}%")) + count_stmt = count_stmt.where(Event.type.ilike(f"{normalized}%")) + if host_id is not None: + stmt = stmt.where(Event.host_id == host_id) + count_stmt = count_stmt.where(Event.host_id == host_id) + if hostname: + like = f"%{escape_ilike_pattern(hostname)}%" + stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) + count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) + dt_from = _parse_optional_dt(from_time) + dt_to = _parse_optional_dt(to_time, end_of_day=True) + if dt_from: + stmt = stmt.where(Event.occurred_at >= dt_from) + count_stmt = count_stmt.where(Event.occurred_at >= dt_from) + if dt_to: + stmt = stmt.where(Event.occurred_at <= dt_to) + count_stmt = count_stmt.where(Event.occurred_at <= dt_to) + if q: + like = f"%{escape_ilike_pattern(q)}%" + stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\")) + count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\")) + + total = db.scalar(count_stmt) or 0 + rows = db.scalars( + stmt.order_by(Event.occurred_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + + items = [event_to_summary(e, db) for e in rows] + return EventListResponse(items=items, total=total, page=page, page_size=page_size) + + +class AgentCommandResponse(BaseModel): + command_uuid: str + command_type: str + status: str + result_stdout: str | None = None + result_stderr: str | None = None + created_at: str | None = None + completed_at: str | None = None + target: str | None = None + client_hostname: str | None = None + internal_ip: str | None = None + + +def _agent_command_response(cmd) -> AgentCommandResponse: + data = command_to_dict(cmd) + extra = command_response_fields(cmd) + return AgentCommandResponse( + command_uuid=data["id"], + command_type=data["type"], + status=data["status"], + result_stdout=data.get("result_stdout"), + result_stderr=data.get("result_stderr"), + created_at=data.get("created_at"), + completed_at=data.get("completed_at"), + target=extra.get("target"), + client_hostname=extra.get("client_hostname"), + internal_ip=extra.get("internal_ip"), + ) + + +class LogoffActionBody(BaseModel): + session_id: int + + +class EventSessionTerminateBody(BaseModel): + session_id: str | None = None + + +class EventSessionTerminateResponse(BaseModel): + ok: bool + message: str + target: str | None = None + stdout: str | None = None + stderr: str | None = None + + +@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse) +def post_event_terminate_session( + event_db_id: int, + body: EventSessionTerminateBody | None = Body(default=None), + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> EventSessionTerminateResponse: + event = db.scalar( + select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id) + ) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if not event_supports_session_terminate(event): + raise HTTPException(status_code=400, detail="Event type does not support session terminate") + if event_session_terminated(event, db=db): + raise HTTPException(status_code=409, detail="Session already terminated for this event") + + if event.host is None: + raise HTTPException(status_code=400, detail="Event has no host") + linux_cfg = get_effective_linux_admin_for_host(db, event.host) + win_cfg = get_effective_win_admin_for_host(db, event.host) + sid = (body.session_id if body else None) or event_session_id(event) + + try: + result = terminate_session_for_event( + event, + linux_cfg=linux_cfg, + win_cfg=win_cfg, + session_id=sid, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HostNotWindowsError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostNotLinuxError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + response = EventSessionTerminateResponse( + ok=result.ok, + message=result.message, + target=getattr(result, "target", None), + stdout=getattr(result, "stdout", None) or None, + stderr=getattr(result, "stderr", None) or None, + ) + if result.ok: + mark_event_session_terminated(event, by_username=user.username) + db.commit() + return response + + +@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse) +def post_event_qwinsta( + event_db_id: int, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> AgentCommandResponse: + event = db.get(Event, event_db_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + cmd = execute_qwinsta_via_winrm(db, event, requested_by=user.username) + db.commit() + return _agent_command_response(cmd) + + +@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse) +def post_event_logoff( + event_db_id: int, + body: LogoffActionBody, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> AgentCommandResponse: + event = db.get(Event, event_db_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + cmd = execute_logoff_via_winrm( + db, + event, + session_id=body.session_id, + requested_by=user.username, + ) + db.commit() + return _agent_command_response(cmd) + + +@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse) +def get_event_action_status( + event_db_id: int, + command_uuid: str, + db: Session = Depends(get_db), + _user=Depends(get_current_user), +) -> AgentCommandResponse: + cmd = get_command_by_uuid(db, command_uuid) + if cmd is None or cmd.event_id != event_db_id: + raise HTTPException(status_code=404, detail="Command not found") + return _agent_command_response(cmd) + + +@router.get("/{event_db_id}", response_model=EventDetail) +def get_event( + event_db_id: int, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> EventDetail: + event = db.scalar( + select(Event).where(Event.id == event_db_id).options(joinedload(Event.host)) + ) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + base = event_to_summary(event, db) + return EventDetail( + **base.model_dump(), + details=event.details, + raw=event.raw, + dedup_key=event.dedup_key, + correlation_id=event.correlation_id, + payload=event.payload, + ) diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..41fb2df --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import func, select, text +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.database import get_db +from app.models import Event +from app.services.host_health import count_stale_hosts +from app.version import APP_NAME, APP_VERSION + +router = APIRouter(tags=["health"]) + + +def build_health_payload(db: Session) -> dict: + db_status = "ok" + try: + db.execute(text("SELECT 1")) + except Exception: + db_status = "error" + + hosts_stale = 0 + last_event_received_at: str | None = None + if db_status == "ok": + settings = get_settings() + hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes) + last_received = db.scalar(select(func.max(Event.received_at))) + if last_received is not None: + last_event_received_at = last_received.isoformat() + + overall = "ok" if db_status == "ok" else "degraded" + if db_status == "ok" and hosts_stale > 0: + overall = "degraded" + + return { + "status": overall, + "database": db_status, + "service": "security-alert-center", + "name": APP_NAME, + "version": APP_VERSION, + "hosts_stale": hosts_stale, + "last_event_received_at": last_event_received_at, + } + + +@router.get("/health") +def health(db: Session = Depends(get_db)) -> dict: + return build_health_payload(db) + + +@router.get("/healthz") +def healthz(db: Session = Depends(get_db)) -> dict: + """Kubernetes-style alias; same payload as /health.""" + return build_health_payload(db) diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py new file mode 100644 index 0000000..1cd70a7 --- /dev/null +++ b/backend/app/api/v1/hosts.py @@ -0,0 +1,932 @@ +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from datetime import datetime, timezone + +from app.auth.jwt_auth import get_current_user, require_admin +from app.config import get_settings +from app.database import get_db +from app.models import Event, Host +from app.schemas.list_models import HostDetail, HostListResponse, HostSummary +from app.utils.sql_like import escape_ilike_pattern +from app.services.agent_version import ( + latest_agent_versions_by_product, + reference_agent_versions_by_product, +) +from app.services.agent_git_release import get_git_release_versions +from app.services.agent_update import ( + AgentUpdateNotAllowedError, + host_version_status, + request_agent_update, +) +from app.services.host_remote_actions import ( + RemoteActionAlreadyRunningError, + cancel_host_remote_action, + clear_stale_running_remote_actions, + get_remote_action_status, + run_agent_fallback_action, + run_ssh_monitor_update_action, + start_host_remote_action, +) +from app.services.agent_update_settings import ( + PRODUCT_RDP, + PRODUCT_SSH, + get_effective_agent_update_config, +) +from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config +from app.services.host_sessions import ( + HostSessionRow, + list_linux_sessions, + list_windows_sessions, + terminate_linux_session, + terminate_windows_session, +) +from app.services.host_delete import delete_host_and_related +from app.services.host_mgmt_credentials import ( + get_host_mgmt_access_view, + upsert_host_mgmt_credentials, +) +from app.services.linux_admin_settings import get_effective_linux_admin_for_host +from app.services.win_admin_settings import get_effective_win_admin_for_host +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError, + WinAdminNotConfiguredError, + WinRmTestResult, + iter_winrm_targets, + test_winrm_connection, +) +from app.services.ssh_connect import ( + HostNotLinuxError as SshHostNotLinuxError, + HostTargetMissingError as SshHostTargetMissingError, + LinuxAdminNotConfiguredError, + SshCommandResult, + iter_ssh_targets, + run_ssh_monitor_update, + test_ssh_connection, +) +from app.services.host_health import ( + HEARTBEAT_TYPE, + agent_status as compute_agent_status, + apply_agent_status_host_filter, + max_daily_report_time_by_host, + max_event_time_by_host, +) +from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter + +router = APIRouter(prefix="/hosts", tags=["hosts"]) + + +def _count_visible_events(db: Session, host_id: int, hidden: frozenset[str]) -> int: + stmt = select(func.count()).select_from(Event).where(Event.host_id == host_id) + type_filter = visibility_type_filter(hidden) + if type_filter is not None: + stmt = stmt.where(type_filter) + return int(db.scalar(stmt) or 0) + + +@router.get("", response_model=HostListResponse) +def list_hosts( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + product: str | None = None, + hostname: str | None = None, + agent_status: str | None = Query(None, pattern="^(online|stale|unknown)$"), + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> HostListResponse: + stmt = select(Host) + count_stmt = select(func.count()).select_from(Host) + + if product: + stmt = stmt.where(Host.product == product) + count_stmt = count_stmt.where(Host.product == product) + if hostname: + like = f"%{escape_ilike_pattern(hostname)}%" + host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\") + stmt = stmt.where(host_match) + count_stmt = count_stmt.where(host_match) + + settings = get_settings() + if agent_status: + stmt, count_stmt = apply_agent_status_host_filter( + stmt, + count_stmt, + agent_status, + stale_minutes=settings.sac_heartbeat_stale_minutes, + ) + + total = db.scalar(count_stmt) or 0 + rows = db.scalars( + stmt.order_by(Host.last_seen_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + + hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) + report_map = max_daily_report_time_by_host(db) + latest_map = latest_agent_versions_by_product(db) + update_cfg = get_effective_agent_update_config(db) + git_release = get_git_release_versions(update_cfg, db=db) + reference_map = reference_agent_versions_by_product( + update_cfg, + latest_map, + git_versions=git_release.versions, + ) + hidden = get_hidden_event_types(db) + + items: list[HostSummary] = [] + for host in rows: + event_count = _count_visible_events(db, host.id, hidden) + last_hb = hb_map.get(host.id) + items.append( + HostSummary( + id=host.id, + hostname=host.hostname, + display_name=host.display_name, + os_family=host.os_family, + os_version=host.os_version, + product=host.product, + product_version=host.product_version, + ipv4=host.ipv4, + last_seen_at=host.last_seen_at, + event_count=int(event_count or 0), + last_heartbeat_at=last_hb, + last_daily_report_at=report_map.get(host.id), + last_inventory_at=host.inventory_updated_at, + agent_status=compute_agent_status( + last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes + ), + version_status=host_version_status( + host, + cfg=update_cfg, + latest_map=latest_map, + git_versions=git_release.versions, + ), + pending_agent_update=bool(host.pending_agent_update), + ) + ) + + return HostListResponse( + items=items, + total=total, + page=page, + page_size=page_size, + latest_agent_versions=latest_map, + reference_agent_versions=reference_map, + git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None, + git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None, + ) + + +class HostManualAddBody(BaseModel): + platform: Literal["windows", "linux"] + target: str = Field(..., min_length=1, max_length=253) + + +def _host_detail_from_model( + host: Host, + *, + db: Session, + settings, +) -> HostDetail: + hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) + report_map = max_daily_report_time_by_host(db) + hidden = get_hidden_event_types(db) + event_count = _count_visible_events(db, host.id, hidden) + last_hb = hb_map.get(host.id) + latest_map = latest_agent_versions_by_product(db) + update_cfg = get_effective_agent_update_config(db) + git_release = get_git_release_versions(update_cfg, db=db) + return HostDetail( + id=host.id, + hostname=host.hostname, + display_name=host.display_name, + os_family=host.os_family, + os_version=host.os_version, + product=host.product, + product_version=host.product_version, + ipv4=host.ipv4, + ipv6=host.ipv6, + last_seen_at=host.last_seen_at, + event_count=int(event_count or 0), + last_heartbeat_at=last_hb, + last_daily_report_at=report_map.get(host.id), + last_inventory_at=host.inventory_updated_at, + agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes), + version_status=host_version_status( + host, + cfg=update_cfg, + latest_map=latest_map, + git_versions=git_release.versions, + ), + pending_agent_update=bool(host.pending_agent_update), + agent_instance_id=host.agent_instance_id, + tags=host.tags if isinstance(host.tags, list) else [], + use_sac_mode=host.use_sac_mode, + inventory=host.inventory if isinstance(host.inventory, dict) else None, + inventory_updated_at=host.inventory_updated_at, + created_at=host.created_at, + ssh_admin_ok=host.ssh_admin_ok, + ssh_admin_checked_at=host.ssh_admin_checked_at, + pending_update_requested_at=host.pending_update_requested_at, + pending_update_target_version=host.pending_update_target_version, + agent_config_revision=int(host.agent_config_revision or 0), + agent_config=get_host_agent_settings(host) or None, + agent_update_state=host.agent_update_state, + agent_update_last_at=host.agent_update_last_at, + agent_update_last_error=host.agent_update_last_error, + ) + + +def _set_ssh_admin_status(host: Host, ok: bool) -> None: + host.ssh_admin_ok = ok + host.ssh_admin_checked_at = datetime.now(timezone.utc) + + +@router.get("/{host_id}", response_model=HostDetail) +def get_host( + host_id: int, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> HostDetail: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + settings = get_settings() + return _host_detail_from_model(host, db=db, settings=settings) + + +class HostDeleteResponse(BaseModel): + status: str + host_id: int + hostname: str + deleted_events: int + deleted_problems: int + + +class HostWinRmTestResponse(BaseModel): + ok: bool + message: str + target: str + hostname: str | None = None + + +class HostSshActionResponse(BaseModel): + ok: bool + message: str + target: str + stdout: str | None = None + stderr: str | None = None + exit_code: int | None = None + product_version: str | None = None + ssh_admin_ok: bool | None = None + + +class HostAgentUpdateRequestResponse(BaseModel): + status: str + pending_agent_update: bool + target_version: str | None = None + agent_update_state: str | None = None + + +class HostAgentConfigUpdate(BaseModel): + settings: dict[str, object] = {} + + +class HostAgentConfigResponse(BaseModel): + config_revision: int + settings: dict[str, object] + + +class HostMgmtAccessResponse(BaseModel): + host_id: int + has_override: bool + user: str | None = None + password_set: bool = False + password_hint: str | None = None + effective_source: str + effective_configured: bool + effective_user: str | None = None + + +class HostMgmtAccessUpdate(BaseModel): + user: str | None = None + password: str | None = None + clear: bool = False + + +class HostRemoteActionStartResponse(BaseModel): + status: str + host_id: int + title: str + message: str + + +@router.post( + "/manual-add", + response_model=HostRemoteActionStartResponse, + status_code=status.HTTP_202_ACCEPTED, +) +def post_host_manual_add( + body: HostManualAddBody, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostRemoteActionStartResponse: + from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add + + try: + host = prepare_manual_host_add(db, platform=body.platform, target=body.target) + except ManualHostAddError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if body.platform == "windows": + title = f"Ручное добавление Windows: {host.hostname}" + else: + title = f"Ручное добавление Linux: {host.hostname}" + + try: + start_host_remote_action( + db, + host, + title=title, + runner=run_agent_fallback_action, + ) + except RemoteActionAlreadyRunningError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + return HostRemoteActionStartResponse( + status="running", + host_id=host.id, + title=title, + message="Проверка пройдена, установка агента запущена на сервере SAC", + ) + + +class HostRemoteActionJobResponse(BaseModel): + active: bool + host_id: int + hostname: str | None = None + status: str | None = None + title: str = "" + message: str = "" + stdout: str | None = None + stderr: str | None = None + output: str | None = None + ok: bool | None = None + exit_code: int | None = None + product_version: str | None = None + target: str | None = None + agent_update_state: str | None = None + started_at: str | None = None + finished_at: str | None = None + + +class HostSessionItem(BaseModel): + session_id: str + user: str + tty: str | None = None + state: str | None = None + source_ip: str | None = None + session_name: str | None = None + + +class HostSessionsResponse(BaseModel): + ok: bool + message: str + target: str | None = None + sessions: list[HostSessionItem] + + +class HostSessionTerminateBody(BaseModel): + session_id: str + + +class HostSessionTerminateResponse(BaseModel): + ok: bool + message: str + target: str | None = None + stdout: str | None = None + stderr: str | None = None + + +def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]: + return [ + HostSessionItem( + session_id=r.session_id, + user=r.user, + tty=r.tty, + state=r.state, + source_ip=r.source_ip, + session_name=r.session_name, + ) + for r in rows + ] + + +def _ssh_action_response( + result: SshCommandResult, + *, + attempts: list[str] | None = None, + ssh_admin_ok: bool | None = None, +) -> HostSshActionResponse: + message = result.message + if attempts: + message = f"{message} (пробовали: {', '.join(attempts)})" + return HostSshActionResponse( + ok=result.ok, + message=message, + target=result.target, + stdout=result.stdout or None, + stderr=result.stderr or None, + exit_code=result.exit_code, + product_version=result.agent_version, + ssh_admin_ok=ssh_admin_ok, + ) + + +def _run_ssh_action_on_host( + host: Host, + cfg, + *, + action, +) -> HostSshActionResponse: + try: + targets = iter_ssh_targets(host) + except SshHostNotLinuxError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + last_result: SshCommandResult | None = None + attempts: list[str] = [] + for target in targets: + try: + result = action(target=target, user=cfg.user, password=cfg.password) + except LinuxAdminNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + last_result = result + attempts.append(f"{target}={'OK' if result.ok else 'fail'}") + if result.ok: + return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None) + + assert last_result is not None + return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None) + + +@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse) +def test_host_winrm( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostWinRmTestResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + cfg = get_effective_win_admin_for_host(db, host) + if not cfg.configured: + raise HTTPException( + status_code=400, + detail="Windows admin is not configured (host override or Settings → Windows)", + ) + + try: + targets = iter_winrm_targets(host) + except HostNotWindowsError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + last_result = None + attempts: list[str] = [] + for target in targets: + try: + result = test_winrm_connection( + target=target, + user=cfg.user, + password=cfg.password, + ) + except WinAdminNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + last_result = result + attempts.append(f"{target}={'OK' if result.ok else 'fail'}") + if result.ok: + if len(targets) > 1: + result = WinRmTestResult( + ok=True, + message=f"{result.message} (пробовали: {', '.join(attempts)})", + target=result.target, + hostname=result.hostname, + ) + return HostWinRmTestResponse( + ok=result.ok, + message=result.message, + target=result.target, + hostname=result.hostname, + ) + + assert last_result is not None + message = last_result.message + if len(attempts) > 1: + message = f"{message} (пробовали: {', '.join(attempts)})" + return HostWinRmTestResponse( + ok=False, + message=message, + target=last_result.target, + hostname=last_result.hostname, + ) + + +@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse) +def test_host_ssh( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostSshActionResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + cfg = get_effective_linux_admin_for_host(db, host) + if not cfg.configured: + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) + + response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) + _set_ssh_admin_status(host, response.ok) + db.commit() + return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok}) + + +@router.post( + "/{host_id}/actions/agent-update", + response_model=HostRemoteActionStartResponse, + status_code=status.HTTP_202_ACCEPTED, +) +def update_host_agent_via_ssh( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostRemoteActionStartResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + cfg = get_effective_linux_admin_for_host(db, host) + if not cfg.configured: + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) + + title = "Обновление ssh-monitor (SSH)" + try: + start_host_remote_action( + db, + host, + title=title, + runner=run_ssh_monitor_update_action, + poll_remote_log=True, + ) + except RemoteActionAlreadyRunningError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return HostRemoteActionStartResponse( + status="running", + host_id=host.id, + title=title, + message="Обновление запущено на сервере SAC и продолжится в фоне", + ) + + +@router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse) +def post_request_agent_update( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostAgentUpdateRequestResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + try: + request_agent_update(db, host) + except AgentUpdateNotAllowedError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + db.commit() + return HostAgentUpdateRequestResponse( + status="requested", + pending_agent_update=host.pending_agent_update, + target_version=host.pending_update_target_version, + agent_update_state=host.agent_update_state, + ) + + +@router.post( + "/{host_id}/actions/agent-update-fallback", + response_model=HostRemoteActionStartResponse, + status_code=status.HTTP_202_ACCEPTED, +) +def post_agent_update_fallback( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostRemoteActionStartResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH + from app.services.ssh_connect import is_linux_host + from app.services.winrm_connect import is_windows_host + + product = (host.product or "").strip() + if product == PRODUCT_RDP or is_windows_host(host): + title = "Обновление через WinRM" + elif product == PRODUCT_SSH or is_linux_host(host): + title = "Fallback SSH (ssh-monitor)" + else: + title = "Обновление агента (fallback)" + + try: + start_host_remote_action( + db, + host, + title=title, + runner=run_agent_fallback_action, + ) + except RemoteActionAlreadyRunningError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return HostRemoteActionStartResponse( + status="running", + host_id=host.id, + title=title, + message="Обновление запущено на сервере SAC и продолжится в фоне", + ) + + +@router.get("/{host_id}/actions/remote-job", response_model=HostRemoteActionJobResponse) +def get_host_remote_job( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostRemoteActionJobResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + db.refresh(host) + return HostRemoteActionJobResponse(**get_remote_action_status(host)) + + +@router.post("/{host_id}/actions/remote-job/cancel", response_model=HostRemoteActionJobResponse) +def cancel_host_remote_job( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostRemoteActionJobResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + if not cancel_host_remote_action(db, host): + raise HTTPException(status_code=409, detail="No running remote action for this host") + db.refresh(host) + return HostRemoteActionJobResponse(**get_remote_action_status(host)) + + +@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse) +def list_host_sessions( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostSessionsResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + from app.services.ssh_connect import is_linux_host + from app.services.winrm_connect import is_windows_host + + if is_linux_host(host): + cfg = get_effective_linux_admin_for_host(db, host) + if not cfg.configured: + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) + try: + sessions, result = list_linux_sessions(host, cfg) + except SshHostNotLinuxError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return HostSessionsResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + sessions=_session_items(sessions), + ) + + if is_windows_host(host): + cfg = get_effective_win_admin_for_host(db, host) + if not cfg.configured: + raise HTTPException( + status_code=400, + detail="Windows admin is not configured (host override or Settings → Windows)", + ) + try: + sessions, result = list_windows_sessions(host, cfg) + except HostNotWindowsError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result is None: + raise HTTPException(status_code=502, detail="WinRM qwinsta failed") + return HostSessionsResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + sessions=_session_items(sessions), + ) + + raise HTTPException(status_code=400, detail="Host OS does not support live sessions") + + +@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse) +def terminate_host_session( + host_id: int, + body: HostSessionTerminateBody, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostSessionTerminateResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + from app.services.ssh_connect import is_linux_host + from app.services.winrm_connect import is_windows_host + + sid = body.session_id.strip() + if not sid: + raise HTTPException(status_code=422, detail="session_id is required") + + if is_linux_host(host): + cfg = get_effective_linux_admin_for_host(db, host) + if not cfg.configured: + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) + try: + result = terminate_linux_session(host, cfg, sid) + except SshHostNotLinuxError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return HostSessionTerminateResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + stdout=result.stdout or None, + stderr=result.stderr or None, + ) + + if is_windows_host(host): + cfg = get_effective_win_admin_for_host(db, host) + if not cfg.configured: + raise HTTPException( + status_code=400, + detail="Windows admin is not configured (host override or Settings → Windows)", + ) + try: + result = terminate_windows_session(host, cfg, sid) + except HostNotWindowsError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result is None: + raise HTTPException(status_code=502, detail="WinRM logoff failed") + return HostSessionTerminateResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + stdout=result.stdout or None, + stderr=result.stderr or None, + ) + + raise HTTPException(status_code=400, detail="Host OS does not support session terminate") + + +@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse) +def patch_host_agent_config( + host_id: int, + body: HostAgentConfigUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostAgentConfigResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + try: + update_host_agent_config(db, host, body.settings) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + db.commit() + settings = get_host_agent_settings(host) + return HostAgentConfigResponse( + config_revision=int(host.agent_config_revision or 0), + settings=settings, + ) + + +@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse) +def get_host_access( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostMgmtAccessResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + view = get_host_mgmt_access_view(db, host) + return HostMgmtAccessResponse( + host_id=view.host_id, + has_override=view.has_override, + user=view.user, + password_set=view.password_set, + password_hint=view.password_hint, + effective_source=view.effective_source, + effective_configured=view.effective_configured, + effective_user=view.effective_user, + ) + + +@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse) +def put_host_access( + host_id: int, + body: HostMgmtAccessUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostMgmtAccessResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + view = upsert_host_mgmt_credentials( + db, + host, + user=body.user, + password=body.password, + clear=body.clear, + ) + return HostMgmtAccessResponse( + host_id=view.host_id, + has_override=view.has_override, + user=view.user, + password_set=view.password_set, + password_hint=view.password_hint, + effective_source=view.effective_source, + effective_configured=view.effective_configured, + effective_user=view.effective_user, + ) + + +@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse) +def get_host_agent_config( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostAgentConfigResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + settings = get_host_agent_settings(host) + return HostAgentConfigResponse( + config_revision=int(host.agent_config_revision or 0), + settings=settings, + ) + + +@router.delete("/{host_id}", response_model=HostDeleteResponse) +def delete_host( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostDeleteResponse: + result = delete_host_and_related(db, host_id) + if result is None: + raise HTTPException(status_code=404, detail="Host not found") + db.commit() + return HostDeleteResponse( + status="deleted", + host_id=result.host_id, + hostname=result.hostname, + deleted_events=result.deleted_events, + deleted_problems=result.deleted_problems, + ) + diff --git a/backend/app/api/v1/mobile.py b/backend/app/api/v1/mobile.py new file mode 100644 index 0000000..9c1cbcf --- /dev/null +++ b/backend/app/api/v1/mobile.py @@ -0,0 +1,378 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import create_access_token, get_current_user, require_admin, CurrentUser +from app.database import get_db +from app.models.user import User +from app.models.mobile_settings import LOGIN_MODES +from app.services.mobile_devices import ( + get_device_or_raise, + list_mobile_devices, + revoke_device, + delete_device_record, + touch_device, + update_fcm_token, + assert_device_active, +) +from app.services.mobile_enrollment import ( + create_enrollment_code, + enroll_device, + list_enrollment_codes, + revoke_enrollment_code, +) +from app.services.mobile_notify import ( + MobileNotConfiguredError, + MobileSendError, + get_effective_fcm_config, + send_test_push, +) +from app.services.mobile_settings import upsert_mobile_settings, get_effective_mobile_settings +from app.services.mobile_tokens import find_valid_refresh_token, revoke_refresh_tokens_for_device, store_refresh_token + +router = APIRouter(prefix="/mobile", tags=["mobile"]) + + +class MobileSettingsResponse(BaseModel): + devices_allowed: bool + max_devices_per_user: int + min_app_version: str | None = None + fcm_enabled: bool + fcm_configured: bool + source: str + + +class MobileSettingsUpdate(BaseModel): + devices_allowed: bool + max_devices_per_user: int = Field(ge=1, le=50) + min_app_version: str | None = None + + +class EnrollmentCodeCreate(BaseModel): + label: str = "" + target_user_id: int | None = None + login_mode: str = "password" + max_uses: int = Field(default=1, ge=1, le=100) + expires_in_hours: int = Field(default=24, ge=1, le=720) + + +class EnrollmentCodeResponse(BaseModel): + id: int + label: str + code_prefix: str + target_user_id: int | None + target_username: str | None + login_mode: str + max_uses: int + use_count: int + expires_at: datetime + revoked_at: datetime | None + created_at: datetime + is_active: bool + + +class EnrollmentCodeCreatedResponse(EnrollmentCodeResponse): + enrollment_code: str = Field(description="Показывается один раз при создании") + + +class MobileDeviceResponse(BaseModel): + id: int + user_id: int + username: str + device_uuid: str + display_name: str + platform: str + app_version: str | None + has_fcm_token: bool + enrolled_at: datetime + last_seen_at: datetime | None + revoked_at: datetime | None + is_active: bool + + +class EnrollRequest(BaseModel): + enrollment_code: str + username: str | None = None + password: str | None = None + device_uuid: str + display_name: str = "Android" + platform: str = "android" + app_version: str | None = None + fcm_token: str | None = None + + +class EnrollResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + device_id: int + username: str + role: str + + +class RefreshRequest(BaseModel): + refresh_token: str + device_uuid: str + + +class FcmTokenUpdate(BaseModel): + fcm_token: str + + +class TestPushResponse(BaseModel): + ok: bool = True + message: str + + +def _settings_response(db: Session) -> MobileSettingsResponse: + cfg = get_effective_mobile_settings(db) + fcm = get_effective_fcm_config() + return MobileSettingsResponse( + devices_allowed=cfg.devices_allowed, + max_devices_per_user=cfg.max_devices_per_user, + min_app_version=cfg.min_app_version, + fcm_enabled=fcm.enabled, + fcm_configured=fcm.configured, + source=cfg.source, + ) + + +def _code_to_response(item) -> EnrollmentCodeResponse: + return EnrollmentCodeResponse( + id=item.id, + label=item.label, + code_prefix=item.code_prefix, + target_user_id=item.target_user_id, + target_username=item.target_username, + login_mode=item.login_mode, + max_uses=item.max_uses, + use_count=item.use_count, + expires_at=item.expires_at, + revoked_at=item.revoked_at, + created_at=item.created_at, + is_active=item.is_active, + ) + + +def _device_to_response(item) -> MobileDeviceResponse: + return MobileDeviceResponse( + id=item.id, + user_id=item.user_id, + username=item.username, + device_uuid=item.device_uuid, + display_name=item.display_name, + platform=item.platform, + app_version=item.app_version, + has_fcm_token=item.has_fcm_token, + enrolled_at=item.enrolled_at, + last_seen_at=item.last_seen_at, + revoked_at=item.revoked_at, + is_active=item.is_active, + ) + + +def _admin_user_id(db: Session, username: str) -> int | None: + user = db.scalar(select(User).where(func.lower(User.username) == username.lower())) + return user.id if user else None + + +@router.get("/settings", response_model=MobileSettingsResponse) +def get_mobile_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> MobileSettingsResponse: + return _settings_response(db) + + +@router.put("/settings", response_model=MobileSettingsResponse) +def update_mobile_settings( + body: MobileSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> MobileSettingsResponse: + try: + upsert_mobile_settings( + db, + devices_allowed=body.devices_allowed, + max_devices_per_user=body.max_devices_per_user, + min_app_version=body.min_app_version, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _settings_response(db) + + +@router.get("/enrollment-codes", response_model=list[EnrollmentCodeResponse]) +def get_enrollment_codes( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> list[EnrollmentCodeResponse]: + return [_code_to_response(item) for item in list_enrollment_codes(db)] + + +@router.post("/enrollment-codes", response_model=EnrollmentCodeCreatedResponse) +def post_enrollment_code( + body: EnrollmentCodeCreate, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_admin), +) -> EnrollmentCodeCreatedResponse: + if body.login_mode not in LOGIN_MODES: + raise HTTPException(status_code=422, detail=f"login_mode must be one of: {sorted(LOGIN_MODES)}") + try: + summary, plaintext = create_enrollment_code( + db, + created_by_user_id=_admin_user_id(db, user.username), + label=body.label, + target_user_id=body.target_user_id, + login_mode=body.login_mode, + max_uses=body.max_uses, + expires_in_hours=body.expires_in_hours, + ) + db.commit() + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + base = _code_to_response(summary) + return EnrollmentCodeCreatedResponse(**base.model_dump(), enrollment_code=plaintext) + + +@router.delete("/enrollment-codes/{code_id}", status_code=204) +def delete_enrollment_code( + code_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> None: + try: + revoke_enrollment_code(db, code_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/devices", response_model=list[MobileDeviceResponse]) +def get_devices( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> list[MobileDeviceResponse]: + return [_device_to_response(item) for item in list_mobile_devices(db)] + + +@router.post("/devices/{device_id}/revoke", response_model=MobileDeviceResponse) +def post_revoke_device( + device_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> MobileDeviceResponse: + try: + summary = revoke_device(db, device_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return _device_to_response(summary) + + +@router.delete("/devices/{device_id}", status_code=204) +def delete_device( + device_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> None: + try: + delete_device_record(db, device_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse) +def test_device_push( + device_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> TestPushResponse: + try: + send_test_push(db=db, device_id=device_id) + except MobileNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except MobileSendError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return TestPushResponse(message="Тестовое push отправлено на устройство") + + +@router.post("/enroll", response_model=EnrollResponse) +def post_enroll(body: EnrollRequest, db: Session = Depends(get_db)) -> EnrollResponse: + try: + result = enroll_device( + db, + enrollment_code=body.enrollment_code, + username=body.username, + password=body.password, + device_uuid=body.device_uuid, + display_name=body.display_name, + platform=body.platform, + app_version=body.app_version, + fcm_token=body.fcm_token, + create_access_token=create_access_token, + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return EnrollResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + device_id=result.device_id, + username=result.username, + role=result.role, + ) + + +@router.post("/auth/refresh", response_model=EnrollResponse) +def post_refresh(body: RefreshRequest, db: Session = Depends(get_db)) -> EnrollResponse: + token_row = find_valid_refresh_token(db, body.refresh_token.strip()) + if token_row is None: + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + device = get_device_or_raise(db, token_row.device_id) + try: + assert_device_active(device) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + if device.device_uuid != body.device_uuid.strip(): + raise HTTPException(status_code=401, detail="device_uuid mismatch") + + user = db.get(User, device.user_id) + if user is None or not user.is_active: + raise HTTPException(status_code=401, detail="User inactive") + + revoke_refresh_tokens_for_device(db, device.id) + refresh_plain = store_refresh_token(db, device_id=device.id) + touch_device(db, device) + access_token = create_access_token(user.username, user.role, device_id=device.id) + db.commit() + + return EnrollResponse( + access_token=access_token, + refresh_token=refresh_plain, + device_id=device.id, + username=user.username, + role=user.role, + ) + + +@router.put("/devices/me/fcm", status_code=204) +def put_my_fcm_token( + body: FcmTokenUpdate, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> None: + if user.device_id is None: + raise HTTPException(status_code=403, detail="Mobile device token required") + device = get_device_or_raise(db, user.device_id) + try: + assert_device_active(device) + update_fcm_token(db, device, body.fcm_token) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc diff --git a/backend/app/api/v1/problems.py b/backend/app/api/v1/problems.py new file mode 100644 index 0000000..56bc74b --- /dev/null +++ b/backend/app/api/v1/problems.py @@ -0,0 +1,376 @@ +from datetime import datetime, timedelta, timezone + + + +from fastapi import APIRouter, Depends, HTTPException, Query + +from pydantic import BaseModel + +from sqlalchemy import func, select + +from sqlalchemy.orm import Session, joinedload + + + +from app.auth.jwt_auth import get_current_user + +from app.database import get_db + +from app.models import Event, Host, Problem, ProblemEvent +from app.services.event_type_visibility import get_hidden_event_types +from app.utils.sql_like import escape_ilike_pattern + + + +router = APIRouter(prefix="/problems", tags=["problems"]) + + + + + +class ProblemSummary(BaseModel): + + id: int + + host_id: int | None + + hostname: str | None + + display_name: str | None = None + + title: str + + summary: str + + severity: str + + status: str + + rule_id: str | None + + fingerprint: str + + event_count: int + + last_seen_at: datetime + + created_at: datetime + + updated_at: datetime + + + + + +class ProblemListResponse(BaseModel): + + items: list[ProblemSummary] + + total: int + + page: int + + page_size: int + + + + + +class ProblemEventItem(BaseModel): + + id: int + + event_id: str + + occurred_at: datetime + + type: str + + severity: str + + title: str + + summary: str + + + + + +class ProblemDetail(ProblemSummary): + + events: list[ProblemEventItem] + + + + + +def _problem_summary(p: Problem) -> ProblemSummary: + + return ProblemSummary( + + id=p.id, + + host_id=p.host_id, + + hostname=p.host.hostname if p.host else None, + + display_name=p.host.display_name if p.host else None, + + title=p.title, + + summary=p.summary, + + severity=p.severity, + + status=p.status, + + rule_id=p.rule_id, + + fingerprint=p.fingerprint, + + event_count=p.event_count, + + last_seen_at=p.last_seen_at, + + created_at=p.created_at, + + updated_at=p.updated_at, + + ) + + + + + +@router.get("", response_model=ProblemListResponse) + +def list_problems( + + page: int = Query(1, ge=1), + + page_size: int = Query(50, ge=1, le=200), + + status: str | None = Query(None), + + severity: str | None = Query(None), + + host_id: int | None = Query(None), + + hostname: str | None = Query(None), + + created_within_hours: int | None = Query(None, ge=1, le=8760), + + resolved_within_hours: int | None = Query(None, ge=1, le=8760), + + db: Session = Depends(get_db), + + _user: str = Depends(get_current_user), + +) -> ProblemListResponse: + + stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host)) + + count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True) + + + + if status: + + stmt = stmt.where(Problem.status == status) + + count_stmt = count_stmt.where(Problem.status == status) + + if severity: + + stmt = stmt.where(Problem.severity == severity) + + count_stmt = count_stmt.where(Problem.severity == severity) + + if host_id is not None: + + stmt = stmt.where(Problem.host_id == host_id) + + count_stmt = count_stmt.where(Problem.host_id == host_id) + + if hostname: + + like = f"%{escape_ilike_pattern(hostname)}%" + + stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) + + count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) + + if created_within_hours is not None: + + since = datetime.now(timezone.utc) - timedelta(hours=created_within_hours) + + stmt = stmt.where(Problem.created_at >= since, Problem.status != "resolved") + + count_stmt = count_stmt.where(Problem.created_at >= since, Problem.status != "resolved") + + if resolved_within_hours is not None: + + since = datetime.now(timezone.utc) - timedelta(hours=resolved_within_hours) + + stmt = stmt.where(Problem.status == "resolved", Problem.updated_at >= since) + + count_stmt = count_stmt.where(Problem.status == "resolved", Problem.updated_at >= since) + + + + total = db.scalar(count_stmt) or 0 + + rows = db.scalars( + + stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size) + + ).all() + + + + items = [_problem_summary(p) for p in rows] + + return ProblemListResponse(items=items, total=total, page=page, page_size=page_size) + + + + + +@router.get("/{problem_id}", response_model=ProblemDetail) + +def get_problem( + + problem_id: int, + + db: Session = Depends(get_db), + + _user: str = Depends(get_current_user), + +) -> ProblemDetail: + + problem = db.scalar( + + select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host)) + + ) + + if problem is None: + + raise HTTPException(status_code=404, detail="Problem not found") + + + + event_rows = db.scalars( + + select(Event) + + .join(ProblemEvent, ProblemEvent.event_id == Event.id) + + .where(ProblemEvent.problem_id == problem_id) + + .order_by(Event.occurred_at.desc()) + + ).all() + hidden = get_hidden_event_types(db) + event_rows = [event for event in event_rows if event.type not in hidden] + + + + base = _problem_summary(problem) + + return ProblemDetail( + + **base.model_dump(), + + events=[ + + ProblemEventItem( + + id=e.id, + + event_id=e.event_id, + + occurred_at=e.occurred_at, + + type=e.type, + + severity=e.severity, + + title=e.title, + + summary=e.summary, + + ) + + for e in event_rows + + ], + + ) + + + + + +@router.post("/{problem_id}/ack") + +def ack_problem( + + problem_id: int, + + db: Session = Depends(get_db), + + _user: str = Depends(get_current_user), + +) -> dict: + + problem = db.get(Problem, problem_id) + + if problem is None: + + raise HTTPException(status_code=404, detail="Problem not found") + + if problem.status == "resolved": + + raise HTTPException(status_code=409, detail="Problem already resolved") + + problem.status = "acknowledged" + + db.commit() + + return {"id": problem.id, "status": problem.status} + + + + + +@router.post("/{problem_id}/resolve") + +def resolve_problem( + + problem_id: int, + + db: Session = Depends(get_db), + + _user: str = Depends(get_current_user), + +) -> dict: + + problem = db.get(Problem, problem_id) + + if problem is None: + + raise HTTPException(status_code=404, detail="Problem not found") + + if problem.status == "resolved": + + raise HTTPException(status_code=409, detail="Problem already resolved") + + problem.status = "resolved" + problem.resolved_by = "manual" + + db.commit() + + return {"id": problem.id, "status": problem.status} + + diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..55b3657 --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from app.api.v1 import agent, auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users + +api_router = APIRouter() +api_router.include_router(health.router) +api_router.include_router(auth.router) +api_router.include_router(agent.router) +api_router.include_router(events.router) +api_router.include_router(hosts.router) +api_router.include_router(problems.router) +api_router.include_router(dashboards.router) +api_router.include_router(settings.router) +api_router.include_router(system.router) +api_router.include_router(users.router) +api_router.include_router(stream.router) +api_router.include_router(mobile.router) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..94c3dfa --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -0,0 +1,860 @@ +from dataclasses import replace + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import require_admin +from app.database import get_db +from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message +from app.services.notification_policy import ( + NotificationPolicyConfig, + get_effective_notification_policy, + upsert_notification_policy, +) +from app.services.notification_settings import ( + VALID_SEVERITIES, + EmailConfig, + TelegramConfig, + WebhookConfig, + get_effective_email_config, + get_effective_telegram_config, + get_effective_webhook_config, + upsert_email_channel, + upsert_telegram_channel, + upsert_webhook_channel, +) +from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message +from app.services.webhook_notify import ( + WebhookNotConfiguredError, + WebhookSendError, + send_webhook_test_message, +) +from app.services.event_severity_overrides import ( + SOURCE_DB, + list_severity_override_items, + replace_severity_overrides, +) +from app.services.event_type_visibility import replace_event_visibility +from app.services.ui_settings import ( + get_effective_ui_settings, + upsert_ui_settings, +) +from app.services.linux_admin_settings import ( + get_effective_linux_admin_config, + upsert_linux_admin_settings, +) +from app.services.win_admin_settings import ( + get_effective_win_admin_config, + upsert_win_admin_settings, +) +from app.services.rdp_flap_settings import ( + get_effective_rdp_flap_settings, + upsert_rdp_flap_settings, +) +from app.services.agent_git_release import get_git_release_versions +from app.services.agent_update_settings import ( + get_effective_agent_update_config, + upsert_agent_update_settings, +) +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError, + WinAdminNotConfiguredError, + resolve_windows_host_target, + test_winrm_connection, +) + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None: + text = value.strip() + if not text: + return None + if len(text) <= visible_tail: + return "*" * len(text) + return f"{'*' * 8}…{text[-visible_tail:]}" + + +def _mask_url(value: str) -> str | None: + text = value.strip() + if not text: + return None + if len(text) <= 20: + return f"{'*' * 6}…{text[-4:]}" + return f"{text[:16]}…{text[-6:]}" + + +class TelegramSettingsResponse(BaseModel): + enabled: bool + configured: bool + chat_id_hint: str | None = None + bot_token_hint: str | None = None + min_severity: str + source: str = Field(description="env или db") + + +class WebhookSettingsResponse(BaseModel): + enabled: bool + configured: bool + url_hint: str | None = None + secret_header: str | None = None + secret_hint: str | None = None + min_severity: str + source: str = Field(description="env или db") + + +class EmailSettingsResponse(BaseModel): + enabled: bool + configured: bool + smtp_host_hint: str | None = None + smtp_port: int + smtp_user: str | None = None + smtp_password_hint: str | None = None + mail_from: str | None = None + mail_to_hint: str | None = None + smtp_starttls: bool + smtp_ssl: bool + min_severity: str + source: str = Field(description="env или db") + + +class NotificationPolicyResponse(BaseModel): + min_severity: str + use_telegram: bool + use_webhook: bool + use_email: bool + use_mobile: bool + source: str = Field(description="env или db") + + +class NotificationSettingsResponse(BaseModel): + policy: NotificationPolicyResponse + telegram: TelegramSettingsResponse + webhook: WebhookSettingsResponse + email: EmailSettingsResponse + + +class NotificationPolicyUpdate(BaseModel): + min_severity: str + use_telegram: bool + use_webhook: bool + use_email: bool + use_mobile: bool = False + + +class TelegramSettingsUpdate(BaseModel): + enabled: bool + bot_token: str | None = None + chat_id: str | None = None + + +class WebhookSettingsUpdate(BaseModel): + enabled: bool + url: str | None = None + secret_header: str | None = None + secret: str | None = None + + +class EmailSettingsUpdate(BaseModel): + enabled: bool + smtp_host: str | None = None + smtp_port: int | None = None + smtp_user: str | None = None + smtp_password: str | None = None + mail_from: str | None = None + mail_to: str | None = None + smtp_starttls: bool | None = None + smtp_ssl: bool | None = None + + +class ChannelTestResponse(BaseModel): + ok: bool = True + message: str + + +def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResponse: + return NotificationPolicyResponse( + min_severity=cfg.min_severity, + use_telegram=cfg.use_telegram, + use_webhook=cfg.use_webhook, + use_email=cfg.use_email, + use_mobile=cfg.use_mobile, + source=cfg.source, + ) + + +def _telegram_to_response(cfg: TelegramConfig, policy: NotificationPolicyConfig) -> TelegramSettingsResponse: + return TelegramSettingsResponse( + enabled=cfg.enabled, + configured=cfg.configured, + chat_id_hint=_mask_secret(cfg.chat_id), + bot_token_hint=_mask_secret(cfg.bot_token), + min_severity=policy.min_severity, + source=cfg.source, + ) + + +def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse: + return WebhookSettingsResponse( + enabled=cfg.enabled, + configured=cfg.configured, + url_hint=_mask_url(cfg.url), + secret_header=cfg.secret_header or None, + secret_hint=_mask_secret(cfg.secret), + min_severity=policy.min_severity, + source=cfg.source, + ) + + +def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse: + return EmailSettingsResponse( + enabled=cfg.enabled, + configured=cfg.configured, + smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None, + smtp_port=cfg.smtp_port, + smtp_user=cfg.smtp_user or None, + smtp_password_hint=_mask_secret(cfg.smtp_password), + mail_from=cfg.mail_from or None, + mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None, + smtp_starttls=cfg.smtp_starttls, + smtp_ssl=cfg.smtp_ssl, + min_severity=policy.min_severity, + source=cfg.source, + ) + + +@router.get("/notifications", response_model=NotificationSettingsResponse) +def get_notification_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> NotificationSettingsResponse: + policy = get_effective_notification_policy(db) + return NotificationSettingsResponse( + policy=_policy_to_response(policy), + telegram=_telegram_to_response(get_effective_telegram_config(db), policy), + webhook=_webhook_to_response(get_effective_webhook_config(db), policy), + email=_email_to_response(get_effective_email_config(db), policy), + ) + + +@router.put("/notifications/policy", response_model=NotificationPolicyResponse) +def update_notification_policy( + body: NotificationPolicyUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> NotificationPolicyResponse: + if body.min_severity not in VALID_SEVERITIES: + raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") + if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)): + raise HTTPException(status_code=422, detail="Выберите хотя бы один канал") + try: + policy = upsert_notification_policy( + db, + min_severity=body.min_severity, + use_telegram=body.use_telegram, + use_webhook=body.use_webhook, + use_email=body.use_email, + use_mobile=body.use_mobile, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _policy_to_response(policy) + + +@router.put("/notifications/telegram", response_model=TelegramSettingsResponse) +def update_telegram_settings( + body: TelegramSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> TelegramSettingsResponse: + try: + cfg = upsert_telegram_channel( + db, + enabled=body.enabled, + bot_token=body.bot_token, + chat_id=body.chat_id, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + policy = get_effective_notification_policy(db) + return _telegram_to_response(cfg, policy) + + +@router.put("/notifications/webhook", response_model=WebhookSettingsResponse) +def update_webhook_settings( + body: WebhookSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> WebhookSettingsResponse: + try: + cfg = upsert_webhook_channel( + db, + enabled=body.enabled, + url=body.url, + secret_header=body.secret_header, + secret=body.secret, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + policy = get_effective_notification_policy(db) + return _webhook_to_response(cfg, policy) + + +@router.put("/notifications/email", response_model=EmailSettingsResponse) +def update_email_settings( + body: EmailSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> EmailSettingsResponse: + try: + cfg = upsert_email_channel( + db, + enabled=body.enabled, + smtp_host=body.smtp_host, + smtp_port=body.smtp_port, + smtp_user=body.smtp_user, + smtp_password=body.smtp_password, + mail_from=body.mail_from, + mail_to=body.mail_to, + smtp_starttls=body.smtp_starttls, + smtp_ssl=body.smtp_ssl, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + policy = get_effective_notification_policy(db) + return _email_to_response(cfg, policy) + + +@router.post("/notifications/telegram/test", response_model=ChannelTestResponse) +def test_telegram_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> ChannelTestResponse: + cfg = get_effective_telegram_config(db) + try: + send_telegram_test_message(config=cfg) + except TelegramNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except TelegramSendError as exc: + code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 + raise HTTPException(status_code=code, detail=str(exc)) from exc + return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram") + + +@router.post("/notifications/webhook/test", response_model=ChannelTestResponse) +def test_webhook_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> ChannelTestResponse: + cfg = get_effective_webhook_config(db) + try: + send_webhook_test_message(config=cfg) + except WebhookNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except WebhookSendError as exc: + code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 + raise HTTPException(status_code=code, detail=str(exc)) from exc + return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL") + + +@router.post("/notifications/email/test", response_model=ChannelTestResponse) +def test_email_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> ChannelTestResponse: + cfg = get_effective_email_config(db) + try: + send_email_test_message(config=cfg) + except EmailNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except EmailSendError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP") + + +class EventSeverityOverrideRow(BaseModel): + event_type: str + default_severity: str + override_severity: str | None = None + show_in_events: bool = True + + +class EventSeverityOverridesResponse(BaseModel): + items: list[EventSeverityOverrideRow] + source: str = Field(description="db — overrides хранятся в PostgreSQL") + + +class EventSeverityOverridesUpdate(BaseModel): + overrides: dict[str, str | None] = Field( + default_factory=dict, + description="event_type → severity; null или пустая строка сбрасывает override", + ) + visibility: dict[str, bool | None] = Field( + default_factory=dict, + description="event_type → show_in_events; null или true сбрасывает скрытие (показывать)", + ) + + +def _severity_override_rows(items) -> list[EventSeverityOverrideRow]: + return [ + EventSeverityOverrideRow( + event_type=i.event_type, + default_severity=i.default_severity, + override_severity=i.override_severity, + show_in_events=i.show_in_events, + ) + for i in items + ] + + +@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse) +def get_severity_overrides( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> EventSeverityOverridesResponse: + items = list_severity_override_items(db) + return EventSeverityOverridesResponse( + items=_severity_override_rows(items), + source=SOURCE_DB, + ) + + +@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse) +def update_severity_overrides( + body: EventSeverityOverridesUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> EventSeverityOverridesResponse: + try: + items = list_severity_override_items(db) + if body.overrides: + items = replace_severity_overrides(db, body.overrides) + if body.visibility: + replace_event_visibility(db, body.visibility) + items = list_severity_override_items(db) + if not body.overrides and not body.visibility: + raise HTTPException(status_code=422, detail="Нет изменений для сохранения") + db.commit() + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return EventSeverityOverridesResponse( + items=_severity_override_rows(items), + source=SOURCE_DB, + ) + + +class UiSettingsResponse(BaseModel): + show_sidebar_system_stats: bool + source: str = Field(description="db или default") + + +class UiSettingsUpdate(BaseModel): + show_sidebar_system_stats: bool + + +@router.get("/ui", response_model=UiSettingsResponse) +def get_ui_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> UiSettingsResponse: + cfg = get_effective_ui_settings(db) + return UiSettingsResponse( + show_sidebar_system_stats=cfg.show_sidebar_system_stats, + source=cfg.source, + ) + + +@router.put("/ui", response_model=UiSettingsResponse) +def update_ui_settings( + body: UiSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> UiSettingsResponse: + cfg = upsert_ui_settings(db, show_sidebar_system_stats=body.show_sidebar_system_stats) + return UiSettingsResponse( + show_sidebar_system_stats=cfg.show_sidebar_system_stats, + source=cfg.source, + ) + + +class WinAdminSettingsResponse(BaseModel): + configured: bool + user: str | None = None + password_hint: str | None = None + source: str = Field(description="env или db") + + +class WinAdminSettingsUpdate(BaseModel): + user: str | None = None + password: str | None = None + + +@router.get("/win-admin", response_model=WinAdminSettingsResponse) +def get_win_admin_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> WinAdminSettingsResponse: + cfg = get_effective_win_admin_config(db) + return WinAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) + + +@router.put("/win-admin", response_model=WinAdminSettingsResponse) +def update_win_admin_settings( + body: WinAdminSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> WinAdminSettingsResponse: + if body.user is not None and not body.user.strip(): + raise HTTPException(status_code=422, detail="user must not be empty") + cfg = upsert_win_admin_settings(db, user=body.user, password=body.password) + return WinAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) + + +class RdpFlapSettingsResponse(BaseModel): + auto_disconnect: bool + win_admin_configured: bool + source: str = Field(description="db или default") + + +class RdpFlapSettingsUpdate(BaseModel): + auto_disconnect: bool + + +@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse) +def get_rdp_flap_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> RdpFlapSettingsResponse: + cfg = get_effective_rdp_flap_settings(db) + win_cfg = get_effective_win_admin_config(db) + return RdpFlapSettingsResponse( + auto_disconnect=cfg.auto_disconnect, + win_admin_configured=win_cfg.configured, + source=cfg.source, + ) + + +@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse) +def update_rdp_flap_settings( + body: RdpFlapSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> RdpFlapSettingsResponse: + cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect) + win_cfg = get_effective_win_admin_config(db) + return RdpFlapSettingsResponse( + auto_disconnect=cfg.auto_disconnect, + win_admin_configured=win_cfg.configured, + source=cfg.source, + ) + + +class LinuxAdminSettingsResponse(BaseModel): + configured: bool + user: str | None = None + password_hint: str | None = None + source: str = Field(description="env или db") + + +class LinuxAdminSettingsUpdate(BaseModel): + user: str | None = None + password: str | None = None + + +@router.get("/linux-admin", response_model=LinuxAdminSettingsResponse) +def get_linux_admin_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LinuxAdminSettingsResponse: + cfg = get_effective_linux_admin_config(db) + return LinuxAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) + + +@router.put("/linux-admin", response_model=LinuxAdminSettingsResponse) +def update_linux_admin_settings( + body: LinuxAdminSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LinuxAdminSettingsResponse: + if body.user is not None and not body.user.strip(): + raise HTTPException(status_code=422, detail="user must not be empty") + cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password) + return LinuxAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) + + +class AgentUpdateSettingsResponse(BaseModel): + mode: str + fallback_enabled: bool + fallback_after_minutes: int + recommended_rdp_version: str | None = None + recommended_ssh_version: str | None = None + min_rdp_version: str | None = None + min_ssh_version: str | None = None + win_agent_update_script: str | None = None + rdp_git_repo_url: str | None = None + ssh_git_repo_url: str | None = None + git_branch: str = "main" + git_latest_rdp_version: str | None = None + git_latest_ssh_version: str | None = None + git_fetched_at: datetime | None = None + git_errors: dict[str, str] = Field(default_factory=dict) + source: str = Field(description="env или db") + + +class AgentUpdateSettingsUpdate(BaseModel): + mode: str | None = None + fallback_enabled: bool | None = None + fallback_after_minutes: int | None = None + recommended_rdp_version: str | None = None + recommended_ssh_version: str | None = None + min_rdp_version: str | None = None + min_ssh_version: str | None = None + win_agent_update_script: str | None = None + rdp_git_repo_url: str | None = None + ssh_git_repo_url: str | None = None + git_branch: str | None = None + + +class AgentGitTestRequest(BaseModel): + rdp_git_repo_url: str | None = None + ssh_git_repo_url: str | None = None + git_branch: str | None = None + + +class AgentGitTestResponse(BaseModel): + git_latest_rdp_version: str | None = None + git_latest_ssh_version: str | None = None + git_fetched_at: datetime | None = None + git_errors: dict[str, str] = Field(default_factory=dict) + from_cache: bool = False + + +def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse: + git_latest_rdp = None + git_latest_ssh = None + git_fetched_at = None + git_errors: dict[str, str] = {} + if git_release is not None: + from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH + + git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None + git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None + git_fetched_at = git_release.fetched_at + git_errors = dict(git_release.errors) + return AgentUpdateSettingsResponse( + mode=cfg.mode, + fallback_enabled=cfg.fallback_enabled, + fallback_after_minutes=cfg.fallback_after_minutes, + recommended_rdp_version=cfg.recommended_rdp_version or None, + recommended_ssh_version=cfg.recommended_ssh_version or None, + min_rdp_version=cfg.min_rdp_version or None, + min_ssh_version=cfg.min_ssh_version or None, + win_agent_update_script=cfg.win_agent_update_script or None, + rdp_git_repo_url=cfg.rdp_git_repo_url or None, + ssh_git_repo_url=cfg.ssh_git_repo_url or None, + git_branch=cfg.git_branch or "main", + git_latest_rdp_version=git_latest_rdp, + git_latest_ssh_version=git_latest_ssh, + git_fetched_at=git_fetched_at, + git_errors=git_errors, + source=cfg.source, + ) + + +@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse) +def get_agent_update_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> AgentUpdateSettingsResponse: + cfg = get_effective_agent_update_config(db) + git_release = get_git_release_versions(cfg, db=db) + return _agent_update_to_response(cfg, git_release) + + +@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse) +def update_agent_update_settings( + body: AgentUpdateSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> AgentUpdateSettingsResponse: + try: + cfg = upsert_agent_update_settings( + db, + mode=body.mode, + fallback_enabled=body.fallback_enabled, + fallback_after_minutes=body.fallback_after_minutes, + recommended_rdp_version=body.recommended_rdp_version, + recommended_ssh_version=body.recommended_ssh_version, + min_rdp_version=body.min_rdp_version, + min_ssh_version=body.min_ssh_version, + win_agent_update_script=body.win_agent_update_script, + rdp_git_repo_url=body.rdp_git_repo_url, + ssh_git_repo_url=body.ssh_git_repo_url, + git_branch=body.git_branch, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + git_release = get_git_release_versions(cfg, db=db, force_refresh=True) + return _agent_update_to_response(cfg, git_release) + + +@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse) +def test_agent_git_release( + body: AgentGitTestRequest | None = None, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> AgentGitTestResponse: + cfg = get_effective_agent_update_config(db) + if body is not None: + cfg = replace( + cfg, + rdp_git_repo_url=(body.rdp_git_repo_url or cfg.rdp_git_repo_url).strip(), + ssh_git_repo_url=(body.ssh_git_repo_url or cfg.ssh_git_repo_url).strip(), + git_branch=(body.git_branch or cfg.git_branch or "main").strip() or "main", + ) + git_release = get_git_release_versions(cfg, db=db, force_refresh=True) + from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH + + return AgentGitTestResponse( + git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None, + git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None, + git_fetched_at=git_release.fetched_at, + git_errors=dict(git_release.errors), + from_cache=git_release.from_cache, + ) + + +class LoginSecuritySettingsResponse(BaseModel): + ip_whitelist: list[str] = Field(default_factory=list) + ip_whitelist_text: str = "" + sync_fail2ban: bool = False + max_failures: int = 3 + window_minutes: int = 15 + fail2ban_available: bool = False + source: str = "default" + + +class LoginSecuritySettingsUpdate(BaseModel): + ip_whitelist_text: str = "" + sync_fail2ban: bool | None = None + + +class LoginBlockItem(BaseModel): + ip_address: str + scope: str + failure_count: int | None = None + usernames: list[str] = Field(default_factory=list) + blocked_until: datetime | None = None + reason: str + + +class LoginUnblockRequest(BaseModel): + ip_address: str + scope: str = Field(default="both", description="web | ssh | both") + + +class LoginUnblockResponse(BaseModel): + messages: list[str] = Field(default_factory=list) + + +def _login_security_response(db: Session) -> LoginSecuritySettingsResponse: + from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + from app.services.fail2ban_sync import fail2ban_available + from app.services.login_security_settings import get_effective_login_security_config + + cfg = get_effective_login_security_config(db) + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + text = (row.login_ip_whitelist or "") if row is not None else "" + return LoginSecuritySettingsResponse( + ip_whitelist=list(cfg.ip_whitelist), + ip_whitelist_text=text, + sync_fail2ban=cfg.sync_fail2ban, + max_failures=cfg.max_failures, + window_minutes=cfg.window_minutes, + fail2ban_available=fail2ban_available(), + source=cfg.source, + ) + + +def _block_item(entry) -> LoginBlockItem: + return LoginBlockItem( + ip_address=entry.ip_address, + scope=entry.scope, + failure_count=entry.failure_count, + usernames=list(entry.usernames), + blocked_until=entry.blocked_until, + reason=entry.reason, + ) + + +@router.get("/login-security", response_model=LoginSecuritySettingsResponse) +def get_login_security_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LoginSecuritySettingsResponse: + return _login_security_response(db) + + +@router.put("/login-security", response_model=LoginSecuritySettingsResponse) +def update_login_security_settings( + body: LoginSecuritySettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LoginSecuritySettingsResponse: + from app.services.login_security_settings import upsert_login_security_settings + + upsert_login_security_settings( + db, + ip_whitelist_text=body.ip_whitelist_text, + sync_fail2ban=bool(body.sync_fail2ban), + ) + return _login_security_response(db) + + +@router.get("/login-security/blocks", response_model=list[LoginBlockItem]) +def list_login_security_blocks( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> list[LoginBlockItem]: + from app.services.login_security_settings import list_all_login_blocks + + return [_block_item(e) for e in list_all_login_blocks(db)] + + +@router.post("/login-security/unblock", response_model=LoginUnblockResponse) +def unblock_login_security_ip( + body: LoginUnblockRequest, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LoginUnblockResponse: + from app.services.login_security_settings import unblock_login_ip + + scope = (body.scope or "both").strip().lower() + if scope not in ("web", "ssh", "both"): + raise HTTPException(status_code=422, detail="scope must be web, ssh, or both") + messages = unblock_login_ip(db, body.ip_address, scope=scope) + return LoginUnblockResponse(messages=messages) diff --git a/backend/app/api/v1/stream.py b/backend/app/api/v1/stream.py new file mode 100644 index 0000000..17584c7 --- /dev/null +++ b/backend/app/api/v1/stream.py @@ -0,0 +1,88 @@ +import asyncio +import json +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Cookie, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import verify_access_token +from app.auth.stream_cookie import STREAM_COOKIE_NAME +from app.database import SessionLocal, get_db +from app.models import Event, Problem +from app.config import get_settings +from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts +from app.version import APP_VERSION + +router = APIRouter(prefix="/stream", tags=["stream"]) + +SSE_INTERVAL_SEC = 5 + + +def _dashboard_snapshot(db: Session) -> dict: + since = datetime.now(timezone.utc) - timedelta(hours=24) + events_24h = db.scalar( + select(func.count()).select_from(Event).where(Event.received_at >= since) + ) or 0 + problems_open = ( + db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0 + ) + last_event = db.scalar(select(Event.id).order_by(Event.received_at.desc()).limit(1)) + settings = get_settings() + return { + "type": "dashboard", + "version": APP_VERSION, + "events_last_24h": events_24h, + "hosts_stale": count_stale_hosts(db, settings.sac_heartbeat_stale_minutes), + "heartbeats_24h": db.scalar( + select(func.count()) + .select_from(Event) + .where(Event.received_at >= since, Event.type == HEARTBEAT_TYPE) + ) + or 0, + "daily_reports_24h": db.scalar( + select(func.count()) + .select_from(Event) + .where(Event.received_at >= since, Event.type.in_(DAILY_REPORT_TYPES)) + ) + or 0, + "problems_open": problems_open, + "last_event_id": last_event, + "at": datetime.now(timezone.utc).isoformat(), + } + + +async def _sse_generator(): + try: + while True: + db = SessionLocal() + try: + payload = _dashboard_snapshot(db) + finally: + db.close() + yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + await asyncio.sleep(SSE_INTERVAL_SEC) + except asyncio.CancelledError: + return + + +def _sse_auth( + sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME), + db: Session = Depends(get_db), +) -> str: + token = (sac_stream or "").strip() + if not token: + raise HTTPException(status_code=401, detail="SSE authentication required") + return verify_access_token(token, db=db) + + +@router.get("/events") +async def stream_events( + _user: str = Depends(_sse_auth), +) -> StreamingResponse: + return StreamingResponse( + _sse_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/backend/app/api/v1/system.py b/backend/app/api/v1/system.py new file mode 100644 index 0000000..e37c319 --- /dev/null +++ b/backend/app/api/v1/system.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import get_current_user +from app.database import get_db +from app.services.system_stats import collect_system_stats +from app.services.ui_settings import get_effective_ui_settings + +router = APIRouter(prefix="/system", tags=["system"]) + + +class SystemStatsResponse(BaseModel): + visible: bool = Field(description="Показывать блок в боковой панели (глобальная настройка)") + collected_at: str + cpu_percent: float | None = None + memory: dict | None = None + disk: dict | None = None + database: dict + app: dict + + +@router.get("/stats", response_model=SystemStatsResponse) +def get_system_stats( + db: Session = Depends(get_db), + _user=Depends(get_current_user), +) -> SystemStatsResponse: + ui = get_effective_ui_settings(db) + payload = collect_system_stats(db) + return SystemStatsResponse(visible=ui.show_sidebar_system_stats, **payload) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..215f763 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,144 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import CurrentUser, require_admin +from app.database import get_db +from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User +from app.services.user_auth import create_user, hash_password + +router = APIRouter(prefix="/users", tags=["users"]) + + +class UserSummary(BaseModel): + id: int + username: str + role: str + is_active: bool + created_at: datetime + + +class UserListResponse(BaseModel): + items: list[UserSummary] + + +class CreateUserRequest(BaseModel): + username: str = Field(min_length=2, max_length=64) + password: str = Field(min_length=8, max_length=128) + role: str = Field(default=USER_ROLE_MONITOR) + + +class UpdateUserRequest(BaseModel): + username: str | None = Field(default=None, min_length=2, max_length=64) + role: str | None = None + password: str | None = Field(default=None, min_length=8, max_length=128) + is_active: bool | None = None + + +@router.get("", response_model=UserListResponse) +def list_users( + _admin: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +) -> UserListResponse: + rows = db.scalars(select(User).order_by(User.username)).all() + return UserListResponse( + items=[ + UserSummary( + id=u.id, + username=u.username, + role=u.role, + is_active=u.is_active, + created_at=u.created_at, + ) + for u in rows + ] + ) + + +@router.post("", response_model=UserSummary, status_code=201) +def create_user_endpoint( + body: CreateUserRequest, + admin: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +) -> UserSummary: + if body.role not in USER_ROLES: + raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}") + try: + user = create_user(db, username=body.username, password=body.password, role=body.role) + db.commit() + db.refresh(user) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return UserSummary( + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + created_at=user.created_at, + ) + + +@router.patch("/{user_id}", response_model=UserSummary) +def update_user( + user_id: int, + body: UpdateUserRequest, + admin: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +) -> UserSummary: + user = db.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + if body.username is not None: + normalized = body.username.strip() + if not normalized: + raise HTTPException(status_code=422, detail="username is required") + existing = db.scalar( + select(User).where( + func.lower(User.username) == normalized.lower(), + User.id != user.id, + ) + ) + if existing is not None: + raise HTTPException(status_code=400, detail="username already exists") + user.username = normalized + + if body.role is not None: + if body.role not in USER_ROLES: + raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}") + if user.role == USER_ROLE_ADMIN and body.role != USER_ROLE_ADMIN: + _ensure_not_last_admin(db, user.id) + user.role = body.role + + if body.password is not None: + user.password_hash = hash_password(body.password) + + if body.is_active is not None: + if not body.is_active and user.username.lower() == admin.username.lower(): + raise HTTPException(status_code=400, detail="Cannot deactivate your own account") + if not body.is_active and user.role == USER_ROLE_ADMIN: + _ensure_not_last_admin(db, user.id) + user.is_active = body.is_active + + db.commit() + db.refresh(user) + return UserSummary( + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + created_at=user.created_at, + ) + + +def _ensure_not_last_admin(db: Session, user_id: int) -> None: + active_admins = db.scalar( + select(func.count()) + .select_from(User) + .where(User.role == USER_ROLE_ADMIN, User.is_active.is_(True), User.id != user_id) + ) + if not active_admins: + raise HTTPException(status_code=400, detail="Cannot remove or deactivate the last admin user") diff --git a/backend/app/auth/api_key.py b/backend/app/auth/api_key.py new file mode 100644 index 0000000..ead5118 --- /dev/null +++ b/backend/app/auth/api_key.py @@ -0,0 +1,55 @@ +import hashlib +import hmac +import secrets + +from fastapi import Depends, HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.database import get_db +from app.models import ApiKey + +security_scheme = HTTPBearer(auto_error=False) + + +def _legacy_hash_api_key(raw_key: str) -> str: + return hashlib.sha256(raw_key.encode("utf-8")).hexdigest() + + +def hash_api_key(raw_key: str) -> str: + secret = get_settings().jwt_secret.encode("utf-8") + return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest() + + +def verify_api_key_hash(raw_key: str, stored_hash: str) -> bool: + if hmac.compare_digest(hash_api_key(raw_key), stored_hash): + return True + return hmac.compare_digest(_legacy_hash_api_key(raw_key), stored_hash) + + +def generate_api_key() -> tuple[str, str, str]: + """Returns (full_key, prefix, hash).""" + raw = f"sac_{secrets.token_urlsafe(32)}" + prefix = raw[:12] + return raw, prefix, hash_api_key(raw) + + +def verify_api_key(db: Session, raw_key: str) -> bool: + rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all() + for row in rows: + if verify_api_key_hash(raw_key, row.key_hash): + return True + return False + + +def get_api_key_auth( + credentials: HTTPAuthorizationCredentials | None = Security(security_scheme), + db: Session = Depends(get_db), +) -> str: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") + if not verify_api_key(db, credentials.credentials): + raise HTTPException(status_code=401, detail="Invalid API key") + return credentials.credentials diff --git a/backend/app/auth/jwt_auth.py b/backend/app/auth/jwt_auth.py new file mode 100644 index 0000000..e58d02f --- /dev/null +++ b/backend/app/auth/jwt_auth.py @@ -0,0 +1,352 @@ +from dataclasses import dataclass + + + +from datetime import datetime, timedelta, timezone + + + + + + + +from fastapi import Depends, HTTPException, status + + + +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + + + +from jose import JWTError, jwt + + + +from sqlalchemy import func, select + + + +from sqlalchemy.orm import Session + + + + + + + +from app.config import get_settings + + + +from app.database import get_db + + + +from app.models.user import USER_ROLE_ADMIN, User + + + + + + + +bearer_scheme = HTTPBearer(auto_error=False) + + + + + + + + + + + +@dataclass(frozen=True) + + + +class CurrentUser: + username: str + role: str + device_id: int | None = None + + + + + + + + @property + + + + def is_admin(self) -> bool: + + + + return self.role == USER_ROLE_ADMIN + + + + + + + + + + + +def create_access_token(subject: str, role: str, *, device_id: int | None = None) -> str: + settings = get_settings() + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload: dict[str, object] = {"sub": subject, "role": role, "exp": expire} + if device_id is not None: + payload["device_id"] = device_id + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + + + + + + + + + + +def _assert_mobile_device_active(db: Session, device_id: int, user_id: int) -> None: + from app.models.mobile_device import MobileDevice + + device = db.get(MobileDevice, device_id) + if device is None or device.user_id != user_id or device.revoked_at is not None: + raise HTTPException(status_code=401, detail="Mobile device revoked or invalid") + + +def get_token_device_id(token: str) -> int | None: + settings = get_settings() + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + except JWTError: + return None + raw = payload.get("device_id") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _user_from_db(db: Session, username: str) -> User: + + + + normalized = username.strip() + + + + user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower())) + + + + if user is None or not user.is_active: + + + + raise HTTPException(status_code=401, detail="Invalid token") + + + + return user + + + + + + + + + + + +def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser: + + + + settings = get_settings() + + + + try: + + + + payload = jwt.decode( + + + + token, + + + + settings.jwt_secret, + + + + algorithms=[settings.jwt_algorithm], + + + + ) + + + + username = payload.get("sub") + + + + if not username: + + + + raise HTTPException(status_code=401, detail="Invalid token") + + + + parsed_device_id: int | None = None + raw_device_id = payload.get("device_id") + if raw_device_id is not None: + try: + parsed_device_id = int(raw_device_id) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=401, detail="Invalid token") from exc + + if db is not None: + user = _user_from_db(db, str(username)) + if parsed_device_id is not None: + _assert_mobile_device_active(db, parsed_device_id, user.id) + return CurrentUser(username=user.username, role=user.role, device_id=parsed_device_id) + + role = str(payload.get("role") or USER_ROLE_ADMIN) + return CurrentUser(username=str(username), role=role, device_id=parsed_device_id) + + + + except JWTError as exc: + + + + raise HTTPException(status_code=401, detail="Invalid token") from exc + + + + + + + + + + + +def verify_access_token(token: str, db: Session | None = None) -> str: + + + + """Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username.""" + + + + return _decode_current_user(token, db=db).username + + + + + + + + + + + +def get_current_user( + + + + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + + + + db: Session = Depends(get_db), + + + +) -> CurrentUser: + + + + if credentials is None or credentials.scheme.lower() != "bearer": + + + + raise HTTPException( + + + + status_code=status.HTTP_401_UNAUTHORIZED, + + + + detail="Not authenticated", + + + + ) + + + + return _decode_current_user(credentials.credentials, db=db) + + + + + + + + + + + +def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + + + + if not user.is_admin: + + + + raise HTTPException( + + + + status_code=status.HTTP_403_FORBIDDEN, + + + + detail="Admin access required", + + + + ) + + + + return user + + + + diff --git a/backend/app/auth/stream_cookie.py b/backend/app/auth/stream_cookie.py new file mode 100644 index 0000000..52a6fca --- /dev/null +++ b/backend/app/auth/stream_cookie.py @@ -0,0 +1,37 @@ +"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header).""" + +from __future__ import annotations + +from fastapi import Response + +from app.config import Settings + +STREAM_COOKIE_NAME = "sac_stream" +STREAM_COOKIE_PATH = "/api/v1/stream" + + +def stream_cookie_kwargs(settings: Settings) -> dict[str, object]: + secure = settings.sac_public_url.lower().startswith("https://") + return { + "key": STREAM_COOKIE_NAME, + "httponly": True, + "secure": secure, + "samesite": "strict", + "path": STREAM_COOKIE_PATH, + "max_age": max(60, int(settings.jwt_expire_minutes) * 60), + } + + +def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None: + response.set_cookie(value=token, **stream_cookie_kwargs(settings)) + + +def clear_stream_auth_cookie(response: Response, settings: Settings) -> None: + kwargs = stream_cookie_kwargs(settings) + response.delete_cookie( + key=kwargs["key"], + path=kwargs["path"], + secure=bool(kwargs["secure"]), + httponly=True, + samesite="strict", + ) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..11256e5 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,170 @@ +import os +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA_PATH = ROOT / "schemas" / "event-schema-v1.json" +DEFAULT_CONFIG_FILE = Path("/opt/security-alert-center/config/sac-api.env") + + +def _resolve_config_files() -> tuple[str, ...]: + """Файлы конфигурации для pydantic (корректный разбор кавычек и URL).""" + candidates: list[Path] = [] + explicit = os.environ.get("SAC_CONFIG_FILE", "").strip() + if explicit: + candidates.append(Path(explicit)) + else: + candidates.append(DEFAULT_CONFIG_FILE) + candidates.append(Path(".env")) + return tuple(str(p) for p in candidates if p.is_file()) + + +def _settings_config() -> SettingsConfigDict: + files = _resolve_config_files() + return SettingsConfigDict( + env_file=files if files else None, + env_file_encoding="utf-8", + extra="ignore", + ) + + +class Settings(BaseSettings): + model_config = _settings_config() + + database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac" + sac_db_pool_size: int = 15 + sac_db_max_overflow: int = 25 + sac_uvicorn_workers: int = 4 + sac_public_url: str = "http://localhost:8000" + # URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL. + sac_agent_bundle_base_url: str = "" + jwt_secret: str = "change-me-in-production" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 60 * 24 + # Fail-fast on weak JWT/CORS defaults (disable only for local dev/tests). + sac_security_enforce: bool = True + sac_ingest_max_body_bytes: int = 2_097_152 + + sac_bootstrap_api_key: str = "" + sac_admin_username: str = "admin" + sac_admin_password: str = "" + + event_schema_path: str = str(SCHEMA_PATH) + cors_origins: str = "*" + telegram_enabled: bool = False + telegram_bot_token: str = "" + telegram_chat_id: str = "" + telegram_min_severity: str = "high" + + webhook_enabled: bool = False + webhook_url: str = "" + webhook_secret_header: str = "" + webhook_secret: str = "" + webhook_min_severity: str = "high" + + smtp_enabled: bool = False + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_to: str = "" + smtp_starttls: bool = True + smtp_ssl: bool = False + smtp_min_severity: str = "high" + + notify_min_severity: str = "warning" + notify_channels: str = "telegram,webhook,email" + + # F-NOT-03: cooldown исходящих оповещений (dedup_key / fingerprint) + sac_notify_cooldown_enabled: bool = True + sac_notify_event_cooldown_sec: int = 90 + sac_notify_problem_cooldown_sec: int = 300 + + # F-NOT-05: суточные отчёты из агрегации событий SAC (для exclusive / без отчёта агента) + sac_daily_report_enabled: bool = True + sac_daily_report_hour: int = 9 + sac_daily_report_timezone: str = "Europe/Moscow" + sac_daily_report_skip_if_agent_sent: bool = True + sac_daily_report_require_activity: bool = True + + # Порог «живости» агента + sac_heartbeat_stale_minutes: int = 300 + + # Периодическое сканирование stale heartbeat (proactive host_silence) + sac_host_silence_scan_enabled: bool = True + sac_host_silence_scan_interval_minutes: int = 5 + # После ручного закрытия host_silence не открывать снова раньше N часов (auto-close по heartbeat — без паузы). + sac_host_silence_manual_resolve_cooldown_hours: int = 12 + + # Окно корреляции Problems: host + type + rule в одном open Problem + sac_problem_correlation_window_minutes: int = 60 + + # rule:brute_force_burst — ssh.login.failed / rdp.login.failed + sac_brute_force_window_minutes: int = 15 + sac_brute_force_threshold: int = 30 + + # rule:privilege_spike — privilege.sudo.command + sac_privilege_spike_window_minutes: int = 10 + sac_privilege_spike_threshold: int = 10 + + # rule:rdg_session_flap — RD Gateway 302→303 within window + sac_rdg_flap_window_min_sec: int = 1 + sac_rdg_flap_window_max_sec: int = 10 + sac_rdg_flap_dedup_sec: int = 30 + # Внешние IP HAProxy/RDG-прокси: если external_ip в списке — путь Haproxy-RDG-Comp, иначе RDG-Comp + sac_rdg_haproxy_external_ips: str = "" + + # Windows admin for agent qwinsta/logoff (domain-wide) + sac_win_admin_user: str = "" + sac_win_admin_password: str = "" + sac_winrm_use_https: bool = False + sac_winrm_server_cert_validation: str = "validate" + + # Linux SSH admin for remote agent update (fallback SSH, phase 5) + sac_linux_admin_user: str = "" + sac_linux_admin_password: str = "" + sac_ssh_known_hosts_file: str = "/opt/security-alert-center/config/ssh_known_hosts" + sac_ssh_auto_add_host_key: bool = False + + # Agent updates (mode gpo|sac, recommended versions, WinRM fallback script) + sac_agent_update_mode: str = "gpo" + sac_agent_update_fallback_enabled: bool = True + sac_agent_update_fallback_minutes: int = 15 + sac_agent_recommended_rdp_version: str = "" + sac_agent_recommended_ssh_version: str = "" + sac_agent_min_rdp_version: str = "" + sac_agent_min_ssh_version: str = "" + sac_win_agent_update_script: str = "" + sac_agent_rdp_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git" + sac_agent_ssh_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git" + sac_agent_git_branch: str = "main" + sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos" + sac_agent_git_cache_ttl_minutes: int = 30 + sac_agent_git_token: str = "" + + # Retention (app.jobs.retention / systemd timer) + sac_events_retention_days: int = 90 + sac_problems_retention_days: int = 180 + + # UI login brute-force protection + sac_login_max_failures: int = 3 + sac_login_failure_window_minutes: int = 15 + sac_login_alert_telegram: bool = True + sac_login_ip_whitelist: str = "" + sac_fail2ban_ssh_jail: str = "sshd" + sac_fail2ban_sync_enabled: bool = False + sac_fail2ban_ignoreip_file: str = "/etc/fail2ban/jail.d/sac-login-whitelist.local" + + # Seaca mobile (FCM push) + sac_fcm_enabled: bool = False + sac_fcm_project_id: str = "" + sac_fcm_service_account_json: str = "" + sac_mobile_refresh_expire_days: int = 90 + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/constants/event_types.py b/backend/app/constants/event_types.py new file mode 100644 index 0000000..ad01692 --- /dev/null +++ b/backend/app/constants/event_types.py @@ -0,0 +1,40 @@ +"""Known SAC event types and default severities from agents (schema v1 / agent-integration).""" + +from __future__ import annotations + +DEFAULT_EVENT_SEVERITIES: dict[str, str] = { + # Agent / lifecycle + "agent.heartbeat": "info", + "agent.lifecycle": "info", + "agent.inventory": "info", + "agent.test": "info", + "agent.recovered": "info", + # SSH + "ssh.login.success": "info", + "ssh.login.failed": "warning", + "ssh.ip.banned": "high", + "ssh.ip.bruteforce.threshold": "warning", + "ssh.bruteforce.mass": "high", + "privilege.sudo.command": "warning", + "session.logind.new": "info", + "session.logind.removed": "info", + "session.logind.failed": "warning", + "report.daily.ssh": "info", + # 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", + "winrm.session.started": "warning", + "smb.admin_share.access": "warning", + "auth.explicit.credentials": "warning", + "report.daily.rdp": "info", + "rdp.ip.banned": "high", + "rdp.ip.ban": "high", + # RD Gateway + "rdg.connection.success": "info", + "rdg.connection.disconnected": "info", + "rdg.connection.failed": "warning", +} diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..8bbd10c --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,26 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + +settings = get_settings() +_engine_kwargs: dict = {"pool_pre_ping": True} +if settings.database_url.startswith("postgresql"): + _engine_kwargs["pool_size"] = max(1, int(settings.sac_db_pool_size)) + _engine_kwargs["max_overflow"] = max(0, int(settings.sac_db_max_overflow)) +engine = create_engine(settings.database_url, **_engine_kwargs) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/jobs/__init__.py b/backend/app/jobs/__init__.py new file mode 100644 index 0000000..238512c --- /dev/null +++ b/backend/app/jobs/__init__.py @@ -0,0 +1 @@ +"""Фоновые задачи SAC (retention и др.).""" diff --git a/backend/app/jobs/daily_report.py b/backend/app/jobs/daily_report.py new file mode 100644 index 0000000..fec063e --- /dev/null +++ b/backend/app/jobs/daily_report.py @@ -0,0 +1,45 @@ +"""CLI: python -m app.jobs.daily_report [--force]""" + +from __future__ import annotations + +import argparse +import logging +import sys + +from app.database import SessionLocal +from app.services.daily_report import run_daily_reports + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("sac.daily_report") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate SAC daily reports from DB events") + parser.add_argument( + "--force", + action="store_true", + help="Ignore SAC_DAILY_REPORT_HOUR (for manual run / tests)", + ) + args = parser.parse_args(argv) + + db = SessionLocal() + try: + results = run_daily_reports(db, force=args.force) + created = [r for r in results if r.created] + logger.info("created %s reports", len(created)) + for r in created: + logger.info(" %s %s event_id=%s", r.hostname, r.report_type, r.event_id) + skipped = [r for r in results if not r.created and r.skipped_reason] + for r in skipped: + logger.info(" skip %s: %s", r.hostname, r.skipped_reason) + return 0 + except Exception: + db.rollback() + logger.exception("daily report job failed") + return 1 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/jobs/host_silence_background.py b/backend/app/jobs/host_silence_background.py new file mode 100644 index 0000000..2535aba --- /dev/null +++ b/backend/app/jobs/host_silence_background.py @@ -0,0 +1,60 @@ +"""Фоновый asyncio-цикл сканирования stale heartbeat (в процессе sac-api).""" + +from __future__ import annotations + +import asyncio +import logging + +from app.config import get_settings +from app.database import SessionLocal +from app.services.host_silence_scan import dispatch_host_silence_notifications, run_host_silence_scan + +logger = logging.getLogger("sac.host_silence_scan") + + +async def host_silence_scan_loop(stop_event: asyncio.Event) -> None: + settings = get_settings() + interval_sec = max(60, int(settings.sac_host_silence_scan_interval_minutes) * 60) + logger.info( + "host_silence background scan enabled (interval=%s min, stale=%s min)", + settings.sac_host_silence_scan_interval_minutes, + settings.sac_heartbeat_stale_minutes, + ) + while not stop_event.is_set(): + try: + await asyncio.to_thread(_run_scan_once) + except Exception: + logger.exception("host_silence background scan error") + try: + await asyncio.wait_for(stop_event.wait(), timeout=interval_sec) + except asyncio.TimeoutError: + continue + + +def _run_scan_once() -> None: + db = SessionLocal() + try: + results = run_host_silence_scan(db) + notified = dispatch_host_silence_notifications(db, results) + from app.services.agent_update import process_agent_update_fallbacks + + fallback_results = process_agent_update_fallbacks(db) + db.commit() + if results: + logger.info( + "host_silence background: stale=%s created=%s notified=%s", + len(results), + sum(1 for r in results if r.created), + notified, + ) + if fallback_results: + logger.info( + "agent_update fallback: processed=%s ok=%s", + len(fallback_results), + sum(1 for r in fallback_results if r.get("ok")), + ) + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/backend/app/jobs/host_silence_scan.py b/backend/app/jobs/host_silence_scan.py new file mode 100644 index 0000000..eea06b8 --- /dev/null +++ b/backend/app/jobs/host_silence_scan.py @@ -0,0 +1,38 @@ +"""CLI: python -m app.jobs.host_silence_scan""" + +from __future__ import annotations + +import logging +import sys + +from app.database import SessionLocal +from app.services.host_silence_scan import dispatch_host_silence_notifications, run_host_silence_scan + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("sac.host_silence_scan") + + +def main() -> int: + db = SessionLocal() + try: + results = run_host_silence_scan(db) + notified = dispatch_host_silence_notifications(db, results) + db.commit() + created = sum(1 for r in results if r.created) + logger.info( + "host_silence scan done stale_hosts=%s created=%s notified=%s", + len(results), + created, + notified, + ) + return 0 + except Exception: + db.rollback() + logger.exception("host_silence scan failed") + return 1 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/jobs/retention.py b/backend/app/jobs/retention.py new file mode 100644 index 0000000..4556fbb --- /dev/null +++ b/backend/app/jobs/retention.py @@ -0,0 +1,36 @@ +"""CLI: python -m app.jobs.retention""" + +from __future__ import annotations + +import logging +import sys + +from app.config import get_settings +from app.database import SessionLocal +from app.services.retention import purge_old_data + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("sac.retention") + + +def main() -> int: + settings = get_settings() + db = SessionLocal() + try: + stats = purge_old_data(db, settings) + logger.info( + "retention done events_deleted=%s problems_deleted=%s", + stats["events_deleted"], + stats["problems_deleted"], + ) + return 0 + except Exception: + db.rollback() + logger.exception("retention failed") + return 1 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7329338 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,163 @@ +import asyncio +import logging +from contextlib import asynccontextmanager, suppress +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from sqlalchemy import select + +from app.api.v1.router import api_router +from app.auth.api_key import hash_api_key +from app.config import get_settings +from app.database import SessionLocal +from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware +from app.models import ApiKey +from app.security_bootstrap import validate_security_settings, warn_relaxed_security +from app.services.user_auth import bootstrap_admin_user +from app.version import APP_VERSION, APP_VERSION_LABEL + +ROOT = Path(__file__).resolve().parents[2] +logger = logging.getLogger("sac") + + +def bootstrap_api_key() -> None: + settings = get_settings() + if not settings.sac_bootstrap_api_key: + return + db = SessionLocal() + try: + key_hash = hash_api_key(settings.sac_bootstrap_api_key) + exists = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash)) + if exists: + return + prefix = settings.sac_bootstrap_api_key[:12] + db.add( + ApiKey( + name="bootstrap", + key_prefix=prefix, + key_hash=key_hash, + is_active=True, + ) + ) + db.commit() + finally: + db.close() + + +def bootstrap_users() -> None: + db = SessionLocal() + try: + bootstrap_admin_user(db) + finally: + db.close() + + +def bootstrap_stale_remote_actions() -> None: + db = SessionLocal() + try: + from app.services.host_remote_actions import clear_stale_running_remote_actions + + cleared = clear_stale_running_remote_actions(db) + for hostname in cleared: + logger.info("Stale remote action reset on startup: %s", hostname) + finally: + db.close() + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + settings = get_settings() + validate_security_settings(settings) + warn_relaxed_security(settings) + logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION) + bootstrap_api_key() + bootstrap_users() + bootstrap_stale_remote_actions() + + stop_scan = asyncio.Event() + scan_task: asyncio.Task | None = None + if settings.sac_host_silence_scan_enabled: + from app.jobs.host_silence_background import host_silence_scan_loop + + scan_task = asyncio.create_task(host_silence_scan_loop(stop_scan)) + + yield + + if scan_task is not None: + stop_scan.set() + scan_task.cancel() + with suppress(asyncio.CancelledError): + await scan_task + logger.info("%s — application shutdown", APP_VERSION_LABEL) + + +def configure_logging() -> None: + if logging.getLogger().handlers: + return + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(message)s", + ) + + +def create_app() -> FastAPI: + configure_logging() + settings = get_settings() + app = FastAPI( + title="Security Alert Center", + version=APP_VERSION, + lifespan=lifespan, + ) + origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] + wildcard = origins == ["*"] + app.add_middleware( + CORSMiddleware, + allow_origins=origins if not wildcard else ["*"], + allow_credentials=not wildcard, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware( + IngestBodySizeLimitMiddleware, + max_bytes=settings.sac_ingest_max_body_bytes, + ) + from app.api.v1.health import router as health_router + + app.include_router(api_router, prefix="/api/v1") + app.include_router(health_router) + + frontend_dist = ROOT / "frontend" / "dist" + if frontend_dist.is_dir(): + assets_dir = frontend_dist / "assets" + if assets_dir.is_dir(): + app.mount( + "/assets", + StaticFiles(directory=str(assets_dir)), + name="ui-assets", + ) + + @app.get("/{spa_path:path}") + def spa_fallback(spa_path: str) -> FileResponse: + if spa_path.startswith("api/"): + raise HTTPException(status_code=404, detail="Not Found") + dist_root = frontend_dist.resolve() + if spa_path: + candidate = (frontend_dist / spa_path).resolve() + try: + candidate.relative_to(dist_root) + except ValueError: + raise HTTPException(status_code=404, detail="Not Found") from None + if candidate.is_file(): + return FileResponse(candidate) + index = frontend_dist / "index.html" + if not index.is_file(): + raise HTTPException(status_code=404, detail="UI not built") + return FileResponse(index) + + return app + + +app = create_app() diff --git a/backend/app/middleware/ingest_body_limit.py b/backend/app/middleware/ingest_body_limit.py new file mode 100644 index 0000000..37e0c80 --- /dev/null +++ b/backend/app/middleware/ingest_body_limit.py @@ -0,0 +1,28 @@ +"""Limit POST /api/v1/events body size (matches nginx client_max_body_size).""" + +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + + +class IngestBodySizeLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, *, max_bytes: int = 2_097_152) -> None: + super().__init__(app) + self._max_bytes = max_bytes + + async def dispatch(self, request: Request, call_next): + if request.method == "POST" and request.url.path.rstrip("/") == "/api/v1/events": + content_length = request.headers.get("content-length") + if content_length: + try: + size = int(content_length) + except ValueError: + size = 0 + if size > self._max_bytes: + return JSONResponse( + status_code=413, + content={"detail": f"Request body too large (max {self._max_bytes} bytes)"}, + ) + return await call_next(request) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..a4152aa --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,38 @@ +from app.models.agent_command import AgentCommand +from app.models.api_key import ApiKey +from app.models.event import Event +from app.models.host import Host +from app.models.notification_channel import NotificationChannel +from app.models.notification_cooldown import NotificationCooldown +from app.models.notification_policy import NotificationPolicy +from app.models.problem import Problem, ProblemEvent +from app.models.login_attempt import LoginAttempt +from app.models.ui_settings import UiSettings +from app.models.event_severity_override import EventSeverityOverride +from app.models.event_type_visibility import EventTypeVisibility +from app.models.user import User +from app.models.mobile_settings import MobileSettings +from app.models.mobile_enrollment_code import MobileEnrollmentCode +from app.models.mobile_device import MobileDevice +from app.models.mobile_refresh_token import MobileRefreshToken + +__all__ = [ + "ApiKey", + "AgentCommand", + "Event", + "Host", + "NotificationChannel", + "NotificationCooldown", + "NotificationPolicy", + "Problem", + "ProblemEvent", + "User", + "EventSeverityOverride", + "EventTypeVisibility", + "LoginAttempt", + "UiSettings", + "MobileSettings", + "MobileEnrollmentCode", + "MobileDevice", + "MobileRefreshToken", +] diff --git a/backend/app/models/agent_command.py b/backend/app/models/agent_command.py new file mode 100644 index 0000000..96f1e2c --- /dev/null +++ b/backend/app/models/agent_command.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AgentCommand(Base): + __tablename__ = "agent_commands" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + command_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True) + host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id", ondelete="CASCADE"), index=True) + event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True) + command_type: Mapped[str] = mapped_column(String(32)) + params: Mapped[dict | None] = mapped_column(JSONB, default=dict) + status: Mapped[str] = mapped_column(String(16), default="pending", index=True) + result_stdout: Mapped[str | None] = mapped_column(Text) + result_stderr: Mapped[str | None] = mapped_column(Text) + requested_by: Mapped[str | None] = mapped_column(String(128)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + host: Mapped["Host"] = relationship(back_populates="agent_commands") + event: Mapped["Event | None"] = relationship() diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py new file mode 100644 index 0000000..57e44d5 --- /dev/null +++ b/backend/app/models/api_key.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128)) + key_prefix: Mapped[str] = mapped_column(String(16), index=True) + key_hash: Mapped[str] = mapped_column(String(128), unique=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/event.py b/backend/app/models/event.py new file mode 100644 index 0000000..b4995cb --- /dev/null +++ b/backend/app/models/event.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Event(Base): + __tablename__ = "events" + __table_args__ = (UniqueConstraint("event_id", name="uq_events_event_id"),) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + event_id: Mapped[str] = mapped_column(String(36), index=True) + host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id"), index=True) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + category: Mapped[str] = mapped_column(String(64), index=True) + type: Mapped[str] = mapped_column(String(128), index=True) + severity: Mapped[str] = mapped_column(String(16), index=True) + title: Mapped[str] = mapped_column(String(256)) + summary: Mapped[str] = mapped_column(Text) + details: Mapped[dict | None] = mapped_column(JSONB) + raw: Mapped[dict | None] = mapped_column(JSONB) + dedup_key: Mapped[str | None] = mapped_column(String(512), index=True) + correlation_id: Mapped[str | None] = mapped_column(String(36)) + payload: Mapped[dict] = mapped_column(JSONB) + + host: Mapped["Host"] = relationship(back_populates="events") diff --git a/backend/app/models/event_severity_override.py b/backend/app/models/event_severity_override.py new file mode 100644 index 0000000..b448f3d --- /dev/null +++ b/backend/app/models/event_severity_override.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class EventSeverityOverride(Base): + __tablename__ = "event_severity_overrides" + + event_type: Mapped[str] = mapped_column(String(128), primary_key=True) + severity: Mapped[str] = mapped_column(String(16), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/models/event_type_visibility.py b/backend/app/models/event_type_visibility.py new file mode 100644 index 0000000..8426232 --- /dev/null +++ b/backend/app/models/event_type_visibility.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class EventTypeVisibility(Base): + """Per event-type UI visibility (default: show in events lists).""" + + __tablename__ = "event_type_visibility" + + event_type: Mapped[str] = mapped_column(String(128), primary_key=True) + show_in_events: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/models/host.py b/backend/app/models/host.py new file mode 100644 index 0000000..7e27bed --- /dev/null +++ b/backend/app/models/host.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Host(Base): + __tablename__ = "hosts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + agent_instance_id: Mapped[str | None] = mapped_column(String(128), unique=True, index=True) + hostname: Mapped[str] = mapped_column(String(255), index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + os_family: Mapped[str] = mapped_column(String(32)) + os_version: Mapped[str | None] = mapped_column(String(128)) + product: Mapped[str] = mapped_column(String(64), index=True) + product_version: Mapped[str | None] = mapped_column(String(64)) + ipv4: Mapped[str | None] = mapped_column(String(45)) + ipv6: Mapped[str | None] = mapped_column(String(45)) + tags: Mapped[list | None] = mapped_column(JSONB, default=list) + inventory: Mapped[dict | None] = mapped_column(JSONB) + inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + use_sac_mode: Mapped[str | None] = mapped_column(String(32)) + ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True) + ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings. + mgmt_user: Mapped[str | None] = mapped_column(String(256)) + mgmt_password: Mapped[str | None] = mapped_column(Text) + pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false") + pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + pending_update_target_version: Mapped[str | None] = mapped_column(String(64)) + agent_config: Mapped[dict | None] = mapped_column(JSONB) + agent_config_revision: Mapped[int] = mapped_column(default=0, server_default="0") + agent_update_state: Mapped[str | None] = mapped_column(String(32)) + agent_update_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + agent_update_last_error: Mapped[str | None] = mapped_column(Text) + remote_action: Mapped[dict | None] = mapped_column(JSONB) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + events: Mapped[list["Event"]] = relationship(back_populates="host") + agent_commands: Mapped[list["AgentCommand"]] = relationship(back_populates="host") diff --git a/backend/app/models/login_attempt.py b/backend/app/models/login_attempt.py new file mode 100644 index 0000000..56b0df6 --- /dev/null +++ b/backend/app/models/login_attempt.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class LoginAttempt(Base): + __tablename__ = "login_attempts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ip_address: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + username: Mapped[str | None] = mapped_column(String(64), nullable=True) + success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + index=True, + ) diff --git a/backend/app/models/mobile_device.py b/backend/app/models/mobile_device.py new file mode 100644 index 0000000..a998ebe --- /dev/null +++ b/backend/app/models/mobile_device.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class MobileDevice(Base): + __tablename__ = "mobile_devices" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("sac_users.id", ondelete="CASCADE"), index=True) + device_uuid: Mapped[str] = mapped_column(String(64), unique=True, index=True) + display_name: Mapped[str] = mapped_column(String(128), default="") + platform: Mapped[str] = mapped_column(String(32), default="android") + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + fcm_token: Mapped[str | None] = mapped_column(String(512), nullable=True) + fcm_token_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + enrollment_code_id: Mapped[int | None] = mapped_column( + ForeignKey("mobile_enrollment_codes.id", ondelete="SET NULL"), + ) + enrolled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/mobile_enrollment_code.py b/backend/app/models/mobile_enrollment_code.py new file mode 100644 index 0000000..f10cf8f --- /dev/null +++ b/backend/app/models/mobile_enrollment_code.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class MobileEnrollmentCode(Base): + __tablename__ = "mobile_enrollment_codes" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + label: Mapped[str] = mapped_column(String(128), default="") + code_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + code_prefix: Mapped[str] = mapped_column(String(16)) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL")) + target_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL")) + login_mode: Mapped[str] = mapped_column(String(16), default="password") + max_uses: Mapped[int] = mapped_column(Integer, default=1) + use_count: Mapped[int] = mapped_column(Integer, default=0) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/mobile_refresh_token.py b/backend/app/models/mobile_refresh_token.py new file mode 100644 index 0000000..b02cc11 --- /dev/null +++ b/backend/app/models/mobile_refresh_token.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class MobileRefreshToken(Base): + __tablename__ = "mobile_refresh_tokens" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + device_id: Mapped[int] = mapped_column(ForeignKey("mobile_devices.id", ondelete="CASCADE")) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/mobile_settings.py b/backend/app/models/mobile_settings.py new file mode 100644 index 0000000..6219be7 --- /dev/null +++ b/backend/app/models/mobile_settings.py @@ -0,0 +1,21 @@ +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + +MOBILE_SETTINGS_ROW_ID = 1 + +LOGIN_MODE_PASSWORD = "password" +LOGIN_MODE_CODE_ONLY = "code_only" +LOGIN_MODES = frozenset({LOGIN_MODE_PASSWORD, LOGIN_MODE_CODE_ONLY}) + + +class MobileSettings(Base): + """Singleton: глобальные настройки мобильного клиента Seaca.""" + + __tablename__ = "mobile_settings" + + id: Mapped[int] = mapped_column(primary_key=True) + devices_allowed: Mapped[bool] = mapped_column(Boolean, default=False) + max_devices_per_user: Mapped[int] = mapped_column(Integer, default=3) + min_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) diff --git a/backend/app/models/notification_channel.py b/backend/app/models/notification_channel.py new file mode 100644 index 0000000..e14ecb3 --- /dev/null +++ b/backend/app/models/notification_channel.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class NotificationChannel(Base): + __tablename__ = "notification_channels" + + channel: Mapped[str] = mapped_column(String(32), primary_key=True) + enabled: Mapped[bool] = mapped_column(default=False) + min_severity: Mapped[str] = mapped_column(String(16), default="warning") + bot_token: Mapped[str | None] = mapped_column(Text, nullable=True) + chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True) + webhook_secret_header: Mapped[str | None] = mapped_column(String(64), nullable=True) + webhook_secret: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_host: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_port: Mapped[int | None] = mapped_column(nullable=True) + smtp_user: Mapped[str | None] = mapped_column(String(128), nullable=True) + smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True) + mail_from: Mapped[str | None] = mapped_column(String(256), nullable=True) + mail_to: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_starttls: Mapped[bool] = mapped_column(default=True) + smtp_ssl: Mapped[bool] = mapped_column(default=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/notification_cooldown.py b/backend/app/models/notification_cooldown.py new file mode 100644 index 0000000..a079d76 --- /dev/null +++ b/backend/app/models/notification_cooldown.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class NotificationCooldown(Base): + """Последняя отправка оповещения по ключу (dedup_key / fingerprint).""" + + __tablename__ = "notification_cooldown" + + cooldown_key: Mapped[str] = mapped_column(String(512), primary_key=True) + kind: Mapped[str] = mapped_column(String(16)) # event | problem + last_notified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/models/notification_policy.py b/backend/app/models/notification_policy.py new file mode 100644 index 0000000..4cadd65 --- /dev/null +++ b/backend/app/models/notification_policy.py @@ -0,0 +1,19 @@ +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + +POLICY_ROW_ID = 1 + + +class NotificationPolicy(Base): + """Singleton: глобальное правило severity → каналы (F-NOT-02 урезанно).""" + + __tablename__ = "notification_policy" + + id: Mapped[int] = mapped_column(primary_key=True) + min_severity: Mapped[str] = mapped_column(String(16), default="warning") + use_telegram: Mapped[bool] = mapped_column(Boolean, default=True) + use_webhook: Mapped[bool] = mapped_column(Boolean, default=False) + use_email: Mapped[bool] = mapped_column(Boolean, default=False) + use_mobile: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/backend/app/models/problem.py b/backend/app/models/problem.py new file mode 100644 index 0000000..161b315 --- /dev/null +++ b/backend/app/models/problem.py @@ -0,0 +1,39 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Problem(Base): + __tablename__ = "problems" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + host_id: Mapped[int | None] = mapped_column(ForeignKey("hosts.id"), index=True) + title: Mapped[str] = mapped_column(String(256)) + summary: Mapped[str] = mapped_column(Text) + severity: Mapped[str] = mapped_column(String(16), index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="open") + resolved_by: Mapped[str | None] = mapped_column(String(16)) + rule_id: Mapped[str | None] = mapped_column(String(64)) + fingerprint: Mapped[str] = mapped_column(String(128), index=True) + event_count: Mapped[int] = mapped_column(Integer, default=1) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + host: Mapped["Host | None"] = relationship() + event_links: Mapped[list["ProblemEvent"]] = relationship(back_populates="problem") + + +class ProblemEvent(Base): + __tablename__ = "problem_events" + + problem_id: Mapped[int] = mapped_column(ForeignKey("problems.id", ondelete="CASCADE"), primary_key=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), primary_key=True) + + problem: Mapped["Problem"] = relationship(back_populates="event_links") + event: Mapped["Event"] = relationship() diff --git a/backend/app/models/ui_settings.py b/backend/app/models/ui_settings.py new file mode 100644 index 0000000..f39334b --- /dev/null +++ b/backend/app/models/ui_settings.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + +UI_SETTINGS_ROW_ID = 1 + + +class UiSettings(Base): + """Singleton: глобальные настройки интерфейса SAC.""" + + __tablename__ = "ui_settings" + + id: Mapped[int] = mapped_column(primary_key=True) + show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True) + win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True) + linux_admin_user: Mapped[str | None] = mapped_column(String(128), nullable=True) + linux_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True) + agent_update_mode: Mapped[str] = mapped_column(String(16), default="gpo", nullable=False) + agent_update_fallback_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + agent_update_fallback_minutes: Mapped[int] = mapped_column(default=15, nullable=False) + agent_recommended_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_recommended_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_min_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_min_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True) + agent_rdp_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + agent_ssh_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + agent_git_branch: Mapped[str] = mapped_column(String(64), default="main", nullable=False) + agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + login_ip_whitelist: Mapped[str | None] = mapped_column(Text, nullable=True) + login_sync_fail2ban: Mapped[bool] = mapped_column(default=False, server_default="false", nullable=False) + auto_rdp_flap_disconnect: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", nullable=False + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..9cc49f3 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + +USER_ROLE_ADMIN = "admin" +USER_ROLE_MONITOR = "monitor" +USER_ROLES = frozenset({USER_ROLE_ADMIN, USER_ROLE_MONITOR}) + + +class User(Base): + __tablename__ = "sac_users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(128)) + role: Mapped[str] = mapped_column(String(16), default=USER_ROLE_MONITOR) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py new file mode 100644 index 0000000..665d92e --- /dev/null +++ b/backend/app/schemas/list_models.py @@ -0,0 +1,104 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class HostSummary(BaseModel): + id: int + hostname: str + display_name: str | None + os_family: str + os_version: str | None = None + product: str + product_version: str | None + ipv4: str | None + last_seen_at: datetime + event_count: int | None = None + last_heartbeat_at: datetime | None = None + last_daily_report_at: datetime | None = None + last_inventory_at: datetime | None = None + agent_status: str = "unknown" + version_status: str = "unknown" + pending_agent_update: bool = False + + model_config = {"from_attributes": True} + + +class HostDetail(HostSummary): + agent_instance_id: str | None = None + ipv6: str | None = None + tags: list | None = None + use_sac_mode: str | None = None + inventory: dict | None = None + inventory_updated_at: datetime | None = None + created_at: datetime | None = None + ssh_admin_ok: bool | None = None + ssh_admin_checked_at: datetime | None = None + pending_update_requested_at: datetime | None = None + pending_update_target_version: str | None = None + agent_config_revision: int = 0 + agent_config: dict | None = None + agent_update_state: str | None = None + agent_update_last_at: datetime | None = None + agent_update_last_error: str | None = None + + +class HostListResponse(BaseModel): + items: list[HostSummary] + total: int + page: int + page_size: int + latest_agent_versions: dict[str, str] = Field(default_factory=dict) + reference_agent_versions: dict[str, str] = Field( + default_factory=dict, + description="Эталон для «устарела»: max(git latest, min, max в fleet)", + ) + git_latest_rdp_version: str | None = Field( + None, + description="Последняя версия rdp-login-monitor из git (version.txt / main)", + ) + git_latest_ssh_version: str | None = Field( + None, + description="Последняя версия ssh-monitor из git (version.txt / main)", + ) + + +class EventSummary(BaseModel): + id: int + event_id: str + host_id: int + hostname: str + display_name: str | None = None + product_version: str | None = None + occurred_at: datetime + received_at: datetime + category: str + type: str + severity: str + title: str + summary: str + actor_user: str | None = None + session_duration_sec: int | None = None + rdg_flap: bool = False + rdg_flap_pair_event_id: int | None = None + rdg_flap_qwinsta_event_id: int | None = None + rdg_access_path: str | None = None + rdg_qwinsta_enabled: bool = False + session_terminated: bool = False + + model_config = {"from_attributes": True} + + +class EventDetail(EventSummary): + details: dict | None = None + raw: dict | None = None + dedup_key: str | None = None + correlation_id: str | None = None + payload: dict + + +class EventListResponse(BaseModel): + items: list[EventSummary] + total: int + page: int + page_size: int diff --git a/backend/app/security_bootstrap.py b/backend/app/security_bootstrap.py new file mode 100644 index 0000000..ff1e386 --- /dev/null +++ b/backend/app/security_bootstrap.py @@ -0,0 +1,52 @@ +"""Fail-fast checks for insecure production defaults.""" + +from __future__ import annotations + +import logging +from urllib.parse import urlparse + +from app.config import Settings + +logger = logging.getLogger("sac") + +_WEAK_JWT_SECRETS = frozenset( + { + "", + "change-me-in-production", + "change-me-openssl-rand-hex-32", + "CHANGE_ME_openssl_rand_hex_32", + } +) + + +def _is_local_dev_url(public_url: str) -> bool: + parsed = urlparse(public_url.strip() or "http://localhost:8000") + host = (parsed.hostname or "").lower() + return host in {"localhost", "127.0.0.1", "::1", "testserver"} + + +def validate_security_settings(settings: Settings) -> None: + """Raise on dangerous defaults when SAC_SECURITY_ENFORCE=true.""" + if not settings.sac_security_enforce: + return + + if settings.jwt_secret in _WEAK_JWT_SECRETS: + raise RuntimeError( + "JWT_SECRET is missing or uses an insecure default. " + "Set a random value in sac-api.env (openssl rand -hex 32)." + ) + + if settings.cors_origins.strip() == "*" and not _is_local_dev_url(settings.sac_public_url): + raise RuntimeError( + "CORS_ORIGINS=* is not allowed with SAC_SECURITY_ENFORCE=true on non-local SAC_PUBLIC_URL. " + "Set CORS_ORIGINS to your UI origin, e.g. https://sac.example.com" + ) + + +def warn_relaxed_security(settings: Settings) -> None: + if settings.sac_security_enforce: + return + if settings.jwt_secret in _WEAK_JWT_SECRETS: + logger.warning("JWT_SECRET uses insecure default — set a strong secret before production") + if settings.cors_origins.strip() == "*": + logger.warning("CORS_ORIGINS=* — restrict to explicit origins in production") diff --git a/backend/app/services/agent_commands.py b/backend/app/services/agent_commands.py new file mode 100644 index 0000000..e53037d --- /dev/null +++ b/backend/app/services/agent_commands.py @@ -0,0 +1,109 @@ +"""Queue and resolve agent commands (legacy poll path + helpers).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import AgentCommand, Host +from app.services.win_admin_settings import ( + get_effective_win_admin_config, + get_effective_win_admin_for_host, +) + + +def _win_admin_configured() -> bool: + return get_effective_win_admin_config().configured + + +def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None: + if host is not None and db is not None: + cfg = get_effective_win_admin_for_host(db, host) + else: + cfg = get_effective_win_admin_config(db) + if not cfg.configured: + return None + return { + "user": cfg.user, + "password": cfg.password, + } + + +def command_to_dict( + cmd: AgentCommand, + *, + include_run_as: bool = False, + db: Session | None = None, + host: Host | None = None, +) -> dict: + out = { + "id": cmd.command_uuid, + "type": cmd.command_type, + "params": cmd.params if isinstance(cmd.params, dict) else {}, + "status": cmd.status, + "result_stdout": cmd.result_stdout, + "result_stderr": cmd.result_stderr, + "created_at": cmd.created_at.isoformat() if cmd.created_at else None, + "completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None, + } + if include_run_as and cmd.status == "pending": + run_as = win_admin_run_as(db, host) + if run_as: + out["run_as"] = run_as + return out + + +def command_response_fields(cmd: AgentCommand) -> dict: + params = cmd.params if isinstance(cmd.params, dict) else {} + return { + "target": params.get("winrm_target") or params.get("client_hostname"), + "client_hostname": params.get("client_hostname"), + "internal_ip": params.get("internal_ip"), + } + + +def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]: + return list( + db.scalars( + select(AgentCommand) + .where( + AgentCommand.host_id == host.id, + AgentCommand.status == "pending", + ) + .order_by(AgentCommand.created_at.asc()) + .limit(limit) + ).all() + ) + + +def resolve_host_by_agent_instance_id(db: Session, agent_instance_id: str) -> Host | None: + if not agent_instance_id.strip(): + return None + return db.scalar(select(Host).where(Host.agent_instance_id == agent_instance_id.strip())) + + +def complete_command( + db: Session, + command_uuid: str, + *, + status: str, + stdout: str | None = None, + stderr: str | None = None, +) -> AgentCommand | None: + cmd = db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid)) + if cmd is None: + return None + if cmd.status != "pending": + return cmd + cmd.status = status + cmd.result_stdout = stdout + cmd.result_stderr = stderr + cmd.completed_at = datetime.now(timezone.utc) + db.flush() + return cmd + + +def get_command_by_uuid(db: Session, command_uuid: str) -> AgentCommand | None: + return db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid)) diff --git a/backend/app/services/agent_git_release.py b/backend/app/services/agent_git_release.py new file mode 100644 index 0000000..ef0f10c --- /dev/null +++ b/backend/app/services/agent_git_release.py @@ -0,0 +1,308 @@ +"""Fetch latest agent release versions from git repositories.""" + +from __future__ import annotations + +import hashlib +import re +import shutil +import subprocess +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlparse + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings +from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig +from app.services.agent_version import parse_agent_version + +_SCRIPT_VERSION_RE = re.compile(r'\$ScriptVersion\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) +_SSH_VERSION_RE = re.compile(r'^SSH_MONITOR_VERSION=["\']([^"\']+)["\']', re.MULTILINE) + +_MEMORY_CACHE: dict[str, tuple[dict[str, str], float, str]] = {} + + +@dataclass(frozen=True) +class GitReleaseVersions: + versions: dict[str, str] + fetched_at: datetime | None + from_cache: bool + errors: dict[str, str] = field(default_factory=dict) + + +def normalize_git_repo_url(raw: str) -> str: + text = (raw or "").strip() + if not text: + return "" + if not text.startswith(("http://", "https://", "git@")): + text = f"https://{text.lstrip('/')}" + if text.startswith("git@"): + return text + if not text.endswith(".git"): + text = f"{text.rstrip('/')}.git" + return text + + +def git_repo_url_with_auth(repo_url: str, token: str) -> str: + normalized = normalize_git_repo_url(repo_url) + if not token or not normalized.startswith("https://"): + return normalized + parsed = urlparse(normalized) + host = parsed.netloc + path = parsed.path or "" + return f"https://oauth2:{token}@{host}{path}" + + +def _cache_key(cfg: AgentUpdateConfig) -> str: + parts = [ + cfg.rdp_git_repo_url, + cfg.ssh_git_repo_url, + cfg.git_branch, + ] + return hashlib.sha256("|".join(parts).encode()).hexdigest() + + +def _read_text(path: Path) -> str | None: + if not path.is_file(): + return None + return path.read_text(encoding="utf-8-sig") + + +def parse_rdp_version_from_files(version_txt: str | None, login_monitor_ps1: str | None) -> str | None: + if version_txt: + first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else "" + if parse_agent_version(first_line): + return first_line + if login_monitor_ps1: + match = _SCRIPT_VERSION_RE.search(login_monitor_ps1) + if match: + candidate = match.group(1).strip() + if parse_agent_version(candidate): + return candidate + return None + + +def parse_ssh_version_from_files(ssh_monitor: str | None, version_txt: str | None) -> str | None: + if ssh_monitor: + match = _SSH_VERSION_RE.search(ssh_monitor) + if match: + candidate = match.group(1).strip() + if parse_agent_version(candidate): + return candidate + if version_txt: + first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else "" + if parse_agent_version(first_line): + return first_line + return None + + +def parse_product_version_from_dir(repo_dir: Path, product: str) -> str | None: + if product == PRODUCT_RDP: + return parse_rdp_version_from_files( + _read_text(repo_dir / "version.txt"), + _read_text(repo_dir / "Login_Monitor.ps1"), + ) + if product == PRODUCT_SSH: + return parse_ssh_version_from_files( + _read_text(repo_dir / "ssh-monitor"), + _read_text(repo_dir / "version.txt"), + ) + return None + + +def _run_git(args: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + +def _repo_slug(repo_url: str) -> str: + normalized = normalize_git_repo_url(repo_url) + name = normalized.rstrip("/").split("/")[-1] + if name.endswith(".git"): + name = name[:-4] + digest = hashlib.sha256(normalized.encode()).hexdigest()[:12] + return f"{name}-{digest}" + + +def clone_or_update_repo(repo_url: str, branch: str, cache_dir: Path, token: str = "") -> Path: + clone_url = git_repo_url_with_auth(repo_url, token) + normalized = normalize_git_repo_url(repo_url) + if not normalized: + raise ValueError("empty repo url") + + branch_name = (branch or "main").strip() or "main" + target = cache_dir / _repo_slug(normalized) + cache_dir.mkdir(parents=True, exist_ok=True) + + if target.is_dir() and (target / ".git").is_dir(): + fetch = _run_git(["fetch", "--depth", "1", "origin", branch_name], cwd=target) + if fetch.returncode != 0: + shutil.rmtree(target, ignore_errors=True) + else: + checkout = _run_git(["checkout", "FETCH_HEAD"], cwd=target) + if checkout.returncode == 0: + return target + shutil.rmtree(target, ignore_errors=True) + + clone = _run_git( + ["clone", "--depth", "1", "--branch", branch_name, clone_url, str(target)], + ) + if clone.returncode != 0: + err = (clone.stderr or clone.stdout or "git clone failed").strip() + raise RuntimeError(err) + return target + + +def fetch_product_version(repo_url: str, branch: str, product: str, cache_dir: Path, token: str = "") -> str: + repo_dir = clone_or_update_repo(repo_url, branch, cache_dir, token=token) + version = parse_product_version_from_dir(repo_dir, product) + if not version: + raise RuntimeError(f"version not found in {repo_url} ({product})") + return version + + +def recommended_from_git(cfg: AgentUpdateConfig, git_versions: dict[str, str], product: str) -> str: + git_version = (git_versions.get(product) or "").strip() + if git_version: + return git_version + manual = cfg.recommended_for_product(product) + return manual + + +def _ttl_seconds() -> int: + settings = get_settings() + return max(60, int(settings.sac_agent_git_cache_ttl_minutes) * 60) + + +def _cache_dir() -> Path: + settings = get_settings() + return Path(settings.sac_agent_git_cache_dir or "/tmp/sac-agent-repos") + + +def _db_cache_fresh(row: UiSettings | None, cfg: AgentUpdateConfig) -> bool: + if row is None or row.agent_git_fetched_at is None: + return False + fetched_at = row.agent_git_fetched_at + if fetched_at.tzinfo is None: + fetched_at = fetched_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - fetched_at).total_seconds() + if age > _ttl_seconds(): + return False + if (row.agent_rdp_git_repo_url or "").strip() != cfg.rdp_git_repo_url: + return False + if (row.agent_ssh_git_repo_url or "").strip() != cfg.ssh_git_repo_url: + return False + if (row.agent_git_branch or "main").strip() != cfg.git_branch: + return False + return True + + +def _versions_from_db_row(row: UiSettings) -> dict[str, str]: + out: dict[str, str] = {} + rdp = (row.agent_git_rdp_version or "").strip() + ssh = (row.agent_git_ssh_version or "").strip() + if rdp: + out[PRODUCT_RDP] = rdp + if ssh: + out[PRODUCT_SSH] = ssh + return out + + +def _persist_db_cache(db: Session, cfg: AgentUpdateConfig, versions: dict[str, str]) -> datetime: + now = datetime.now(timezone.utc) + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True) + db.add(row) + row.agent_rdp_git_repo_url = cfg.rdp_git_repo_url or None + row.agent_ssh_git_repo_url = cfg.ssh_git_repo_url or None + row.agent_git_branch = cfg.git_branch or "main" + row.agent_git_rdp_version = versions.get(PRODUCT_RDP) or None + row.agent_git_ssh_version = versions.get(PRODUCT_SSH) or None + row.agent_git_fetched_at = now + db.commit() + return now + + +def get_git_release_versions( + cfg: AgentUpdateConfig, + *, + db: Session | None = None, + force_refresh: bool = False, +) -> GitReleaseVersions: + key = _cache_key(cfg) + now_ts = time.time() + ttl = _ttl_seconds() + + if not force_refresh: + cached = _MEMORY_CACHE.get(key) + if cached and now_ts - cached[1] <= ttl: + fetched_at = datetime.fromtimestamp(cached[1], tz=timezone.utc) + return GitReleaseVersions(versions=dict(cached[0]), fetched_at=fetched_at, from_cache=True) + + if db is not None: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if _db_cache_fresh(row, cfg): + versions = _versions_from_db_row(row) + if versions: + _MEMORY_CACHE[key] = (versions, row.agent_git_fetched_at.timestamp(), key) + return GitReleaseVersions( + versions=versions, + fetched_at=row.agent_git_fetched_at, + from_cache=True, + ) + + settings = get_settings() + cache_dir = _cache_dir() + token = (settings.sac_agent_git_token or "").strip() + branch = cfg.git_branch or "main" + versions: dict[str, str] = {} + errors: dict[str, str] = {} + + product_repo = { + PRODUCT_RDP: cfg.rdp_git_repo_url, + PRODUCT_SSH: cfg.ssh_git_repo_url, + } + for product, repo_url in product_repo.items(): + if not repo_url: + errors[product] = "repo url not configured" + continue + try: + versions[product] = fetch_product_version(repo_url, branch, product, cache_dir, token=token) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc: + errors[product] = str(exc).strip() or "fetch failed" + + fetched_at: datetime | None = None + if versions: + fetched_at = datetime.now(timezone.utc) + _MEMORY_CACHE[key] = (dict(versions), fetched_at.timestamp(), key) + if db is not None: + fetched_at = _persist_db_cache(db, cfg, versions) + + if not versions and db is not None: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is not None: + stale = _versions_from_db_row(row) + if stale: + return GitReleaseVersions( + versions=stale, + fetched_at=row.agent_git_fetched_at, + from_cache=True, + errors=errors, + ) + + return GitReleaseVersions( + versions=versions, + fetched_at=fetched_at, + from_cache=False, + errors=errors, + ) diff --git a/backend/app/services/agent_host_config.py b/backend/app/services/agent_host_config.py new file mode 100644 index 0000000..c0f0496 --- /dev/null +++ b/backend/app/services/agent_host_config.py @@ -0,0 +1,74 @@ +"""Desired agent config per host (poll delivery).""" + +from __future__ import annotations + +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models import Host +from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH + +RDP_CONFIG_KEYS = frozenset( + { + "ServerDisplayName", + "EnableRcmShadowControlMonitoring", + "EnableWinRmInboundMonitoring", + "EnableAdminShareMonitoring", + "GetInventory", + "SacCommandPollIntervalSec", + } +) +SSH_CONFIG_KEYS = frozenset( + { + "SERVER_DISPLAY_NAME", + "HEARTBEAT_INTERVAL", + "DAILY_REPORT_ENABLED", + "UseSAC", + } +) + + +def _allowed_keys(host: Host) -> frozenset[str]: + product = (host.product or "").strip() + if product == PRODUCT_RDP or (host.os_family or "").strip().lower() == "windows": + return RDP_CONFIG_KEYS + if product == PRODUCT_SSH or (host.os_family or "").strip().lower() == "linux": + return SSH_CONFIG_KEYS + return frozenset() + + +def get_host_agent_settings(host: Host) -> dict: + raw = host.agent_config if isinstance(host.agent_config, dict) else {} + allowed = _allowed_keys(host) + if not allowed: + return {} + return {k: v for k, v in raw.items() if k in allowed} + + +def build_agent_config_payload(host: Host) -> dict: + settings = get_host_agent_settings(host) + if not settings: + return {} + return {"settings": settings} + + +def update_host_agent_config(db: Session, host: Host, settings: dict) -> Host: + allowed = _allowed_keys(host) + if not allowed: + raise ValueError("Host product does not support desired agent config") + + current = host.agent_config if isinstance(host.agent_config, dict) else {} + merged = dict(current) + for key, value in settings.items(): + if key not in allowed: + raise ValueError(f"Unsupported config key: {key}") + if value is None: + merged.pop(key, None) + else: + merged[key] = value + + host.agent_config = merged + host.agent_config_revision = int(host.agent_config_revision or 0) + 1 + flag_modified(host, "agent_config") + db.flush() + return host diff --git a/backend/app/services/agent_poll.py b/backend/app/services/agent_poll.py new file mode 100644 index 0000000..505b061 --- /dev/null +++ b/backend/app/services/agent_poll.py @@ -0,0 +1,31 @@ +"""Agent poll payload: commands, desired config, pending update.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from app.models import Host +from app.services.agent_commands import command_to_dict, get_command_for_host_poll +from app.services.agent_host_config import build_agent_config_payload +from app.services.agent_update_settings import get_effective_agent_update_config + + +def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]: + cfg = get_effective_agent_update_config(db) + pending = get_command_for_host_poll(db, host) + config_payload = build_agent_config_payload(host) + + update_block: dict[str, Any] = { + "requested": bool(host.pending_agent_update), + "target_version": host.pending_update_target_version, + "source": "sac" if cfg.sac_managed else "gpo", + } + + return { + "config_revision": int(host.agent_config_revision or 0), + "config": config_payload, + "update": update_block, + "commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending], + } diff --git a/backend/app/services/agent_update.py b/backend/app/services/agent_update.py new file mode 100644 index 0000000..e939742 --- /dev/null +++ b/backend/app/services/agent_update.py @@ -0,0 +1,325 @@ +"""Request agent self-update, ingest lifecycle, SSH/WinRM fallback.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Host +from app.services.agent_update_types import AgentUpdateFallbackResult +from app.services.agent_update_settings import ( + PRODUCT_RDP, + PRODUCT_SSH, + AgentUpdateConfig, + get_effective_agent_update_config, +) +from app.services.agent_git_release import get_git_release_versions, recommended_from_git +from app.services.agent_version import ( + host_version_outdated, + latest_agent_versions_by_product, +) +from app.services.linux_admin_settings import get_effective_linux_admin_for_host +from app.services.ssh_connect import ( + HostNotLinuxError, + HostTargetMissingError, + is_linux_host, + iter_ssh_targets, + run_ssh_monitor_update, +) +from app.services.win_admin_settings import get_effective_win_admin_for_host +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError as WinRmHostTargetMissingError, + is_windows_host, + iter_winrm_targets, + run_winrm_on_host_targets, + run_winrm_rdp_monitor_update, +) + +AGENT_UPDATE_STARTED = "agent.update.started" +AGENT_UPDATE_SUCCESS = "agent.update.success" +AGENT_UPDATE_FAILED = "agent.update.failed" +AGENT_UPDATE_TYPES = frozenset( + {AGENT_UPDATE_STARTED, AGENT_UPDATE_SUCCESS, AGENT_UPDATE_FAILED} +) + + +class AgentUpdateNotAllowedError(Exception): + pass + + +def resolve_target_version( + db: Session, + host: Host, + cfg: AgentUpdateConfig, + *, + git_versions: dict[str, str] | None = None, +) -> str: + product = host.product or "" + if git_versions is None: + git_versions = get_git_release_versions(cfg, db=db).versions + git_target = recommended_from_git(cfg, git_versions, product) + if git_target: + return git_target + latest_map = latest_agent_versions_by_product(db) + return latest_map.get(product, "") or (host.product_version or "") + + +def host_version_status( + host: Host, + *, + cfg: AgentUpdateConfig, + latest_map: dict[str, str], + git_versions: dict[str, str] | None = None, +) -> str: + product = host.product or "" + if git_versions is None: + git_versions = get_git_release_versions(cfg).versions + outdated = host_version_outdated( + host.product_version, + recommended=recommended_from_git(cfg, git_versions, product), + fleet_latest=latest_map.get(product, ""), + min_version=cfg.min_for_product(product), + ) + if outdated is None: + return "unknown" + return "outdated" if outdated else "ok" + + +def request_agent_update(db: Session, host: Host) -> Host: + cfg = get_effective_agent_update_config(db) + if not cfg.sac_managed: + raise AgentUpdateNotAllowedError( + "Agent updates from SAC are disabled (mode=gpo). Use GPO/manual update or switch to mode=sac." + ) + if not host.agent_instance_id: + raise AgentUpdateNotAllowedError("Host has no agent_instance_id (agent never connected via SAC)") + + target = resolve_target_version(db, host, cfg) + now = datetime.now(timezone.utc) + host.pending_agent_update = True + host.pending_update_requested_at = now + host.pending_update_target_version = target or None + host.agent_update_state = "requested" + host.agent_update_last_error = None + db.flush() + return host + + +def _clear_pending(host: Host) -> None: + host.pending_agent_update = False + host.pending_update_requested_at = None + host.pending_update_target_version = None + + +def process_agent_update_ingest(db: Session, host: Host, event_type: str, details: dict | None) -> None: + if event_type not in AGENT_UPDATE_TYPES: + return + + now = datetime.now(timezone.utc) + host.agent_update_last_at = now + details = details if isinstance(details, dict) else {} + + if event_type == AGENT_UPDATE_STARTED: + host.agent_update_state = "started" + return + + if event_type == AGENT_UPDATE_SUCCESS: + host.agent_update_state = "success" + host.agent_update_last_error = None + _clear_pending(host) + version = details.get("product_version") or details.get("version") + if isinstance(version, str) and version.strip(): + host.product_version = version.strip() + return + + if event_type == AGENT_UPDATE_FAILED: + host.agent_update_state = "failed" + err = details.get("error") or details.get("message") + host.agent_update_last_error = str(err)[:2000] if err else "agent.update.failed" + _clear_pending(host) + + +def run_linux_agent_update_fallback( + host: Host, + cfg_linux, + *, + repo_url: str | None = None, + git_branch: str | None = None, +) -> AgentUpdateFallbackResult: + if not is_linux_host(host): + return AgentUpdateFallbackResult(ok=False, message="Host is not Linux", product_version=None) + if not cfg_linux.configured: + return AgentUpdateFallbackResult( + ok=False, + message="Linux SSH admin is not configured", + product_version=None, + ) + + last = AgentUpdateFallbackResult(ok=False, message="SSH fallback failed", product_version=None) + try: + targets = iter_ssh_targets(host) + except (HostNotLinuxError, HostTargetMissingError) as exc: + return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None) + + effective_repo = (repo_url or "").strip() or None + effective_branch = (git_branch or "main").strip() or "main" + for target in targets: + result = run_ssh_monitor_update( + target=target, + user=cfg_linux.user, + password=cfg_linux.password, + repo_url=effective_repo, + git_branch=effective_branch, + ) + last = AgentUpdateFallbackResult( + ok=result.ok, + message=result.message, + product_version=result.agent_version, + target=result.target, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + ) + if result.ok: + return last + return last + + +def run_windows_agent_update_fallback( + host: Host, + cfg_win, + script_path: str, + *, + repo_url: str = "", + git_branch: str = "main", +) -> AgentUpdateFallbackResult: + if not is_windows_host(host): + return AgentUpdateFallbackResult(ok=False, message="Host is not Windows", product_version=None) + if not cfg_win.configured: + return AgentUpdateFallbackResult( + ok=False, + message="Windows domain admin is not configured", + product_version=None, + ) + + try: + targets = iter_winrm_targets(host) + except (HostNotWindowsError, WinRmHostTargetMissingError) as exc: + return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None) + + result, attempts = run_winrm_on_host_targets( + host, + user=cfg_win.user, + password=cfg_win.password, + action=lambda target: run_winrm_rdp_monitor_update( + target=target, + user=cfg_win.user, + password=cfg_win.password, + script_path=script_path, + repo_url=repo_url, + git_branch=git_branch, + ), + ) + if result is None: + return AgentUpdateFallbackResult(ok=False, message="WinRM fallback failed", product_version=None) + message = result.message + if attempts: + message = f"{message} (пробовали: {', '.join(attempts)})" + version = None + if result.ok and result.stdout: + from app.services.ssh_connect import parse_ssh_monitor_version_text + + version = parse_ssh_monitor_version_text(result.stdout) + return AgentUpdateFallbackResult( + ok=result.ok, + message=message, + product_version=version, + target=result.target, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + ) + + +def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbackResult: + cfg = get_effective_agent_update_config(db) + product = (host.product or "").strip() + + if product == PRODUCT_SSH or is_linux_host(host): + linux_cfg = get_effective_linux_admin_for_host(db, host) + agent_cfg = get_effective_agent_update_config(db) + repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None + branch = (agent_cfg.git_branch or "main").strip() or "main" + result = run_linux_agent_update_fallback( + host, + linux_cfg, + repo_url=repo_url, + git_branch=branch, + ) + elif product == PRODUCT_RDP or is_windows_host(host): + win_cfg = get_effective_win_admin_for_host(db, host) + result = run_windows_agent_update_fallback( + host, + win_cfg, + cfg.win_agent_update_script, + repo_url=cfg.rdp_git_repo_url, + git_branch=cfg.git_branch, + ) + else: + return AgentUpdateFallbackResult( + ok=False, + message=f"Unsupported product for fallback update: {product}", + product_version=None, + ) + + now = datetime.now(timezone.utc) + host.agent_update_last_at = now + if result.ok: + host.agent_update_state = "success" + host.agent_update_last_error = None + _clear_pending(host) + if result.product_version: + host.product_version = result.product_version + if is_linux_host(host): + host.ssh_admin_ok = True + host.ssh_admin_checked_at = now + else: + host.agent_update_state = "failed" + host.agent_update_last_error = result.message[:2000] + + db.flush() + return result + + +def process_agent_update_fallbacks(db: Session) -> list[dict]: + """Find stale pending updates and run SSH/WinRM fallback when enabled.""" + cfg = get_effective_agent_update_config(db) + if not cfg.sac_managed or not cfg.fallback_enabled: + return [] + + cutoff = datetime.now(timezone.utc) - timedelta(minutes=cfg.fallback_after_minutes) + rows = db.scalars( + select(Host).where( + Host.pending_agent_update.is_(True), + Host.pending_update_requested_at.isnot(None), + Host.pending_update_requested_at <= cutoff, + Host.agent_update_state.in_(("requested", "started")), + ) + ).all() + + results: list[dict] = [] + for host in rows: + result = execute_agent_update_fallback(db, host) + results.append( + { + "host_id": host.id, + "hostname": host.hostname, + "ok": result.ok, + "message": result.message, + "product_version": result.product_version, + } + ) + return results diff --git a/backend/app/services/agent_update_settings.py b/backend/app/services/agent_update_settings.py new file mode 100644 index 0000000..3a07dad --- /dev/null +++ b/backend/app/services/agent_update_settings.py @@ -0,0 +1,192 @@ +"""Global agent update policy (DB overrides env).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + +PRODUCT_RDP = "rdp-login-monitor" +PRODUCT_SSH = "ssh-monitor" +AGENT_UPDATE_MODES = frozenset({"gpo", "sac"}) + + +@dataclass(frozen=True) +class AgentUpdateConfig: + mode: str + fallback_enabled: bool + fallback_after_minutes: int + recommended_rdp_version: str + recommended_ssh_version: str + min_rdp_version: str + min_ssh_version: str + win_agent_update_script: str + rdp_git_repo_url: str + ssh_git_repo_url: str + git_branch: str + source: str # env | db + + @property + def sac_managed(self) -> bool: + return self.mode == "sac" + + def recommended_for_product(self, product: str) -> str: + if product == PRODUCT_RDP: + return self.recommended_rdp_version + if product == PRODUCT_SSH: + return self.recommended_ssh_version + return "" + + def min_for_product(self, product: str) -> str: + if product == PRODUCT_RDP: + return self.min_rdp_version + if product == PRODUCT_SSH: + return self.min_ssh_version + return "" + + +def _agent_update_from_env() -> AgentUpdateConfig: + settings = get_settings() + mode = (settings.sac_agent_update_mode or "gpo").strip().lower() + if mode not in AGENT_UPDATE_MODES: + mode = "gpo" + return AgentUpdateConfig( + mode=mode, + fallback_enabled=settings.sac_agent_update_fallback_enabled, + fallback_after_minutes=max(1, int(settings.sac_agent_update_fallback_minutes)), + recommended_rdp_version=(settings.sac_agent_recommended_rdp_version or "").strip(), + recommended_ssh_version=(settings.sac_agent_recommended_ssh_version or "").strip(), + min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(), + min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(), + win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(), + rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(), + ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(), + git_branch=(settings.sac_agent_git_branch or "main").strip() or "main", + source="env", + ) + + +def _row_has_agent_update_values(row: UiSettings) -> bool: + return any( + [ + (row.agent_update_mode or "").strip() not in ("", "gpo"), + row.agent_update_fallback_enabled is not None, + row.agent_update_fallback_minutes not in (None, 15), + bool((row.agent_recommended_rdp_version or "").strip()), + bool((row.agent_recommended_ssh_version or "").strip()), + bool((row.agent_min_rdp_version or "").strip()), + bool((row.agent_min_ssh_version or "").strip()), + bool((row.win_agent_update_script or "").strip()), + bool((row.agent_rdp_git_repo_url or "").strip()), + bool((row.agent_ssh_git_repo_url or "").strip()), + (row.agent_git_branch or "main").strip() not in ("", "main"), + ] + ) + + +def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_agent_update_config(session) + finally: + session.close() + + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _agent_update_from_env() + if row is None: + return env_cfg + + mode = (row.agent_update_mode or env_cfg.mode).strip().lower() + if mode not in AGENT_UPDATE_MODES: + mode = env_cfg.mode + + return AgentUpdateConfig( + mode=mode, + fallback_enabled=( + row.agent_update_fallback_enabled + if row.agent_update_fallback_enabled is not None + else env_cfg.fallback_enabled + ), + fallback_after_minutes=max( + 1, + int(row.agent_update_fallback_minutes or env_cfg.fallback_after_minutes), + ), + recommended_rdp_version=(row.agent_recommended_rdp_version or "").strip() + or env_cfg.recommended_rdp_version, + recommended_ssh_version=(row.agent_recommended_ssh_version or "").strip() + or env_cfg.recommended_ssh_version, + min_rdp_version=(row.agent_min_rdp_version or "").strip() or env_cfg.min_rdp_version, + min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version, + win_agent_update_script=(row.win_agent_update_script or "").strip() + or env_cfg.win_agent_update_script, + rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url, + ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url, + git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch, + source="db" if _row_has_agent_update_values(row) else env_cfg.source, + ) + + +def upsert_agent_update_settings( + db: Session, + *, + mode: str | None = None, + fallback_enabled: bool | None = None, + fallback_after_minutes: int | None = None, + recommended_rdp_version: str | None = None, + recommended_ssh_version: str | None = None, + min_rdp_version: str | None = None, + min_ssh_version: str | None = None, + win_agent_update_script: str | None = None, + rdp_git_repo_url: str | None = None, + ssh_git_repo_url: str | None = None, + git_branch: str | None = None, +) -> AgentUpdateConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True) + db.add(row) + + if mode is not None: + normalized = mode.strip().lower() + if normalized not in AGENT_UPDATE_MODES: + raise ValueError(f"mode must be one of: {sorted(AGENT_UPDATE_MODES)}") + row.agent_update_mode = normalized + if fallback_enabled is not None: + row.agent_update_fallback_enabled = fallback_enabled + if fallback_after_minutes is not None: + row.agent_update_fallback_minutes = max(1, int(fallback_after_minutes)) + if recommended_rdp_version is not None: + row.agent_recommended_rdp_version = recommended_rdp_version.strip() or None + if recommended_ssh_version is not None: + row.agent_recommended_ssh_version = recommended_ssh_version.strip() or None + if min_rdp_version is not None: + row.agent_min_rdp_version = min_rdp_version.strip() or None + if min_ssh_version is not None: + row.agent_min_ssh_version = min_ssh_version.strip() or None + if win_agent_update_script is not None: + row.win_agent_update_script = win_agent_update_script.strip() or None + if rdp_git_repo_url is not None: + row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None + row.agent_git_rdp_version = None + row.agent_git_fetched_at = None + if ssh_git_repo_url is not None: + row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None + row.agent_git_ssh_version = None + row.agent_git_fetched_at = None + if git_branch is not None: + branch = git_branch.strip() or "main" + if branch != (row.agent_git_branch or "main"): + row.agent_git_rdp_version = None + row.agent_git_ssh_version = None + row.agent_git_fetched_at = None + row.agent_git_branch = branch + + db.commit() + db.refresh(row) + return get_effective_agent_update_config(db) diff --git a/backend/app/services/agent_update_types.py b/backend/app/services/agent_update_types.py new file mode 100644 index 0000000..486b9d1 --- /dev/null +++ b/backend/app/services/agent_update_types.py @@ -0,0 +1,16 @@ +"""Agent update fallback result with remote command streams.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgentUpdateFallbackResult: + ok: bool + message: str + product_version: str | None + target: str = "" + stdout: str = "" + stderr: str = "" + exit_code: int | None = None diff --git a/backend/app/services/agent_version.py b/backend/app/services/agent_version.py new file mode 100644 index 0000000..12dbf8b --- /dev/null +++ b/backend/app/services/agent_version.py @@ -0,0 +1,149 @@ +"""Сравнение версий агентов (ssh-monitor / rdp-login-monitor, формат X.Y.Z-SAC).""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Host + +_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-SAC)?$", re.IGNORECASE) +_DEFAULT_LAG_THRESHOLD = 3 + + +@dataclass(frozen=True, order=True) +class ParsedAgentVersion: + major: int + minor: int + patch: int + + +def parse_agent_version(raw: str | None) -> ParsedAgentVersion | None: + if not raw: + return None + text = raw.strip() + match = _VERSION_RE.match(text) + if not match: + return None + return ParsedAgentVersion( + major=int(match.group(1)), + minor=int(match.group(2)), + patch=int(match.group(3)), + ) + + +def agent_version_lag(host: ParsedAgentVersion, latest: ParsedAgentVersion) -> int | None: + """Отставание от latest: patch при том же major.minor, иначе None (любое отставание).""" + if host > latest: + return 0 + if host.major != latest.major or host.minor != latest.minor: + return None + return latest.patch - host.patch + + +def is_agent_version_outdated( + host_version: str | None, + latest_version: str | None, + *, + lag_threshold: int = _DEFAULT_LAG_THRESHOLD, +) -> bool: + host = parse_agent_version(host_version) + latest = parse_agent_version(latest_version) + if host is None or latest is None or host >= latest: + return False + lag = agent_version_lag(host, latest) + if lag is None: + return True + return lag >= lag_threshold + + +def effective_reference_version( + *, + recommended: str = "", + fleet_latest: str = "", + min_version: str = "", +) -> str | None: + """Единый эталон: max(recommended, min_version, max версия в fleet по продукту).""" + best: ParsedAgentVersion | None = None + best_raw: str | None = None + for raw in (recommended, min_version, fleet_latest): + text = (raw or "").strip() + parsed = parse_agent_version(text) + if parsed is None: + continue + if best is None or parsed > best: + best = parsed + best_raw = text + return best_raw + + +def reference_agent_versions_by_product( + cfg, + latest_map: dict[str, str], + *, + git_versions: dict[str, str] | None = None, + products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"), +) -> dict[str, str]: + from app.services.agent_git_release import recommended_from_git + from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH + + git_versions = git_versions or {} + product_cfg = { + PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)), + PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)), + } + out: dict[str, str] = {} + for product in products: + recommended, min_version = product_cfg.get(product, ("", "")) + ref = effective_reference_version( + recommended=recommended, + fleet_latest=latest_map.get(product, ""), + min_version=min_version, + ) + if ref: + out[product] = ref + return out + + +def host_version_outdated( + host_version: str | None, + *, + recommended: str = "", + fleet_latest: str = "", + min_version: str = "", + lag_threshold: int = _DEFAULT_LAG_THRESHOLD, +) -> bool | None: + """True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста).""" + if not host_version or parse_agent_version(host_version) is None: + return None + reference = effective_reference_version( + recommended=recommended, + fleet_latest=fleet_latest, + min_version=min_version, + ) + if not reference: + return None + return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold) + + +def latest_agent_versions_by_product(db: Session) -> dict[str, str]: + rows = db.execute( + select(Host.product, Host.product_version).where( + Host.product_version.isnot(None), + Host.product_version != "", + ) + ).all() + best: dict[str, ParsedAgentVersion] = {} + best_raw: dict[str, str] = {} + for product, version in rows: + parsed = parse_agent_version(version) + if parsed is None: + continue + current = best.get(product) + if current is None or parsed > current: + best[product] = parsed + best_raw[product] = version.strip() + return best_raw diff --git a/backend/app/services/client_ip.py b/backend/app/services/client_ip.py new file mode 100644 index 0000000..fc3de6e --- /dev/null +++ b/backend/app/services/client_ip.py @@ -0,0 +1,27 @@ +"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for).""" + +from __future__ import annotations + +from fastapi import Request + + +def client_ip_from_request(request: Request) -> str: + """Client IP for rate limiting. + + nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`` + appends the real peer address as the *last* hop. Spoofed values in the + incoming header therefore cannot replace the actual client IP. + """ + if request.client and request.client.host: + direct = request.client.host.strip() + else: + direct = "unknown" + + forwarded = (request.headers.get("x-forwarded-for") or "").strip() + if not forwarded: + return direct[:64] + + parts = [part.strip() for part in forwarded.split(",") if part.strip()] + if not parts: + return direct[:64] + return parts[-1][:64] diff --git a/backend/app/services/daily_report.py b/backend/app/services/daily_report.py new file mode 100644 index 0000000..f81aabe --- /dev/null +++ b/backend/app/services/daily_report.py @@ -0,0 +1,368 @@ +"""SAC-generated daily reports from ingested events (F-NOT-05 / notif-32).""" + +from __future__ import annotations + +import logging +import uuid +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import Settings, get_settings +from app.models import Event, Host +from app.services.daily_report_format import ( + RDP_BAN_TYPES, + SSH_BAN_TYPES, + body_to_report_html, + build_report_body, + enrich_stats_for_storage, + normalize_daily_report_details, +) +from app.services.ingest import ingest_event +from app.services.notify_dispatch import notify_daily_report + +logger = logging.getLogger(__name__) + +REPORT_TYPES = { + "ssh-monitor": "report.daily.ssh", + "rdp-login-monitor": "report.daily.rdp", +} + +SSH_SUCCESS = "ssh.login.success" +SSH_FAILED = "ssh.login.failed" +SSH_SUDO = "privilege.sudo.command" +RDP_SUCCESS = "rdp.login.success" +RDP_FAILED = "rdp.login.failed" +SESSION_LOGIND_NEW = "session.logind.new" + + +@dataclass(frozen=True) +class DailyReportResult: + host_id: int + hostname: str + report_type: str + event_id: str + created: bool + skipped_reason: str | None = None + + +def _now_in_tz(settings: Settings) -> datetime: + tz = ZoneInfo(settings.sac_daily_report_timezone.strip() or "Europe/Moscow") + return datetime.now(timezone.utc).astimezone(tz) + + +def _report_type_for_host(host: Host) -> str | None: + return REPORT_TYPES.get((host.product or "").strip()) + + +def _day_start_in_tz(settings: Settings, ref: datetime | None = None) -> datetime: + local = ref or _now_in_tz(settings) + start = local.replace(hour=0, minute=0, second=0, microsecond=0) + return start.astimezone(timezone.utc) + + +def host_has_report_today(db: Session, host_id: int, report_type: str, settings: Settings) -> bool: + since = _day_start_in_tz(settings) + row = db.scalar( + select(Event.id) + .where( + Event.host_id == host_id, + Event.type == report_type, + Event.received_at >= since, + ) + .limit(1) + ) + return row is not None + + +def _ip_from_details(details: dict[str, Any] | None) -> str: + if not details: + return "" + for key in ("source_ip", "ip_address", "ip"): + val = details.get(key) + if val is not None and str(val).strip() not in ("", "-"): + return str(val).strip() + return "" + + +def _user_from_details(details: dict[str, Any] | None) -> str: + if not details: + return "" + for key in ("user", "username"): + val = details.get(key) + if val is not None and str(val).strip(): + return str(val).strip() + return "" + + +def _format_user_line_ssh(details: dict[str, Any] | None) -> str | None: + user = _user_from_details(details) + if not user: + return None + if not details: + return f"👤 {user}" + tty = str(details.get("tty") or details.get("pts") or "").strip() + since = str(details.get("since") or details.get("session_since") or "").strip() + ip = _ip_from_details(details) + parts = [f"👤 {user}"] + if tty: + parts.append(f"| {tty}") + if since: + parts.append(f"| с {since}") + if ip: + parts.append(f"| 🌐 {ip}") + return " ".join(parts) if len(parts) > 1 else f"👤 {user}" + + +def _collect_active_users_ssh(events: list[Event]) -> list[str]: + lines: list[str] = [] + seen: set[str] = set() + for ev in events: + if ev.type != SESSION_LOGIND_NEW: + continue + details = ev.details if isinstance(ev.details, dict) else None + line = _format_user_line_ssh(details) + if line and line not in seen: + lines.append(line) + seen.add(line) + if lines: + return lines + for ev in events: + if ev.type != SSH_SUCCESS: + continue + details = ev.details if isinstance(ev.details, dict) else None + user = _user_from_details(details) + if not user: + continue + key = user.lower() + if key in seen: + continue + seen.add(key) + ip = _ip_from_details(details) + line = f"👤 {user}" + if ip: + line += f" | 🌐 {ip}" + lines.append(line) + return lines + + +def _collect_active_users_rdp(events: list[Event]) -> list[str]: + users: list[str] = [] + seen: set[str] = set() + for ev in events: + if ev.type != RDP_SUCCESS: + continue + details = ev.details if isinstance(ev.details, dict) else None + user = _user_from_details(details) + if not user: + continue + key = user.lower() + if key in seen: + continue + seen.add(key) + users.append(f"👤 {user}") + return users + + +def _aggregate_ssh(events: list[Event]) -> dict[str, Any]: + ok = failed = sudo = 0 + failed_ips: Counter[str] = Counter() + for ev in events: + if ev.type == SSH_SUCCESS: + ok += 1 + elif ev.type == SSH_FAILED: + failed += 1 + ip = _ip_from_details(ev.details if isinstance(ev.details, dict) else None) + if ip: + failed_ips[ip] += 1 + elif ev.type == SSH_SUDO: + sudo += 1 + top_ips = [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)] + active_users = _collect_active_users_ssh(events) + return { + "successful_ssh": ok, + "failed_ssh": failed, + "sudo_commands": sudo, + "active_bans": sum(1 for e in events if e.type in SSH_BAN_TYPES), + "top_failed_ips": top_ips, + "active_users": active_users, + } + + +def _aggregate_rdp(events: list[Event]) -> dict[str, Any]: + ok = failed = 0 + failed_ips: Counter[str] = Counter() + for ev in events: + details = ev.details if isinstance(ev.details, dict) else None + if ev.type == RDP_SUCCESS: + ok += 1 + elif ev.type == RDP_FAILED: + failed += 1 + ip = _ip_from_details(details) + if ip: + failed_ips[ip] += 1 + active_users = _collect_active_users_rdp(events) + unique = [] + seen: set[str] = set() + for line in active_users: + u = line.replace("👤", "").strip().split("|")[0].strip() + if u.lower() not in seen: + seen.add(u.lower()) + unique.append(u) + return { + "rdp_success": ok, + "rdp_failed": failed, + "active_bans": sum(1 for e in events if e.type in RDP_BAN_TYPES), + "top_failed_ips": [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)], + "active_users": active_users, + "unique_users": unique, + } + + +def _build_report_body_ssh(host: Host, stats: dict[str, Any], when_local: datetime) -> str: + enriched = enrich_stats_for_storage("ssh", stats) + return build_report_body("ssh", host, enriched, when_local, sac_generated=True) + + +def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str: + enriched = enrich_stats_for_storage("windows", stats) + return build_report_body("windows", host, enriched, when_local, sac_generated=True) + + +def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]: + return { + "stats": stats, + "report_body": body, + "report_format": "plain", + "report_html": body_to_report_html(body), + "generated_by": "sac", + } + + +def _events_last_24h(db: Session, host_id: int) -> list[Event]: + since = datetime.now(timezone.utc) - timedelta(hours=24) + return list( + db.scalars( + select(Event) + .where(Event.host_id == host_id, Event.occurred_at >= since) + .order_by(Event.occurred_at.desc()) + ).all() + ) + + +def generate_daily_report_for_host(db: Session, host: Host, settings: Settings | None = None) -> DailyReportResult | None: + cfg = settings or get_settings() + report_type = _report_type_for_host(host) + if report_type is None: + return None + + if cfg.sac_daily_report_skip_if_agent_sent and host_has_report_today(db, host.id, report_type, cfg): + return DailyReportResult( + host_id=host.id, + hostname=host.hostname, + report_type=report_type, + event_id="", + created=False, + skipped_reason="agent_report_exists_today", + ) + + events = _events_last_24h(db, host.id) + if not events and cfg.sac_daily_report_require_activity: + return DailyReportResult( + host_id=host.id, + hostname=host.hostname, + report_type=report_type, + event_id="", + created=False, + skipped_reason="no_events_24h", + ) + + when_local = _now_in_tz(cfg) + if report_type == "report.daily.ssh": + stats = enrich_stats_for_storage("ssh", _aggregate_ssh(events)) + body = _build_report_body_ssh(host, stats, when_local) + title = "Ежедневный отчёт SSH" + summary = ( + f"SSH 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, " + f"sudo {stats['sudo_commands']}" + ) + else: + stats = enrich_stats_for_storage("windows", _aggregate_rdp(events)) + body = _build_report_body_rdp(host, stats, when_local) + title = "Ежедневный отчёт Windows" + summary = ( + f"RDP 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, " + f"банов {stats['active_bans']}" + ) + + event_id = str(uuid.uuid4()) + day_key = when_local.strftime("%Y-%m-%d") + payload = { + "schema_version": "1.0", + "event_id": event_id, + "occurred_at": datetime.now(timezone.utc).isoformat(), + "source": { + "product": host.product or "unknown", + "product_version": host.product_version or "sac", + }, + "host": { + "hostname": host.hostname, + "os_family": host.os_family or ("windows" if report_type == "report.daily.rdp" else "linux"), + "display_name": host.display_name, + "ipv4": host.ipv4, + }, + "category": "report", + "type": report_type, + "severity": "info", + "title": title, + "summary": summary, + "details": _details_from_body(body, stats), + "dedup_key": f"sac|{host.id}|{report_type}|{day_key}", + } + + event, created = ingest_event(db, payload) + if created: + notify_daily_report(event, db=db) + return DailyReportResult( + host_id=host.id, + hostname=host.hostname, + report_type=report_type, + event_id=event.event_id, + created=created, + skipped_reason=None if created else "duplicate_event_id", + ) + + +def run_daily_reports(db: Session, settings: Settings | None = None, *, force: bool = False) -> list[DailyReportResult]: + cfg = settings or get_settings() + if not cfg.sac_daily_report_enabled: + logger.info("daily report disabled (SAC_DAILY_REPORT_ENABLED=false)") + return [] + + local_now = _now_in_tz(cfg) + if not force and local_now.hour < cfg.sac_daily_report_hour: + logger.info( + "daily report skipped: local hour %s < SAC_DAILY_REPORT_HOUR=%s", + local_now.hour, + cfg.sac_daily_report_hour, + ) + return [] + + hosts = db.scalars(select(Host).order_by(Host.hostname)).all() + results: list[DailyReportResult] = [] + for host in hosts: + try: + res = generate_daily_report_for_host(db, host, cfg) + if res is not None: + results.append(res) + except Exception: + logger.exception("daily report failed host_id=%s hostname=%s", host.id, host.hostname) + db.commit() + created_n = sum(1 for r in results if r.created) + logger.info("daily report finished hosts=%s created=%s", len(results), created_n) + return results diff --git a/backend/app/services/daily_report_format.py b/backend/app/services/daily_report_format.py new file mode 100644 index 0000000..6a01f3b --- /dev/null +++ b/backend/app/services/daily_report_format.py @@ -0,0 +1,492 @@ +"""Единый текст и stats для report.daily.ssh / report.daily.rdp (агент и SAC).""" + +from __future__ import annotations + +import html +import re +from datetime import datetime +from typing import Any, Literal + +from app.models import Host + +Platform = Literal["ssh", "windows"] + +RDP_BAN_TYPES = frozenset({"rdp.ip.banned", "rdp.ip.ban"}) +SSH_BAN_TYPES = frozenset({"ssh.ip.banned"}) + +_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:") +_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ") +_ACTIVE_USERS_EMPTY_LINE_RE = re.compile( + r"^\(?нет данных|нет активных пользователей", + re.IGNORECASE, +) +_AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE) +_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*") +_LEGACY_SAC_SOURCE_RE = re.compile( + r"(?m)^Источник:\s*Security Alert Center\b.*$", + re.IGNORECASE, +) +_DAILY_REPORT_TITLE_MARKER = "ЕЖЕДНЕВНЫЙ ОТЧЕТ" +_SECTION_HEADER_RE = re.compile(r"^[📈🧾👥🖥️🕐]") +NOTIFICATION_SOURCE_PREFIX = "📡 Оповещение:" + + +def format_notification_source_plain( + *, + generated_by: str, + product: str | None = None, + product_version: str | None = None, +) -> str: + gb = (generated_by or "agent").strip().lower() + if gb == "sac": + return f"{NOTIFICATION_SOURCE_PREFIX} SAC (Security Alert Center)" + label = (product or "агент").strip() + version = (product_version or "").strip() + if version: + return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label} {version})" + return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label})" + + +def has_notification_source_line(text: str) -> bool: + return bool(_NOTIFICATION_SOURCE_RE.search(text or "")) + + +def strip_legacy_sac_source_line(text: str) -> str: + return _LEGACY_SAC_SOURCE_RE.sub("", text or "").strip() + + +def append_notification_source_plain( + body: str, + *, + generated_by: str, + product: str | None = None, + product_version: str | None = None, +) -> str: + text = strip_legacy_sac_source_line(body or "") + if has_notification_source_line(text): + return text + line = format_notification_source_plain( + generated_by=generated_by, + product=product, + product_version=product_version, + ) + if text: + return f"{text.rstrip()}\n\n{line}" + return line + + +def append_notification_source_html( + html_msg: str, + *, + generated_by: str, + product: str | None = None, + product_version: str | None = None, +) -> str: + plain_line = format_notification_source_plain( + generated_by=generated_by, + product=product, + product_version=product_version, + ) + if NOTIFICATION_SOURCE_PREFIX in (html_msg or ""): + return (html_msg or "").rstrip() + line = html.escape(plain_line) + msg = (html_msg or "").rstrip() + if msg: + return f"{msg}\n{line}" + return line + + +def resolve_product_label(host: Host | None, platform: Platform | None = None) -> tuple[str | None, str | None]: + if host is None: + return None, None + product = (host.product or "").strip() + if not product: + if platform == "ssh": + product = "ssh-monitor" + elif platform == "windows": + product = "rdp-login-monitor" + version = (host.product_version or "").strip() or None + return (product or None), version + + +def host_server_line(host: Host | None) -> str: + if host is None: + return "unknown" + label = (host.display_name or host.hostname or "unknown").strip() + ip = (host.ipv4 or "").strip() + if ip: + return f"{label} ({ip})" + return label + + +def collapse_blank_lines(text: str, *, max_run: int = 1) -> str: + lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + out: list[str] = [] + blank_run = 0 + for line in lines: + if not line.strip(): + blank_run += 1 + if blank_run <= max_run: + out.append("") + continue + blank_run = 0 + out.append(line.rstrip()) + return "\n".join(out).strip() + + +def is_active_users_empty_line(text: str) -> bool: + """Строка-заглушка вместо списка сессий (агент или SAC).""" + s = text.strip() + if not s: + return True + inner = s.lstrip("👤").strip() + if _ACTIVE_USERS_EMPTY_LINE_RE.search(inner): + return True + return False + + +def split_active_user_tokens(entry: str) -> list[str]: + """Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента).""" + entry = entry.strip() + if not entry or is_active_users_empty_line(entry): + return [] + if entry.count("👤") <= 1: + line = entry if entry.startswith("👤") else f"👤 {entry}" + return [f" {line}"] + parts = re.split(r"(?=👤)", entry) + result: list[str] = [] + for part in parts: + part = part.strip() + if not part: + continue + if not part.startswith("👤"): + part = f"👤 {part}" + result.append(f" {part}") + return result + + +def normalize_active_users_list(users: list[Any] | None) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in users or []: + for line in split_active_user_tokens(str(raw)): + key = line.strip().lower() + if key and key not in seen: + seen.add(key) + out.append(line) + return out + + +def format_agent_version_line(version: str) -> str: + v = (version or "").strip() + if not v: + return "" + return f"Agent version {v}" + + +def ensure_agent_version_line(body: str, version: str | None) -> str: + line = format_agent_version_line(version or "") + if not line: + return body + if _AGENT_VERSION_LINE_RE.search(body): + return body + lines = body.replace("\r\n", "\n").split("\n") + for i, ln in enumerate(lines): + if _DAILY_REPORT_TITLE_MARKER in ln: + lines.insert(i + 1, line) + return "\n".join(lines) + return body + + +def _fix_active_users_header_count(header_line: str, user_count: int) -> str: + stripped = header_line.rstrip() + if _ACTIVE_USERS_HEADER_RE.match(stripped.strip()): + return re.sub( + r"(\👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\s*\()[^)]+(\))", + rf"\g<1>{user_count}\2", + stripped, + count=1, + ) + return stripped + + +def ensure_server_line(body: str, host: Host | None) -> str: + server = host_server_line(host) + if not server or server == "unknown": + return body + if _SERVER_LINE_RE.search(body): + return body + lines = body.replace("\r\n", "\n").split("\n") + for i, line in enumerate(lines): + if "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line: + insert_at = i + 1 + if insert_at < len(lines) and _AGENT_VERSION_LINE_RE.match(lines[insert_at].strip()): + insert_at += 1 + lines.insert(insert_at, f"🖥️ Сервер: {server}") + return "\n".join(lines) + return f"🖥️ Сервер: {server}\n{body}" + + +def normalize_active_users_in_body(body: str) -> str: + lines = body.replace("\r\n", "\n").split("\n") + out: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + if _ACTIVE_USERS_HEADER_RE.match(line.strip()): + out.append(line) + i += 1 + user_lines: list[str] = [] + while i < len(lines): + cur = lines[i] + stripped = cur.strip() + if not stripped: + break + if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX): + break + if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"): + break + if is_active_users_empty_line(stripped): + out[-1] = _fix_active_users_header_count(out[-1], 0) + out.append(cur) + i += 1 + break + user_lines.extend(split_active_user_tokens(cur)) + i += 1 + user_lines = [ln for ln in user_lines if not is_active_users_empty_line(ln.strip())] + if user_lines: + out[-1] = _fix_active_users_header_count(out[-1], len(user_lines)) + out.extend(user_lines) + else: + out[-1] = _fix_active_users_header_count(out[-1], 0) + continue + out.append(line) + i += 1 + return "\n".join(out) + + +def extract_active_users_from_report_body(body: str) -> list[str]: + lines = body.replace("\r\n", "\n").split("\n") + in_section = False + users: list[str] = [] + for line in lines: + stripped = line.strip() + if _ACTIVE_USERS_HEADER_RE.match(stripped): + in_section = True + continue + if not in_section: + continue + if not stripped: + break + if is_active_users_empty_line(stripped): + break + if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX): + break + if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"): + break + users.extend(split_active_user_tokens(stripped)) + return normalize_active_users_list(users) + + +def reconcile_stats_from_report_body( + stats: dict[str, Any], + body: str, + platform: Platform, +) -> dict[str, Any]: + """Синхронизирует stats.active_users и счётчики с текстом отчёта после нормализации.""" + out = dict(stats) + users = extract_active_users_from_report_body(body) + out["active_users"] = users + count = len(users) + if platform == "ssh": + out["active_sessions"] = count + else: + out["active_sessions_rdp"] = count + names: list[str] = [] + for raw in users: + name = re.sub(r"^👤\s*", "", raw.strip()).strip() + if name and not is_active_users_empty_line(name): + names.append(name) + out["unique_users"] = sorted(set(names)) + return out + + +def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str: + """Приводит текст отчёта (агент/SAC) к единому компактному виду.""" + text = collapse_blank_lines(body.replace("\r\n", "\n")) + agent_version = host.product_version if host else None + text = ensure_agent_version_line(text, agent_version) + text = ensure_server_line(text, host) + text = normalize_active_users_in_body(text) + return collapse_blank_lines(text) + + +def body_to_report_html(body: str) -> str: + escaped = html.escape(body) + return f'
{escaped.replace(chr(10), "
")}
' + + +def _section_top_ips(top: list[str]) -> list[str]: + lines = [" 🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"] + if top: + lines.extend(f" {line}" if not line.startswith(" ") else line for line in top[:5]) + else: + lines.append(" (нет данных)") + return lines + + +def _section_active_users(users: list[str], *, sac_generated: bool) -> list[str]: + count = len(users) + lines = [f" 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"] + if users: + for line in users: + lines.append(f" {line}" if not line.startswith(" ") else line) + elif sac_generated: + lines.append( + " (нет данных — полный список сессий только в отчёте агента; " + "в SAC — пользователи из событий входа за 24 ч, если были)" + ) + else: + lines.append(" (нет данных)") + return lines + + +def build_report_body( + platform: Platform, + host: Host, + stats: dict[str, Any], + when_local: datetime, + *, + sac_generated: bool = False, +) -> str: + server = host_server_line(host) + time_str = when_local.strftime("%d.%m.%Y %H:%M:%S") + top = list(stats.get("top_failed_ips") or [])[:5] + active_users = normalize_active_users_list(list(stats.get("active_users") or [])) + agent_version = ( + str(stats.get("agent_version") or stats.get("product_version") or host.product_version or "") + ).strip() + if sac_generated and not agent_version: + agent_version = "sac" + + if platform == "ssh": + title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" + ok = int(stats.get("successful_logins", stats.get("successful_ssh", 0))) + fail = int(stats.get("failed_logins", stats.get("failed_ssh", 0))) + sudo = int(stats.get("sudo_commands", 0)) + bans = int(stats.get("active_bans", 0)) + lines = [title] + if agent_version: + lines.append(format_agent_version_line(agent_version)) + lines.extend([ + f"🖥️ Сервер: {server}", + f"🕐 Время отчета: {time_str}", + "", + " 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", + f" ✅ Успешных SSH подключений: {ok}", + f" ❌ Неудачных попыток SSH: {fail}", + f" ⚠️ Команд через sudo: {sudo}", + f" 🚫 Активных банов (ipset): {bans}", + ]) + else: + title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" + ok = int(stats.get("successful_logins", stats.get("rdp_success", 0))) + fail = int(stats.get("failed_logins", stats.get("rdp_failed", 0))) + bans = int(stats.get("active_bans", 0)) + lines = [title] + if agent_version: + lines.append(format_agent_version_line(agent_version)) + lines.extend([ + f"🖥️ Сервер: {server}", + f"🕐 Время отчета: {time_str}", + "", + " 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", + f" ✅ Успешных RDP подключений: {ok}", + f" ❌ Неудачных попыток RDP: {fail}", + f" 🚫 Активных банов: {bans}", + ]) + + lines.append("") + lines.extend(_section_top_ips(top)) + lines.append("") + lines.extend(_section_active_users(active_users, sac_generated=sac_generated)) + + generated_by = "sac" if sac_generated else "agent" + product, version = resolve_product_label(host, platform) + if not sac_generated and agent_version: + version = agent_version + lines.append("") + lines.append( + format_notification_source_plain( + generated_by=generated_by, + product=product, + product_version=version, + ) + ) + return "\n".join(lines) + + +def enrich_stats_for_storage(platform: Platform, stats: dict[str, Any]) -> dict[str, Any]: + """Канонические поля + legacy-ключи для UI и старых отчётов.""" + out = dict(stats) + out["platform"] = platform + if platform == "ssh": + ok = int(out.get("successful_logins", out.get("successful_ssh", 0))) + fail = int(out.get("failed_logins", out.get("failed_ssh", 0))) + out["successful_logins"] = ok + out["failed_logins"] = fail + out["successful_ssh"] = ok + out["failed_ssh"] = fail + else: + ok = int(out.get("successful_logins", out.get("rdp_success", 0))) + fail = int(out.get("failed_logins", out.get("rdp_failed", 0))) + out["successful_logins"] = ok + out["failed_logins"] = fail + out["rdp_success"] = ok + out["rdp_failed"] = fail + if "unique_users" in out and "active_users" not in out: + users = out.get("unique_users") or [] + out["active_users"] = [f"👤 {u}" if not str(u).startswith("👤") else str(u) for u in users] + if "active_users" in out: + out["active_users"] = normalize_active_users_list(list(out.get("active_users") or [])) + return out + + +def normalize_daily_report_details( + details: dict[str, Any] | None, + host: Host | None, + report_type: str, +) -> dict[str, Any] | None: + if not isinstance(details, dict): + return details + platform: Platform = "windows" if report_type == "report.daily.rdp" else "ssh" + body = details.get("report_body") + if not isinstance(body, str) or not body.strip(): + return details + + normalized_body = normalize_report_body(body, host, platform) + gb = str(details.get("generated_by") or "agent").strip().lower() + if gb not in ("agent", "sac"): + gb = "agent" + product, version = resolve_product_label(host, platform) + stats_raw = details.get("stats") + if isinstance(stats_raw, dict) and gb == "agent": + av = stats_raw.get("agent_version") or stats_raw.get("product_version") + if av: + version = str(av).strip() + normalized_body = append_notification_source_plain( + normalized_body, + generated_by=gb, + product=product, + product_version=version, + ) + out = dict(details) + out["report_body"] = normalized_body + out["report_html"] = body_to_report_html(normalized_body) + + stats = out.get("stats") + if isinstance(stats, dict): + stats = enrich_stats_for_storage(platform, dict(stats)) + stats = reconcile_stats_from_report_body(stats, normalized_body, platform) + out["stats"] = stats + return out diff --git a/backend/app/services/email_notify.py b/backend/app/services/email_notify.py new file mode 100644 index 0000000..0557b36 --- /dev/null +++ b/backend/app/services/email_notify.py @@ -0,0 +1,131 @@ +"""SMTP email notifications from SAC.""" + +from __future__ import annotations + +import logging +import re +import smtplib +from email.mime.text import MIMEText + +from sqlalchemy.orm import Session + +from app.models import Event, Problem +from app.services.notification_severity import severity_meets_minimum +from app.services.notification_settings import EmailConfig, get_effective_email_config + +logger = logging.getLogger(__name__) + + +class EmailNotConfiguredError(Exception): + """Email disabled or missing SMTP settings.""" + + +class EmailSendError(Exception): + pass + + +def parse_mail_recipients(mail_to: str) -> list[str]: + return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()] + + +def send_email_message( + subject: str, + body: str, + *, + config: EmailConfig | None = None, + force: bool = False, +) -> None: + cfg = config or get_effective_email_config() + if not force and not cfg.active: + return + if not cfg.configured: + if force: + raise EmailNotConfiguredError("SMTP: не задан host, from или to") + return + + recipients = parse_mail_recipients(cfg.mail_to) + if not recipients: + if force: + raise EmailNotConfiguredError("SMTP: список получателей пуст") + return + + msg = MIMEText(body, "plain", "utf-8") + msg["Subject"] = subject + msg["From"] = cfg.mail_from + msg["To"] = ", ".join(recipients) + + try: + if cfg.smtp_ssl: + server: smtplib.SMTP = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=12) + else: + server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=12) + with server: + server.ehlo() + if cfg.smtp_starttls and not cfg.smtp_ssl: + server.starttls() + server.ehlo() + if cfg.smtp_user: + server.login(cfg.smtp_user, cfg.smtp_password) + server.sendmail(cfg.mail_from, recipients, msg.as_string()) + except Exception as exc: + logger.exception("smtp send failed") + raise EmailSendError(str(exc)) from exc + + +def send_email_test_message(*, config: EmailConfig | None = None) -> None: + cfg = config or get_effective_email_config() + if not cfg.enabled: + raise EmailNotConfiguredError("Email отключён (enabled=false)") + if not cfg.configured: + raise EmailNotConfiguredError("Не задан SMTP host, from или to") + send_email_message( + "SAC: тестовое письмо", + "Тестовое сообщение Security Alert Center.\nКанал SMTP для оповещений работает.", + config=cfg, + force=True, + ) + + +def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: + cfg = get_effective_email_config(db) + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity): + return + host = event.host.hostname if event.host else "unknown" + body = ( + f"SAC событие\n" + f"Host: {host}\n" + f"Severity: {event.severity}\n" + f"Type: {event.type}\n" + f"Title: {event.title}\n" + f"Summary: {event.summary}" + ) + try: + send_email_message(f"SAC: {event.type} ({event.severity})", body, config=cfg) + except EmailSendError: + logger.exception("notify_event email failed event_id=%s", event.event_id) + + +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: + cfg = get_effective_email_config(db) + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity): + return + host = problem.host.hostname if problem.host else "unknown" + related = "" + if event is not None: + related = f"\nEvent: {event.type} ({event.severity})" + body = ( + f"SAC Problem opened\n" + f"Host: {host}\n" + f"Severity: {problem.severity}\n" + f"Rule: {problem.rule_id or '-'}\n" + f"Title: {problem.title}\n" + f"Summary: {problem.summary}{related}" + ) + try: + send_email_message(f"SAC Problem: {problem.title}", body, config=cfg) + except EmailSendError: + logger.exception("notify_problem email failed problem_id=%s", problem.id) diff --git a/backend/app/services/event_actor_user.py b/backend/app/services/event_actor_user.py new file mode 100644 index 0000000..2a48a17 --- /dev/null +++ b/backend/app/services/event_actor_user.py @@ -0,0 +1,50 @@ +"""Имя пользователя для событий подключения/аутентификации (колонка «Пользователь» в UI).""" + +from __future__ import annotations + +from typing import Any + +# События, где в details может быть учётная запись (SSH/RDP/sudo/logind и т.д.). +ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset( + { + "ssh.login.success", + "ssh.login.failed", + "privilege.sudo.command", + "session.logind.new", + "session.logind.removed", + "session.logind.failed", + "rdp.login.success", + "rdp.login.failed", + "rdp.session.logoff", + "rdp.shadow.control.started", + "rdp.shadow.control.stopped", + "rdp.shadow.control.permission", + "winrm.session.started", + "smb.admin_share.access", + "auth.explicit.credentials", + "rdg.connection.success", + "rdg.connection.disconnected", + "rdg.connection.failed", + } +) + +_SHADOW_DETAIL_KEYS = ("shadower_user", "user", "username") +_DEFAULT_DETAIL_KEYS = ("user", "username") + + +def _pick_detail_value(details: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + val = details.get(key) + if val is not None and str(val).strip() not in ("", "-"): + return str(val).strip() + return None + + +def extract_event_actor_user(event_type: str, details: dict[str, Any] | None) -> str | None: + """Вернуть имя пользователя для auth/connect событий или None (UI покажет «—»).""" + if event_type not in ACTOR_USER_EVENT_TYPES: + return None + if not isinstance(details, dict): + return None + keys = _SHADOW_DETAIL_KEYS if event_type.startswith("rdp.shadow.") else _DEFAULT_DETAIL_KEYS + return _pick_detail_value(details, keys) diff --git a/backend/app/services/event_severity_overrides.py b/backend/app/services/event_severity_overrides.py new file mode 100644 index 0000000..b5fc9d6 --- /dev/null +++ b/backend/app/services/event_severity_overrides.py @@ -0,0 +1,88 @@ +"""Per event-type severity overrides (admin Settings UI).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.constants.event_types import DEFAULT_EVENT_SEVERITIES +from app.models.event_severity_override import EventSeverityOverride +from app.services.event_type_visibility import get_visibility_map +from app.services.notification_settings import VALID_SEVERITIES + +SOURCE_DB = "db" + + +@dataclass(frozen=True) +class EventSeverityOverrideItem: + event_type: str + default_severity: str + override_severity: str | None + show_in_events: bool = True + + +def get_override_map(db: Session) -> dict[str, str]: + rows = db.scalars(select(EventSeverityOverride)).all() + return {row.event_type: row.severity for row in rows} + + +def apply_severity_override(payload: dict, db: Session) -> dict: + """Return payload copy with severity replaced when override exists.""" + event_type = str(payload.get("type") or "") + if not event_type: + return payload + override_map = get_override_map(db) + override = override_map.get(event_type) + if not override: + return payload + agent_severity = str(payload.get("severity") or "info") + if agent_severity == override: + return payload + out = dict(payload) + details = dict(out.get("details") or {}) + details["severity_agent"] = agent_severity + out["details"] = details + out["severity"] = override + return out + + +def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]: + override_map = get_override_map(db) + visibility_map = get_visibility_map(db) + types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map) | set(visibility_map) + items: list[EventSeverityOverrideItem] = [] + for event_type in sorted(types): + default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info") + items.append( + EventSeverityOverrideItem( + event_type=event_type, + default_severity=default, + override_severity=override_map.get(event_type), + show_in_events=visibility_map.get(event_type, True), + ) + ) + return items + + +def replace_severity_overrides(db: Session, overrides: dict[str, str | None]) -> list[EventSeverityOverrideItem]: + for event_type, severity in overrides.items(): + normalized_type = event_type.strip() + if not normalized_type: + raise ValueError("event_type is required") + if severity is None or severity == "": + db.execute( + delete(EventSeverityOverride).where(EventSeverityOverride.event_type == normalized_type) + ) + continue + if severity not in VALID_SEVERITIES: + raise ValueError(f"severity must be one of: {sorted(VALID_SEVERITIES)}") + row = db.get(EventSeverityOverride, normalized_type) + if row is None: + row = EventSeverityOverride(event_type=normalized_type, severity=severity) + db.add(row) + else: + row.severity = severity + db.flush() + return list_severity_override_items(db) diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py new file mode 100644 index 0000000..24453d3 --- /dev/null +++ b/backend/app/services/event_summary.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session + +from app.models.event import Event +from app.schemas.list_models import EventSummary +from app.services.event_actor_user import extract_event_actor_user +from app.services.host_sessions import event_session_terminated +from app.services.rdg_display import build_rdg_display +from app.services.rdg_session_flap import resolve_rdg_flap_summary +from app.services.session_duration import extract_session_duration_sec + + +def event_to_summary(event: Event, db: Session | None = None) -> EventSummary: + host = event.host + rdg_flap = False + rdg_flap_pair_event_id: int | None = None + rdg_flap_qwinsta_event_id: int | None = None + if db is not None: + rdg_flap, rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id = resolve_rdg_flap_summary( + db, event + ) + + title = event.title + summary = event.summary + rdg_access_path: str | None = None + rdg_qwinsta_enabled = False + rdg_display = build_rdg_display(event, db) + if rdg_display is not None: + title = rdg_display.title + summary = rdg_display.summary + rdg_access_path = rdg_display.access_path + rdg_qwinsta_enabled = rdg_display.qwinsta_enabled + + return EventSummary( + id=event.id, + event_id=event.event_id, + host_id=event.host_id, + hostname=host.hostname, + display_name=host.display_name, + product_version=host.product_version, + occurred_at=event.occurred_at, + received_at=event.received_at, + category=event.category, + type=event.type, + severity=event.severity, + title=title, + summary=summary, + actor_user=extract_event_actor_user(event.type, event.details), + session_duration_sec=extract_session_duration_sec( + event.details if isinstance(event.details, dict) else None + ), + rdg_flap=rdg_flap, + rdg_flap_pair_event_id=rdg_flap_pair_event_id, + rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id, + rdg_access_path=rdg_access_path, + rdg_qwinsta_enabled=rdg_qwinsta_enabled, + session_terminated=event_session_terminated(event, db=db), + ) diff --git a/backend/app/services/event_type_visibility.py b/backend/app/services/event_type_visibility.py new file mode 100644 index 0000000..7eb1d1c --- /dev/null +++ b/backend/app/services/event_type_visibility.py @@ -0,0 +1,66 @@ +"""Hide selected event types from SAC UI/API event lists.""" + +from __future__ import annotations + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session +from sqlalchemy.sql import ColumnElement + +from app.models.event import Event +from app.models.event_type_visibility import EventTypeVisibility + + +def get_hidden_event_types(db: Session) -> frozenset[str]: + rows = db.scalars( + select(EventTypeVisibility.event_type).where(EventTypeVisibility.show_in_events.is_(False)) + ).all() + return frozenset(rows) + + +def get_visibility_map(db: Session) -> dict[str, bool]: + rows = db.scalars(select(EventTypeVisibility)).all() + return {row.event_type: row.show_in_events for row in rows} + + +def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool: + return event_type not in hidden + + +def event_type_notifications_enabled(event_type: str, db: Session | None) -> bool: + """Outbound alerts (Telegram/push/email/webhook) only for types visible in events UI.""" + if db is None: + return True + hidden = get_hidden_event_types(db) + return show_in_events_for(event_type, hidden=hidden) + + +def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None: + if not hidden: + return None + return Event.type.not_in(tuple(sorted(hidden))) + + +def replace_event_visibility(db: Session, visibility: dict[str, bool | None]) -> dict[str, bool]: + stored = get_visibility_map(db) + for event_type, show in visibility.items(): + normalized_type = event_type.strip() + if not normalized_type: + raise ValueError("event_type is required") + if show is None or show is True: + db.execute( + delete(EventTypeVisibility).where(EventTypeVisibility.event_type == normalized_type) + ) + stored.pop(normalized_type, None) + continue + if show is False: + row = db.get(EventTypeVisibility, normalized_type) + if row is None: + row = EventTypeVisibility(event_type=normalized_type, show_in_events=False) + db.add(row) + else: + row.show_in_events = False + stored[normalized_type] = False + else: + raise ValueError("show_in_events must be true, false, or null") + db.flush() + return stored diff --git a/backend/app/services/fail2ban_sync.py b/backend/app/services/fail2ban_sync.py new file mode 100644 index 0000000..dd6a691 --- /dev/null +++ b/backend/app/services/fail2ban_sync.py @@ -0,0 +1,132 @@ +"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB).""" + +from __future__ import annotations + +import logging +import re +import subprocess +from dataclasses import dataclass + +from app.config import get_settings + +logger = logging.getLogger(__name__) + +_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") + + +@dataclass(frozen=True) +class Fail2banActionResult: + ok: bool + message: str + + +def _ssh_jail() -> str: + settings = get_settings() + jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip() + return jail or "sshd" + + +def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["fail2ban-client", *args], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def fail2ban_available() -> bool: + try: + proc = _run_fail2ban(["ping"], timeout=5) + return proc.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def list_ssh_banned_ips() -> list[str]: + if not fail2ban_available(): + return [] + jail = _ssh_jail() + try: + proc = _run_fail2ban(["status", jail]) + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("fail2ban status failed: %s", exc) + return [] + if proc.returncode != 0: + return [] + text = (proc.stdout or "") + "\n" + (proc.stderr or "") + ips: list[str] = [] + capture = False + for line in text.splitlines(): + lower = line.strip().lower() + if "banned ip list" in lower: + capture = True + tail = line.split(":", 1)[-1] + ips.extend(_BANNED_IP_RE.findall(tail)) + continue + if capture: + if not line.strip(): + break + ips.extend(_BANNED_IP_RE.findall(line)) + # preserve order, unique + seen: set[str] = set() + out: list[str] = [] + for ip in ips: + if ip not in seen: + seen.add(ip) + out.append(ip) + return out + + +def unban_ssh_ip(ip_address: str) -> Fail2banActionResult: + ip = (ip_address or "").strip() + if not ip: + return Fail2banActionResult(ok=False, message="empty ip") + if not fail2ban_available(): + return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC") + jail = _ssh_jail() + try: + proc = _run_fail2ban(["set", jail, "unbanip", ip]) + except (OSError, subprocess.SubprocessError) as exc: + return Fail2banActionResult(ok=False, message=str(exc)) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "unban failed").strip() + return Fail2banActionResult(ok=False, message=err[:500]) + return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})") + + +def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult: + settings = get_settings() + if not getattr(settings, "sac_fail2ban_sync_enabled", False): + return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)") + path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip() + if not path: + return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)") + + jail = _ssh_jail() + base_ignore = "127.0.0.1/8 ::1" + extra = " ".join(ip for ip in ip_addresses if ip) + ignore_line = f"{base_ignore} {extra}".strip() + content = ( + f"# Managed by SAC — login IP whitelist for fail2ban\n" + f"[{jail}]\n" + f"ignoreip = {ignore_line}\n" + ) + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + except OSError as exc: + return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}") + + if not fail2ban_available(): + return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен") + + try: + proc = _run_fail2ban(["reload"], timeout=30) + except (OSError, subprocess.SubprocessError) as exc: + return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}") + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "reload failed").strip() + return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}") + return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})") diff --git a/backend/app/services/host_delete.py b/backend/app/services/host_delete.py new file mode 100644 index 0000000..35b0321 --- /dev/null +++ b/backend/app/services/host_delete.py @@ -0,0 +1,51 @@ +"""Удаление хоста и связанных events/problems (ручная очистка UI).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import delete, or_, select +from sqlalchemy.orm import Session + +from app.models import Event, Host, Problem, ProblemEvent + + +@dataclass(frozen=True) +class HostDeleteResult: + host_id: int + hostname: str + deleted_events: int + deleted_problems: int + + +def delete_host_and_related(db: Session, host_id: int) -> HostDeleteResult | None: + host = db.get(Host, host_id) + if host is None: + return None + + hostname = host.hostname + event_ids = list(db.scalars(select(Event.id).where(Event.host_id == host_id)).all()) + problem_ids = list(db.scalars(select(Problem.id).where(Problem.host_id == host_id)).all()) + + if event_ids or problem_ids: + link_filter = [] + if event_ids: + link_filter.append(ProblemEvent.event_id.in_(event_ids)) + if problem_ids: + link_filter.append(ProblemEvent.problem_id.in_(problem_ids)) + db.execute(delete(ProblemEvent).where(or_(*link_filter))) + + if problem_ids: + db.execute(delete(Problem).where(Problem.id.in_(problem_ids))) + if event_ids: + db.execute(delete(Event).where(Event.id.in_(event_ids))) + + db.delete(host) + db.flush() + + return HostDeleteResult( + host_id=host_id, + hostname=hostname, + deleted_events=len(event_ids), + deleted_problems=len(problem_ids), + ) diff --git a/backend/app/services/host_health.py b/backend/app/services/host_health.py new file mode 100644 index 0000000..7c95344 --- /dev/null +++ b/backend/app/services/host_health.py @@ -0,0 +1,100 @@ +"""Статус агентов по событиям agent.heartbeat / report.daily.* в БД.""" + +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import Event, Host + +HEARTBEAT_TYPE = "agent.heartbeat" +DAILY_REPORT_SSH = "report.daily.ssh" +DAILY_REPORT_RDP = "report.daily.rdp" +DAILY_REPORT_TYPES = (DAILY_REPORT_SSH, DAILY_REPORT_RDP) + + +def max_event_time_by_host(db: Session, event_type: str) -> dict[int, datetime]: + rows = db.execute( + select(Event.host_id, func.max(Event.received_at)) + .where(Event.type == event_type) + .group_by(Event.host_id) + ).all() + return {int(host_id): ts for host_id, ts in rows if ts is not None} + + +def max_daily_report_time_by_host(db: Session) -> dict[int, datetime]: + rows = db.execute( + select(Event.host_id, func.max(Event.received_at)) + .where(Event.type.in_(DAILY_REPORT_TYPES)) + .group_by(Event.host_id) + ).all() + return {int(host_id): ts for host_id, ts in rows if ts is not None} + + +def agent_status( + last_heartbeat_at: datetime | None, + *, + stale_minutes: int, + now: datetime | None = None, +) -> str: + """ + online — heartbeat не старше порога; + stale — был хост, но heartbeat устарел или отсутствует; + unknown — heartbeat ещё не приходил. + """ + if last_heartbeat_at is None: + return "unknown" + ref = now or datetime.now(timezone.utc) + hb = last_heartbeat_at + if hb.tzinfo is None: + hb = hb.replace(tzinfo=timezone.utc) + age_sec = (ref - hb).total_seconds() + if age_sec > stale_minutes * 60: + return "stale" + return "online" + + +def count_stale_hosts(db: Session, stale_minutes: int) -> int: + hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) + host_ids = db.scalars(select(Host.id)).all() + return sum( + 1 + for host_id in host_ids + if agent_status(hb_map.get(host_id), stale_minutes=stale_minutes) == "stale" + ) + + +def apply_agent_status_host_filter( + stmt, + count_stmt, + agent_status_filter: str, + *, + stale_minutes: int, +): + """Ограничить выборку Host по online / stale / unknown (как в agent_status).""" + from datetime import timedelta + + hb_subq = ( + select(Event.host_id, func.max(Event.received_at).label("last_hb")) + .where(Event.type == HEARTBEAT_TYPE) + .group_by(Event.host_id) + .subquery() + ) + cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes) + + if agent_status_filter == "online": + join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + cond = hb_subq.c.last_hb >= cutoff + return join.where(cond), count_join.where(cond) + if agent_status_filter == "stale": + join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id) + cond = hb_subq.c.last_hb < cutoff + return join.where(cond), count_join.where(cond) + if agent_status_filter == "unknown": + join = stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id) + count_join = count_stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id) + cond = hb_subq.c.last_hb.is_(None) + return join.where(cond), count_join.where(cond) + return stmt, count_stmt diff --git a/backend/app/services/host_inventory.py b/backend/app/services/host_inventory.py new file mode 100644 index 0000000..2b0ec6b --- /dev/null +++ b/backend/app/services/host_inventory.py @@ -0,0 +1,162 @@ +"""Host hardware/software inventory from agent.inventory ingest.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +from app.models import Host + +INVENTORY_EVENT_TYPE = "agent.inventory" + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _round_gb(value: Any) -> float | None: + try: + return round(float(value), 2) + except (TypeError, ValueError): + return None + + +def extract_inventory_payload(details: dict | None) -> dict[str, Any] | None: + if not isinstance(details, dict): + return None + inv = details.get("inventory") + return inv if isinstance(inv, dict) else None + + +def hardware_snapshot(inventory: dict[str, Any]) -> dict[str, Any]: + processors: list[tuple[str, int | None, int | None]] = [] + for item in _as_list(inventory.get("processor")): + row = _as_dict(item) + name = str(row.get("name") or "").strip() + if not name: + continue + cores = row.get("cores") + logical = row.get("logical_processors") + try: + cores_i = int(cores) if cores is not None else None + except (TypeError, ValueError): + cores_i = None + try: + logical_i = int(logical) if logical is not None else None + except (TypeError, ValueError): + logical_i = None + processors.append((name, cores_i, logical_i)) + processors.sort() + + motherboard = _as_dict(inventory.get("motherboard")) + mobo_product = str(motherboard.get("product") or "").strip() + + memory_gb = _round_gb(inventory.get("memory_gb")) + + disks: list[tuple[str, float | None, str]] = [] + for item in _as_list(inventory.get("disks")): + row = _as_dict(item) + friendly = str(row.get("friendly_name") or "").strip() + media = str(row.get("media_type") or "").strip() + size_gb = _round_gb(row.get("size_gb")) + if friendly: + disks.append((friendly, size_gb, media)) + disks.sort() + + video: list[str] = sorted( + { + str(_as_dict(item).get("name") or "").strip() + for item in _as_list(inventory.get("video")) + if str(_as_dict(item).get("name") or "").strip() + } + ) + + return { + "processor": processors, + "motherboard_product": mobo_product, + "memory_gb": memory_gb, + "disks": disks, + "video": video, + } + + +def _format_change(field: str, old: Any, new: Any) -> dict[str, Any]: + return {"field": field, "old": old, "new": new} + + +def diff_hardware(old: dict[str, Any] | None, new: dict[str, Any]) -> list[dict[str, Any]]: + if not old: + return [] + prev = hardware_snapshot(old) + curr = hardware_snapshot(new) + changes: list[dict[str, Any]] = [] + + if prev["processor"] != curr["processor"]: + changes.append(_format_change("processor", prev["processor"], curr["processor"])) + if prev["motherboard_product"] != curr["motherboard_product"]: + changes.append( + _format_change("motherboard", prev["motherboard_product"], curr["motherboard_product"]) + ) + if prev["memory_gb"] != curr["memory_gb"]: + changes.append(_format_change("memory_gb", prev["memory_gb"], curr["memory_gb"])) + if prev["disks"] != curr["disks"]: + changes.append(_format_change("disks", prev["disks"], curr["disks"])) + if prev["video"] != curr["video"]: + changes.append(_format_change("video", prev["video"], curr["video"])) + return changes + + +def inventory_fingerprint(inventory: dict[str, Any]) -> str: + snap = hardware_snapshot(inventory) + raw = json.dumps(snap, sort_keys=True, ensure_ascii=False, default=str) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def apply_os_version_from_inventory(host: Host, inventory: dict[str, Any]) -> None: + windows = _as_dict(inventory.get("windows")) + product = str(windows.get("product_name") or "").strip() + version = str(windows.get("version") or "").strip() + if product and version: + host.os_version = f"{product} ({version})" + elif product: + host.os_version = product + elif version: + host.os_version = version + + +def process_inventory_ingest( + host: Host, + details: dict | None, +) -> tuple[str, list[dict[str, Any]], dict[str, Any] | None]: + """ + Update host.inventory from agent.inventory details. + + Returns (severity, hardware_changes, merged_details_patch). + severity is warning when hardware changed, else info. + """ + inventory = extract_inventory_payload(details) + if inventory is None: + return "info", [], None + + previous = host.inventory if isinstance(host.inventory, dict) else None + changes = diff_hardware(previous, inventory) + now = datetime.now(timezone.utc) + host.inventory = inventory + host.inventory_updated_at = now + apply_os_version_from_inventory(host, inventory) + + patch: dict[str, Any] = { + "inventory_fingerprint": inventory_fingerprint(inventory), + "first_inventory": previous is None, + } + if changes: + patch["hardware_changes"] = changes + + severity = "warning" if changes else "info" + return severity, changes, patch diff --git a/backend/app/services/host_manual_add.py b/backend/app/services/host_manual_add.py new file mode 100644 index 0000000..bd00fb9 --- /dev/null +++ b/backend/app/services/host_manual_add.py @@ -0,0 +1,127 @@ +"""Probe remote host and register in SAC before agent deploy (WinRM / SSH).""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Host +from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH +from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection +from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.winrm_connect import test_winrm_connection + +_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") + + +class ManualHostAddError(Exception): + pass + + +def validate_windows_target(target: str) -> str: + text = (target or "").strip() + if not text: + raise ManualHostAddError("Укажите имя Windows-ПК") + if _IPV4_RE.match(text): + raise ManualHostAddError( + "Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен" + ) + short = text.split(".")[0].split("\\")[-1].strip() + if not short or " " in short: + raise ManualHostAddError("Некорректное имя хоста") + return short + + +def validate_linux_target(target: str) -> str: + text = (target or "").strip() + if not text: + raise ManualHostAddError("Укажите IP или hostname Linux-хоста") + return text + + +def probe_windows_host(db: Session, hostname: str) -> str: + cfg = get_effective_win_admin_config(db) + if not cfg.configured: + raise ManualHostAddError("Windows domain admin не настроен в SAC") + probe = test_winrm_connection( + target=hostname, + user=cfg.user, + password=cfg.password, + ) + if not probe.ok: + raise ManualHostAddError(probe.message) + return (probe.hostname or hostname).strip() + + +def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]: + cfg = get_effective_linux_admin_config(db) + if not cfg.configured: + raise ManualHostAddError("Linux SSH admin не настроен в SAC") + probe = test_ssh_connection( + target=target, + user=cfg.user, + password=cfg.password, + ) + if not probe.ok: + raise ManualHostAddError(probe.message) + hostname = _ssh_output_hostname(probe.stdout) or target + ipv4 = target if _is_ipv4(target) else None + return hostname, ipv4 + + +def get_or_create_manual_host( + db: Session, + *, + hostname: str, + product: str, + os_family: str, + ipv4: str | None = None, +) -> Host: + host = db.scalar( + select(Host).where(Host.hostname == hostname, Host.product == product).limit(1) + ) + now = datetime.now(timezone.utc) + if host is None: + host = Host( + hostname=hostname, + os_family=os_family, + product=product, + ipv4=ipv4, + last_seen_at=now, + ) + db.add(host) + else: + host.os_family = os_family + if ipv4: + host.ipv4 = ipv4 + host.last_seen_at = now + db.flush() + return host + + +def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host: + platform_norm = (platform or "").strip().lower() + if platform_norm == "windows": + win_target = validate_windows_target(target) + hostname = probe_windows_host(db, win_target) + return get_or_create_manual_host( + db, + hostname=hostname, + product=PRODUCT_RDP, + os_family="windows", + ) + if platform_norm == "linux": + linux_target = validate_linux_target(target) + hostname, ipv4 = probe_linux_host(db, linux_target) + return get_or_create_manual_host( + db, + hostname=hostname, + product=PRODUCT_SSH, + os_family="linux", + ipv4=ipv4, + ) + raise ManualHostAddError("platform должен быть windows или linux") diff --git a/backend/app/services/host_mgmt_credentials.py b/backend/app/services/host_mgmt_credentials.py new file mode 100644 index 0000000..ae5d865 --- /dev/null +++ b/backend/app/services/host_mgmt_credentials.py @@ -0,0 +1,116 @@ +"""Per-host management credentials (override global Win/Linux admin).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models.host import Host +from app.services.linux_admin_settings import ( + LinuxAdminConfig, + get_effective_linux_admin_for_host, +) +from app.services.win_admin_settings import ( + WinAdminConfig, + get_effective_win_admin_for_host, + normalize_win_admin_user, +) + + +def mask_secret(value: str | None) -> str | None: + if not value: + return None + text = value.strip() + if not text: + return None + if len(text) <= 4: + return "****" + return f"{text[:2]}…{text[-2:]}" + + +@dataclass(frozen=True) +class HostMgmtAccessView: + host_id: int + has_override: bool + user: str | None + password_set: bool + password_hint: str | None + effective_source: str + effective_configured: bool + effective_user: str | None + + +def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig: + os_family = (host.os_family or "").strip().lower() + if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}: + return get_effective_win_admin_for_host(db, host) + if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}: + return get_effective_linux_admin_for_host(db, host) + # Fallback: try host override first via win helper (same columns), then global win, then linux. + win_cfg = get_effective_win_admin_for_host(db, host) + if win_cfg.configured: + return win_cfg + return get_effective_linux_admin_for_host(db, host) + + +def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView: + host_user = (host.mgmt_user or "").strip() or None + host_password = (host.mgmt_password or "").strip() or None + has_override = bool(host_user or host_password) + effective = _effective_for_host(db, host) + return HostMgmtAccessView( + host_id=host.id, + has_override=has_override, + user=host_user, + password_set=bool(host_password), + password_hint=mask_secret(host_password), + effective_source=effective.source, + effective_configured=effective.configured, + effective_user=effective.user or None, + ) + + +def upsert_host_mgmt_credentials( + db: Session, + host: Host, + *, + user: str | None = None, + password: str | None = None, + clear: bool = False, +) -> HostMgmtAccessView: + """Update per-host credentials. + + - clear=True → wipe host override (use global Settings). + - user="" → clear username. + - password omitted (None) → keep existing password. + - password="" → clear password. + """ + if clear: + host.mgmt_user = None + host.mgmt_password = None + db.commit() + db.refresh(host) + return get_host_mgmt_access_view(db, host) + + if user is not None: + cleaned = user.strip() + if not cleaned: + host.mgmt_user = None + else: + # Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip. + os_family = (host.os_family or "").strip().lower() + if os_family == "windows" or "\\" in cleaned or "@" in cleaned: + host.mgmt_user = normalize_win_admin_user(cleaned) + else: + host.mgmt_user = cleaned + + if password is not None: + if not password.strip(): + host.mgmt_password = None + else: + host.mgmt_password = password + + db.commit() + db.refresh(host) + return get_host_mgmt_access_view(db, host) diff --git a/backend/app/services/host_remote_actions.py b/backend/app/services/host_remote_actions.py new file mode 100644 index 0000000..8f1ed9d --- /dev/null +++ b/backend/app/services/host_remote_actions.py @@ -0,0 +1,449 @@ +"""Background SSH/WinRM host actions (survives UI navigation and multi-worker API).""" + +from __future__ import annotations + +import logging +import os +import threading +from datetime import datetime, timezone +from typing import Any, Callable + +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.models import Host +from app.services.agent_update import execute_agent_update_fallback +from app.services.agent_update_types import AgentUpdateFallbackResult +from app.services.linux_admin_settings import get_effective_linux_admin_for_host +from app.services.agent_update_settings import get_effective_agent_update_config +from app.services.ssh_connect import ( + iter_ssh_targets, + run_ssh_monitor_update, + SshCommandResult, + tail_ssh_monitor_update_log, +) + +logger = logging.getLogger(__name__) + +ActionRunner = Callable[[Session, Host], AgentUpdateFallbackResult | SshCommandResult] + +_lock = threading.Lock() +_running: set[int] = set() + + +class RemoteActionAlreadyRunningError(Exception): + pass + + +def _run_inline() -> bool: + return os.environ.get("SAC_REMOTE_ACTION_INLINE", "").strip().lower() in ("1", "true", "yes") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _format_output(result: AgentUpdateFallbackResult | SshCommandResult) -> str: + parts: list[str] = [] + stdout = (getattr(result, "stdout", None) or "").strip() + stderr = (getattr(result, "stderr", None) or "").strip() + if stdout: + parts.append(stdout) + if stderr: + parts.append(stderr) + exit_code = getattr(result, "exit_code", None) + if exit_code is not None: + parts.append(f"exit code: {exit_code}") + return "\n\n".join(parts) + + +def _result_payload( + result: AgentUpdateFallbackResult | SshCommandResult, + *, + title: str, + started_at: str, +) -> dict[str, Any]: + finished = _utcnow().isoformat() + product_version = getattr(result, "product_version", None) or getattr(result, "agent_version", None) + return { + "title": title, + "status": "success" if result.ok else "failed", + "message": result.message, + "stdout": getattr(result, "stdout", None) or "", + "stderr": getattr(result, "stderr", None) or "", + "output": _format_output(result), + "ok": result.ok, + "exit_code": getattr(result, "exit_code", None), + "product_version": product_version, + "target": getattr(result, "target", None) or "", + "started_at": started_at, + "finished_at": finished, + } + + +def _completion_title(base_title: str, result: SshCommandResult) -> str: + if result.ok: + return f"{base_title} — готово" + return f"{base_title} — ошибка" + + +def _completion_message(result: SshCommandResult) -> str: + if result.ok: + version = (result.agent_version or "").strip() + if version: + return f"Обновление завершено успешно. Версия агента: {version}" + return "Обновление завершено успешно" + return (result.message or "Обновление не удалось").strip() + + +def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str: + cfg = get_effective_linux_admin_for_host(db, host) + if not cfg.configured: + return "" + try: + targets = iter_ssh_targets(host) + except Exception: + return "" + for target in targets: + try: + tail = tail_ssh_monitor_update_log( + target=target, + user=cfg.user, + password=cfg.password, + lines=lines, + ) + if tail: + return tail + except Exception: + logger.debug("final update log tail failed host_id=%s target=%s", host.id, target, exc_info=True) + return "" + + +def _enrich_ssh_update_payload( + payload: dict[str, Any], + *, + base_title: str, + result: SshCommandResult, + previous_output: str, + log_tail: str, +) -> dict[str, Any]: + payload["title"] = _completion_title(base_title, result) + payload["message"] = _completion_message(result) + if log_tail: + payload["output"] = log_tail + elif previous_output.strip(): + payload["output"] = previous_output + return payload + + +def _mark_running(db: Session, host: Host, *, title: str) -> str: + started_at = _utcnow().isoformat() + host.agent_update_state = "running" + host.agent_update_last_error = None + host.remote_action = { + "title": title, + "status": "running", + "message": "Подключение к хосту и выполнение команды…", + "stdout": "", + "stderr": "", + "output": "", + "ok": None, + "exit_code": None, + "product_version": None, + "target": host.hostname, + "started_at": started_at, + "finished_at": None, + } + db.flush() + return started_at + + +def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult) -> None: + now = _utcnow() + host.agent_update_last_at = now + if result.ok: + host.agent_update_state = "success" + host.agent_update_last_error = None + if result.agent_version: + host.product_version = result.agent_version + host.ssh_admin_ok = True + host.ssh_admin_checked_at = now + else: + host.agent_update_state = "failed" + host.agent_update_last_error = (result.message or "SSH update failed")[:2000] + + +_REMOTE_LOG_POLL_SEC = 2.0 + + +def _poll_ssh_update_log_loop( + host_id: int, + *, + stop: threading.Event, +) -> None: + """Периодически подтягивает tail update_script.log в hosts.remote_action для UI.""" + while not stop.wait(_REMOTE_LOG_POLL_SEC): + session = SessionLocal() + try: + row = session.get(Host, host_id) + if row is None or (row.agent_update_state or "").strip().lower() != "running": + continue + payload = dict(row.remote_action or {}) + if (payload.get("status") or "").strip().lower() != "running": + continue + cfg = get_effective_linux_admin_for_host(session, row) + if not cfg.configured: + continue + try: + targets = iter_ssh_targets(row) + except Exception: + continue + tail = "" + for target in targets: + try: + tail = tail_ssh_monitor_update_log( + target=target, + user=cfg.user, + password=cfg.password, + lines=200, + ) + except Exception: + logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True) + continue + if tail: + break + session.refresh(row) + if (row.agent_update_state or "").strip().lower() != "running": + continue + payload = dict(row.remote_action or {}) + if (payload.get("status") or "").strip().lower() != "running": + continue + if tail: + payload["output"] = tail + payload["message"] = "Выполняется обновление… (лог с хоста)" + row.remote_action = payload + session.commit() + except Exception: + logger.debug("remote log poll failed host_id=%s", host_id, exc_info=True) + session.rollback() + finally: + session.close() + + +def start_host_remote_action( + db: Session, + host: Host, + *, + title: str, + runner: ActionRunner, + poll_remote_log: bool = False, +) -> dict[str, Any]: + host_id = int(host.id) + with _lock: + if host_id in _running or host.agent_update_state == "running": + raise RemoteActionAlreadyRunningError( + f"Remote action already running for host {host.hostname}" + ) + _running.add(host_id) + + started_at = _mark_running(db, host, title=title) + db.commit() + + def _worker() -> None: + session = SessionLocal() + stop_poll = threading.Event() + poll_thread: threading.Thread | None = None + if poll_remote_log and runner is run_ssh_monitor_update_action: + poll_thread = threading.Thread( + target=_poll_ssh_update_log_loop, + args=(host_id,), + kwargs={"stop": stop_poll}, + name=f"sac-remote-log-{host_id}", + daemon=True, + ) + poll_thread.start() + try: + row = session.get(Host, host_id) + if row is None: + return + previous_output = (row.remote_action or {}).get("output") or "" + result = runner(session, row) + started = (row.remote_action or {}).get("started_at") or started_at + payload = _result_payload(result, title=title, started_at=started) + if poll_remote_log and isinstance(result, SshCommandResult): + log_tail = _fetch_ssh_update_log_tail(session, row) + payload = _enrich_ssh_update_payload( + payload, + base_title=title, + result=result, + previous_output=previous_output, + log_tail=log_tail, + ) + row.remote_action = payload + if isinstance(result, SshCommandResult): + _apply_ssh_update_result(session, row, result) + session.commit() + except Exception: + logger.exception("Background remote action failed for host_id=%s", host_id) + session.rollback() + row = session.get(Host, host_id) + if row is not None: + started = (row.remote_action or {}).get("started_at") or started_at + row.agent_update_state = "failed" + row.agent_update_last_error = "Internal error during remote action" + row.remote_action = { + "title": title, + "status": "failed", + "message": "Внутренняя ошибка SAC при выполнении действия", + "stdout": "", + "stderr": "", + "output": "", + "ok": False, + "exit_code": None, + "product_version": None, + "target": row.hostname, + "started_at": started, + "finished_at": _utcnow().isoformat(), + } + session.commit() + finally: + stop_poll.set() + if poll_thread is not None: + poll_thread.join(timeout=5.0) + session.close() + with _lock: + _running.discard(host_id) + + if _run_inline(): + _worker() + else: + threading.Thread(target=_worker, name=f"sac-remote-action-{host_id}", daemon=True).start() + return dict(host.remote_action or {}) + + +def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult: + cfg = get_effective_linux_admin_for_host(db, host) + if not cfg.configured: + return SshCommandResult( + ok=False, + message="Linux SSH admin is not configured", + target=host.hostname or "", + ) + agent_cfg = get_effective_agent_update_config(db) + repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None + branch = (agent_cfg.git_branch or "main").strip() or "main" + try: + targets = iter_ssh_targets(host) + except Exception as exc: + return SshCommandResult( + ok=False, + message=str(exc), + target=host.hostname or "", + ) + last: SshCommandResult | None = None + for target in targets: + last = run_ssh_monitor_update( + target=target, + user=cfg.user, + password=cfg.password, + repo_url=repo_url, + git_branch=branch, + ) + if last.ok: + return last + return last or SshCommandResult( + ok=False, + message="SSH update failed", + target=host.hostname or "", + ) + + +def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackResult: + return execute_agent_update_fallback(db, host) + + +def clear_stale_running_remote_actions( + db: Session, + *, + reason: str = "Прервано перезапуском SAC (фоновый worker не завершил job)", + host_ids: list[int] | None = None, +) -> list[str]: + """Сбросить зависшие running после restart sac-api (daemon thread умер, запись в БД осталась).""" + from sqlalchemy import select + + stmt = select(Host).where(Host.agent_update_state == "running") + if host_ids is not None: + stmt = stmt.where(Host.id.in_(host_ids)) + rows = list(db.scalars(stmt).all()) + if not rows: + return [] + + finished = _utcnow().isoformat() + hostnames: list[str] = [] + for host in rows: + started = (host.remote_action or {}).get("started_at") + payload = dict(host.remote_action or {}) + payload.update( + { + "title": payload.get("title") or "Remote action", + "status": "failed", + "message": reason, + "ok": False, + "finished_at": finished, + "started_at": started, + "target": payload.get("target") or host.hostname, + } + ) + host.remote_action = payload + host.agent_update_state = "failed" + host.agent_update_last_error = reason[:2000] + hostnames.append(host.hostname or str(host.id)) + + db.commit() + with _lock: + for host in rows: + _running.discard(int(host.id)) + return hostnames + + +def cancel_host_remote_action(db: Session, host: Host, *, reason: str | None = None) -> bool: + """Отменить зависший running job вручную (admin).""" + if host.agent_update_state != "running": + return False + msg = reason or "Отменено администратором SAC" + cleared = clear_stale_running_remote_actions(db, reason=msg, host_ids=[int(host.id)]) + return bool(cleared) + + +def get_remote_action_status(host: Host) -> dict[str, Any]: + payload = dict(host.remote_action or {}) + if not payload: + return {"active": False, "host_id": host.id} + agent_state = (host.agent_update_state or "").strip().lower() + if agent_state == "running": + active = True + status = payload.get("status") or "running" + elif agent_state in ("success", "failed"): + active = False + status = payload.get("status") or agent_state + else: + status = payload.get("status") or host.agent_update_state or "unknown" + active = status == "running" + return { + "active": active, + "host_id": host.id, + "hostname": host.hostname, + "status": status, + "title": payload.get("title") or "", + "message": payload.get("message") or "", + "stdout": payload.get("stdout") or "", + "stderr": payload.get("stderr") or "", + "output": payload.get("output") or "", + "ok": payload.get("ok"), + "exit_code": payload.get("exit_code"), + "product_version": payload.get("product_version") or host.product_version, + "target": payload.get("target") or host.hostname, + "agent_update_state": host.agent_update_state, + "started_at": payload.get("started_at"), + "finished_at": payload.get("finished_at"), + } diff --git a/backend/app/services/host_sessions.py b/backend/app/services/host_sessions.py new file mode 100644 index 0000000..ef062e9 --- /dev/null +++ b/backend/app/services/host_sessions.py @@ -0,0 +1,578 @@ +"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models import Event, Host +from app.services.event_actor_user import extract_event_actor_user +from app.services.linux_admin_settings import LinuxAdminConfig +from app.services.ssh_connect import ( + HostNotLinuxError as SshHostNotLinuxError, + HostTargetMissingError as SshHostTargetMissingError, + SshCommandResult, + is_linux_host, + iter_ssh_targets, + run_ssh_command, +) +from app.services.win_admin_settings import WinAdminConfig +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError, + WinRmCmdResult, + is_windows_host, + run_winrm_logoff, + run_winrm_on_host_targets, + run_winrm_qwinsta, +) + +SESSION_EVENT_TYPES_LINUX = frozenset( + { + "session.logind.new", + "ssh.login.success", + "privilege.sudo.command", + } +) +SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"}) + +SESSION_TERMINATED_AT_KEY = "session_terminated_at" +SESSION_TERMINATED_BY_KEY = "session_terminated_by" + +_LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE) +_LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE) +_LOGIND_STATES = frozenset( + { + "active", + "online", + "closing", + "opening", + "degraded", + "lingering", + "preparing", + "unknown", + } +) +_LOGIND_TTY_RE = re.compile(r"^(pts|tty)/", re.IGNORECASE) +_NO_SESSION_MARKERS = ("no session", "unknown session", "does not exist") + + +@dataclass(frozen=True) +class HostSessionRow: + session_id: str + user: str + tty: str | None = None + state: str | None = None + source_ip: str | None = None + session_name: str | None = None + + +def _details_dict(event: Event) -> dict: + raw = event.details + return raw if isinstance(raw, dict) else {} + + +def _event_login_user(event: Event) -> str: + actor = extract_event_actor_user(event.type, event.details) + if actor: + return actor.strip() + return str(_details_dict(event).get("user") or "").strip() + + +def event_session_terminated(event: Event, db: Session | None = None) -> bool: + from app.services.rdp_session_logoff import ( + event_closed_by_logoff, + resolve_workstation_login_closed_by_logoff, + ) + from app.services.rdg_workstation_session import ( + event_closed_by_rdg, + resolve_workstation_login_closed, + ) + + details = _details_dict(event) + if details.get("session_terminated") is True: + return True + at = details.get(SESSION_TERMINATED_AT_KEY) + if at is not None and str(at).strip() != "": + return True + if event_closed_by_rdg(event): + return True + if event_closed_by_logoff(event): + return True + if db is not None and event.type == "rdp.login.success": + if resolve_workstation_login_closed_by_logoff(db, event): + return True + return resolve_workstation_login_closed(db, event) + return False + + +def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None: + details = dict(_details_dict(event)) + details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat() + if by_username and by_username.strip(): + details[SESSION_TERMINATED_BY_KEY] = by_username.strip() + event.details = details + + +def event_session_id(event: Event) -> str | None: + details = _details_dict(event) + for key in ("session_id", "sid"): + val = details.get(key) + if val is not None and str(val).strip(): + return str(val).strip() + return None + + +def event_supports_session_terminate(event: Event) -> bool: + if event.type in SESSION_EVENT_TYPES_LINUX: + return is_linux_host_event(event) + if event.type in SESSION_EVENT_TYPES_WINDOWS: + return is_windows_host_event(event) + return False + + +def is_linux_host_event(event: Event) -> bool: + host = event.host + return host is not None and is_linux_host(host) + + +def is_windows_host_event(event: Event) -> bool: + host = event.host + return host is not None and is_windows_host(host) + + +def _normalize_logind_tty(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + if not text or text == "-": + return None + return text + + +def _logind_row_priority(row: HostSessionRow) -> tuple[int, int]: + """Prefer interactive TTY sessions over ephemeral scope/SSH-exec rows (tty «-»).""" + has_tty = 1 if row.tty and _LOGIND_TTY_RE.match(row.tty) else 0 + try: + sid_num = int(row.session_id) + except ValueError: + sid_num = 0 + return (has_tty, sid_num) + + +def filter_logind_session_rows(rows: list[HostSessionRow]) -> list[HostSessionRow]: + """Drop no-TTY duplicates when the same user already has a pts/tty session.""" + if len(rows) < 2: + return rows + + by_user: dict[str, list[HostSessionRow]] = {} + for row in rows: + key = row.user.casefold() + by_user.setdefault(key, []).append(row) + + out: list[HostSessionRow] = [] + for group in by_user.values(): + if len(group) == 1: + out.append(group[0]) + continue + with_tty = [r for r in group if r.tty and _LOGIND_TTY_RE.match(r.tty)] + if with_tty: + out.append(max(with_tty, key=_logind_row_priority)) + continue + out.append(max(group, key=_logind_row_priority)) + out.sort(key=lambda r: (r.user.casefold(), -_logind_row_priority(r)[1])) + return out + + +def parse_loginctl_sessions_json(stdout: str) -> list[HostSessionRow]: + text = stdout.strip() + if not text: + return [] + try: + payload = json.loads(text) + except json.JSONDecodeError: + return [] + if not isinstance(payload, list): + return [] + + rows: list[HostSessionRow] = [] + for item in payload: + if not isinstance(item, dict): + continue + sid = str(item.get("session") or "").strip() + user = str(item.get("user") or "").strip() + if not sid or not user: + continue + if not _LOGIND_SESSION_ID_RE.match(sid): + continue + if not _LOGIND_USER_RE.match(user): + continue + state = str(item.get("state") or "").strip().lower() or None + if state and state not in _LOGIND_STATES: + continue + rows.append( + HostSessionRow( + session_id=sid, + user=user, + tty=_normalize_logind_tty(item.get("tty")), + state=state, + ) + ) + return filter_logind_session_rows(rows) + + +def _looks_like_loginctl_row(parts: list[str]) -> bool: + if len(parts) < 6: + return False + sid, uid, user, state = parts[0], parts[1], parts[2], parts[5].lower() + if not _LOGIND_SESSION_ID_RE.match(sid): + return False + if not uid.isdigit(): + return False + if not _LOGIND_USER_RE.match(user): + return False + return state in _LOGIND_STATES + + +def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]: + rows: list[HostSessionRow] = [] + for line in stdout.splitlines(): + text = line.strip() + if not text or text.startswith("SESSION"): + continue + parts = text.split() + if not _looks_like_loginctl_row(parts): + continue + sid, user = parts[0], parts[2] + tty = parts[4] if len(parts) > 4 and parts[4] != "-" else None + state = parts[5] if len(parts) > 5 else None + rows.append( + HostSessionRow( + session_id=sid, + user=user, + tty=_normalize_logind_tty(tty), + state=state, + ) + ) + return filter_logind_session_rows(rows) + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _linux_session_exists_message(stderr: str, stdout: str) -> bool: + blob = f"{stderr}\n{stdout}".casefold() + return any(marker in blob for marker in _NO_SESSION_MARKERS) + + +def _linux_show_session_user(host: Host, cfg: LinuxAdminConfig, session_id: str) -> str | None: + sid = session_id.strip() + if not sid: + return None + remote_cmd = f"loginctl show-session {_shell_quote(sid)} -p Name --value" + targets = iter_ssh_targets(host) + for target in targets: + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=remote_cmd, + connect_timeout_sec=15, + command_timeout_sec=30, + need_root=True, + login_shell=False, + ) + if not result.ok: + continue + user = result.stdout.strip().splitlines()[-1].strip() if result.stdout.strip() else "" + if user and _LOGIND_USER_RE.match(user): + return user + return None + + +def list_linux_sessions(host: Host, cfg: LinuxAdminConfig) -> tuple[list[HostSessionRow], SshCommandResult]: + if not is_linux_host(host): + raise SshHostNotLinuxError("Host is not Linux") + targets = iter_ssh_targets(host) + json_cmd = "loginctl list-sessions --output=json --no-legend" + text_cmd = "loginctl list-sessions --no-legend --no-pager" + last: SshCommandResult | None = None + for target in targets: + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=json_cmd, + connect_timeout_sec=15, + command_timeout_sec=45, + need_root=True, + login_shell=False, + ) + last = result + if result.ok and result.stdout.strip(): + rows = parse_loginctl_sessions_json(result.stdout) + if rows: + return rows, result + rows = parse_loginctl_sessions(result.stdout) + if rows: + return rows, result + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=text_cmd, + connect_timeout_sec=15, + command_timeout_sec=45, + need_root=True, + login_shell=False, + ) + last = result + if result.ok and result.stdout.strip(): + return parse_loginctl_sessions(result.stdout), result + assert last is not None + return [], last + + +def terminate_linux_session( + host: Host, + cfg: LinuxAdminConfig, + session_id: str, +) -> SshCommandResult: + sid = session_id.strip() + if not sid: + return SshCommandResult(ok=False, message="session_id is required", target="") + if not is_linux_host(host): + raise SshHostNotLinuxError("Host is not Linux") + targets = iter_ssh_targets(host) + remote_cmd = f"loginctl terminate-session {_shell_quote(sid)}" + last: SshCommandResult | None = None + session_user = _linux_show_session_user(host, cfg, sid) + for target in targets: + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=remote_cmd, + connect_timeout_sec=15, + command_timeout_sec=45, + need_root=True, + login_shell=False, + ) + last = result + if result.ok: + return result + if session_user: + return _terminate_linux_user_sessions(host, cfg, session_user) + if last is not None and _linux_session_exists_message(last.stderr, last.stdout): + return SshCommandResult( + ok=False, + message=( + f"Сессия {sid} не найдена (устарела). Обновите список и повторите " + "или завершите все сессии пользователя." + ), + target=last.target, + stdout=last.stdout, + stderr=last.stderr, + exit_code=last.exit_code, + ) + assert last is not None + return last + + +def _normalize_sam_account(user: str) -> str: + text = (user or "").strip() + if "\\" in text: + return text.split("\\")[-1].strip().casefold() + if "@" in text: + return text.split("@")[0].strip().casefold() + return text.casefold() + + +def windows_user_matches_session(login_user: str, session_user: str) -> bool: + login = _normalize_sam_account(login_user) + session = _normalize_sam_account(session_user) + return bool(login and session and login == session) + + +def filter_windows_sessions_for_user( + sessions: list[HostSessionRow], + login_user: str, +) -> list[HostSessionRow]: + user = (login_user or "").strip() + if not user: + return sessions + return [s for s in sessions if windows_user_matches_session(user, s.user)] + + +def _qwinsta_sam_account(value: str) -> str: + text = (value or "").strip() + if "\\" in text: + text = text.split("\\")[-1] + if "@" in text: + text = text.split("@")[0] + return text.casefold() + + +def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]: + """Parse ``qwinsta`` output. + + Disconnected sessions often have an empty SESSIONNAME column, so the line + becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens + and skipped those rows — that broke RDG flap auto-logoff for Disc sessions. + """ + rows: list[HostSessionRow] = [] + filter_sam = _qwinsta_sam_account(filter_user or "") + skip_users = frozenset({"services"}) + + for line in stdout.splitlines(): + text = line.strip() + if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"): + continue + parts = text.split() + id_idx: int | None = None + sid = 0 + for i, part in enumerate(parts): + token = part.lstrip(">") + if token.isdigit(): + id_idx = i + sid = int(token) + break + if id_idx is None or id_idx < 1: + continue + state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else "" + if not state or state.casefold().startswith("listen"): + continue + before = parts[:id_idx] + if len(before) == 1: + session_name = "" + user_name = before[0].lstrip(">") + else: + session_name = before[0].lstrip(">") + user_name = before[1] + if user_name.casefold() in skip_users: + continue + if filter_sam and filter_sam not in _qwinsta_sam_account(user_name): + continue + rows.append( + HostSessionRow( + session_id=str(sid), + user=user_name, + session_name=session_name, + state=state, + ) + ) + + if rows or not filter_sam: + return rows + + return parse_qwinsta_sessions(stdout, filter_user=None) + + +def list_windows_sessions(host: Host, cfg: WinAdminConfig) -> tuple[list[HostSessionRow], WinRmCmdResult | None]: + if not is_windows_host(host): + raise HostNotWindowsError("Host is not Windows") + + def action(target: str) -> WinRmCmdResult: + return run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password) + + result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action) + if result is None or not result.ok: + return [], result + return parse_qwinsta_sessions(result.stdout), result + + +def terminate_windows_session( + host: Host, + cfg: WinAdminConfig, + session_id: str, +) -> WinRmCmdResult | None: + try: + sid = int(session_id.strip()) + except ValueError: + return WinRmCmdResult(ok=False, message="Invalid Windows session_id", target="") + + def action(target: str) -> WinRmCmdResult: + return run_winrm_logoff( + target=target, + user=cfg.user, + password=cfg.password, + session_id=sid, + ) + + result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action) + return result + + +def terminate_session_for_event( + event: Event, + *, + linux_cfg: LinuxAdminConfig, + win_cfg: WinAdminConfig, + session_id: str | None = None, +) -> SshCommandResult | WinRmCmdResult: + host = event.host + if host is None: + raise ValueError("Event has no host") + + sid = (session_id or event_session_id(event) or "").strip() + if is_linux_host(host): + if not sid: + user = _event_login_user(event) + if user: + return _terminate_linux_user_sessions(host, linux_cfg, user) + raise ValueError("session_id is required for this event") + return terminate_linux_session(host, linux_cfg, sid) + + if is_windows_host(host): + if not sid: + user = _event_login_user(event) + sessions, qwinsta = list_windows_sessions(host, win_cfg) + if not qwinsta or not qwinsta.ok: + raise ValueError(qwinsta.message if qwinsta else "qwinsta failed") + matched = filter_windows_sessions_for_user(sessions, user) if user else sessions + if len(matched) == 1: + sid = matched[0].session_id + elif not matched: + # Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто». + return WinRmCmdResult( + ok=True, + message="На хосте нет активной сессии пользователя (уже вышел из RDP)", + target=qwinsta.target, + stdout=qwinsta.stdout, + ) + else: + raise ValueError("Multiple sessions; specify session_id") + result = terminate_windows_session(host, win_cfg, sid) + if result is None: + raise ValueError("WinRM logoff failed") + return result + + raise ValueError("Unsupported host OS for session terminate") + + +def _terminate_linux_user_sessions(host: Host, cfg: LinuxAdminConfig, user: str) -> SshCommandResult: + safe_user = _shell_quote(user.strip()) + remote_cmd = f"loginctl terminate-user {safe_user}" + targets = iter_ssh_targets(host) + last: SshCommandResult | None = None + for target in targets: + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=remote_cmd, + connect_timeout_sec=15, + command_timeout_sec=45, + need_root=True, + login_shell=False, + ) + last = result + if result.ok: + return result + assert last is not None + return last diff --git a/backend/app/services/host_silence_scan.py b/backend/app/services/host_silence_scan.py new file mode 100644 index 0000000..920e02b --- /dev/null +++ b/backend/app/services/host_silence_scan.py @@ -0,0 +1,290 @@ +"""Периодическое сканирование хостов с устаревшим agent.heartbeat (proactive host_silence).""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Host, Problem +from app.services.problem_rules import RULE_HOST_SILENCE, RuleMatch, build_fingerprint, host_silence_match_for_host + +logger = logging.getLogger(__name__) + +ACTIVE_HOST_SILENCE_STATUSES = ("open", "acknowledged") +_HOST_SILENCE_SCAN_LOCK_KEY = 915_001_001 + + +@dataclass(frozen=True) +class HostSilenceScanResult: + host_id: int + hostname: str + problem: Problem + created: bool + + +def _host_silence_scan_lock(db: Session) -> bool: + """Один scan за раз (uvicorn workers + systemd timer).""" + bind = db.get_bind() + if bind.dialect.name != "postgresql": + return True + acquired = db.execute( + text("SELECT pg_try_advisory_xact_lock(:key)"), + {"key": _HOST_SILENCE_SCAN_LOCK_KEY}, + ).scalar() + return bool(acquired) + + +def _find_active_host_silence_problem( + db: Session, + *, + host_id: int, + hostname: str | None, +) -> Problem | None: + active = db.scalar( + select(Problem) + .where( + Problem.host_id == host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES), + ) + .order_by(Problem.last_seen_at.desc()) + .limit(1) + ) + if active is not None: + return active + + name = (hostname or "").strip() + if not name: + return None + + sibling = db.scalar( + select(Problem) + .join(Host, Problem.host_id == Host.id) + .where( + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES), + func.lower(Host.hostname) == name.lower(), + ) + .order_by(Problem.last_seen_at.desc()) + .limit(1) + ) + if sibling is not None and sibling.host_id != host_id: + sibling.host_id = host_id + db.flush() + return sibling + + +def _apply_host_silence_refresh( + problem: Problem, + *, + host_id: int, + match: RuleMatch, + fingerprint: str, + ref: datetime, +) -> None: + problem.host_id = host_id + problem.summary = match.summary + problem.title = match.title + problem.fingerprint = fingerprint + problem.last_seen_at = ref + problem.updated_at = ref + + +def open_or_refresh_host_silence_problem( + db: Session, + host_id: int, + match: RuleMatch, + *, + now: datetime | None = None, + hostname: str | None = None, +) -> tuple[Problem | None, bool]: + """ + Открыть или обновить open/ack Problem host_silence без ingest-события. + + Returns (problem, created). problem is None when suppressed by manual-resolve cooldown. + """ + ref = now or datetime.now(timezone.utc) + if hostname is None: + host = db.get(Host, host_id) + hostname = host.hostname if host is not None else None + + fingerprint = build_fingerprint( + host_id, + match.correlation_type, + match.rule_id, + match.fingerprint_suffix, + ) + + active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname) + if active_problem is not None: + _apply_host_silence_refresh( + active_problem, + host_id=host_id, + match=match, + fingerprint=fingerprint, + ref=ref, + ) + db.flush() + return active_problem, False + + settings = get_settings() + cooldown_hours = settings.sac_host_silence_manual_resolve_cooldown_hours + if cooldown_hours > 0: + cooldown = timedelta(hours=cooldown_hours) + manual_recent = db.scalar( + select(Problem) + .where( + Problem.host_id == host_id, + Problem.rule_id == match.rule_id, + Problem.fingerprint == fingerprint, + Problem.status == "resolved", + Problem.resolved_by == "manual", + Problem.updated_at >= ref - cooldown, + ) + .order_by(Problem.updated_at.desc()) + .limit(1) + ) + if manual_recent is not None: + logger.debug( + "host_silence suppressed (manual cooldown %sh) host_id=%s problem_id=%s", + cooldown_hours, + host_id, + manual_recent.id, + ) + return None, False + + manual_expired = db.scalar( + select(Problem) + .where( + Problem.host_id == host_id, + Problem.rule_id == match.rule_id, + Problem.fingerprint == fingerprint, + Problem.status == "resolved", + Problem.resolved_by == "manual", + Problem.updated_at < ref - cooldown, + ) + .order_by(Problem.updated_at.desc()) + .limit(1) + ) + if manual_expired is not None: + active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname) + if active_problem is not None: + _apply_host_silence_refresh( + active_problem, + host_id=host_id, + match=match, + fingerprint=fingerprint, + ref=ref, + ) + db.flush() + return active_problem, False + + manual_expired.status = "open" + manual_expired.resolved_by = None + _apply_host_silence_refresh( + manual_expired, + host_id=host_id, + match=match, + fingerprint=fingerprint, + ref=ref, + ) + db.flush() + return manual_expired, True + + problem = Problem( + host_id=host_id, + title=match.title, + summary=match.summary, + severity=match.severity, + status="open", + rule_id=match.rule_id, + fingerprint=fingerprint, + event_count=0, + last_seen_at=ref, + ) + try: + with db.begin_nested(): + db.add(problem) + db.flush() + except IntegrityError: + active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname) + if active_problem is None: + raise + _apply_host_silence_refresh( + active_problem, + host_id=host_id, + match=match, + fingerprint=fingerprint, + ref=ref, + ) + db.flush() + return active_problem, False + return problem, True + + +def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]: + """Найти stale-хосты и создать/обновить Problems rule:host_silence.""" + if not _host_silence_scan_lock(db): + logger.debug("host_silence scan skipped (another runner holds advisory lock)") + return [] + + ref = now or datetime.now(timezone.utc) + hosts = db.scalars(select(Host).order_by(Host.id)).all() + results: list[HostSilenceScanResult] = [] + + for host in hosts: + match = host_silence_match_for_host(db, host.id, hostname=host.hostname, now=ref) + if match is None: + continue + problem, created = open_or_refresh_host_silence_problem( + db, + host.id, + match, + now=ref, + hostname=host.hostname, + ) + if problem is None: + continue + results.append( + HostSilenceScanResult( + host_id=host.id, + hostname=host.hostname, + problem=problem, + created=created, + ) + ) + if created: + logger.info( + "host_silence scan: new problem host=%s id=%s problem_id=%s", + host.hostname, + host.id, + problem.id, + ) + + return results + + +def dispatch_host_silence_notifications(db: Session, results: list[HostSilenceScanResult]) -> int: + """Telegram/webhook/email только для вновь созданных Problems.""" + from app.services.notify_dispatch import notify_problem + + sent = 0 + for item in results: + if not item.created: + continue + try: + notify_problem(item.problem, event=None, db=db) + sent += 1 + except Exception: + logger.exception( + "host_silence notify failed host=%s problem_id=%s", + item.hostname, + item.problem.id, + ) + return sent diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py new file mode 100644 index 0000000..72fbd6a --- /dev/null +++ b/backend/app/services/ingest.py @@ -0,0 +1,142 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models import Event, Host +from app.services.agent_update import process_agent_update_ingest +from app.services.daily_report_format import normalize_daily_report_details +from app.services.event_severity_overrides import apply_severity_override +from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest +from app.services.rdp_session_logoff import close_workstation_session_for_rdp_logoff +from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end + +DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) + + +def _parse_dt(value: str) -> datetime: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def upsert_host(db: Session, payload: dict) -> Host: + source = payload.get("source") or {} + host_data = payload.get("host") or {} + agent_id = source.get("agent_instance_id") + hostname = host_data.get("hostname") or "unknown" + product = source.get("product") or "unknown" + + host: Host | None = None + if agent_id: + host = db.scalar(select(Host).where(Host.agent_instance_id == agent_id)) + + if host is None: + host = db.scalar( + select(Host).where(Host.hostname == hostname, Host.product == product).limit(1) + ) + + if host is None: + host = Host( + agent_instance_id=agent_id, + hostname=hostname, + display_name=host_data.get("display_name"), + os_family=host_data.get("os_family", "linux"), + os_version=host_data.get("os_version"), + product=product, + product_version=source.get("product_version"), + ipv4=host_data.get("ipv4"), + ipv6=host_data.get("ipv6"), + tags=payload.get("tags") or [], + ) + db.add(host) + else: + host.hostname = hostname + dn = host_data.get("display_name") + if isinstance(dn, str) and dn.strip(): + host.display_name = dn.strip() + host.os_version = host_data.get("os_version") or host.os_version + host.product_version = source.get("product_version") or host.product_version + host.ipv4 = host_data.get("ipv4") or host.ipv4 + host.ipv6 = host_data.get("ipv6") or host.ipv6 + if agent_id and not host.agent_instance_id: + host.agent_instance_id = agent_id + + host.last_seen_at = datetime.now(timezone.utc) + db.flush() + return host + + +def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]: + """ + Returns (event, created). + created=False if event_id already exists (idempotent). + """ + event_id = payload["event_id"] + existing = db.scalar(select(Event).where(Event.event_id == event_id)) + if existing: + return existing, False + + host = upsert_host(db, payload) + payload = apply_severity_override(payload, db) + details = payload.get("details") + if payload.get("type") in DAILY_REPORT_TYPES: + details = normalize_daily_report_details(details, host, payload["type"]) + + severity = payload["severity"] + title = payload["title"] + summary = payload["summary"] + if payload.get("type") == INVENTORY_EVENT_TYPE: + inv_severity, hw_changes, inv_patch = process_inventory_ingest(host, details) + severity = inv_severity + if inv_patch: + merged = dict(details) if isinstance(details, dict) else {} + merged.update(inv_patch) + details = merged + if hw_changes: + change_fields = ", ".join(c["field"] for c in hw_changes) + title = f"Hardware inventory changed on {host.hostname}" + summary = ( + f"Изменилось железо на {host.display_name or host.hostname}: {change_fields}" + ) + + event = Event( + event_id=event_id, + host_id=host.id, + occurred_at=_parse_dt(payload["occurred_at"]), + category=payload["category"], + type=payload["type"], + severity=severity, + title=title, + summary=summary, + details=details, + raw=payload.get("raw"), + dedup_key=payload.get("dedup_key"), + correlation_id=payload.get("correlation_id"), + payload=payload, + ) + db.add(event) + try: + db.flush() + except IntegrityError: + db.rollback() + raced = db.scalar(select(Event).where(Event.event_id == event_id)) + if raced is not None: + return raced, False + raise + + process_agent_update_ingest(db, host, payload.get("type", ""), details) + from app.services.rdg_workstation_session import ( + enrich_empty_login_from_rdg_success, + enrich_workstation_login_user_from_rdg, + ) + + if event.host is None: + event.host = host + enrich_workstation_login_user_from_rdg(db, event) + enrich_empty_login_from_rdg_success(db, event) + close_workstation_session_for_rdg_end(db, event) + close_workstation_session_for_rdp_logoff(db, event) + return event, True diff --git a/backend/app/services/linux_admin_settings.py b/backend/app/services/linux_admin_settings.py new file mode 100644 index 0000000..ad0fd77 --- /dev/null +++ b/backend/app/services/linux_admin_settings.py @@ -0,0 +1,96 @@ +"""Effective Linux SSH admin credentials (host → DB → env).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.host import Host +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +@dataclass(frozen=True) +class LinuxAdminConfig: + user: str + password: str + source: str # host | env | db + + @property + def configured(self) -> bool: + return bool(self.user.strip() and self.password.strip()) + + +def _linux_admin_from_env() -> LinuxAdminConfig: + settings = get_settings() + return LinuxAdminConfig( + user=settings.sac_linux_admin_user.strip(), + password=settings.sac_linux_admin_password, + source="env", + ) + + +def get_effective_linux_admin_config(db: Session | None = None) -> LinuxAdminConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_linux_admin_config(session) + finally: + session.close() + + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _linux_admin_from_env() + if row is None: + return env_cfg + + user = (row.linux_admin_user or "").strip() or env_cfg.user + password = (row.linux_admin_password or "") or env_cfg.password + has_db_values = bool((row.linux_admin_user or "").strip() or (row.linux_admin_password or "").strip()) + return LinuxAdminConfig( + user=user, + password=password, + source="db" if has_db_values else env_cfg.source, + ) + + +def upsert_linux_admin_settings( + db: Session, + *, + user: str | None = None, + password: str | None = None, +) -> LinuxAdminConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _linux_admin_from_env() + if row is None: + row = UiSettings( + id=UI_SETTINGS_ROW_ID, + show_sidebar_system_stats=True, + linux_admin_user=env_cfg.user or None, + linux_admin_password=env_cfg.password or None, + ) + db.add(row) + + if user is not None and user.strip(): + row.linux_admin_user = user.strip() + if password is not None and password.strip(): + row.linux_admin_password = password + + db.commit() + db.refresh(row) + return get_effective_linux_admin_config(db) + + +def get_effective_linux_admin_for_host(db: Session, host: Host) -> LinuxAdminConfig: + """Prefer per-host mgmt_* when both user and password are set; else global Settings/env.""" + host_user = (host.mgmt_user or "").strip() + host_password = (host.mgmt_password or "").strip() + if host_user and host_password: + return LinuxAdminConfig(user=host_user, password=host_password, source="host") + + global_cfg = get_effective_linux_admin_config(db) + if host_user and global_cfg.password.strip(): + return LinuxAdminConfig(user=host_user, password=global_cfg.password, source="host") + return global_cfg diff --git a/backend/app/services/login_rate_limit.py b/backend/app/services/login_rate_limit.py new file mode 100644 index 0000000..b62ef96 --- /dev/null +++ b/backend/app/services/login_rate_limit.py @@ -0,0 +1,146 @@ +"""Rate limit failed SAC UI logins + optional Telegram alert.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from fastapi import HTTPException, Request +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.login_attempt import LoginAttempt +from app.services.client_ip import client_ip_from_request +from app.services.login_security_settings import ( + get_effective_login_security_config, + is_ip_login_whitelisted, +) +from app.services.telegram_notify import send_telegram_text + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class LoginRateLimitConfig: + max_failures: int + window_minutes: int + alert_telegram: bool + + +def get_login_rate_limit_config() -> LoginRateLimitConfig: + settings = get_settings() + return LoginRateLimitConfig( + max_failures=max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3)), + window_minutes=max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15)), + alert_telegram=bool(getattr(settings, "sac_login_alert_telegram", True)), + ) + + +def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int: + return int( + db.scalar( + select(func.count()) + .select_from(LoginAttempt) + .where( + LoginAttempt.ip_address == ip_address, + LoginAttempt.success.is_(False), + LoginAttempt.created_at >= since, + ) + ) + or 0 + ) + + +def _alert_already_sent(db: Session, ip_address: str, *, since: datetime) -> bool: + """Avoid spamming Telegram on every subsequent 429.""" + row = db.scalar( + select(LoginAttempt.username) + .where( + LoginAttempt.ip_address == ip_address, + LoginAttempt.success.is_(False), + LoginAttempt.created_at >= since, + LoginAttempt.username == "__alert_sent__", + ) + .limit(1) + ) + return row is not None + + +def _send_login_bruteforce_alert(ip_address: str, username: str, failures: int) -> None: + user_hint = username if username else "—" + text = ( + f"⚠️ SAC: подбор пароля UI\n" + f"IP: {ip_address}\n" + f"Логин: {user_hint}\n" + f"Неудачных попыток: {failures}\n" + f"Дальнейший вход с этого IP временно заблокирован." + ) + try: + send_telegram_text(text, parse_mode="HTML") + except Exception: + logger.exception("failed to send SAC login brute-force Telegram alert") + + +def ensure_login_allowed(db: Session, request: Request) -> str: + """Raise 429 if IP exceeded failed login threshold. Returns client IP.""" + cfg = get_login_rate_limit_config() + ip_address = client_ip_from_request(request) + sec = get_effective_login_security_config(db) + if is_ip_login_whitelisted(ip_address, sec.ip_whitelist): + return ip_address + since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) + failures = _failure_count(db, ip_address, since=since) + if failures >= cfg.max_failures: + raise HTTPException( + status_code=429, + detail=f"Слишком много неудачных попыток входа. Повторите через {cfg.window_minutes} мин.", + ) + return ip_address + + +def record_login_success(db: Session, *, ip_address: str, username: str) -> None: + db.add( + LoginAttempt( + ip_address=ip_address, + username=username[:64], + success=True, + ) + ) + + +def record_login_failure( + db: Session, + *, + ip_address: str, + username: str, + request: Request | None = None, +) -> None: + del request # reserved + sec = get_effective_login_security_config(db) + if is_ip_login_whitelisted(ip_address, sec.ip_whitelist): + return + cfg = get_login_rate_limit_config() + since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) + prior_failures = _failure_count(db, ip_address, since=since) + + db.add( + LoginAttempt( + ip_address=ip_address, + username=username[:64] if username else None, + success=False, + ) + ) + db.flush() + + new_failures = prior_failures + 1 + if new_failures >= cfg.max_failures and cfg.alert_telegram and not _alert_already_sent(db, ip_address, since=since): + db.add( + LoginAttempt( + ip_address=ip_address, + username="__alert_sent__", + success=False, + ) + ) + _send_login_bruteforce_alert(ip_address, username, new_failures) diff --git a/backend/app/services/login_security_settings.py b/backend/app/services/login_security_settings.py new file mode 100644 index 0000000..1e67629 --- /dev/null +++ b/backend/app/services/login_security_settings.py @@ -0,0 +1,254 @@ +"""SAC UI login security: IP whitelist, blocked list, unblock.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.login_attempt import LoginAttempt +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings +from app.services.fail2ban_sync import ( + Fail2banActionResult, + list_ssh_banned_ips, + sync_fail2ban_ignore_ips, + unban_ssh_ip, +) + +_IP_RE = re.compile( + r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$" +) + + +@dataclass(frozen=True) +class LoginSecurityConfig: + ip_whitelist: tuple[str, ...] + sync_fail2ban: bool + max_failures: int + window_minutes: int + source: str + + +@dataclass(frozen=True) +class LoginBlockEntry: + ip_address: str + scope: str + failure_count: int | None + usernames: tuple[str, ...] + blocked_until: datetime | None + reason: str + + +def normalize_ip_token(raw: str) -> str | None: + text = (raw or "").strip() + if not text or text.startswith("#"): + return None + if _IP_RE.match(text): + return text + return None + + +def parse_ip_whitelist_text(text: str) -> list[str]: + if not text: + return [] + tokens = re.split(r"[\s,;]+", text.replace("\n", " ")) + out: list[str] = [] + seen: set[str] = set() + for token in tokens: + ip = normalize_ip_token(token) + if ip and ip not in seen: + seen.add(ip) + out.append(ip) + return out + + +def _env_whitelist() -> list[str]: + settings = get_settings() + raw = (getattr(settings, "sac_login_ip_whitelist", None) or "").strip() + return parse_ip_whitelist_text(raw.replace(",", " ")) + + +def _db_whitelist(row: UiSettings | None) -> list[str]: + if row is None: + return [] + return parse_ip_whitelist_text(row.login_ip_whitelist or "") + + +def merge_whitelist(*parts: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for part in parts: + for ip in part: + if ip not in seen: + seen.add(ip) + out.append(ip) + return out + + +def get_effective_login_security_config(db: Session) -> LoginSecurityConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_ips = _env_whitelist() + db_ips = _db_whitelist(row) + merged = merge_whitelist(env_ips, db_ips) + settings = get_settings() + max_failures = max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3)) + window_minutes = max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15)) + sources: list[str] = [] + if env_ips: + sources.append("env") + if db_ips: + sources.append("db") + source = "+".join(sources) if sources else "default" + sync_fb = bool(row.login_sync_fail2ban) if row is not None else False + return LoginSecurityConfig( + ip_whitelist=tuple(merged), + sync_fail2ban=sync_fb, + max_failures=max_failures, + window_minutes=window_minutes, + source=source, + ) + + +def is_ip_login_whitelisted(ip_address: str, whitelist: tuple[str, ...] | list[str]) -> bool: + ip = (ip_address or "").strip() + if not ip: + return False + return ip in whitelist + + +def upsert_login_security_settings( + db: Session, + *, + ip_whitelist_text: str, + sync_fail2ban: bool, +) -> LoginSecurityConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True) + db.add(row) + row.login_ip_whitelist = ip_whitelist_text.strip() or None + row.login_sync_fail2ban = bool(sync_fail2ban) + db.commit() + db.refresh(row) + cfg = get_effective_login_security_config(db) + if cfg.sync_fail2ban: + sync_fail2ban_ignore_ips(list(cfg.ip_whitelist)) + return cfg + + +def _window_since(cfg: LoginSecurityConfig) -> datetime: + return datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) + + +def list_web_login_blocks(db: Session) -> list[LoginBlockEntry]: + cfg = get_effective_login_security_config(db) + since = _window_since(cfg) + rows = db.execute( + select( + LoginAttempt.ip_address, + func.count().label("cnt"), + func.max(LoginAttempt.created_at).label("last_at"), + ) + .where( + LoginAttempt.success.is_(False), + LoginAttempt.username != "__alert_sent__", + LoginAttempt.created_at >= since, + ) + .group_by(LoginAttempt.ip_address) + .having(func.count() >= cfg.max_failures) + ).all() + + blocks: list[LoginBlockEntry] = [] + for ip_address, cnt, last_at in rows: + ip = str(ip_address) + if is_ip_login_whitelisted(ip, cfg.ip_whitelist): + continue + user_rows = db.scalars( + select(LoginAttempt.username) + .where( + LoginAttempt.ip_address == ip, + LoginAttempt.success.is_(False), + LoginAttempt.username.isnot(None), + LoginAttempt.username != "__alert_sent__", + LoginAttempt.created_at >= since, + ) + .distinct() + .limit(10) + ).all() + usernames = tuple(sorted({u for u in user_rows if u})) + blocked_until = (last_at + timedelta(minutes=cfg.window_minutes)) if last_at else None + blocks.append( + LoginBlockEntry( + ip_address=ip, + scope="web", + failure_count=int(cnt or 0), + usernames=usernames, + blocked_until=blocked_until, + reason=f"≥{cfg.max_failures} неудачных входов в UI за {cfg.window_minutes} мин", + ) + ) + return blocks + + +def list_ssh_login_blocks() -> list[LoginBlockEntry]: + banned = list_ssh_banned_ips() + return [ + LoginBlockEntry( + ip_address=ip, + scope="ssh", + failure_count=None, + usernames=(), + blocked_until=None, + reason="fail2ban (sshd)", + ) + for ip in banned + ] + + +def list_all_login_blocks(db: Session) -> list[LoginBlockEntry]: + web = {b.ip_address: b for b in list_web_login_blocks(db)} + ssh = list_ssh_login_blocks() + out = list(web.values()) + for entry in ssh: + if entry.ip_address in web: + existing = web[entry.ip_address] + out[out.index(existing)] = LoginBlockEntry( + ip_address=entry.ip_address, + scope="web+ssh", + failure_count=existing.failure_count, + usernames=existing.usernames, + blocked_until=existing.blocked_until, + reason=f"{existing.reason}; {entry.reason}", + ) + else: + out.append(entry) + out.sort(key=lambda e: e.ip_address) + return out + + +def clear_web_login_block(db: Session, ip_address: str) -> int: + ip = (ip_address or "").strip() + if not ip: + return 0 + result = db.execute(delete(LoginAttempt).where(LoginAttempt.ip_address == ip)) + db.commit() + return int(result.rowcount or 0) + + +def unblock_login_ip(db: Session, ip_address: str, *, scope: str = "both") -> list[str]: + ip = (ip_address or "").strip() + if not ip: + return ["empty ip"] + messages: list[str] = [] + scope_norm = (scope or "both").strip().lower() + if scope_norm in ("web", "both"): + deleted = clear_web_login_block(db, ip) + messages.append(f"Web UI: удалено записей login_attempts: {deleted}") + if scope_norm in ("ssh", "both"): + res: Fail2banActionResult = unban_ssh_ip(ip) + messages.append(res.message) + return messages diff --git a/backend/app/services/mobile_devices.py b/backend/app/services/mobile_devices.py new file mode 100644 index 0000000..4bc4051 --- /dev/null +++ b/backend/app/services/mobile_devices.py @@ -0,0 +1,104 @@ +"""Управление зарегистрированными устройствами Seaca.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.mobile_device import MobileDevice +from app.models.user import User +from app.services.mobile_tokens import revoke_refresh_tokens_for_device + + +@dataclass(frozen=True) +class MobileDeviceSummary: + id: int + user_id: int + username: str + device_uuid: str + display_name: str + platform: str + app_version: str | None + has_fcm_token: bool + enrolled_at: datetime + last_seen_at: datetime | None + revoked_at: datetime | None + is_active: bool + + +def list_mobile_devices(db: Session) -> list[MobileDeviceSummary]: + rows = db.scalars(select(MobileDevice).order_by(MobileDevice.enrolled_at.desc())).all() + user_ids = {r.user_id for r in rows} + usernames: dict[int, str] = {} + if user_ids: + for user in db.scalars(select(User).where(User.id.in_(user_ids))).all(): + usernames[user.id] = user.username + return [_to_summary(row, usernames.get(row.user_id, "?")) for row in rows] + + +def _to_summary(row: MobileDevice, username: str) -> MobileDeviceSummary: + return MobileDeviceSummary( + id=row.id, + user_id=row.user_id, + username=username, + device_uuid=row.device_uuid, + display_name=row.display_name or "", + platform=row.platform or "android", + app_version=row.app_version, + has_fcm_token=bool((row.fcm_token or "").strip()), + enrolled_at=row.enrolled_at, + last_seen_at=row.last_seen_at, + revoked_at=row.revoked_at, + is_active=row.revoked_at is None, + ) + + +def get_device_or_raise(db: Session, device_id: int) -> MobileDevice: + row = db.get(MobileDevice, device_id) + if row is None: + raise ValueError("device not found") + return row + + +def assert_device_active(device: MobileDevice) -> None: + if device.revoked_at is not None: + raise PermissionError("device has been revoked") + + +def touch_device(db: Session, device: MobileDevice, *, commit: bool = False) -> None: + device.last_seen_at = datetime.now(timezone.utc) + if commit: + db.commit() + + +def revoke_device(db: Session, device_id: int) -> MobileDeviceSummary: + device = get_device_or_raise(db, device_id) + if device.revoked_at is None: + device.revoked_at = datetime.now(timezone.utc) + revoke_refresh_tokens_for_device(db, device.id) + db.commit() + db.refresh(device) + user = db.get(User, device.user_id) + username = user.username if user else "?" + return _to_summary(device, username) + + +def delete_device_record(db: Session, device_id: int) -> None: + device = get_device_or_raise(db, device_id) + revoke_refresh_tokens_for_device(db, device.id) + db.delete(device) + db.commit() + + +def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None: + cleaned = (fcm_token or "").strip() + if not cleaned: + raise ValueError("fcm_token is required") + now = datetime.now(timezone.utc) + device.fcm_token = cleaned + device.fcm_token_updated_at = now + device.last_seen_at = now + db.commit() diff --git a/backend/app/services/mobile_enrollment.py b/backend/app/services/mobile_enrollment.py new file mode 100644 index 0000000..3b91998 --- /dev/null +++ b/backend/app/services/mobile_enrollment.py @@ -0,0 +1,296 @@ +"""Создание и проверка кодов регистрации Seaca.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.mobile_device import MobileDevice +from app.models.mobile_enrollment_code import MobileEnrollmentCode +from app.models.mobile_settings import LOGIN_MODE_CODE_ONLY, LOGIN_MODE_PASSWORD, LOGIN_MODES +from app.models.user import User +from app.services.mobile_settings import get_effective_mobile_settings +from app.services.mobile_tokens import ( + generate_enrollment_code, + hash_mobile_secret, + revoke_refresh_tokens_for_device, + store_refresh_token, +) +from app.services.user_auth import authenticate_user + + +@dataclass(frozen=True) +class EnrollmentCodeSummary: + id: int + label: str + code_prefix: str + target_user_id: int | None + target_username: str | None + login_mode: str + max_uses: int + use_count: int + expires_at: datetime + revoked_at: datetime | None + created_at: datetime + is_active: bool + + +@dataclass(frozen=True) +class EnrollResult: + access_token: str + refresh_token: str + device_id: int + username: str + role: str + + +def _as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _code_is_usable(row: MobileEnrollmentCode, *, now: datetime) -> bool: + if row.revoked_at is not None: + return False + if _as_utc(row.expires_at) <= now: + return False + if row.use_count >= row.max_uses: + return False + return True + + +def list_enrollment_codes(db: Session) -> list[EnrollmentCodeSummary]: + rows = db.scalars( + select(MobileEnrollmentCode).order_by(MobileEnrollmentCode.created_at.desc()) + ).all() + usernames: dict[int, str] = {} + user_ids = {r.target_user_id for r in rows if r.target_user_id} + if user_ids: + for user in db.scalars(select(User).where(User.id.in_(user_ids))).all(): + usernames[user.id] = user.username + now = datetime.now(timezone.utc) + return [ + EnrollmentCodeSummary( + id=row.id, + label=row.label or "", + code_prefix=row.code_prefix, + target_user_id=row.target_user_id, + target_username=usernames.get(row.target_user_id) if row.target_user_id else None, + login_mode=row.login_mode, + max_uses=row.max_uses, + use_count=row.use_count, + expires_at=row.expires_at, + revoked_at=row.revoked_at, + created_at=row.created_at, + is_active=_code_is_usable(row, now=now), + ) + for row in rows + ] + + +def create_enrollment_code( + db: Session, + *, + created_by_user_id: int | None, + label: str, + target_user_id: int | None, + login_mode: str, + max_uses: int, + expires_in_hours: int, +) -> tuple[EnrollmentCodeSummary, str]: + if login_mode not in LOGIN_MODES: + raise ValueError(f"login_mode must be one of: {sorted(LOGIN_MODES)}") + if login_mode == LOGIN_MODE_CODE_ONLY and target_user_id is None: + raise ValueError("code_only requires target_user_id") + if max_uses < 1 or max_uses > 100: + raise ValueError("max_uses must be between 1 and 100") + if expires_in_hours < 1 or expires_in_hours > 24 * 30: + raise ValueError("expires_in_hours must be between 1 and 720") + + if target_user_id is not None: + user = db.get(User, target_user_id) + if user is None or not user.is_active: + raise ValueError("target user not found or inactive") + + plaintext, prefix, code_hash = generate_enrollment_code() + expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_in_hours) + row = MobileEnrollmentCode( + label=(label or "").strip(), + code_hash=code_hash, + code_prefix=prefix, + created_by_user_id=created_by_user_id, + target_user_id=target_user_id, + login_mode=login_mode, + max_uses=max_uses, + use_count=0, + expires_at=expires_at, + ) + db.add(row) + db.flush() + target_username = None + if row.target_user_id: + u = db.get(User, row.target_user_id) + target_username = u.username if u else None + summary = EnrollmentCodeSummary( + id=row.id, + label=row.label or "", + code_prefix=row.code_prefix, + target_user_id=row.target_user_id, + target_username=target_username, + login_mode=row.login_mode, + max_uses=row.max_uses, + use_count=row.use_count, + expires_at=row.expires_at, + revoked_at=row.revoked_at, + created_at=row.created_at, + is_active=True, + ) + return summary, plaintext + + +def revoke_enrollment_code(db: Session, code_id: int) -> None: + row = db.get(MobileEnrollmentCode, code_id) + if row is None: + raise ValueError("enrollment code not found") + if row.revoked_at is None: + row.revoked_at = datetime.now(timezone.utc) + db.commit() + + +def _count_active_devices(db: Session, user_id: int) -> int: + return ( + db.scalar( + select(func.count()) + .select_from(MobileDevice) + .where(MobileDevice.user_id == user_id, MobileDevice.revoked_at.is_(None)) + ) + or 0 + ) + + +def enroll_device( + db: Session, + *, + enrollment_code: str, + username: str | None, + password: str | None, + device_uuid: str, + display_name: str, + platform: str, + app_version: str | None, + fcm_token: str | None, + create_access_token, +) -> EnrollResult: + from app.auth.jwt_auth import create_access_token as _create_access_token + + token_factory = create_access_token or _create_access_token + + mobile_cfg = get_effective_mobile_settings(db) + if not mobile_cfg.devices_allowed: + raise PermissionError("mobile enrollment is disabled") + + cleaned_uuid = (device_uuid or "").strip() + if len(cleaned_uuid) < 8: + raise ValueError("device_uuid is required") + + if mobile_cfg.min_app_version and app_version: + if _version_lt(app_version, mobile_cfg.min_app_version): + raise ValueError(f"app version {app_version} is below minimum {mobile_cfg.min_app_version}") + + now = datetime.now(timezone.utc) + code_hash = hash_mobile_secret(enrollment_code.strip()) + code_row = db.scalar( + select(MobileEnrollmentCode).where(MobileEnrollmentCode.code_hash == code_hash) + ) + if code_row is None or not _code_is_usable(code_row, now=now): + raise ValueError("invalid or expired enrollment code") + + if code_row.login_mode == LOGIN_MODE_PASSWORD: + if not username or not password: + raise ValueError("username and password required") + user = authenticate_user(db, username, password) + if user is None: + raise ValueError("invalid username or password") + else: + user = db.get(User, code_row.target_user_id) if code_row.target_user_id else None + if user is None or not user.is_active: + raise ValueError("enrollment code user is invalid") + + if code_row.target_user_id is not None and user.id != code_row.target_user_id: + raise ValueError("enrollment code is bound to another user") + + existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid)) + if existing is not None and existing.user_id != user.id: + raise ValueError("device_uuid already registered to another user") + + active_count = _count_active_devices(db, user.id) + if ( + existing is not None + and existing.user_id == user.id + and existing.revoked_at is None + ): + active_count -= 1 + if active_count >= mobile_cfg.max_devices_per_user: + raise ValueError("device limit reached for this user") + + cleaned_display = (display_name or "").strip() or "Android" + cleaned_platform = (platform or "android").strip() or "android" + cleaned_app_version = (app_version or "").strip() or None + cleaned_fcm = (fcm_token or "").strip() or None + + if existing is not None: + revoke_refresh_tokens_for_device(db, existing.id) + existing.revoked_at = None + existing.user_id = user.id + existing.display_name = cleaned_display + existing.platform = cleaned_platform + existing.app_version = cleaned_app_version + existing.fcm_token = cleaned_fcm + existing.fcm_token_updated_at = now if cleaned_fcm else None + existing.enrollment_code_id = code_row.id + existing.last_seen_at = now + device = existing + else: + device = MobileDevice( + user_id=user.id, + device_uuid=cleaned_uuid, + display_name=cleaned_display, + platform=cleaned_platform, + app_version=cleaned_app_version, + fcm_token=cleaned_fcm, + fcm_token_updated_at=now if cleaned_fcm else None, + enrollment_code_id=code_row.id, + last_seen_at=now, + ) + db.add(device) + code_row.use_count += 1 + db.flush() + + refresh_plain = store_refresh_token(db, device_id=device.id) + access_token = token_factory(user.username, user.role, device_id=device.id) + db.commit() + + return EnrollResult( + access_token=access_token, + refresh_token=refresh_plain, + device_id=device.id, + username=user.username, + role=user.role, + ) + + +def _version_lt(current: str, minimum: str) -> bool: + def parse(v: str) -> tuple[int, ...]: + parts: list[int] = [] + for piece in v.strip().split("."): + try: + parts.append(int(piece)) + except ValueError: + parts.append(0) + return tuple(parts) + + return parse(current) < parse(minimum) diff --git a/backend/app/services/mobile_notify.py b/backend/app/services/mobile_notify.py new file mode 100644 index 0000000..5e238bd --- /dev/null +++ b/backend/app/services/mobile_notify.py @@ -0,0 +1,244 @@ +"""Push-уведомления Seaca через Firebase Cloud Messaging (HTTP v1).""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +import httpx +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Event, Problem +from app.models.mobile_device import MobileDevice +from app.services.notification_severity import severity_meets_minimum +from app.services.notification_policy import get_effective_notification_policy +from app.services.telegram_templates import format_event_mobile_push + +logger = logging.getLogger(__name__) + +FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging" + + +class MobileNotConfiguredError(Exception): + """FCM отключён или не задан service account.""" + + +class MobileSendError(Exception): + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +@dataclass(frozen=True) +class FcmConfig: + enabled: bool + project_id: str + service_account_path: str + + @property + def configured(self) -> bool: + return bool(self.project_id.strip() and self.service_account_path.strip()) + + @property + def active(self) -> bool: + return self.enabled and self.configured + + +def get_effective_fcm_config() -> FcmConfig: + settings = get_settings() + return FcmConfig( + enabled=bool(settings.sac_fcm_enabled), + project_id=(settings.sac_fcm_project_id or "").strip(), + service_account_path=(settings.sac_fcm_service_account_json or "").strip(), + ) + + +def _load_service_account(path: str) -> dict: + file_path = Path(path) + if not file_path.is_file(): + raise MobileNotConfiguredError(f"FCM service account file not found: {path}") + with file_path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def _fcm_access_token(service_account_path: str) -> str: + try: + from google.oauth2 import service_account + from google.auth.transport.requests import Request + except ImportError as exc: + msg = str(exc).lower() + if "requests" in msg: + raise MobileNotConfiguredError( + "FCM: установите пакет requests (pip install requests)" + ) from exc + raise MobileNotConfiguredError("FCM: установите google-auth") from exc + + creds = service_account.Credentials.from_service_account_file( + service_account_path, + scopes=[FCM_SCOPE], + ) + creds.refresh(Request()) + if not creds.token: + raise MobileSendError("Failed to obtain FCM access token") + return creds.token + + +def _active_device_tokens(db: Session | None) -> list[str]: + if db is None: + return [] + rows = db.scalars( + select(MobileDevice).where( + MobileDevice.revoked_at.is_(None), + MobileDevice.fcm_token.is_not(None), + ) + ).all() + tokens: list[str] = [] + for row in rows: + token = (row.fcm_token or "").strip() + if token: + tokens.append(token) + return tokens + + +def _send_fcm_data( + tokens: list[str], + data: dict[str, str], + *, + title: str, + body: str, + require_success: bool = False, +) -> None: + if not tokens: + if require_success: + raise MobileSendError("Нет FCM-токенов для отправки") + return + cfg = get_effective_fcm_config() + if not cfg.active: + if require_success: + raise MobileNotConfiguredError("FCM не настроен на сервере") + return + + try: + access_token = _fcm_access_token(cfg.service_account_path) + except (MobileNotConfiguredError, MobileSendError) as exc: + logger.warning("FCM skipped: %s", exc) + if require_success: + raise + return + except Exception: + logger.exception("FCM token acquisition failed") + if require_success: + raise MobileSendError("Failed to obtain FCM access token", status_code=502) + return + + url = f"https://fcm.googleapis.com/v1/projects/{cfg.project_id}/messages:send" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + errors: list[str] = [] + + # Data-only: Android always calls SeacaMessagingService.onMessageReceived so the app + # can honour per-device notification mode (sound / silent / off). + message_data = {**data, "title": title, "body": body} + + with httpx.Client(timeout=12.0) as client: + for token in tokens: + payload = { + "message": { + "token": token, + "data": message_data, + "android": {"priority": "high"}, + } + } + try: + response = client.post(url, headers=headers, json=payload) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:200] if exc.response is not None else str(exc) + logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail) + errors.append(detail or f"HTTP {exc.response.status_code if exc.response else '?'}") + except Exception as exc: + logger.exception("FCM send failed") + errors.append(str(exc)) + + if require_success and errors: + raise MobileSendError(errors[0], status_code=502) + + +def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]: + title, body = format_event_mobile_push(event) + data = { + "kind": "event", + "id": str(event.id), + "severity": event.severity, + "type": event.type, + } + return title, body, data + + +def _problem_payload(problem: Problem) -> tuple[str, str, dict[str, str]]: + title = f"[{problem.severity}] {problem.title}" + body = (problem.summary or "")[:200] + data = { + "kind": "problem", + "id": str(problem.id), + "severity": problem.severity, + "status": problem.status, + } + return title, body, data + + +def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: + policy = get_effective_notification_policy(db) + if apply_policy_gate and not severity_meets_minimum(event.severity, policy.min_severity): + return + tokens = _active_device_tokens(db) + if not tokens: + return + title, body, data = _event_payload(event) + _send_fcm_data(tokens, data, title=title, body=body) + + +def notify_problem( + problem: Problem, + event: Event | None = None, + *, + db: Session | None = None, + apply_policy_gate: bool = True, +) -> None: + policy = get_effective_notification_policy(db) + if apply_policy_gate and not severity_meets_minimum(problem.severity, policy.min_severity): + return + tokens = _active_device_tokens(db) + if not tokens: + return + title, body, data = _problem_payload(problem) + _send_fcm_data(tokens, data, title=title, body=body) + + +def send_test_push(*, db: Session, device_id: int) -> None: + cfg = get_effective_fcm_config() + if not cfg.enabled: + raise MobileNotConfiguredError("FCM отключён (SAC_FCM_ENABLED=false)") + if not cfg.configured: + raise MobileNotConfiguredError("Не задан SAC_FCM_PROJECT_ID или SAC_FCM_SERVICE_ACCOUNT_JSON") + + device = db.get(MobileDevice, device_id) + if device is None or device.revoked_at is not None: + raise ValueError("device not found or revoked") + token = (device.fcm_token or "").strip() + if not token: + raise ValueError("device has no FCM token") + + _send_fcm_data( + [token], + {"kind": "test", "id": "0", "severity": "info"}, + title="SAC: тестовое push", + body="Канал Seaca (FCM) работает.", + require_success=True, + ) diff --git a/backend/app/services/mobile_settings.py b/backend/app/services/mobile_settings.py new file mode 100644 index 0000000..109b312 --- /dev/null +++ b/backend/app/services/mobile_settings.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings + + +@dataclass(frozen=True) +class MobileSettingsConfig: + devices_allowed: bool + max_devices_per_user: int + min_app_version: str | None + source: str = "db" + + +def get_effective_mobile_settings(db: Session) -> MobileSettingsConfig: + row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID) + if row is None: + return MobileSettingsConfig( + devices_allowed=False, + max_devices_per_user=3, + min_app_version=None, + source="default", + ) + return MobileSettingsConfig( + devices_allowed=bool(row.devices_allowed), + max_devices_per_user=max(1, int(row.max_devices_per_user or 3)), + min_app_version=(row.min_app_version or "").strip() or None, + source="db", + ) + + +def upsert_mobile_settings( + db: Session, + *, + devices_allowed: bool, + max_devices_per_user: int, + min_app_version: str | None, +) -> MobileSettingsConfig: + if max_devices_per_user < 1 or max_devices_per_user > 50: + raise ValueError("max_devices_per_user must be between 1 and 50") + cleaned_version = (min_app_version or "").strip() or None + row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID) + if row is None: + row = MobileSettings( + id=MOBILE_SETTINGS_ROW_ID, + devices_allowed=devices_allowed, + max_devices_per_user=max_devices_per_user, + min_app_version=cleaned_version, + ) + db.add(row) + else: + row.devices_allowed = devices_allowed + row.max_devices_per_user = max_devices_per_user + row.min_app_version = cleaned_version + db.commit() + db.refresh(row) + return get_effective_mobile_settings(db) diff --git a/backend/app/services/mobile_tokens.py b/backend/app/services/mobile_tokens.py new file mode 100644 index 0000000..00fcd02 --- /dev/null +++ b/backend/app/services/mobile_tokens.py @@ -0,0 +1,68 @@ +"""Хеширование enrollment/refresh токенов и генерация.""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.mobile_refresh_token import MobileRefreshToken + + +def hash_mobile_secret(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def generate_enrollment_code() -> tuple[str, str, str]: + """(plaintext, prefix, hash)""" + raw = f"sacmob_{secrets.token_urlsafe(18)}" + prefix = raw[:12] + return raw, prefix, hash_mobile_secret(raw) + + +def generate_refresh_token() -> tuple[str, str]: + """(plaintext, hash)""" + raw = secrets.token_urlsafe(48) + return raw, hash_mobile_secret(raw) + + +def store_refresh_token(db: Session, *, device_id: int) -> str: + settings = get_settings() + days = max(1, int(settings.sac_mobile_refresh_expire_days)) + plaintext, token_hash = generate_refresh_token() + expires_at = datetime.now(timezone.utc) + timedelta(days=days) + row = MobileRefreshToken( + device_id=device_id, + token_hash=token_hash, + expires_at=expires_at, + ) + db.add(row) + return plaintext + + +def revoke_refresh_tokens_for_device(db: Session, device_id: int) -> None: + now = datetime.now(timezone.utc) + rows = db.scalars( + select(MobileRefreshToken).where( + MobileRefreshToken.device_id == device_id, + MobileRefreshToken.revoked_at.is_(None), + ) + ).all() + for row in rows: + row.revoked_at = now + + +def find_valid_refresh_token(db: Session, raw_token: str) -> MobileRefreshToken | None: + token_hash = hash_mobile_secret(raw_token) + now = datetime.now(timezone.utc) + return db.scalar( + select(MobileRefreshToken).where( + MobileRefreshToken.token_hash == token_hash, + MobileRefreshToken.revoked_at.is_(None), + MobileRefreshToken.expires_at > now, + ) + ) diff --git a/backend/app/services/notification_cooldown.py b/backend/app/services/notification_cooldown.py new file mode 100644 index 0000000..0e69450 --- /dev/null +++ b/backend/app/services/notification_cooldown.py @@ -0,0 +1,100 @@ +"""Cooldown / dedup for outbound notifications (F-NOT-03).""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Event, Problem +from app.models.notification_cooldown import NotificationCooldown + +logger = logging.getLogger(__name__) + +COOLDOWN_KIND_EVENT = "event" +COOLDOWN_KIND_PROBLEM = "problem" + +# Не душим служебные проверки ingest +_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test", "agent.lifecycle"}) + + +def _as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def build_event_cooldown_key(event: Event) -> str: + if event.dedup_key and str(event.dedup_key).strip(): + return f"event:{event.dedup_key.strip()}" + + details: dict[str, Any] = event.details if isinstance(event.details, dict) else {} + host = event.host.hostname if event.host else "unknown" + ip = str(details.get("source_ip") or details.get("ip_address") or details.get("ip") or "").strip() + user = str(details.get("user") or details.get("username") or "").strip() + return f"event:{event.type}|{host}|{ip}|{user}" + + +def build_problem_cooldown_key(problem: Problem) -> str: + fp = (problem.fingerprint or "").strip() or f"id:{problem.id}" + return f"problem:{fp}" + + +def _cooldown_seconds_for_kind(kind: str) -> int: + settings = get_settings() + if not settings.sac_notify_cooldown_enabled: + return 0 + if kind == COOLDOWN_KIND_PROBLEM: + return max(0, int(settings.sac_notify_problem_cooldown_sec)) + return max(0, int(settings.sac_notify_event_cooldown_sec)) + + +def allow_notify(db: Session | None, *, key: str, kind: str) -> bool: + """True = можно слать сейчас; при True обновляет last_notified_at.""" + if kind == COOLDOWN_KIND_EVENT: + cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_EVENT) + else: + cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_PROBLEM) + + if cooldown_sec <= 0: + return True + if db is None: + return True + + now = datetime.now(timezone.utc) + row = db.get(NotificationCooldown, key) + if row is not None: + last = _as_utc(row.last_notified_at) + elapsed = (now - last).total_seconds() + if elapsed >= 0 and elapsed < cooldown_sec: + logger.info( + "notify cooldown skip kind=%s key=%s elapsed=%.0fs need=%ss", + kind, + key[:120], + elapsed, + cooldown_sec, + ) + return False + + if row is None: + db.add(NotificationCooldown(cooldown_key=key, kind=kind, last_notified_at=now)) + else: + row.kind = kind + row.last_notified_at = now + db.flush() + return True + + +def should_notify_event(event: Event, db: Session | None) -> bool: + if event.type in _EVENT_COOLDOWN_EXEMPT_TYPES: + return True + key = build_event_cooldown_key(event) + return allow_notify(db, key=key, kind=COOLDOWN_KIND_EVENT) + + +def should_notify_problem(problem: Problem, db: Session | None) -> bool: + key = build_problem_cooldown_key(problem) + return allow_notify(db, key=key, kind=COOLDOWN_KIND_PROBLEM) diff --git a/backend/app/services/notification_policy.py b/backend/app/services/notification_policy.py new file mode 100644 index 0000000..c5cfdd3 --- /dev/null +++ b/backend/app/services/notification_policy.py @@ -0,0 +1,140 @@ +"""Global notification policy: severity ≥ min → selected channels.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.notification_channel import NotificationChannel +from app.models.notification_policy import POLICY_ROW_ID, NotificationPolicy +from app.services.notification_settings import ( + CHANNEL_EMAIL, + CHANNEL_TELEGRAM, + CHANNEL_WEBHOOK, + VALID_SEVERITIES, +) + +CHANNEL_MOBILE = "mobile" +DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL, CHANNEL_MOBILE}) + + +@dataclass(frozen=True) +class NotificationPolicyConfig: + min_severity: str + use_telegram: bool + use_webhook: bool + use_email: bool + use_mobile: bool + source: str # env | db + + def allows_channel(self, channel: str) -> bool: + if channel == CHANNEL_TELEGRAM: + return self.use_telegram + if channel == CHANNEL_WEBHOOK: + return self.use_webhook + if channel == CHANNEL_EMAIL: + return self.use_email + if channel == CHANNEL_MOBILE: + return self.use_mobile + return False + + +def _parse_channels_csv(value: str) -> tuple[bool, bool, bool, bool]: + parts = {p.strip().lower() for p in value.split(",") if p.strip()} + return ( + CHANNEL_TELEGRAM in parts or "tg" in parts, + CHANNEL_WEBHOOK in parts, + CHANNEL_EMAIL in parts or "mail" in parts or "smtp" in parts, + CHANNEL_MOBILE in parts or "push" in parts or "fcm" in parts, + ) + + +def _policy_from_env() -> NotificationPolicyConfig: + settings = get_settings() + use_tg, use_wh, use_em, use_mob = _parse_channels_csv(settings.notify_channels) + min_sev = settings.notify_min_severity.strip() or "warning" + if min_sev not in VALID_SEVERITIES: + min_sev = "warning" + return NotificationPolicyConfig( + min_severity=min_sev, + use_telegram=use_tg, + use_webhook=use_wh, + use_email=use_em, + use_mobile=use_mob, + source="env", + ) + + +def get_effective_notification_policy(db: Session | None = None) -> NotificationPolicyConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_notification_policy(session) + finally: + session.close() + + row = db.get(NotificationPolicy, POLICY_ROW_ID) + env_cfg = _policy_from_env() + if row is None: + return env_cfg + + min_sev = (row.min_severity or "").strip() or env_cfg.min_severity + if min_sev not in VALID_SEVERITIES: + min_sev = env_cfg.min_severity + + return NotificationPolicyConfig( + min_severity=min_sev, + use_telegram=bool(row.use_telegram), + use_webhook=bool(row.use_webhook), + use_email=bool(row.use_email), + use_mobile=bool(getattr(row, "use_mobile", False)), + source="db", + ) + + +def _sync_channel_min_severity(db: Session, min_severity: str) -> None: + for channel in (CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL): + row = db.get(NotificationChannel, channel) + if row is not None: + row.min_severity = min_severity + + +def upsert_notification_policy( + db: Session, + *, + min_severity: str, + use_telegram: bool, + use_webhook: bool, + use_email: bool, + use_mobile: bool = False, +) -> NotificationPolicyConfig: + if min_severity not in VALID_SEVERITIES: + raise ValueError(f"invalid min_severity: {min_severity}") + + row = db.get(NotificationPolicy, POLICY_ROW_ID) + if row is None: + row = NotificationPolicy( + id=POLICY_ROW_ID, + min_severity=min_severity, + use_telegram=use_telegram, + use_webhook=use_webhook, + use_email=use_email, + use_mobile=use_mobile, + ) + db.add(row) + else: + row.min_severity = min_severity + row.use_telegram = use_telegram + row.use_webhook = use_webhook + row.use_email = use_email + row.use_mobile = use_mobile + + _sync_channel_min_severity(db, min_severity) + db.commit() + db.refresh(row) + return get_effective_notification_policy(db) diff --git a/backend/app/services/notification_settings.py b/backend/app/services/notification_settings.py new file mode 100644 index 0000000..d7bd502 --- /dev/null +++ b/backend/app/services/notification_settings.py @@ -0,0 +1,355 @@ +"""Effective notification channel config (DB overrides env).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.notification_channel import NotificationChannel + +VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"}) +CHANNEL_TELEGRAM = "telegram" +CHANNEL_WEBHOOK = "webhook" +CHANNEL_EMAIL = "email" + + +@dataclass(frozen=True) +class TelegramConfig: + enabled: bool + bot_token: str + chat_id: str + min_severity: str + source: str # env | db + + @property + def configured(self) -> bool: + return bool(self.bot_token.strip() and self.chat_id.strip()) + + @property + def active(self) -> bool: + return self.enabled and self.configured + + +@dataclass(frozen=True) +class WebhookConfig: + enabled: bool + url: str + secret_header: str + secret: str + min_severity: str + source: str # env | db + + @property + def configured(self) -> bool: + return bool(self.url.strip()) + + @property + def active(self) -> bool: + return self.enabled and self.configured + + +@dataclass(frozen=True) +class EmailConfig: + enabled: bool + smtp_host: str + smtp_port: int + smtp_user: str + smtp_password: str + mail_from: str + mail_to: str + smtp_starttls: bool + smtp_ssl: bool + min_severity: str + source: str # env | db + + @property + def configured(self) -> bool: + return bool(self.smtp_host.strip() and self.mail_from.strip() and self.mail_to.strip()) + + @property + def active(self) -> bool: + return self.enabled and self.configured + + +def _telegram_from_env() -> TelegramConfig: + settings = get_settings() + return TelegramConfig( + enabled=bool(settings.telegram_enabled), + bot_token=settings.telegram_bot_token.strip(), + chat_id=settings.telegram_chat_id.strip(), + min_severity=settings.telegram_min_severity.strip() or "high", + source="env", + ) + + +def get_effective_telegram_config(db: Session | None = None) -> TelegramConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_telegram_config(session) + finally: + session.close() + + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM)) + env_cfg = _telegram_from_env() + if row is None: + return env_cfg + + token = (row.bot_token or "").strip() or env_cfg.bot_token + chat_id = (row.chat_id or "").strip() or env_cfg.chat_id + min_sev = (row.min_severity or "").strip() or env_cfg.min_severity + if min_sev not in VALID_SEVERITIES: + min_sev = env_cfg.min_severity + + return TelegramConfig( + enabled=bool(row.enabled), + bot_token=token, + chat_id=chat_id, + min_severity=min_sev, + source="db", + ) + + +def upsert_telegram_channel( + db: Session, + *, + enabled: bool | None = None, + bot_token: str | None = None, + chat_id: str | None = None, + min_severity: str | None = None, +) -> TelegramConfig: + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM)) + env_cfg = _telegram_from_env() + + if row is None: + row = NotificationChannel( + channel=CHANNEL_TELEGRAM, + enabled=env_cfg.enabled, + bot_token=env_cfg.bot_token or None, + chat_id=env_cfg.chat_id or None, + min_severity=env_cfg.min_severity, + ) + db.add(row) + + if enabled is not None: + row.enabled = enabled + if min_severity is not None: + if min_severity not in VALID_SEVERITIES: + raise ValueError(f"invalid min_severity: {min_severity}") + row.min_severity = min_severity + if bot_token is not None and bot_token.strip(): + row.bot_token = bot_token.strip() + if chat_id is not None and chat_id.strip(): + row.chat_id = chat_id.strip() + + db.commit() + db.refresh(row) + return get_effective_telegram_config(db) + + +def _webhook_from_env() -> WebhookConfig: + settings = get_settings() + return WebhookConfig( + enabled=bool(settings.webhook_enabled), + url=settings.webhook_url.strip(), + secret_header=settings.webhook_secret_header.strip(), + secret=settings.webhook_secret.strip(), + min_severity=settings.webhook_min_severity.strip() or "high", + source="env", + ) + + +def get_effective_webhook_config(db: Session | None = None) -> WebhookConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_webhook_config(session) + finally: + session.close() + + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK)) + env_cfg = _webhook_from_env() + if row is None: + return env_cfg + + url = (row.webhook_url or "").strip() or env_cfg.url + secret_header = (row.webhook_secret_header or "").strip() or env_cfg.secret_header + secret = (row.webhook_secret or "").strip() or env_cfg.secret + min_sev = (row.min_severity or "").strip() or env_cfg.min_severity + if min_sev not in VALID_SEVERITIES: + min_sev = env_cfg.min_severity + + return WebhookConfig( + enabled=bool(row.enabled), + url=url, + secret_header=secret_header, + secret=secret, + min_severity=min_sev, + source="db", + ) + + +def upsert_webhook_channel( + db: Session, + *, + enabled: bool | None = None, + url: str | None = None, + secret_header: str | None = None, + secret: str | None = None, + min_severity: str | None = None, +) -> WebhookConfig: + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK)) + env_cfg = _webhook_from_env() + + if row is None: + row = NotificationChannel( + channel=CHANNEL_WEBHOOK, + enabled=env_cfg.enabled, + min_severity=env_cfg.min_severity, + webhook_url=env_cfg.url or None, + webhook_secret_header=env_cfg.secret_header or None, + webhook_secret=env_cfg.secret or None, + ) + db.add(row) + + if enabled is not None: + row.enabled = enabled + if min_severity is not None: + if min_severity not in VALID_SEVERITIES: + raise ValueError(f"invalid min_severity: {min_severity}") + row.min_severity = min_severity + if url is not None and url.strip(): + row.webhook_url = url.strip() + if secret_header is not None: + row.webhook_secret_header = secret_header.strip() or None + if secret is not None and secret.strip(): + row.webhook_secret = secret.strip() + + db.commit() + db.refresh(row) + return get_effective_webhook_config(db) + + +def _email_from_env() -> EmailConfig: + settings = get_settings() + return EmailConfig( + enabled=bool(settings.smtp_enabled), + smtp_host=settings.smtp_host.strip(), + smtp_port=int(settings.smtp_port or 587), + smtp_user=settings.smtp_user.strip(), + smtp_password=settings.smtp_password, + mail_from=settings.smtp_from.strip(), + mail_to=settings.smtp_to.strip(), + smtp_starttls=bool(settings.smtp_starttls), + smtp_ssl=bool(settings.smtp_ssl), + min_severity=settings.smtp_min_severity.strip() or "high", + source="env", + ) + + +def get_effective_email_config(db: Session | None = None) -> EmailConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_email_config(session) + finally: + session.close() + + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL)) + env_cfg = _email_from_env() + if row is None: + return env_cfg + + host = (row.smtp_host or "").strip() or env_cfg.smtp_host + port = row.smtp_port if row.smtp_port is not None else env_cfg.smtp_port + user = (row.smtp_user or "").strip() or env_cfg.smtp_user + password = (row.smtp_password or "") or env_cfg.smtp_password + mail_from = (row.mail_from or "").strip() or env_cfg.mail_from + mail_to = (row.mail_to or "").strip() or env_cfg.mail_to + min_sev = (row.min_severity or "").strip() or env_cfg.min_severity + if min_sev not in VALID_SEVERITIES: + min_sev = env_cfg.min_severity + + return EmailConfig( + enabled=bool(row.enabled), + smtp_host=host, + smtp_port=int(port or 587), + smtp_user=user, + smtp_password=password, + mail_from=mail_from, + mail_to=mail_to, + smtp_starttls=bool(row.smtp_starttls), + smtp_ssl=bool(row.smtp_ssl), + min_severity=min_sev, + source="db", + ) + + +def upsert_email_channel( + db: Session, + *, + enabled: bool | None = None, + smtp_host: str | None = None, + smtp_port: int | None = None, + smtp_user: str | None = None, + smtp_password: str | None = None, + mail_from: str | None = None, + mail_to: str | None = None, + smtp_starttls: bool | None = None, + smtp_ssl: bool | None = None, + min_severity: str | None = None, +) -> EmailConfig: + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL)) + env_cfg = _email_from_env() + + if row is None: + row = NotificationChannel( + channel=CHANNEL_EMAIL, + enabled=env_cfg.enabled, + min_severity=env_cfg.min_severity, + smtp_host=env_cfg.smtp_host or None, + smtp_port=env_cfg.smtp_port, + smtp_user=env_cfg.smtp_user or None, + smtp_password=env_cfg.smtp_password or None, + mail_from=env_cfg.mail_from or None, + mail_to=env_cfg.mail_to or None, + smtp_starttls=env_cfg.smtp_starttls, + smtp_ssl=env_cfg.smtp_ssl, + ) + db.add(row) + + if enabled is not None: + row.enabled = enabled + if min_severity is not None: + if min_severity not in VALID_SEVERITIES: + raise ValueError(f"invalid min_severity: {min_severity}") + row.min_severity = min_severity + if smtp_host is not None and smtp_host.strip(): + row.smtp_host = smtp_host.strip() + if smtp_port is not None: + row.smtp_port = smtp_port + if smtp_user is not None: + row.smtp_user = smtp_user.strip() or None + if smtp_password is not None and smtp_password.strip(): + row.smtp_password = smtp_password + if mail_from is not None and mail_from.strip(): + row.mail_from = mail_from.strip() + if mail_to is not None and mail_to.strip(): + row.mail_to = mail_to.strip() + if smtp_starttls is not None: + row.smtp_starttls = smtp_starttls + if smtp_ssl is not None: + row.smtp_ssl = smtp_ssl + + db.commit() + db.refresh(row) + return get_effective_email_config(db) diff --git a/backend/app/services/notification_severity.py b/backend/app/services/notification_severity.py new file mode 100644 index 0000000..9805f18 --- /dev/null +++ b/backend/app/services/notification_severity.py @@ -0,0 +1,11 @@ +"""Severity ordering for notification policy gates.""" + +SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40} + + +def severity_value(value: str) -> int: + return SEVERITY_ORDER.get(value, 0) + + +def severity_meets_minimum(severity: str, minimum: str) -> bool: + return severity_value(severity) >= severity_value(minimum) diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py new file mode 100644 index 0000000..10ef463 --- /dev/null +++ b/backend/app/services/notify_dispatch.py @@ -0,0 +1,245 @@ +"""Dispatch ingest notifications per global policy (severity → channels).""" + +import logging + +from sqlalchemy.orm import Session + +from app.models import Event, Host, Problem +from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify +from app.services.notification_cooldown import should_notify_event, should_notify_problem +from app.services.notification_policy import get_effective_notification_policy +from app.services.event_type_visibility import event_type_notifications_enabled +from app.services.host_health import HEARTBEAT_TYPE +from app.services.notification_severity import severity_meets_minimum + +logger = logging.getLogger(__name__) + +LIFECYCLE_EVENT_TYPE = "agent.lifecycle" +PRIVILEGE_SUDO_TYPE = "privilege.sudo.command" +SUDO_MAINTENANCE_MARKERS = ( + "update_ssh_monitor.sh", + "update_via_sac", + "update_script.log", + "/opt/scripts/update", + "agent-update-in-progress", +) +DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) +AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"}) +RDG_CONNECTION_TYPES = frozenset({ + "rdg.connection.success", + "rdg.connection.disconnected", + "rdg.connection.failed", +}) + + +def _event_telegram_via_agent(event: Event) -> bool: + details = event.details if isinstance(event.details, dict) else {} + via = str(details.get("telegram_via") or "").strip().lower() + return via == "agent" + + +def _skip_notifications_for_hidden_event(event: Event, db: Session | None) -> bool: + if event_type_notifications_enabled(event.type, db): + return False + logger.info( + "notify skipped hidden event type=%s event_id=%s", + event.type, + event.event_id, + ) + return True + + +def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None: + if policy.use_telegram: + telegram_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_webhook: + webhook_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_email: + email_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_mobile: + mobile_notify.notify_event(event, db=db, apply_policy_gate=False) + + +def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None: + if policy.use_telegram: + telegram_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) + if policy.use_webhook: + webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) + if policy.use_email: + email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) + if policy.use_mobile: + mobile_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) + + +def notify_event(event: Event, *, db: Session | None = None) -> None: + # Heartbeat — только для UI/статуса хоста, не для Telegram/email/push. + if event.type == HEARTBEAT_TYPE: + return + if _should_suppress_sudo_notify(event, db=db): + return + if _skip_notifications_for_hidden_event(event, db): + return + policy = get_effective_notification_policy(db) + if not severity_meets_minimum(event.severity, policy.min_severity): + return + if not should_notify_event(event, db): + return + _dispatch_event_channels(event, db=db, policy=policy) + + +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: + policy = get_effective_notification_policy(db) + if not severity_meets_minimum(problem.severity, policy.min_severity): + return + if not should_notify_problem(problem, db): + return + _dispatch_problem_channels(problem, event, db=db, policy=policy) + + +def _dispatch_lifecycle_channels(event: Event, *, db: Session | None, policy) -> None: + skip_telegram = _event_telegram_via_agent(event) + if policy.use_telegram and not skip_telegram: + telegram_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_webhook: + webhook_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_email: + email_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_mobile: + mobile_notify.notify_event(event, db=db, apply_policy_gate=False) + + +def notify_daily_report(event: Event, *, db: Session | None = None) -> None: + """Оповещение по суточному отчёту (severity=info, вне порога policy). + + При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует. + """ + if _skip_notifications_for_hidden_event(event, db): + return + policy = get_effective_notification_policy(db) + if not should_notify_event(event, db): + return + _dispatch_lifecycle_channels(event, db=db, policy=policy) + + +def _host_sac_update_running(event: Event, db: Session | None) -> bool: + """Пока SAC выполняет agent-update на хосте — не слать шумные TG.""" + if db is None or not event.host_id: + return False + host = db.get(Host, event.host_id) + if host is None: + return False + if (host.agent_update_state or "").strip().lower() == "running": + logger.info( + "notify skipped (host update running) type=%s host_id=%s event_id=%s", + event.type, + host.id, + event.event_id, + ) + return True + return False + + +def _sudo_event_is_agent_maintenance(event: Event) -> bool: + details = event.details if isinstance(event.details, dict) else {} + cmd = str(details.get("command") or "").strip() + if not cmd: + cmd = str(event.summary or "").strip() + text = cmd.casefold() + return any(marker in text for marker in SUDO_MAINTENANCE_MARKERS) + + +def _should_suppress_sudo_notify(event: Event, *, db: Session | None) -> bool: + if event.type != PRIVILEGE_SUDO_TYPE: + return False + if _host_sac_update_running(event, db): + return True + if _sudo_event_is_agent_maintenance(event): + logger.info( + "notify sudo skipped maintenance command event_id=%s host_id=%s", + event.event_id, + event.host_id, + ) + return True + return False + + +def _lifecycle_suppress_notifications(event: Event, *, db: Session | None = None) -> bool: + """Не слать TG при штатном SAC/cron update (lifecycle с trigger deploy_recycle).""" + if _host_sac_update_running(event, db): + return True + details = event.details if isinstance(event.details, dict) else {} + trigger = str(details.get("trigger") or "").strip().lower() + if trigger in ("deploy_recycle", "sac_update", "agent_update"): + logger.info( + "notify lifecycle skipped trigger=%s event_id=%s", + trigger, + event.event_id, + ) + return True + return False + + +def notify_lifecycle(event: Event, *, db: Session | None = None) -> None: + """Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent).""" + if _lifecycle_suppress_notifications(event, db=db): + return + if _skip_notifications_for_hidden_event(event, db): + return + policy = get_effective_notification_policy(db) + if not should_notify_event(event, db): + return + _dispatch_lifecycle_channels(event, db=db, policy=policy) + + +def notify_auth_login(event: Event, *, db: Session | None = None) -> None: + """Успешный удалённый вход RDP/SSH — всегда в Telegram SAC (info вне min_severity). + + При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует. + """ + if _skip_notifications_for_hidden_event(event, db): + return + policy = get_effective_notification_policy(db) + if not should_notify_event(event, db): + return + _dispatch_lifecycle_channels(event, db=db, policy=policy) + + +def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None: + """RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login).""" + if _skip_notifications_for_hidden_event(event, db): + return + policy = get_effective_notification_policy(db) + if not should_notify_event(event, db): + return + _dispatch_lifecycle_channels(event, db=db, policy=policy) + + +def schedule_notify_daily_report(event_db_id: int) -> None: + """Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST).""" + _schedule_deferred_event_notify(event_db_id, notify_daily_report, label="daily report") + + +def schedule_notify_lifecycle(event_db_id: int) -> None: + """Отложенное lifecycle-оповещение (после commit ingest).""" + _schedule_deferred_event_notify(event_db_id, notify_lifecycle, label="lifecycle") + + +def schedule_notify_auth_login(event_db_id: int) -> None: + """Отложенное оповещение об успешном RDP/SSH входе (после commit ingest).""" + _schedule_deferred_event_notify(event_db_id, notify_auth_login, label="auth login") + + +def _schedule_deferred_event_notify(event_db_id: int, handler, *, label: str) -> None: + from app.database import SessionLocal + + db = SessionLocal() + try: + event = db.get(Event, event_db_id) + if event is None: + logger.warning("deferred %s notify: event id=%s not found", label, event_db_id) + return + handler(event, db=db) + except Exception: + logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id) + finally: + db.close() diff --git a/backend/app/services/problem_rules.py b/backend/app/services/problem_rules.py new file mode 100644 index 0000000..ca70f73 --- /dev/null +++ b/backend/app/services/problem_rules.py @@ -0,0 +1,220 @@ +"""Problem rule evaluators (v1): brute-force, privilege spike, host silence.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Event, Host +from app.services.host_health import HEARTBEAT_TYPE, agent_status + +BRUTE_FAILED_TYPES = frozenset({"ssh.login.failed", "rdp.login.failed"}) +PRIVILEGE_SUDO_TYPE = "privilege.sudo.command" + +RULE_BRUTE_FORCE = "rule:brute_force_burst" +RULE_PRIVILEGE_SPIKE = "rule:privilege_spike" +RULE_HOST_SILENCE = "rule:host_silence" +RULE_RDG_SESSION_FLAP = "rule:rdg_session_flap" + + +@dataclass(frozen=True) +class RuleMatch: + rule_id: str + correlation_type: str + title: str + summary: str + severity: str + fingerprint_suffix: str = "" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _event_source_ip(event: Event) -> str: + details = event.details if isinstance(event.details, dict) else {} + ip = details.get("source_ip") or details.get("ip") + return str(ip) if ip else "unknown" + + +def count_events_in_window( + db: Session, + *, + host_id: int, + event_type: str, + since: datetime, + source_ip: str | None = None, +) -> int: + rows = db.scalars( + select(Event).where( + Event.host_id == host_id, + Event.type == event_type, + Event.occurred_at >= since, + ) + ).all() + if source_ip is None: + return len(rows) + return sum(1 for e in rows if _event_source_ip(e) == source_ip) + + +def last_heartbeat_at(db: Session, host_id: int) -> datetime | None: + return db.scalar( + select(func.max(Event.received_at)).where( + Event.host_id == host_id, + Event.type == HEARTBEAT_TYPE, + ) + ) + + +def evaluate_brute_force_burst(db: Session, event: Event) -> RuleMatch | None: + if event.type not in BRUTE_FAILED_TYPES: + return None + settings = get_settings() + source_ip = _event_source_ip(event) + since = _now() - timedelta(minutes=settings.sac_brute_force_window_minutes) + count = count_events_in_window( + db, + host_id=event.host_id, + event_type=event.type, + since=since, + source_ip=source_ip, + ) + if count < settings.sac_brute_force_threshold: + return None + return RuleMatch( + rule_id=RULE_BRUTE_FORCE, + correlation_type=event.type, + fingerprint_suffix=f"ip{source_ip}", + title=f"Brute-force burst: {count} failed logins", + summary=( + f"{count} событий {event.type} за {settings.sac_brute_force_window_minutes} мин " + f"(IP {source_ip}, порог {settings.sac_brute_force_threshold})" + ), + severity="high", + ) + + +def evaluate_privilege_spike(db: Session, event: Event) -> RuleMatch | None: + if event.type != PRIVILEGE_SUDO_TYPE: + return None + settings = get_settings() + since = _now() - timedelta(minutes=settings.sac_privilege_spike_window_minutes) + count = count_events_in_window( + db, + host_id=event.host_id, + event_type=event.type, + since=since, + ) + if count < settings.sac_privilege_spike_threshold: + return None + return RuleMatch( + rule_id=RULE_PRIVILEGE_SPIKE, + correlation_type=event.type, + title=f"Privilege spike: {count} sudo commands", + summary=( + f"{count} событий {event.type} за {settings.sac_privilege_spike_window_minutes} мин " + f"(порог {settings.sac_privilege_spike_threshold})" + ), + severity="warning" if count < settings.sac_privilege_spike_threshold * 2 else "high", + ) + + +def host_silence_match_for_host( + db: Session, + host_id: int, + *, + hostname: str | None = None, + now: datetime | None = None, +) -> RuleMatch | None: + """Сформировать RuleMatch host_silence, если последний heartbeat устарел.""" + settings = get_settings() + ref = now or _now() + hb = last_heartbeat_at(db, host_id) + if hb is None: + return None + if agent_status(hb, stale_minutes=settings.sac_heartbeat_stale_minutes, now=ref) != "stale": + return None + if hostname is None: + host = db.get(Host, host_id) + hostname = host.hostname if host else f"host#{host_id}" + hb_utc = hb.replace(tzinfo=timezone.utc) if hb.tzinfo is None else hb.astimezone(timezone.utc) + age_min = int((ref - hb_utc).total_seconds() // 60) + return RuleMatch( + rule_id=RULE_HOST_SILENCE, + correlation_type="host.silence", + title=f"Host silence: {hostname}", + summary=( + f"Нет свежего agent.heartbeat более {settings.sac_heartbeat_stale_minutes} мин " + f"(последний ~{age_min} мин назад)" + ), + severity="high", + ) + + +def evaluate_host_silence(db: Session, event: Event) -> RuleMatch | None: + if event.type == HEARTBEAT_TYPE: + return None + host = db.get(Host, event.host_id) + hostname = host.hostname if host else None + return host_silence_match_for_host(db, event.host_id, hostname=hostname) + + +def evaluate_immediate_high(event: Event) -> RuleMatch | None: + """Одиночные high/critical и известные типы (ban, mass bruteforce).""" + problem_types = frozenset({"ssh.ip.banned", "ssh.bruteforce.mass"}) + if event.severity not in frozenset({"high", "critical"}) and event.type not in problem_types: + return None + rule_id = "high_severity" if event.severity in frozenset({"high", "critical"}) else f"type:{event.type}" + return RuleMatch( + rule_id=rule_id, + correlation_type=event.type, + title=event.title, + summary=event.summary, + severity=event.severity, + ) + + +def resolve_host_silence_problems(db: Session, host_id: int) -> int: + """Закрыть open/ack host silence при свежем heartbeat.""" + from app.models import Problem + + problems = db.scalars( + select(Problem).where( + Problem.host_id == host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status.in_(("open", "acknowledged")), + ) + ).all() + for problem in problems: + problem.status = "resolved" + problem.resolved_by = "auto" + if problems: + db.flush() + return len(problems) + + +def build_fingerprint(host_id: int, correlation_type: str, rule_id: str, suffix: str = "") -> str: + base = f"h{host_id}" + if suffix: + base = f"{base}:{suffix}" + return f"{base}:t{correlation_type}:r{rule_id}" + + +def pick_rule_match(db: Session, event: Event) -> RuleMatch | None: + """Первое сработавшее правило (приоритет: rdg flap → burst → spike → silence → immediate).""" + from app.services.rdg_session_flap import evaluate_rdg_session_flap + + for evaluator in ( + evaluate_rdg_session_flap, + evaluate_brute_force_burst, + evaluate_privilege_spike, + evaluate_host_silence, + ): + match = evaluator(db, event) + if match is not None: + return match + return evaluate_immediate_high(event) diff --git a/backend/app/services/problems.py b/backend/app/services/problems.py new file mode 100644 index 0000000..c4b2ccd --- /dev/null +++ b/backend/app/services/problems.py @@ -0,0 +1,119 @@ +"""Auto-create Problems from ingested events (rules + correlation).""" + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Event, Problem, ProblemEvent +from app.services.host_health import HEARTBEAT_TYPE +from app.services.problem_rules import ( + RuleMatch, + RULE_HOST_SILENCE, + RULE_RDG_SESSION_FLAP, + build_fingerprint, + pick_rule_match, + resolve_host_silence_problems, +) + + +def _correlation_cutoff(now: datetime) -> datetime: + minutes = get_settings().sac_problem_correlation_window_minutes + return now - timedelta(minutes=minutes) + + +def _append_event(db: Session, problem: Problem, event: Event) -> None: + linked = db.scalar( + select(ProblemEvent).where( + ProblemEvent.problem_id == problem.id, + ProblemEvent.event_id == event.id, + ) + ) + if linked is not None: + return + db.add(ProblemEvent(problem_id=problem.id, event_id=event.id)) + problem.event_count = (problem.event_count or 0) + 1 + problem.last_seen_at = event.occurred_at + problem.updated_at = datetime.now(timezone.utc) + if event.severity in {"high", "critical"} and problem.severity not in {"high", "critical"}: + problem.severity = event.severity + db.flush() + + +def open_or_append_problem( + db: Session, event: Event, match: RuleMatch +) -> tuple[Problem | None, bool]: + if match.rule_id == RULE_HOST_SILENCE: + from app.services.host_silence_scan import open_or_refresh_host_silence_problem + + ref = event.occurred_at + if ref.tzinfo is None: + ref = ref.replace(tzinfo=timezone.utc) + problem, created = open_or_refresh_host_silence_problem( + db, event.host_id, match, now=ref, hostname=event.host.hostname if event.host else None + ) + if problem is None: + return None, False + _append_event(db, problem, event) + return problem, created + + fingerprint = build_fingerprint( + event.host_id, + match.correlation_type, + match.rule_id, + match.fingerprint_suffix, + ) + now = datetime.now(timezone.utc) + if match.rule_id == RULE_RDG_SESSION_FLAP: + cutoff = now - timedelta(seconds=get_settings().sac_rdg_flap_dedup_sec) + else: + cutoff = _correlation_cutoff(now) + + open_problem = db.scalar( + select(Problem) + .where( + Problem.status == "open", + Problem.fingerprint == fingerprint, + Problem.host_id == event.host_id, + Problem.last_seen_at >= cutoff, + ) + .order_by(Problem.last_seen_at.desc()) + .limit(1) + ) + if open_problem: + _append_event(db, open_problem, event) + if match.severity in {"high", "critical"} and open_problem.severity not in {"high", "critical"}: + open_problem.severity = match.severity + open_problem.summary = match.summary + return open_problem, False + + problem = Problem( + host_id=event.host_id, + title=match.title, + summary=match.summary, + severity=match.severity, + status="open", + rule_id=match.rule_id, + fingerprint=fingerprint, + event_count=1, + last_seen_at=event.occurred_at, + ) + db.add(problem) + db.flush() + db.add(ProblemEvent(problem_id=problem.id, event_id=event.id)) + db.flush() + return problem, True + + +def maybe_create_problem(db: Session, event: Event) -> tuple[Problem | None, bool]: + if event.type == HEARTBEAT_TYPE: + resolve_host_silence_problems(db, event.host_id) + return None, False + + match = pick_rule_match(db, event) + if match is None: + return None, False + + problem, created = open_or_append_problem(db, event, match) + return problem, created diff --git a/backend/app/services/rdg_client_host.py b/backend/app/services/rdg_client_host.py new file mode 100644 index 0000000..b9c7e8a --- /dev/null +++ b/backend/app/services/rdg_client_host.py @@ -0,0 +1,50 @@ +"""Resolve RDG client workstation (session host) from event internal_ip.""" + +from __future__ import annotations + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.models import Event, Host +from app.services.rdg_session_flap import event_internal_ip +from app.services.winrm_connect import is_windows_host + + +class ClientWorkstationNotFoundError(Exception): + def __init__(self, internal_ip: str) -> None: + self.internal_ip = internal_ip + super().__init__( + f"Client workstation not found in SAC hosts for internal_ip={internal_ip}. " + "Ensure the PC is registered with matching ipv4." + ) + + +def find_windows_host_by_ipv4(db: Session, ipv4: str) -> Host | None: + ip = (ipv4 or "").strip() + if not ip: + return None + rows = db.scalars( + select(Host) + .where( + Host.ipv4 == ip, + or_( + Host.os_family.ilike("windows"), + Host.product == "rdp-login-monitor", + ), + ) + .order_by(Host.last_seen_at.desc()) + ).all() + for host in rows: + if is_windows_host(host): + return host + return None + + +def resolve_client_workstation(db: Session, event: Event) -> Host: + internal_ip = event_internal_ip(event) + if not internal_ip: + raise ClientWorkstationNotFoundError("") + host = find_windows_host_by_ipv4(db, internal_ip) + if host is None: + raise ClientWorkstationNotFoundError(internal_ip) + return host diff --git a/backend/app/services/rdg_display.py b/backend/app/services/rdg_display.py new file mode 100644 index 0000000..76bd568 --- /dev/null +++ b/backend/app/services/rdg_display.py @@ -0,0 +1,132 @@ +"""RD Gateway event labels (access path, UI title/summary).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Event +from app.services.rdg_client_host import find_windows_host_by_ipv4 +from app.services.rdg_session_flap import event_internal_ip, resolve_rdg_qwinsta_enabled + +RDG_TYPES = frozenset( + { + "rdg.connection.success", + "rdg.connection.disconnected", + "rdg.connection.failed", + } +) + +ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp" +ACCESS_PATH_DIRECT = "RDG-Comp" + + +@dataclass(frozen=True) +class RdgDisplayInfo: + title: str + summary: str + access_path: str | None + internal_ip: str | None + qwinsta_enabled: bool + + +def _details_dict(event: Event) -> dict: + raw = event.details + return raw if isinstance(raw, dict) else {} + + +def _event_external_ip(event: Event) -> str: + details = _details_dict(event) + for key in ("external_ip", "source_ip", "ip_address"): + val = details.get(key) + if val is not None and str(val).strip(): + return str(val).strip() + return "" + + +def _parse_haproxy_ips() -> frozenset[str]: + raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip() + if not raw: + return frozenset() + parts = [p.strip() for p in raw.replace(";", ",").split(",")] + return frozenset(p for p in parts if p) + + +def classify_rdg_access_path(external_ip: str) -> str | None: + ip = (external_ip or "").strip() + if not ip or ip in ("-", "N/A"): + return None + if ip in _parse_haproxy_ips(): + return ACCESS_PATH_HAPROXY + return ACCESS_PATH_DIRECT + + +def _windows_event_label(event: Event) -> str: + details = _details_dict(event) + win_id = details.get("event_id_windows") + if win_id is None: + return "" + text = str(win_id).strip() + return f"event {text}" if text else "" + + +def _action_label(event: Event) -> str: + if event.type == "rdg.connection.success": + return "подключение" + if event.type == "rdg.connection.disconnected": + return "отключение" + return "ошибка" + + +def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None: + if event.type not in RDG_TYPES: + return None + + details = _details_dict(event) + internal_ip = event_internal_ip(event) + external_ip = _event_external_ip(event) + access_path = classify_rdg_access_path(external_ip) + user = str(details.get("user") or "").strip() + gateway = event.host.hostname if event.host else "—" + + client_label = internal_ip or "—" + if db and internal_ip: + client_host = find_windows_host_by_ipv4(db, internal_ip) + if client_host is not None: + client_label = f"{client_host.hostname} ({internal_ip})" + + path_note = f" ({access_path})" if access_path else "" + win_note = _windows_event_label(event) + action = _action_label(event) + + title = f"RDS {action} → {client_label}{path_note}" + if win_note: + title = f"{title} · {win_note}" + + summary_parts: list[str] = [] + if user: + summary_parts.append(user) + if external_ip: + summary_parts.append(f"внешний {external_ip}") + summary_parts.append(f"шлюз {gateway}") + if win_note: + summary_parts.append(win_note) + summary = " · ".join(summary_parts) + + qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event) + + return RdgDisplayInfo( + title=title, + summary=summary, + access_path=access_path, + internal_ip=internal_ip or None, + qwinsta_enabled=qwinsta_enabled, + ) + + +def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool: + from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled + + return resolve_rdg_qwinsta_enabled(db, event) diff --git a/backend/app/services/rdg_session_flap.py b/backend/app/services/rdg_session_flap.py new file mode 100644 index 0000000..6073d75 --- /dev/null +++ b/backend/app/services/rdg_session_flap.py @@ -0,0 +1,311 @@ +"""RDG 302→303 session flap: detect, flag event, build Problem match.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.config import get_settings +from app.models import Event +from app.services.problem_rules import RULE_RDG_SESSION_FLAP, RuleMatch + +RDG_SUCCESS_TYPE = "rdg.connection.success" +RDG_END_TYPES = frozenset({"rdg.connection.disconnected", "rdg.connection.failed"}) + + +def _event_user(event: Event) -> str: + details = event.details if isinstance(event.details, dict) else {} + user = details.get("user") + return str(user).strip() if user else "" + + +def _event_internal_ip(event: Event) -> str: + details = event.details if isinstance(event.details, dict) else {} + for key in ("internal_ip", "client_ip", "ip_address"): + val = details.get(key) + if val: + return str(val).strip() + return "" + + +def event_internal_ip(event: Event) -> str: + return _event_internal_ip(event) + + +def _users_match(end_event: Event, success_event: Event) -> bool: + return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event) + + +def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool: + end_ip = _event_internal_ip(end_event) + success_ip = _event_internal_ip(success_event) + if not end_ip or not success_ip: + return True + return end_ip == success_ip + + +def _internal_ips_match_strict(end_event: Event, success_event: Event) -> bool: + """Для «сессия завершена» — только при совпадении целевого ПК (оба IP заданы).""" + end_ip = _event_internal_ip(end_event) + success_ip = _event_internal_ip(success_event) + if not end_ip or not success_ip: + return False + return end_ip == success_ip + + +def _as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def find_rdg_success_before_end(db: Session, end_event: Event) -> Event | None: + if end_event.type not in RDG_END_TYPES: + return None + settings = get_settings() + min_sec = settings.sac_rdg_flap_window_min_sec + max_sec = settings.sac_rdg_flap_window_max_sec + end_at = _as_utc(end_event.occurred_at) + + window_start = end_at - timedelta(seconds=max_sec) + window_end = end_at - timedelta(seconds=min_sec) + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == end_event.host_id, + Event.type == RDG_SUCCESS_TYPE, + Event.occurred_at >= window_start, + Event.occurred_at <= window_end, + Event.id != end_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + for prior in candidates: + if not _users_match(end_event, prior): + continue + if not _internal_ips_compatible(end_event, prior): + continue + prior_at = _as_utc(prior.occurred_at) + delta = (end_at - prior_at).total_seconds() + if min_sec <= delta <= max_sec: + return prior + return None + + +def mark_rdg_flap(event: Event, *, pair_event: Event) -> None: + details = dict(event.details) if isinstance(event.details, dict) else {} + details["rdg_flap"] = True + details["rdg_flap_pair_event_id"] = pair_event.id + event.details = details + flag_modified(event, "details") + + +def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None: + prior = find_rdg_success_before_end(db, event) + if prior is None: + return None + + mark_rdg_flap(event, pair_event=prior) + user = _event_user(event) + internal_ip = _event_internal_ip(event) + ip_note = f", client {internal_ip}" if internal_ip else "" + delta_sec = int((_as_utc(event.occurred_at) - _as_utc(prior.occurred_at)).total_seconds()) + return RuleMatch( + rule_id=RULE_RDG_SESSION_FLAP, + correlation_type="rdg.session.flap", + fingerprint_suffix=f"u{user}:ip{internal_ip or 'any'}", + title=f"RDG session flap: {user}", + summary=( + f"302→303 за {delta_sec} с " + f"({user}{ip_note}). Возможна зависшая сессия на ПК пользователя — qwinsta/logoff." + ), + severity="warning", + ) + + +def event_has_rdg_flap(event: Event) -> bool: + details = event.details if isinstance(event.details, dict) else {} + return details.get("rdg_flap") is True + + +def _stored_flap_pair_id(event: Event) -> int | None: + details = event.details if isinstance(event.details, dict) else {} + raw = details.get("rdg_flap_pair_event_id") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | None: + """303 с rdg_flap, у которого пара — этот 302 (или вычисляется по окну).""" + if success_event.type != RDG_SUCCESS_TYPE: + return None + settings = get_settings() + min_sec = settings.sac_rdg_flap_window_min_sec + max_sec = settings.sac_rdg_flap_window_max_sec + start_at = _as_utc(success_event.occurred_at) + window_start = start_at + timedelta(seconds=min_sec) + window_end = start_at + timedelta(seconds=max_sec) + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == success_event.host_id, + Event.type.in_(RDG_END_TYPES), + Event.occurred_at >= window_start, + Event.occurred_at <= window_end, + Event.id != success_event.id, + ) + .order_by(Event.occurred_at.asc()) + ).all() + + for end in candidates: + if not _users_match(end, success_event): + continue + if not _internal_ips_compatible(end, success_event): + continue + pair_id = _stored_flap_pair_id(end) + if pair_id == success_event.id: + return end + prior = find_rdg_success_before_end(db, end) + if prior is not None and prior.id == success_event.id: + return end + return None + + +def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Event | None: + """303 после 302 с паузой больше flap-окна — штатное завершение сессии.""" + if success_event.type != RDG_SUCCESS_TYPE: + return None + settings = get_settings() + max_sec = settings.sac_rdg_flap_window_max_sec + start_at = _as_utc(success_event.occurred_at) + after_flap = start_at + timedelta(seconds=max_sec) + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == success_event.host_id, + Event.type.in_(RDG_END_TYPES), + Event.occurred_at > after_flap, + Event.id != success_event.id, + ) + .order_by(Event.occurred_at.asc()) + ).all() + + for end in candidates: + if not _users_match(end, success_event): + continue + if not _internal_ips_match_strict(end, success_event): + continue + return end + return None + + +def _users_and_ip_match(left: Event, right: Event) -> bool: + return _users_match(left, right) and _internal_ips_match_strict(left, right) + + +def find_later_rdg_success_after( + db: Session, + *, + anchor: Event, + after: datetime, +) -> Event | None: + """Поздний 302 на том же шлюзе, user и client PC — пользователь снова зашёл через RDG.""" + if anchor.type != RDG_SUCCESS_TYPE: + return None + after_at = _as_utc(after) + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == anchor.host_id, + Event.type == RDG_SUCCESS_TYPE, + Event.occurred_at > after_at, + Event.id != anchor.id, + ) + .order_by(Event.occurred_at.asc()) + ).all() + + for later in candidates: + if not _users_and_ip_match(later, anchor): + continue + return later + return None + + +def _flap_auto_disconnect_succeeded(flap_end: Event) -> bool: + details = flap_end.details if isinstance(flap_end.details, dict) else {} + block = details.get("rdp_flap_auto_disconnect") + if not isinstance(block, dict): + return False + return block.get("ok") is True + + +def _flap_workstation_session_closed(db: Session, flap_end: Event) -> bool: + from app.services.host_sessions import event_session_terminated + from app.services.rdg_workstation_session import find_workstation_login_for_rdg_end + + login = find_workstation_login_for_rdg_end(db, flap_end) + if login is None: + return False + return event_session_terminated(login, db=db) + + +def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool: + """Кнопка qwinsta/logoff только на 302, пока сессия может быть активна (или RDG flap).""" + if event.type in RDG_END_TYPES: + return False + if event.type != RDG_SUCCESS_TYPE: + return False + if not _event_internal_ip(event): + return False + if db is None: + return True + flap_end = find_rdg_end_after_success(db, event) + if flap_end is not None: + if _flap_auto_disconnect_succeeded(flap_end): + return False + if _flap_workstation_session_closed(db, flap_end): + return False + if find_later_rdg_success_after(db, anchor=event, after=flap_end.occurred_at) is not None: + return False + return True + if find_normal_rdg_end_after_success(db, event) is not None: + return False + return True + + +def resolve_rdg_flap_summary( + db: Session, event: Event +) -> tuple[bool, int | None, int | None]: + """ + (rdg_flap, pair_event_id, qwinsta_event_id). + qwinsta_event_id — id события 302 для qwinsta (на 303 кнопку не показываем). + """ + if event_has_rdg_flap(event): + pair_id = _stored_flap_pair_id(event) + if event.type == RDG_SUCCESS_TYPE: + return True, pair_id, event.id + return True, pair_id, pair_id + + if event.type in RDG_END_TYPES: + prior = find_rdg_success_before_end(db, event) + if prior is not None: + return True, prior.id, prior.id + + if event.type == RDG_SUCCESS_TYPE: + end = find_rdg_end_after_success(db, event) + if end is not None: + return True, end.id, event.id + + return False, None, None diff --git a/backend/app/services/rdg_winrm_actions.py b/backend/app/services/rdg_winrm_actions.py new file mode 100644 index 0000000..dd38b66 --- /dev/null +++ b/backend/app/services/rdg_winrm_actions.py @@ -0,0 +1,182 @@ +"""RDG qwinsta/logoff via WinRM on client workstation (variant B).""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models import AgentCommand, Event, Host +from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation +from app.services.rdg_display import event_supports_rdg_client_qwinsta +from app.services.rdg_session_flap import event_internal_ip +from app.services.win_admin_settings import get_effective_win_admin_for_host +from app.services.winrm_connect import ( + WinRmCmdResult, + run_winrm_logoff, + run_winrm_on_host_targets, + run_winrm_qwinsta, +) + + +def _require_win_admin(db: Session, host: Host | None = None): + if host is not None: + cfg = get_effective_win_admin_for_host(db, host) + else: + from app.services.win_admin_settings import get_effective_win_admin_config + + cfg = get_effective_win_admin_config(db) + if not cfg.configured: + raise HTTPException( + status_code=503, + detail="Windows admin is not configured (host override or Settings → Windows)", + ) + return cfg + + +def _require_rdg_client_qwinsta(db: Session, event: Event) -> None: + if event_supports_rdg_client_qwinsta(event, db): + return + raise HTTPException( + status_code=400, + detail="Event is not an RD Gateway connection with internal_ip (client workstation)", + ) + + +def _resolve_client(db: Session, event: Event) -> Host: + try: + return resolve_client_workstation(db, event) + except ClientWorkstationNotFoundError as exc: + if not exc.internal_ip: + raise HTTPException( + status_code=400, + detail="Event has no internal_ip (client workstation address)", + ) from exc + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +def _format_result_message(result: WinRmCmdResult, *, attempts: list[str]) -> str: + message = result.message + if attempts: + message = f"{message} (пробовали: {', '.join(attempts)})" + return message + + +def _persist_command( + db: Session, + *, + client_host: Host, + event: Event, + command_type: str, + params: dict, + requested_by: str, + result: WinRmCmdResult, + attempts: list[str], +) -> AgentCommand: + now = datetime.now(timezone.utc) + cmd = AgentCommand( + command_uuid=str(uuid.uuid4()), + host_id=client_host.id, + event_id=event.id, + command_type=command_type, + params=params, + status="completed" if result.ok else "failed", + result_stdout=result.stdout or None, + result_stderr=(result.stderr or _format_result_message(result, attempts=attempts) or None) + if not result.ok + else None, + requested_by=requested_by, + created_at=now, + completed_at=now, + ) + db.add(cmd) + db.flush() + return cmd + + +def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand: + _require_rdg_client_qwinsta(db, event) + client_host = _resolve_client(db, event) + cfg = _require_win_admin(db, client_host) + + details = event.details if isinstance(event.details, dict) else {} + user = details.get("user") + internal_ip = event_internal_ip(event) + + result, attempts = run_winrm_on_host_targets( + client_host, + user=cfg.user, + password=cfg.password, + action=lambda target: run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password), + ) + assert result is not None + + params = { + "execution": "winrm", + "user": user, + "internal_ip": internal_ip, + "client_hostname": client_host.hostname, + "client_host_id": client_host.id, + "winrm_target": result.target, + } + return _persist_command( + db, + client_host=client_host, + event=event, + command_type="qwinsta", + params=params, + requested_by=requested_by, + result=result, + attempts=attempts, + ) + + +def execute_logoff_via_winrm( + db: Session, + event: Event, + *, + session_id: int, + requested_by: str, +) -> AgentCommand: + _require_rdg_client_qwinsta(db, event) + client_host = _resolve_client(db, event) + cfg = _require_win_admin(db, client_host) + + details = event.details if isinstance(event.details, dict) else {} + user = details.get("user") + internal_ip = event_internal_ip(event) + + result, attempts = run_winrm_on_host_targets( + client_host, + user=cfg.user, + password=cfg.password, + action=lambda target: run_winrm_logoff( + target=target, + user=cfg.user, + password=cfg.password, + session_id=session_id, + ), + ) + assert result is not None + + params = { + "execution": "winrm", + "user": user, + "internal_ip": internal_ip, + "client_hostname": client_host.hostname, + "client_host_id": client_host.id, + "session_id": session_id, + "winrm_target": result.target, + } + return _persist_command( + db, + client_host=client_host, + event=event, + command_type="logoff", + params=params, + requested_by=requested_by, + result=result, + attempts=attempts, + ) diff --git a/backend/app/services/rdg_workstation_session.py b/backend/app/services/rdg_workstation_session.py new file mode 100644 index 0000000..d57a0b1 --- /dev/null +++ b/backend/app/services/rdg_workstation_session.py @@ -0,0 +1,290 @@ +"""Correlate RDG 303/303-failed with workstation rdp.login.success (1149).""" + +from __future__ import annotations + +from datetime import timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models import Event +from app.services.host_sessions import ( + SESSION_TERMINATED_AT_KEY, + _details_dict, + _event_login_user, +) +from app.services.rdg_client_host import find_windows_host_by_ipv4 +from app.services.rdg_session_flap import ( + RDG_END_TYPES, + RDG_SUCCESS_TYPE, + _event_user, + event_internal_ip, + find_rdg_success_before_end, +) + +SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at" +SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id" +USER_ENRICHED_FROM_RDG_EVENT_ID_KEY = "user_enriched_from_rdg_event_id" + +# RCM 1149 via RD Gateway often has empty Param1/Param2; RDG 302 has the account. +RDG_LOGIN_USER_ENRICH_WINDOW = timedelta(minutes=5) + +WORKSTATION_LOGIN_TYPE = "rdp.login.success" + + +def _as_utc(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def normalize_sam_account(user: str) -> str: + text = (user or "").strip() + if "\\" in text: + return text.split("\\")[-1].strip().casefold() + if "@" in text: + return text.split("@")[0].strip().casefold() + return text.casefold() + + +def users_match_rdg(login_user: str, rdg_user: str) -> bool: + left = normalize_sam_account(login_user) + right = normalize_sam_account(rdg_user) + return bool(left and right and left == right) + + +def _login_user_missing(event: Event) -> bool: + details = _details_dict(event) + for key in ("user", "username"): + val = details.get(key) + if val is not None and str(val).strip() not in ("", "-"): + return False + return True + + +def _apply_rdg_user_to_login(login_event: Event, *, rdg_event: Event, rdg_user: str) -> None: + details = dict(_details_dict(login_event)) + details["user"] = rdg_user + details[USER_ENRICHED_FROM_RDG_EVENT_ID_KEY] = rdg_event.id + login_event.details = details + flag_modified(login_event, "details") + summary = (login_event.summary or "").strip() + if summary.startswith("RCM 1149") and rdg_user not in summary: + rest = summary[len("RCM 1149") :].strip() + login_event.summary = f"RCM 1149 {rdg_user} {rest}".strip() + + +def find_rdg_success_for_workstation_login(db: Session, login_event: Event) -> Event | None: + """Nearest RDG 302 for this workstation IP within the enrich window.""" + if login_event.type != WORKSTATION_LOGIN_TYPE: + return None + host = login_event.host + if host is None or not (host.ipv4 or "").strip(): + return None + workstation_ip = host.ipv4.strip() + login_at = _as_utc(login_event.occurred_at) + window_start = login_at - RDG_LOGIN_USER_ENRICH_WINDOW + window_end = login_at + RDG_LOGIN_USER_ENRICH_WINDOW + + candidates = db.scalars( + select(Event) + .where( + Event.type == RDG_SUCCESS_TYPE, + Event.occurred_at >= window_start, + Event.occurred_at <= window_end, + Event.id != login_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + best: Event | None = None + best_delta: timedelta | None = None + for rdg in candidates: + if event_internal_ip(rdg) != workstation_ip: + continue + if not _event_user(rdg): + continue + delta = abs(_as_utc(rdg.occurred_at) - login_at) + if best is None or best_delta is None or delta < best_delta: + best = rdg + best_delta = delta + return best + + +def enrich_workstation_login_user_from_rdg(db: Session, login_event: Event) -> Event | None: + """Fill details.user when RCM 1149 EventLog left Param1/Param2 empty (seen on some Win10 Pro).""" + if login_event.type != WORKSTATION_LOGIN_TYPE: + return None + if not _login_user_missing(login_event): + return None + rdg = find_rdg_success_for_workstation_login(db, login_event) + if rdg is None: + return None + rdg_user = _event_user(rdg) + if not rdg_user: + return None + _apply_rdg_user_to_login(login_event, rdg_event=rdg, rdg_user=rdg_user) + return login_event + + +def enrich_empty_login_from_rdg_success(db: Session, rdg_success_event: Event) -> Event | None: + """Backfill empty workstation 1149 when RDG 302 is ingested after it.""" + if rdg_success_event.type != RDG_SUCCESS_TYPE: + return None + internal_ip = event_internal_ip(rdg_success_event) + rdg_user = _event_user(rdg_success_event) + if not internal_ip or not rdg_user: + return None + client_host = find_windows_host_by_ipv4(db, internal_ip) + if client_host is None: + return None + + rdg_at = _as_utc(rdg_success_event.occurred_at) + window_start = rdg_at - RDG_LOGIN_USER_ENRICH_WINDOW + window_end = rdg_at + RDG_LOGIN_USER_ENRICH_WINDOW + candidates = db.scalars( + select(Event) + .where( + Event.host_id == client_host.id, + Event.type == WORKSTATION_LOGIN_TYPE, + Event.occurred_at >= window_start, + Event.occurred_at <= window_end, + Event.id != rdg_success_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + for login in candidates: + if not _login_user_missing(login): + continue + _apply_rdg_user_to_login(login, rdg_event=rdg_success_event, rdg_user=rdg_user) + return login + return None + + +def event_closed_by_rdg(event: Event) -> bool: + details = _details_dict(event) + at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY) + return at is not None and str(at).strip() != "" + + +def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> None: + details = dict(_details_dict(login_event)) + details[SESSION_CLOSED_BY_RDG_AT_KEY] = rdg_end_event.occurred_at.isoformat() + details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] = rdg_end_event.id + login_event.details = details + flag_modified(login_event, "details") + + +def _login_already_closed(login_event: Event) -> bool: + from app.services.rdp_session_logoff import event_closed_by_logoff + + details = _details_dict(login_event) + if details.get("session_terminated") is True: + return True + at = details.get(SESSION_TERMINATED_AT_KEY) + if at is not None and str(at).strip() != "": + return True + if event_closed_by_rdg(login_event): + return True + return event_closed_by_logoff(login_event) + + +def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None: + if rdg_end_event.type not in RDG_END_TYPES: + return None + internal_ip = event_internal_ip(rdg_end_event) + if not internal_ip: + return None + client_host = find_windows_host_by_ipv4(db, internal_ip) + if client_host is None: + return None + + rdg_user = _event_user(rdg_end_event) + if not rdg_user: + return None + end_at = rdg_end_event.occurred_at + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == client_host.id, + Event.type == WORKSTATION_LOGIN_TYPE, + Event.occurred_at <= end_at, + Event.id != rdg_end_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + for login in candidates: + if _login_already_closed(login): + continue + login_user = (_event_login_user(login) or "").strip() + if login_user in ("", "-"): + login_user = "" + # RCM 1149 may lack user (empty Param1); still close by workstation IP + open session. + if login_user and not users_match_rdg(login_user, rdg_user): + continue + return login + return None + + +def find_rdg_end_after_workstation_login(db: Session, login_event: Event) -> Event | None: + """Runtime lookup for historical events without persisted close flag.""" + if login_event.type != WORKSTATION_LOGIN_TYPE: + return None + if _login_already_closed(login_event): + return None + + host = login_event.host + if host is None or not host.ipv4: + return None + + workstation_ip = host.ipv4.strip() + login_user = _event_login_user(login_event) + if not login_user: + return None + login_at = login_event.occurred_at + + candidates = db.scalars( + select(Event) + .where( + Event.type.in_(RDG_END_TYPES), + Event.occurred_at >= login_at, + Event.id != login_event.id, + ) + .order_by(Event.occurred_at.asc()) + ).all() + + for end in candidates: + if event_internal_ip(end) != workstation_ip: + continue + if not users_match_rdg(_event_user(end), login_user): + continue + if find_rdg_success_before_end(db, end) is not None: + continue + return end + return None + + +def resolve_workstation_login_closed(db: Session, login_event: Event) -> bool: + if event_closed_by_rdg(login_event): + return True + return find_rdg_end_after_workstation_login(db, login_event) is not None + + +def close_workstation_session_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None: + """On RDG disconnect, mark matching workstation login as session-closed.""" + if rdg_end_event.type not in RDG_END_TYPES: + return None + if find_rdg_success_before_end(db, rdg_end_event) is not None: + return None + + login = find_workstation_login_for_rdg_end(db, rdg_end_event) + if login is None: + return None + mark_login_closed_by_rdg(login, rdg_end_event=rdg_end_event) + return login diff --git a/backend/app/services/rdp_bundle_delivery.py b/backend/app/services/rdp_bundle_delivery.py new file mode 100644 index 0000000..6645e3d --- /dev/null +++ b/backend/app/services/rdp_bundle_delivery.py @@ -0,0 +1,69 @@ +"""Short-lived RDP agent bundles for WinRM clients to download.""" + +from __future__ import annotations + +import io +import re +import secrets +import time +import zipfile +from pathlib import Path + +_UTF8_BOM = b"\xef\xbb\xbf" +_BOM_SUFFIXES = {".ps1", ".txt"} +_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$") +TTL_SECONDS = 600 +BUNDLE_DIR = Path("/tmp/sac-rdp-bundles") + + +def _bundle_file_bytes(path: Path) -> bytes: + data = path.read_bytes() + if path.suffix.lower() in _BOM_SUFFIXES and not data.startswith(_UTF8_BOM): + return _UTF8_BOM + data + return data + + +def build_rdp_bundle_zip(repo_dir: Path, filenames: tuple[str, ...]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name in filenames: + path = repo_dir / name + if path.is_file(): + info = zipfile.ZipInfo(name) + info.compress_type = zipfile.ZIP_DEFLATED + info.flag_bits |= 0x800 + archive.writestr(info, _bundle_file_bytes(path)) + return buffer.getvalue() + + +def _prune_expired_bundles(now: float | None = None) -> None: + if not BUNDLE_DIR.is_dir(): + return + cutoff = (now or time.time()) - TTL_SECONDS + for path in BUNDLE_DIR.glob("*.zip"): + try: + if path.stat().st_mtime < cutoff: + path.unlink(missing_ok=True) + except OSError: + continue + + +def register_rdp_bundle_zip(zip_bytes: bytes) -> str: + token = secrets.token_urlsafe(32) + BUNDLE_DIR.mkdir(parents=True, exist_ok=True) + _prune_expired_bundles() + (BUNDLE_DIR / f"{token}.zip").write_bytes(zip_bytes) + return token + + +def get_rdp_bundle_zip(token: str) -> bytes | None: + text = (token or "").strip() + if not _TOKEN_RE.fullmatch(text): + return None + path = BUNDLE_DIR / f"{text}.zip" + if not path.is_file(): + return None + if time.time() - path.stat().st_mtime > TTL_SECONDS: + path.unlink(missing_ok=True) + return None + return path.read_bytes() diff --git a/backend/app/services/rdp_flap_auto_disconnect.py b/backend/app/services/rdp_flap_auto_disconnect.py new file mode 100644 index 0000000..f624382 --- /dev/null +++ b/backend/app/services/rdp_flap_auto_disconnect.py @@ -0,0 +1,324 @@ +"""Auto logoff stuck RDP sessions when RDG flap (or direct login failure) is detected.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models import Event, Host +from app.services.host_sessions import ( + list_windows_sessions, + mark_event_session_terminated, + parse_qwinsta_sessions, + terminate_windows_session, +) +from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation +from app.services.rdg_session_flap import ( + RDG_END_TYPES, + RDG_SUCCESS_TYPE, + _event_user, + _stored_flap_pair_id, + event_has_rdg_flap, + find_rdg_success_before_end, +) +from app.services.rdg_winrm_actions import execute_logoff_via_winrm +from app.services.rdg_workstation_session import ( + WORKSTATION_LOGIN_TYPE, + _event_login_user, + _login_already_closed, + find_workstation_login_for_rdg_end, + users_match_rdg, +) +from app.services.rdp_flap_settings import get_effective_rdp_flap_settings +from app.services.win_admin_settings import get_effective_win_admin_for_host + +logger = logging.getLogger("sac.rdp_flap_auto_disconnect") + +AUTO_DISCONNECT_BY = "auto:rdp_flap" +RDP_LOGIN_FAILED = "rdp.login.failed" +AUTO_DISCONNECT_DETAILS_KEY = "rdp_flap_auto_disconnect" + + +@dataclass(frozen=True) +class AutoDisconnectResult: + ok: bool + message: str + trigger_event_id: int + workstation_host_id: int | None = None + login_event_id: int | None = None + session_ids: tuple[int, ...] = () + + +def _details_dict(event: Event) -> dict: + raw = event.details + return raw if isinstance(raw, dict) else {} + + +def _already_auto_disconnected(event: Event) -> bool: + details = _details_dict(event) + block = details.get(AUTO_DISCONNECT_DETAILS_KEY) + if not isinstance(block, dict): + return False + return block.get("ok") is True + + +def _mark_auto_disconnect(event: Event, *, result: AutoDisconnectResult) -> None: + details = dict(_details_dict(event)) + details[AUTO_DISCONNECT_DETAILS_KEY] = { + "ok": result.ok, + "message": result.message, + "workstation_host_id": result.workstation_host_id, + "login_event_id": result.login_event_id, + "session_ids": list(result.session_ids), + "at": datetime.now(timezone.utc).isoformat(), + } + event.details = details + flag_modified(event, "details") + + +def _norm_user_filter(user: str) -> str: + text = (user or "").strip() + if "\\" in text: + return text.split("\\")[-1].strip().lower() + if "@" in text: + return text.split("@")[0].strip().lower() + return text.lower() + + +def _sessions_for_user(sessions, user: str): + needle = _norm_user_filter(user) + if not needle: + return [] + matched = [] + for row in sessions: + if needle in _norm_user_filter(row.user): + matched.append(row) + return matched + + +def find_open_workstation_login(db: Session, *, host_id: int, user: str) -> Event | None: + if not user.strip(): + return None + candidates = db.scalars( + select(Event) + .where( + Event.host_id == host_id, + Event.type == WORKSTATION_LOGIN_TYPE, + ) + .order_by(Event.occurred_at.desc()) + ).all() + for login in candidates: + if _login_already_closed(login): + continue + if users_match_rdg(_event_login_user(login), user): + return login + return None + + +def _rdg_pair_success_event(db: Session, event: Event) -> Event | None: + if event.type == RDG_SUCCESS_TYPE and event_has_rdg_flap(event): + pair_id = _stored_flap_pair_id(event) + if pair_id is not None: + return db.get(Event, pair_id) + return event + if event.type in RDG_END_TYPES: + pair_id = _stored_flap_pair_id(event) + if pair_id is not None: + return db.get(Event, pair_id) + return find_rdg_success_before_end(db, event) + return None + + +def _disconnect_on_workstation( + db: Session, + *, + trigger_event: Event, + workstation: Host, + rdg_event: Event | None, + user: str, + login_event: Event | None, +) -> AutoDisconnectResult: + win_cfg = get_effective_win_admin_for_host(db, workstation) + sessions, qwinsta = list_windows_sessions(workstation, win_cfg) + if qwinsta is None or not qwinsta.ok: + message = qwinsta.message if qwinsta else "qwinsta failed" + return AutoDisconnectResult( + ok=False, + message=message, + trigger_event_id=trigger_event.id, + workstation_host_id=workstation.id, + login_event_id=login_event.id if login_event else None, + ) + + parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user) + matched = _sessions_for_user(parsed, user) + if not matched and qwinsta.stdout.strip(): + parsed = parse_qwinsta_sessions(qwinsta.stdout) + matched = _sessions_for_user(parsed, user) + if not matched: + snippet = " ".join(qwinsta.stdout.split())[:240] + parsed_note = f", parsed={len(parsed)} session(s)" if parsed else "" + message = ( + f"No matching Windows session for user {user} on {workstation.hostname}" + f"{parsed_note}" + ) + if snippet: + message = f"{message}; qwinsta: {snippet}" + return AutoDisconnectResult( + ok=False, + message=message, + trigger_event_id=trigger_event.id, + workstation_host_id=workstation.id, + login_event_id=login_event.id if login_event else None, + ) + + logged_off: list[int] = [] + errors: list[str] = [] + for row in matched: + if rdg_event is not None: + cmd = execute_logoff_via_winrm( + db, + rdg_event, + session_id=int(row.session_id), + requested_by=AUTO_DISCONNECT_BY, + ) + if cmd.status == "completed": + logged_off.append(int(row.session_id)) + else: + errors.append(cmd.result_stderr or cmd.result_stdout or f"logoff {row.session_id} failed") + else: + result = terminate_windows_session(workstation, win_cfg, row.session_id) + if result is not None and result.ok: + logged_off.append(int(row.session_id)) + else: + errors.append(result.message if result else f"logoff {row.session_id} failed") + + if login_event is not None and logged_off: + mark_event_session_terminated(login_event, by_username=AUTO_DISCONNECT_BY) + + if logged_off: + message = f"Auto logoff session(s) {', '.join(str(s) for s in logged_off)} on {workstation.hostname}" + ok = True + else: + ok = False + message = "; ".join(errors) if errors else "logoff failed" + return AutoDisconnectResult( + ok=ok, + message=message, + trigger_event_id=trigger_event.id, + workstation_host_id=workstation.id, + login_event_id=login_event.id if login_event else None, + session_ids=tuple(logged_off), + ) + + +def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult | None: + if not event_has_rdg_flap(event): + return None + if _already_auto_disconnected(event): + return None + + rdg_success = _rdg_pair_success_event(db, event) + if rdg_success is None: + return None + + user = _event_user(event) or _event_user(rdg_success) + if not user: + return None + + try: + workstation = resolve_client_workstation(db, rdg_success) + except ClientWorkstationNotFoundError as exc: + result = AutoDisconnectResult( + ok=False, + message=str(exc), + trigger_event_id=event.id, + ) + _mark_auto_disconnect(event, result=result) + return result + + rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1) + login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None + + result = _disconnect_on_workstation( + db, + trigger_event=event, + workstation=workstation, + rdg_event=rdg_success, + user=user, + login_event=login_event, + ) + _mark_auto_disconnect(event, result=result) + return result + + +def _auto_disconnect_direct_rdp_failed(db: Session, event: Event) -> AutoDisconnectResult | None: + if event.type != RDP_LOGIN_FAILED: + return None + if _already_auto_disconnected(event): + return None + host = event.host + if host is None: + return None + + user = _event_login_user(event) + if not user: + return None + + login_event = find_open_workstation_login(db, host_id=host.id, user=user) + if login_event is None: + return None + + result = _disconnect_on_workstation( + db, + trigger_event=event, + workstation=host, + rdg_event=None, + user=user, + login_event=login_event, + ) + _mark_auto_disconnect(event, result=result) + return result + + +def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDisconnectResult | None: + """Log off stuck user session when auto-disconnect is enabled.""" + if not get_effective_rdp_flap_settings(db).auto_disconnect: + return None + + result = _auto_disconnect_rdg_flap(db, event) + if result is not None: + if result.ok: + logger.info( + "auto rdp flap disconnect ok event_id=%s sessions=%s", + event.event_id, + result.session_ids, + ) + else: + logger.warning( + "auto rdp flap disconnect failed event_id=%s: %s", + event.event_id, + result.message, + ) + return result + + result = _auto_disconnect_direct_rdp_failed(db, event) + if result is not None: + if result.ok: + logger.info( + "auto direct rdp disconnect ok event_id=%s sessions=%s", + event.event_id, + result.session_ids, + ) + else: + logger.warning( + "auto direct rdp disconnect failed event_id=%s: %s", + event.event_id, + result.message, + ) + return result diff --git a/backend/app/services/rdp_flap_settings.py b/backend/app/services/rdp_flap_settings.py new file mode 100644 index 0000000..b5a125d --- /dev/null +++ b/backend/app/services/rdp_flap_settings.py @@ -0,0 +1,33 @@ +"""RDP / RDG flap auto-disconnect settings (ui_settings singleton).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +@dataclass(frozen=True) +class RdpFlapSettings: + auto_disconnect: bool + source: str = "db" + + +def get_effective_rdp_flap_settings(db: Session) -> RdpFlapSettings: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + return RdpFlapSettings(auto_disconnect=False, source="default") + return RdpFlapSettings(auto_disconnect=bool(row.auto_rdp_flap_disconnect), source="db") + + +def upsert_rdp_flap_settings(db: Session, *, auto_disconnect: bool) -> RdpFlapSettings: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True) + db.add(row) + row.auto_rdp_flap_disconnect = bool(auto_disconnect) + db.commit() + db.refresh(row) + return get_effective_rdp_flap_settings(db) diff --git a/backend/app/services/rdp_session_logoff.py b/backend/app/services/rdp_session_logoff.py new file mode 100644 index 0000000..c338266 --- /dev/null +++ b/backend/app/services/rdp_session_logoff.py @@ -0,0 +1,148 @@ +"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models import Event +from app.services.host_sessions import ( + SESSION_TERMINATED_AT_KEY, + _details_dict, + _event_login_user, +) +from app.services.rdg_workstation_session import ( + WORKSTATION_LOGIN_TYPE, + event_closed_by_rdg, + users_match_rdg, +) + +SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at" +SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id" + +LOGOFF_EVENT_TYPE = "rdp.session.logoff" +LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE}) + + +def event_closed_by_logoff(event: Event) -> bool: + details = _details_dict(event) + at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY) + return at is not None and str(at).strip() != "" + + +def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None: + details = dict(_details_dict(login_event)) + details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat() + details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id + login_event.details = details + flag_modified(login_event, "details") + + +def _login_already_closed(login_event: Event) -> bool: + details = _details_dict(login_event) + if details.get("session_terminated") is True: + return True + at = details.get(SESSION_TERMINATED_AT_KEY) + if at is not None and str(at).strip() != "": + return True + if event_closed_by_rdg(login_event): + return True + return event_closed_by_logoff(login_event) + + +def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None: + if logoff_event.type not in LOGOFF_EVENT_TYPES: + return None + + logoff_user = _event_login_user(logoff_event) + if not logoff_user: + return None + logoff_at = logoff_event.occurred_at + host_id = logoff_event.host_id + if host_id is None: + return None + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == host_id, + Event.type == WORKSTATION_LOGIN_TYPE, + Event.occurred_at <= logoff_at, + Event.id != logoff_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + logoff_details = _details_dict(logoff_event) + logoff_ip = str(logoff_details.get("ip_address") or "").strip() + + for login in candidates: + if _login_already_closed(login): + continue + login_user = _event_login_user(login) + if not users_match_rdg(login_user, logoff_user): + continue + if logoff_ip and logoff_ip not in ("", "-"): + login_ip = str(_details_dict(login).get("ip_address") or "").strip() + if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip: + continue + return login + return None + + +def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None: + """Runtime lookup for historical logins without persisted close flag.""" + if login_event.type != WORKSTATION_LOGIN_TYPE: + return None + if _login_already_closed(login_event): + return None + + host_id = login_event.host_id + if host_id is None: + return None + + login_user = _event_login_user(login_event) + if not login_user: + return None + login_at = login_event.occurred_at + login_ip = str(_details_dict(login_event).get("ip_address") or "").strip() + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == host_id, + Event.type == LOGOFF_EVENT_TYPE, + Event.occurred_at >= login_at, + Event.id != login_event.id, + ) + .order_by(Event.occurred_at.asc()) + ).all() + + for logoff in candidates: + if not users_match_rdg(_event_login_user(logoff), login_user): + continue + logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip() + if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"): + if login_ip != logoff_ip: + continue + return logoff + return None + + +def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool: + if event_closed_by_logoff(login_event): + return True + return find_logoff_after_workstation_login(db, login_event) is not None + + +def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None: + """On direct RDP logoff, mark matching open workstation login as session-closed.""" + if logoff_event.type not in LOGOFF_EVENT_TYPES: + return None + + login = find_workstation_login_for_logoff(db, logoff_event) + if login is None: + return None + mark_login_closed_by_logoff(login, logoff_event=logoff_event) + return login diff --git a/backend/app/services/retention.py b/backend/app/services/retention.py new file mode 100644 index 0000000..ee5f9c5 --- /dev/null +++ b/backend/app/services/retention.py @@ -0,0 +1,39 @@ +"""Удаление устаревших events и resolved problems.""" + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete +from sqlalchemy.orm import Session + +from app.config import Settings +from app.models import Event, Problem + + +def purge_old_data(db: Session, settings: Settings) -> dict: + """ + Events: received_at старше sac_events_retention_days. + Problems: status=resolved и updated_at старше sac_problems_retention_days. + """ + now = datetime.now(timezone.utc) + events_cutoff = now - timedelta(days=settings.sac_events_retention_days) + problems_cutoff = now - timedelta(days=settings.sac_problems_retention_days) + + events_result = db.execute(delete(Event).where(Event.received_at < events_cutoff)) + events_deleted = events_result.rowcount or 0 + + problems_result = db.execute( + delete(Problem).where( + Problem.status == "resolved", + Problem.updated_at < problems_cutoff, + ) + ) + problems_deleted = problems_result.rowcount or 0 + + db.commit() + + return { + "events_deleted": events_deleted, + "problems_deleted": problems_deleted, + "events_cutoff": events_cutoff.isoformat(), + "problems_cutoff": problems_cutoff.isoformat(), + } diff --git a/backend/app/services/schema_validate.py b/backend/app/services/schema_validate.py new file mode 100644 index 0000000..a9a99ef --- /dev/null +++ b/backend/app/services/schema_validate.py @@ -0,0 +1,24 @@ +import json +from functools import lru_cache +from pathlib import Path + +import jsonschema +from jsonschema import Draft202012Validator + +from app.config import get_settings + + +@lru_cache +def get_event_validator() -> Draft202012Validator: + path = Path(get_settings().event_schema_path) + if not path.is_file(): + path = Path(__file__).resolve().parents[3] / "schemas" / "event-schema-v1.json" + schema = json.loads(path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER) + + +def validate_event_payload(payload: dict) -> list[str]: + validator = get_event_validator() + errors = sorted(validator.iter_errors(payload), key=lambda e: e.path) + return [e.message for e in errors] diff --git a/backend/app/services/session_duration.py b/backend/app/services/session_duration.py new file mode 100644 index 0000000..c2ae3ed --- /dev/null +++ b/backend/app/services/session_duration.py @@ -0,0 +1,37 @@ +"""Format and extract RDP/RDG session_duration_sec for UI.""" + +from __future__ import annotations + +from typing import Any + + +def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None: + if not isinstance(details, dict): + return None + raw = details.get("session_duration_sec") + if raw is None or raw == "": + return None + try: + value = int(raw) + except (TypeError, ValueError): + return None + if value < 0: + return None + return value + + +def format_session_duration(seconds: int) -> str: + """ + Human-readable session length for lists: + - under 24h → HH:MM:SS (05:05:24) + - 24h+ → 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 diff --git a/backend/app/services/ssh_connect.py b/backend/app/services/ssh_connect.py new file mode 100644 index 0000000..227ae31 --- /dev/null +++ b/backend/app/services/ssh_connect.py @@ -0,0 +1,588 @@ +"""SSH connectivity and remote commands for Linux hosts.""" + +from __future__ import annotations + +import re +import socket +from dataclasses import dataclass + +from app.config import get_settings +from app.models import Host + +SSH_MONITOR_UPDATE_STATE_FILE = "/var/lib/ssh-monitor/agent-update-in-progress" +SSH_MONITOR_UPDATE_LOG_PATH = "/var/log/update_script.log" +SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh" +SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update" +SSH_MONITOR_REPO_NAME = "ssh-monitor" +SSH_MONITOR_UPDATE_REPO_URL = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git" +SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor" +SSH_MONITOR_VERSION_CMD = ( + f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null " + "| sed -E 's/^SSH_MONITOR_VERSION=[\"'\\'']*([^\"'\\'']+)[\"'\\'']*$/\\1/'" +) +SSH_OUTPUT_MAX_LEN = 12_000 +_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE) +_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") +_SSH_TRANSIENT_ERROR_MARKERS = ( + "no existing session", + "error reading ssh protocol banner", + "connection reset", + "connection lost", + "session not active", + "eof", + "socket is closed", + "connection timed out", +) +_SSH_CONNECT_ATTEMPTS = 2 + + +class LinuxAdminNotConfiguredError(Exception): + pass + + +class HostNotLinuxError(Exception): + pass + + +class HostTargetMissingError(Exception): + pass + + +@dataclass(frozen=True) +class SshCommandResult: + ok: bool + message: str + target: str + stdout: str = "" + stderr: str = "" + exit_code: int | None = None + agent_version: str | None = None + + +def is_linux_host(host: Host) -> bool: + if (host.os_family or "").strip().lower() == "linux": + return True + return (host.product or "").strip() == "ssh-monitor" + + +def _is_ipv4(value: str) -> bool: + return bool(_IPV4_RE.match(value.strip())) + + +def _looks_like_ssh_hostname(value: str) -> bool: + """Короткое имя или FQDN; не человекочитаемый display_name вроде «Ubabuba Kalina (10.10.36.9)».""" + text = value.strip() + if not text or " " in text or "(" in text or ")" in text: + return False + if _is_ipv4(text): + return True + if len(text) > 253: + return False + return all(ch.isalnum() or ch in ".-_" for ch in text) + + +def _ssh_name_resolves(name: str) -> bool: + if _is_ipv4(name): + return True + try: + socket.getaddrinfo(name.strip(), 22, type=socket.SOCK_STREAM) + return True + except OSError: + return False + + +def iter_ssh_targets(host: Host) -> list[str]: + if not is_linux_host(host): + raise HostNotLinuxError("Host is not Linux") + + seen: set[str] = set() + ordered: list[str] = [] + + def add(value: str | None) -> None: + text = (value or "").strip() + if not text or text.casefold() in seen: + return + if not _looks_like_ssh_hostname(text): + return + if not _ssh_name_resolves(text): + return + seen.add(text.casefold()) + ordered.append(text) + + add(host.hostname) + + inventory = host.inventory if isinstance(host.inventory, dict) else {} + computer_name = inventory.get("computer_name") + if isinstance(computer_name, str): + add(computer_name) + + if host.display_name and _looks_like_ssh_hostname(host.display_name): + add(host.display_name) + + add(host.ipv4) + if not ordered: + raise HostTargetMissingError("Host has no resolvable hostname or IPv4 for SSH") + return ordered + + +def _truncate_output(text: str) -> str: + if len(text) <= SSH_OUTPUT_MAX_LEN: + return text + return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)" + + +def _is_transient_ssh_error(exc: BaseException) -> bool: + text = str(exc).casefold() + return any(marker in text for marker in _SSH_TRANSIENT_ERROR_MARKERS) + + +def _ssh_connect_hint(exc: BaseException) -> str: + text = str(exc).casefold() + if "no existing session" in text: + return ( + " Сервер закрыл SSH-сессию во время подключения или auth " + "(проверьте пароль admin, лимиты sshd MaxSessions/MaxStartups, " + "доступ SAC→хост по 22/tcp; на SAC не должен мешать ssh-agent)." + ) + return "" + + +def _connect_ssh_client( + client, + *, + target: str, + user: str, + password: str, + connect_timeout_sec: int, +) -> None: + client.connect( + hostname=target, + username=user, + password=password, + timeout=connect_timeout_sec, + banner_timeout=connect_timeout_sec, + auth_timeout=connect_timeout_sec, + allow_agent=False, + look_for_keys=False, + compress=False, + ) + + +def _close_ssh_client(client) -> None: + if client is None: + return + try: + transport = client.get_transport() + if transport is not None and transport.is_active(): + transport.close() + except Exception: + pass + try: + client.close() + except Exception: + pass + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _remote_shell_command( + user: str, + remote_cmd: str, + *, + need_root: bool = False, + login_shell: bool = True, +) -> tuple[str, bool]: + """Build remote shell command. Returns (command, needs_sudo_password_on_stdin).""" + safe_cmd = _shell_single_quote(remote_cmd) + bash_flag = "lc" if login_shell else "c" + if user == "root" or not need_root: + return f"bash -{bash_flag} {safe_cmd}", False + return f"sudo -S -p '' bash -{bash_flag} {safe_cmd}", True + + +def _configure_ssh_client(client, target: str) -> None: + import paramiko + + settings = get_settings() + if settings.sac_ssh_auto_add_host_key: + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + return + + client.set_missing_host_key_policy(paramiko.RejectPolicy()) + client.load_system_host_keys() + known_hosts = (settings.sac_ssh_known_hosts_file or "").strip() + if known_hosts: + try: + client.load_host_keys(known_hosts) + except OSError: + pass + + +def _exec_remote_command( + client, + *, + shell_cmd: str, + password: str, + needs_sudo_password: bool, + command_timeout_sec: int, +) -> tuple[int, str, str]: + if needs_sudo_password: + stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec, get_pty=True) + stdin.write(f"{password}\n") + stdin.flush() + stdin.channel.shutdown_write() + else: + _stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec) + exit_code = stdout.channel.recv_exit_status() + out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace")) + err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace")) + return exit_code, out_text, err_text + + +def run_ssh_command( + *, + target: str, + user: str, + password: str, + remote_cmd: str, + connect_timeout_sec: int = 15, + command_timeout_sec: int = 600, + need_root: bool = False, + login_shell: bool = True, +) -> SshCommandResult: + target = target.strip() + user = user.strip() + if not target or not user or not password: + raise LinuxAdminNotConfiguredError("Linux SSH admin credentials or target missing") + + try: + import paramiko + except ImportError as exc: + raise RuntimeError("paramiko is not installed on SAC server") from exc + + shell_cmd, needs_sudo_password = _remote_shell_command( + user, + remote_cmd, + need_root=need_root, + login_shell=login_shell, + ) + exit_code: int | None = None + out_text = "" + err_text = "" + + for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1): + client = paramiko.SSHClient() + try: + _configure_ssh_client(client, target) + _connect_ssh_client( + client, + target=target, + user=user, + password=password, + connect_timeout_sec=connect_timeout_sec, + ) + exit_code, out_text, err_text = _exec_remote_command( + client, + shell_cmd=shell_cmd, + password=password, + needs_sudo_password=needs_sudo_password, + command_timeout_sec=command_timeout_sec, + ) + break + except paramiko.AuthenticationException: + return SshCommandResult( + ok=False, + message=f"SSH auth failed for {user}@{target}", + target=target, + ) + except (socket.timeout, TimeoutError): + return SshCommandResult( + ok=False, + message=f"SSH timeout ({connect_timeout_sec}s connect / {command_timeout_sec}s cmd) to {target}", + target=target, + ) + except Exception as exc: + if attempt < _SSH_CONNECT_ATTEMPTS and _is_transient_ssh_error(exc): + continue + hint = _ssh_connect_hint(exc) + return SshCommandResult( + ok=False, + message=f"SSH error ({target}): {exc}{hint}", + target=target, + ) + finally: + _close_ssh_client(client) + else: + return SshCommandResult( + ok=False, + message=f"SSH error ({target}): repeated transient connection failures", + target=target, + ) + + ok = exit_code == 0 + if ok: + message = f"SSH OK ({target}), exit 0" + if out_text.strip(): + message = f"{message}\n{out_text.strip().splitlines()[0][:200]}" + else: + detail = err_text.strip() or out_text.strip() or f"exit code {exit_code}" + message = f"SSH command failed ({target}): {detail[:500]}" + + return SshCommandResult( + ok=ok, + message=message, + target=target, + stdout=out_text, + stderr=err_text, + exit_code=exit_code, + ) + + +def _ssh_output_hostname(stdout: str) -> str: + """Last line that looks like a hostname (MOTD/login banners may precede command output).""" + candidates = [ln.strip() for ln in stdout.splitlines() if ln.strip()] + for line in reversed(candidates): + if re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}$", line): + return line + return candidates[-1] if candidates else "" + + +def test_ssh_connection( + *, + target: str, + user: str, + password: str, + timeout_sec: int = 15, +) -> SshCommandResult: + result = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd="hostname", + connect_timeout_sec=timeout_sec, + command_timeout_sec=timeout_sec, + login_shell=False, + ) + if result.ok and result.stdout.strip(): + host = _ssh_output_hostname(result.stdout) + if not host: + return result + return SshCommandResult( + ok=True, + message=f"SSH OK, hostname={host}", + target=target, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + ) + return result + + +def parse_ssh_monitor_version_text(text: str) -> str | None: + if not text: + return None + match = _AGENT_VERSION_RE.search(text) + if not match: + return None + return match.group(1) + + +def probe_ssh_monitor_version( + *, + target: str, + user: str, + password: str, +) -> str | None: + result = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=SSH_MONITOR_VERSION_CMD, + command_timeout_sec=30, + login_shell=False, + ) + if result.ok and result.stdout.strip(): + version = parse_ssh_monitor_version_text(result.stdout) + if version: + return version + if result.stdout or result.stderr: + return parse_ssh_monitor_version_text(f"{result.stdout}\n{result.stderr}") + return None + + +def _ssh_monitor_preflight_git_remote(repo_url: str) -> str: + """Align clone remotes with SAC-trusted URL (works even before updater script is refreshed).""" + safe_repo = _shell_single_quote(repo_url.strip()) + repo_dir = _shell_single_quote(f"{SSH_MONITOR_UPDATE_DIR}/{SSH_MONITOR_REPO_NAME}") + return ( + f"if [ -d {repo_dir}/.git ]; then " + f"git -C {repo_dir} remote add kalinamall {safe_repo} 2>/dev/null || " + f"git -C {repo_dir} remote set-url kalinamall {safe_repo}; " + f"if git -C {repo_dir} remote get-url origin >/dev/null 2>&1; then " + f"git -C {repo_dir} remote set-url origin {safe_repo}; " + f"fi; fi" + ) + + +def _ssh_monitor_update_invoke_command( + repo_url: str, + *, + git_branch: str = "main", +) -> str: + """Run installed updater with SAC-trusted git URL (overrides script default / stale origin).""" + safe_repo = _shell_single_quote(repo_url.strip()) + safe_branch = _shell_single_quote((git_branch or "main").strip() or "main") + preflight = _ssh_monitor_preflight_git_remote(repo_url) + return f"{preflight}; UPDATE_VIA_SAC=1 REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}" + + +def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str: + """Clone ssh-monitor repo and install updater when /opt/scripts/update_ssh_monitor.sh is absent.""" + safe_repo = repo_url.replace("\\", "\\\\").replace('"', '\\"') + safe_branch = (git_branch or "main").replace("\\", "\\\\").replace('"', '\\"') + return ( + f'UPDATE_DIR="{SSH_MONITOR_UPDATE_DIR}";' + f'REPO_URL="{safe_repo}";' + f'GIT_BRANCH="{safe_branch}";' + f'SCRIPT_NAME="{SSH_MONITOR_REPO_NAME}";' + f'INSTALL="{SSH_MONITOR_UPDATE_SCRIPT}";' + f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && ' + f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && ' + f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && ' + f'chmod 750 "$INSTALL" && UPDATE_VIA_SAC=1 REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL" --deploy' + ) + + +def _ssh_monitor_mark_update_begin_prefix() -> str: + """State-file до updater: lifecycle/sudo на агенте глушатся раньше bootstrap.""" + return ( + f"mkdir -p /var/lib/ssh-monitor && " + f"date +%s > {SSH_MONITOR_UPDATE_STATE_FILE} && " + f"chmod 600 {SSH_MONITOR_UPDATE_STATE_FILE} 2>/dev/null; " + ) + + +def run_ssh_monitor_update( + *, + target: str, + user: str, + password: str, + repo_url: str | None = None, + git_branch: str | None = None, +) -> SshCommandResult: + script = SSH_MONITOR_UPDATE_SCRIPT + effective_repo = (repo_url or SSH_MONITOR_UPDATE_REPO_URL).strip() + effective_branch = (git_branch or "main").strip() or "main" + update_cmd = _ssh_monitor_update_invoke_command( + effective_repo, + git_branch=effective_branch, + ) + check_cmd = f"test -x {script} && echo ready || echo missing" + probe = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=check_cmd, + command_timeout_sec=30, + login_shell=False, + ) + if not probe.ok: + return SshCommandResult( + ok=False, + message=f"SSH probe failed on {target}: {probe.message}", + target=target, + stdout=probe.stdout, + stderr=probe.stderr, + exit_code=probe.exit_code, + ) + + bootstrapped = False + if "missing" in probe.stdout: + bootstrap_cmd = _ssh_monitor_bootstrap_command( + effective_repo, + git_branch=effective_branch, + ) + updated = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd, + command_timeout_sec=900, + need_root=True, + login_shell=False, + ) + bootstrapped = True + else: + updated = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd, + command_timeout_sec=900, + need_root=True, + login_shell=False, + ) + if not updated.ok: + prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed" + return SshCommandResult( + ok=False, + message=f"{prefix} ({target}): {updated.message}", + target=updated.target, + stdout=updated.stdout, + stderr=updated.stderr, + exit_code=updated.exit_code, + ) + + version = probe_ssh_monitor_version(target=target, user=user, password=password) + if not version: + version = parse_ssh_monitor_version_text(updated.stdout) + if version: + prefix = "Bootstrap + update OK" if bootstrapped else updated.message + message = f"{prefix}\nagent version: {version}" + return SshCommandResult( + ok=True, + message=message, + target=updated.target, + stdout=updated.stdout, + stderr=updated.stderr, + exit_code=updated.exit_code, + agent_version=version, + ) + if bootstrapped: + return SshCommandResult( + ok=True, + message=f"Bootstrap + update OK ({target}), exit 0", + target=updated.target, + stdout=updated.stdout, + stderr=updated.stderr, + exit_code=updated.exit_code, + ) + return updated + + +def tail_ssh_monitor_update_log( + *, + target: str, + user: str, + password: str, + lines: int = 120, +) -> str: + """Хвост /var/log/update_script.log на удалённом хосте (для live-лога в SAC UI).""" + safe_lines = max(20, min(int(lines), 400)) + remote_cmd = ( + f"test -r {SSH_MONITOR_UPDATE_LOG_PATH} && " + f"tail -n {safe_lines} {SSH_MONITOR_UPDATE_LOG_PATH} 2>/dev/null || true" + ) + result = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=remote_cmd, + command_timeout_sec=25, + need_root=True, + login_shell=False, + ) + text = (result.stdout or "").strip() + return _truncate_output(text) diff --git a/backend/app/services/system_stats.py b/backend/app/services/system_stats.py new file mode 100644 index 0000000..62c1fb7 --- /dev/null +++ b/backend/app/services/system_stats.py @@ -0,0 +1,115 @@ +"""Host and DB metrics for SAC sidebar (best-effort, no heavy queries).""" + +from __future__ import annotations + +import os +import shutil +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from sqlalchemy import func, select, text +from sqlalchemy.orm import Session + +from app.models import Event, Problem + + +def _disk_path() -> str: + explicit = os.environ.get("SAC_STATS_DISK_PATH", "").strip() + if explicit: + return explicit + return str(Path("/").resolve()) + + +def _read_cpu_percent() -> float | None: + try: + import psutil # type: ignore[import-untyped] + + return round(float(psutil.cpu_percent(interval=None)), 1) + except Exception: + return None + + +def _read_memory() -> dict[str, float | int] | None: + try: + import psutil # type: ignore[import-untyped] + + vm = psutil.virtual_memory() + return { + "used_mb": int(vm.used / (1024 * 1024)), + "total_mb": int(vm.total / (1024 * 1024)), + "percent": round(float(vm.percent), 1), + } + except Exception: + return None + + +def _read_disk(path: str) -> dict[str, float | int | str] | None: + try: + usage = shutil.disk_usage(path) + total = int(usage.total) + used = int(usage.used) + if total <= 0: + return None + return { + "path": path, + "used_gb": round(used / (1024**3), 1), + "total_gb": round(total / (1024**3), 1), + "percent": round(100.0 * used / total, 1), + } + except Exception: + return None + + +def collect_system_stats(db: Session) -> dict: + collected_at = datetime.now(timezone.utc).isoformat() + disk_path = _disk_path() + + db_status = "ok" + db_latency_ms: float | None = None + db_connections: int | None = None + t0 = time.perf_counter() + try: + db.execute(text("SELECT 1")) + db_latency_ms = round((time.perf_counter() - t0) * 1000, 1) + try: + db_connections = int( + db.scalar( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND pid <> pg_backend_pid()" + ) + ) + or 0 + ) + except Exception: + db_connections = None + except Exception: + db_status = "error" + + since = datetime.now(timezone.utc) - timedelta(hours=1) + events_last_hour = 0 + problems_open = 0 + if db_status == "ok": + events_last_hour = int( + db.scalar(select(func.count()).select_from(Event).where(Event.received_at >= since)) or 0 + ) + problems_open = int( + db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0 + ) + + return { + "collected_at": collected_at, + "cpu_percent": _read_cpu_percent(), + "memory": _read_memory(), + "disk": _read_disk(disk_path), + "database": { + "status": db_status, + "latency_ms": db_latency_ms, + "active_connections": db_connections, + }, + "app": { + "events_last_hour": events_last_hour, + "problems_open": problems_open, + }, + } diff --git a/backend/app/services/telegram_notify.py b/backend/app/services/telegram_notify.py new file mode 100644 index 0000000..9f0f13f --- /dev/null +++ b/backend/app/services/telegram_notify.py @@ -0,0 +1,105 @@ +import logging + +import httpx +from sqlalchemy.orm import Session + +from app.models import Event, Problem +from app.services.notification_severity import severity_meets_minimum +from app.services.notification_settings import TelegramConfig, get_effective_telegram_config +from app.services.telegram_templates import format_event_telegram_html, format_problem_telegram_html + +logger = logging.getLogger(__name__) + + +class TelegramNotConfiguredError(Exception): + """Telegram disabled or missing token/chat_id.""" + + +class TelegramSendError(Exception): + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +def send_telegram_text( + message: str, + *, + config: TelegramConfig | None = None, + force: bool = False, + parse_mode: str | None = "HTML", +) -> None: + cfg = config or get_effective_telegram_config() + if not force and not cfg.active: + return + if not cfg.bot_token or not cfg.chat_id: + if force: + raise TelegramNotConfiguredError("Telegram: не задан bot token или chat_id") + return + + url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage" + payload: dict[str, str | bool] = { + "chat_id": cfg.chat_id, + "text": message, + "disable_web_page_preview": True, + } + if parse_mode: + payload["parse_mode"] = parse_mode + try: + with httpx.Client(timeout=8.0) as client: + response = client.post(url, json=payload) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:200] if exc.response is not None else str(exc) + raise TelegramSendError( + f"Telegram API HTTP {exc.response.status_code}: {detail}", + status_code=exc.response.status_code, + ) from exc + except Exception as exc: + logger.exception("telegram send failed") + raise TelegramSendError(str(exc)) from exc + + +def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None: + cfg = config or get_effective_telegram_config() + if not cfg.enabled: + raise TelegramNotConfiguredError("Telegram отключён (enabled=false)") + if not cfg.configured: + raise TelegramNotConfiguredError("Не задан bot token или chat_id") + send_telegram_text( + "✅ SAC: тестовое сообщение\nКанал Telegram для оповещений работает.\n" + "📡 Оповещение: SAC (Security Alert Center)", + config=cfg, + force=True, + ) + + +def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: + cfg = get_effective_telegram_config(db) + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity): + return + message = format_event_telegram_html(event) + try: + send_telegram_text(message, config=cfg) + except TelegramSendError: + logger.exception("notify_event telegram failed event_id=%s", event.event_id) + + +def notify_problem( + problem: Problem, + event: Event | None = None, + *, + db: Session | None = None, + apply_policy_gate: bool = True, +) -> None: + cfg = get_effective_telegram_config(db) + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity): + return + message = format_problem_telegram_html(problem, event) + try: + send_telegram_text(message, config=cfg) + except TelegramSendError: + logger.exception("notify_problem telegram failed problem_id=%s", problem.id) diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py new file mode 100644 index 0000000..9475ba6 --- /dev/null +++ b/backend/app/services/telegram_templates.py @@ -0,0 +1,604 @@ +"""HTML Telegram message templates (RDP/SSH style, notif-30).""" + +from __future__ import annotations + +import html +import re +from datetime import datetime +from typing import Any + +from app.models import Event, Host, Problem +from app.services.daily_report_format import ( + append_notification_source_html, + normalize_report_body, + resolve_product_label, +) + +LOGON_TYPE_NAMES: dict[int, str] = { + 2: "Интерактивный (консоль)", + 3: "Сеть/RDP (Network)", + 4: "Пакетный (Batch)", + 5: "Сервис (Service)", + 7: "Разблокировка (Unlock)", + 8: "Сетевой с явными данными", + 9: "Новые учетные данные", + 10: "Удаленный интерактивный (RDP)", +} + + +# Telegram parse_mode=HTML: только теги из Bot API (без div/br/p и т.д.) +_TELEGRAM_BR = re.compile(r"", re.I) +_TELEGRAM_DROP_TAGS = re.compile( + r"]*>", + re.I, +) +_TELEGRAM_SPAN_OPEN = re.compile(r"]*\btg-spoiler\b)[^>]*>", re.I) +_TELEGRAM_SPAN_CLOSE = re.compile(r"", re.I) + + +def sanitize_telegram_html(text: str) -> str: + """Приводит HTML отчёта (UI/агент) к тегам, допустимым в Telegram.""" + out = _TELEGRAM_BR.sub("\n", text) + out = _TELEGRAM_DROP_TAGS.sub("", out) + out = _TELEGRAM_SPAN_OPEN.sub("", out) + out = _TELEGRAM_SPAN_CLOSE.sub("", out) + return out.strip() + + +def html_escape(value: Any) -> str: + if value is None: + return "" + return html.escape(str(value), quote=False) + + +def _details_dict(event: Event | None) -> dict[str, Any]: + if event is None or not isinstance(event.details, dict): + return {} + return event.details + + +def _detail(details: dict[str, Any], *keys: str, default: str = "-") -> str: + for key in keys: + val = details.get(key) + if val is not None and str(val).strip() not in ("", "-"): + return str(val).strip() + return default + + +def logon_type_label(logon_type: Any) -> str: + try: + code = int(logon_type) + except (TypeError, ValueError): + return html_escape(logon_type) if logon_type not in (None, "") else "-" + name = LOGON_TYPE_NAMES.get(code, f"Тип {code}") + return f"{html_escape(name)} ({code})" + + +def host_label(host: Host | None, *, fallback: str = "unknown") -> str: + if host is None: + return html_escape(fallback) + if host.display_name and host.display_name.strip(): + return html_escape(host.display_name.strip()) + return html_escape(host.hostname) + + +def _host_display_name_base(display_name: str | None) -> str: + if not display_name or not display_name.strip(): + return "" + return display_name.strip().split("(", 1)[0].strip() + + +def _rdp_workstation_is_redundant(workstation: str, host: Host | None) -> bool: + """Скрыть Workstation Name, если пусто/«-» или совпадает с hostname/display_name сервера.""" + ws = (workstation or "").strip() + if not ws or ws in ("-", "N/A", "?"): + return True + if host is None: + return False + ws_key = ws.casefold() + if host.hostname and ws_key == host.hostname.strip().casefold(): + return True + dn = (host.display_name or "").strip() + if dn and ws_key == dn.casefold(): + return True + base = _host_display_name_base(host.display_name) + if base and ws_key == base.casefold(): + return True + return False + + +def format_time(dt: datetime | None) -> str: + if dt is None: + return "-" + try: + return html_escape(dt.strftime("%d.%m.%Y %H:%M:%S")) + except Exception: + return html_escape(str(dt)) + + +def _line(emoji: str, label: str, value: str) -> str: + return f"{emoji} {label}: {value}\n" + + +def _event_content_author(event: Event) -> str: + """Кто сгенерировал содержимое события (ingest / отчёт).""" + details = _details_dict(event) + gb = details.get("generated_by") + if isinstance(gb, str) and gb.strip().lower() in ("agent", "sac"): + return gb.strip().lower() + stats = details.get("stats") + if isinstance(stats, dict) and stats.get("sac_generated"): + return "sac" + if event.type in ("report.daily.rdp", "report.daily.ssh") and details.get("report_body"): + return "agent" + return "agent" + + +def _notification_delivered_by(event: Event) -> str: + """Кто доставил сообщение в Telegram (footer «📡 Оповещение»). + + Шаблоны здесь используются только при отправке из SAC. Агент добавляет footer сам. + """ + details = _details_dict(event) + via = details.get("telegram_via") + if isinstance(via, str) and via.strip().lower() in ("agent", "sac"): + return via.strip().lower() + if _event_content_author(event) == "sac": + return "sac" + # ingest от агента, канал Telegram — SAC (exclusive / notify_lifecycle) + return "sac" + + +def _event_product_version(event: Event) -> tuple[str | None, str | None]: + details = _details_dict(event) + platform = None + if event.type == "report.daily.ssh": + platform = "ssh" + elif event.type == "report.daily.rdp": + platform = "windows" + elif event.host is not None: + if event.host.os_family == "linux": + platform = "ssh" + elif event.host.os_family == "windows": + platform = "windows" + product, version = resolve_product_label(event.host, platform) + stats = details.get("stats") + if isinstance(stats, dict): + av = stats.get("agent_version") or stats.get("product_version") + if av: + version = str(av).strip() + payload = event.payload if isinstance(event.payload, dict) else {} + source = payload.get("source") + if isinstance(source, dict): + if source.get("product"): + product = str(source["product"]).strip() + if source.get("product_version"): + version = str(source["product_version"]).strip() + return product, version + + +def _append_event_source(html_msg: str, event: Event) -> str: + delivered = _notification_delivered_by(event) + product, version = _event_product_version(event) + return append_notification_source_html( + html_msg, + generated_by=delivered, + product=product if delivered == "agent" else None, + product_version=version if delivered == "agent" else None, + ) + + +def format_rdp_login_html(event: Event) -> str: + details = _details_dict(event) + is_success = event.type == "rdp.login.success" + header = "✅ УСПЕШНЫЙ ВХОД" if is_success else "❌ НЕУДАЧНАЯ ПОПЫТКА" + win_id = _detail(details, "event_id_windows", default="") + if not win_id and event.type == "rdp.login.failed": + win_id = "4625" + elif not win_id and is_success: + win_id = "4624" + + user = html_escape(_detail(details, "user", "username")) + ip = html_escape(_detail(details, "ip_address", "source_ip", "ip")) + workstation_raw = _detail(details, "workstation_name", "computer_name") + workstation = html_escape(workstation_raw) + process = html_escape(_detail(details, "process_name", "process", default="")) + logon = logon_type_label(details.get("logon_type")) + + msg = f"{header}\n" + msg += _line("👤", "Пользователь", user) + msg += _line("🏢", "Сервер", host_label(event.host)) + if not _rdp_workstation_is_redundant(workstation_raw, event.host): + msg += _line("🖥️", "Рабочая станция", workstation) + msg += _line("🌐", "IP адрес", ip) + if process and process != "-": + msg += _line("⚙️", "Процесс", process) + msg += _line("🔑", "Тип входа", logon) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + if win_id: + msg += f"🔢 Event ID: {html_escape(win_id)}" + return msg.rstrip() + + +def format_ssh_auth_html(event: Event) -> str: + details = _details_dict(event) + is_success = event.type == "ssh.login.success" + header = "✅ SSH: успешный вход" if is_success else "❌ SSH: неудачная попытка" + + user = html_escape(_detail(details, "user", "username")) + ip = html_escape(_detail(details, "source_ip", "ip_address", "ip")) + port = html_escape(_detail(details, "port", default="")) + attempt = _detail(details, "attempt_number", default="") + max_attempts = _detail(details, "max_attempts", default="") + + msg = f"{header}\n" + msg += _line("👤", "Пользователь", user) + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("🌐", "IP", ip) + if port and port != "-": + msg += _line("🔌", "Порт", port) + if attempt != "-" and max_attempts != "-": + msg += _line("🔢", "Попытка", f"{html_escape(attempt)} / {html_escape(max_attempts)}") + msg += _line("🕐", "Время", format_time(event.occurred_at)) + msg += f"📋 {html_escape(event.type)}" + return msg.rstrip() + + +def format_sudo_html(event: Event) -> str: + details = _details_dict(event) + risk = _detail(details, "risk_level", default="") + header = "⚠️ SUDO" + if risk and risk.lower() == "critical": + header = "🔥 SUDO (critical)" + + msg = f"{header}\n" + msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("▶️", "Команда", html_escape(_detail(details, "command", default=event.summary[:200]))) + run_as = _detail(details, "run_as", default="") + if run_as != "-": + msg += _line("👥", "От имени", html_escape(run_as)) + pwd = _detail(details, "pwd", default="") + if pwd != "-": + msg += _line("📁", "Каталог", html_escape(pwd)) + if risk != "-": + msg += _line("⚡", "Risk", html_escape(risk)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + return msg.rstrip() + + +def format_rdp_shadow_html(event: Event) -> str: + details = _details_dict(event) + action = _detail(details, "shadow_action", default="") + if action == "control_stopped" or event.type.endswith(".stopped"): + header = "🎭 RDS SHADOW CONTROL — остановлено" + elif action == "control_permission" or event.type.endswith(".permission"): + header = "🎭 RDS SHADOW CONTROL — разрешение" + else: + header = "🎭 RDS SHADOW CONTROL — начато" + + msg = f"{header}\n" + msg += _line("🏢", "Сервер", host_label(event.host)) + msg += _line("👤", "Администратор", html_escape(_detail(details, "shadower_user", "user"))) + msg += _line("🎯", "Сессия пользователя", html_escape(_detail(details, "target_user", default="-"))) + sid = _detail(details, "session_id", "target_session_id", default="") + if sid != "-": + msg += _line("🔢", "Session ID", html_escape(sid)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + win_id = _detail(details, "event_id_windows", default="") + if win_id != "-": + msg += f"🔢 Event ID: {html_escape(win_id)} (RemoteConnectionManager)" + return msg.rstrip() + + +def format_winrm_session_html(event: Event) -> str: + details = _details_dict(event) + msg = "⚠️ WinRM / Enter-PSSession — удалённая shell\n" + msg += _line("🏢", "Сервер", host_label(event.host)) + msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) + ip = _detail(details, "source_ip", "ip_address", "ip") + if ip != "-": + msg += _line("🌐", "IP источника", html_escape(ip)) + uri = _detail(details, "resource_uri", default="") + if uri != "-": + msg += _line("🔗", "ResourceUri", html_escape(uri)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + win_id = _detail(details, "event_id_windows", default="") + if win_id != "-": + msg += f"🔢 Event ID: {html_escape(win_id)} (WinRM Operational)" + return msg.rstrip() + + +def format_smb_admin_share_html(event: Event) -> str: + details = _details_dict(event) + msg = "📁 Доступ к админ-шару (5140)\n" + msg += _line("🏢", "Сервер", host_label(event.host)) + msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) + ip = _detail(details, "source_ip", "ip_address", "ip") + if ip != "-": + msg += _line("🌐", "IP клиента", html_escape(ip)) + share = _detail(details, "share_name", default="") + if share != "-": + msg += _line("📂", "Share", html_escape(share)) + path = _detail(details, "share_path", default="") + if path != "-": + msg += _line("📍", "Путь", html_escape(path)) + target = _detail(details, "relative_target", default="") + if target != "-": + msg += _line("📄", "Объект", html_escape(target)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + win_id = _detail(details, "event_id_windows", default="") + if win_id != "-": + msg += f"🔢 Event ID: {html_escape(win_id)} (File Share)" + return msg.rstrip() + + +def _format_inventory_change_line(change: dict[str, Any]) -> str: + field = html_escape(str(change.get("field") or "?")) + old = change.get("old") + new = change.get("new") + return f"• {field}: {html_escape(old)} → {html_escape(new)}" + + +def format_agent_inventory_html(event: Event) -> str: + details = _details_dict(event) + changes = details.get("hardware_changes") + if isinstance(changes, list) and changes: + msg = "⚠️ Изменилось железо хоста\n" + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + msg += "\n" + for item in changes: + if isinstance(item, dict): + msg += _format_inventory_change_line(item) + "\n" + if event.summary: + msg += f"\n{html_escape(event.summary)}" + return msg.rstrip() + + inv = details.get("inventory") + if not isinstance(inv, dict): + inv = {} + msg = "🖥️ Инвентаризация хоста\n" + msg += _line("🏢", "Хост", host_label(event.host)) + windows = inv.get("windows") if isinstance(inv.get("windows"), dict) else {} + os_name = windows.get("product_name") or event.host.os_version if event.host else None + if os_name: + msg += _line("💿", "ОС", html_escape(os_name)) + mem = inv.get("memory_gb") + if mem is not None: + msg += _line("🧠", "RAM", f"{html_escape(mem)} GB") + procs = inv.get("processor") + if isinstance(procs, list) and procs: + names = [str(p.get("name") or "").strip() for p in procs if isinstance(p, dict)] + names = [n for n in names if n] + if names: + msg += _line("⚙️", "CPU", html_escape("; ".join(names))) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + return msg.rstrip() + + +def format_generic_event_html(event: Event) -> str: + sev = event.severity.upper() + msg = f"🚨 SAC: {html_escape(event.title)}\n" + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("📋", "Тип", html_escape(event.type)) + msg += _line("⚡", "Severity", html_escape(sev)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + if event.summary: + msg += f"\n{html_escape(event.summary)}" + return msg.rstrip() + + +_LIFECYCLE_HEADERS: dict[str, str] = { + "started": "✅ Агент запущен", + "stopped": "⚠️ Агент остановлен", + "settings_reloaded": "🔄 Настройки агента перечитаны", +} + +_LIFECYCLE_TRIGGER_LABELS: dict[str, str] = { + "boot": "загрузка ОС / задача планировщика", + "deploy_recycle": "обновление скрипта (recycle)", + "settings_reload": "graceful restart (settings)", + "manual_recycle": "ручной recycle", + "shutdown": "остановка процесса", +} + + +def _format_lifecycle_body_html(body: str) -> str: + text = body.replace("\r\n", "\n").strip() + if not text: + return "" + parts: list[str] = [] + for i, line in enumerate(text.split("\n")): + esc = html.escape(line) + if i == 0 and line.strip(): + parts.append(f"{esc.strip()}") + else: + parts.append(esc) + return "\n".join(parts).strip() + + +def format_lifecycle_html(event: Event) -> str: + details = _details_dict(event) + notification_body = details.get("notification_body") + if isinstance(notification_body, str) and notification_body.strip(): + return _format_lifecycle_body_html(notification_body) + + lifecycle = _detail(details, "lifecycle", default="started").strip().lower() + trigger = _detail(details, "trigger", default="").strip().lower() + header = _LIFECYCLE_HEADERS.get(lifecycle, f"📋 Lifecycle: {html_escape(lifecycle)}") + + msg = f"{header}\n" + msg += _line("🏢", "Хост", host_label(event.host)) + product = "" + version = "" + payload = event.payload if isinstance(event.payload, dict) else {} + source = payload.get("source") + if isinstance(source, dict): + product = str(source.get("product") or "").strip() + version = str(source.get("product_version") or "").strip() + if product or version: + label = " ".join(x for x in (product, version) if x).strip() + if label: + msg += _line("📦", "Версия", html_escape(label)) + if trigger and trigger != "-": + trigger_human = _LIFECYCLE_TRIGGER_LABELS.get(trigger, trigger) + msg += _line("🔁", "Причина", html_escape(trigger_human)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + if event.summary: + msg += f"\n{html_escape(event.summary)}" + return msg.rstrip() + + +def _format_event_body_html(event: Event) -> str: + if event.type == "agent.lifecycle": + return format_lifecycle_html(event) + if event.type in ("report.daily.ssh", "report.daily.rdp"): + details = _details_dict(event) + platform = "windows" if event.type == "report.daily.rdp" else "ssh" + body = _detail(details, "report_body", default="") + if body and body != "-": + body = normalize_report_body(body, event.host, platform) + parts: list[str] = [] + for i, line in enumerate(body.split("\n")): + esc = html.escape(line) + if i == 0 and "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line: + parts.append(f"{esc.strip()}") + else: + parts.append(esc) + return "\n".join(parts) + report_html = details.get("report_html") + if isinstance(report_html, str) and report_html.strip(): + text = sanitize_telegram_html(report_html.strip()) + return re.sub(r"\n{3,}", "\n\n", text) + body = _detail(details, "report_body", default=event.summary) + if body != "-": + header = ( + "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" + if event.type == "report.daily.rdp" + else "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" + ) + msg = f"{html.escape(header)}\n" + msg += _line("🏢", "Сервер", host_label(event.host)) + msg += f"\n{html.escape(body)}" + return msg + return format_generic_event_html(event) + if event.type in ("rdp.login.success", "rdp.login.failed"): + return format_rdp_login_html(event) + if event.type in ("ssh.login.success", "ssh.login.failed"): + return format_ssh_auth_html(event) + if event.type == "privilege.sudo.command": + return format_sudo_html(event) + if event.type.startswith("rdg."): + return _format_rdg_html(event) + if event.type.startswith("rdp.shadow."): + return format_rdp_shadow_html(event) + if event.type.startswith("winrm."): + return format_winrm_session_html(event) + if event.type.startswith("smb."): + return format_smb_admin_share_html(event) + if event.type == "agent.inventory": + return format_agent_inventory_html(event) + return format_generic_event_html(event) + + +def format_event_telegram_html(event: Event) -> str: + return _append_event_source(_format_event_body_html(event), event) + + +_TELEGRAM_INLINE_TAGS = re.compile(r"]*)?>", re.I) +_TELEGRAM_ANCHOR_OPEN = re.compile(r"]*>", re.I) +_TELEGRAM_ANCHOR_CLOSE = re.compile(r"", re.I) + +_MOBILE_PUSH_BODY_MAX = 3500 + + +def telegram_html_to_plaintext(text: str) -> str: + """Plain text for Seaca push from the same HTML templates as Telegram.""" + out = sanitize_telegram_html(text) + out = _TELEGRAM_INLINE_TAGS.sub("", out) + out = _TELEGRAM_ANCHOR_OPEN.sub("", out) + out = _TELEGRAM_ANCHOR_CLOSE.sub("", out) + out = html.unescape(out) + out = re.sub(r"\n{3,}", "\n\n", out) + return out.strip() + + +def format_event_mobile_push(event: Event) -> tuple[str, str]: + """Title + body for FCM data payload (mirrors Telegram wording).""" + plain = telegram_html_to_plaintext(format_event_telegram_html(event)) + lines = [line.strip() for line in plain.split("\n") if line.strip()] + if lines: + title = lines[0] + body = "\n".join(lines[1:]).strip() + if not body: + body = (event.summary or "").strip() + else: + title = f"[{event.severity}] {event.title}" + body = (event.summary or "").strip() + if len(title) > 120: + title = title[:117] + "…" + if len(body) > _MOBILE_PUSH_BODY_MAX: + body = body[: _MOBILE_PUSH_BODY_MAX - 1] + "…" + return title, body + + +def _format_rdg_html(event: Event) -> str: + from app.services.rdg_display import build_rdg_display + + details = _details_dict(event) + display = build_rdg_display(event) + if event.type.endswith(".success"): + header = "✅ RDS через RD Gateway" + elif event.type.endswith(".disconnected"): + header = "ℹ️ RDS отключение (RD Gateway)" + else: + header = "❌ RDS ошибка (RD Gateway)" + msg = f"{header}\n" + if display is not None and display.access_path: + msg += _line("🔀", "Путь", html_escape(display.access_path)) + msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) + msg += _line("🚪", "Шлюз RDG", host_label(event.host)) + msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address"))) + msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-"))) + err = _detail(details, "gateway_error_code", "error_code", default="") + if err != "-": + msg += _line("⚠️", "Код ошибки", html_escape(err)) + from app.services.session_duration import extract_session_duration_sec, format_session_duration + + dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None) + if dur_sec is not None and dur_sec > 0: + msg += _line("⏱️", "Длительность", html_escape(format_session_duration(dur_sec))) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + win_id = _detail(details, "event_id_windows", default="") + if win_id != "-": + msg += f"\n🔢 Event ID: {html_escape(win_id)}" + return msg.rstrip() + + +def format_problem_telegram_html(problem: Problem, event: Event | None = None) -> str: + msg = "🔥 SAC Problem\n" + msg += _line("🏢", "Хост", host_label(problem.host)) + msg += _line("📏", "Severity", html_escape(problem.severity.upper())) + if problem.rule_id: + msg += _line("📐", "Правило", html_escape(problem.rule_id)) + msg += _line("🔢", "Событий", html_escape(problem.event_count)) + msg += _line("🕐", "Last seen", format_time(problem.last_seen_at)) + msg += f"\n{html_escape(problem.title)}\n{html_escape(problem.summary)}" + if event is not None: + msg += f"\n\nТриггер: {html_escape(event.type)} ({html_escape(event.severity)})" + if event.type in ( + "rdp.login.success", + "rdp.login.failed", + "ssh.login.success", + "ssh.login.failed", + "rdp.shadow.control.started", + "rdp.shadow.control.stopped", + "rdp.shadow.control.permission", + "winrm.session.started", + "smb.admin_share.access", + ): + msg += "\n" + _format_event_body_html(event) + elif problem.rule_id == "rule:host_silence": + msg += "\n\nТриггер: периодическая проверка SAC (host_silence scan)" + return append_notification_source_html(msg, generated_by="sac") diff --git a/backend/app/services/ui_settings.py b/backend/app/services/ui_settings.py new file mode 100644 index 0000000..f294fd9 --- /dev/null +++ b/backend/app/services/ui_settings.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +@dataclass(frozen=True) +class UiSettingsConfig: + show_sidebar_system_stats: bool + source: str = "db" + + +def get_effective_ui_settings(db: Session) -> UiSettingsConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + return UiSettingsConfig(show_sidebar_system_stats=True, source="default") + return UiSettingsConfig(show_sidebar_system_stats=bool(row.show_sidebar_system_stats), source="db") + + +def upsert_ui_settings(db: Session, *, show_sidebar_system_stats: bool) -> UiSettingsConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=show_sidebar_system_stats) + db.add(row) + else: + row.show_sidebar_system_stats = show_sidebar_system_stats + db.commit() + db.refresh(row) + return get_effective_ui_settings(db) diff --git a/backend/app/services/user_auth.py b/backend/app/services/user_auth.py new file mode 100644 index 0000000..23ebca2 --- /dev/null +++ b/backend/app/services/user_auth.py @@ -0,0 +1,96 @@ +"""SAC UI users: password hashing, authentication, bootstrap.""" + +from __future__ import annotations + +import logging + +from passlib.context import CryptContext +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User + +logger = logging.getLogger(__name__) + +_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + + +def hash_password(plain: str) -> str: + return _pwd_context.hash(plain) + + +def verify_password(plain: str, password_hash: str) -> bool: + try: + return _pwd_context.verify(plain, password_hash) + except ValueError: + return False + + +def get_user_by_username(db: Session, username: str) -> User | None: + normalized = username.strip() + if not normalized: + return None + return db.scalar( + select(User).where( + func.lower(User.username) == normalized.lower(), + User.is_active.is_(True), + ) + ) + + +def authenticate_user(db: Session, username: str, password: str) -> User | None: + user = get_user_by_username(db, username) + if user is None: + return None + if not verify_password(password, user.password_hash): + return None + return user + + +def create_user( + db: Session, + *, + username: str, + password: str, + role: str = USER_ROLE_MONITOR, +) -> User: + normalized = username.strip() + if not normalized: + raise ValueError("username is required") + if role not in USER_ROLES: + raise ValueError(f"invalid role: {role}") + if len(password) < 8: + raise ValueError("password must be at least 8 characters") + existing = db.scalar(select(User).where(func.lower(User.username) == normalized.lower())) + if existing is not None: + raise ValueError("username already exists") + user = User( + username=normalized, + password_hash=hash_password(password), + role=role, + is_active=True, + ) + db.add(user) + db.flush() + return user + + +def bootstrap_admin_user(db: Session) -> None: + """Create first admin from SAC_ADMIN_* when sac_users table is empty.""" + try: + count = db.scalar(select(func.count()).select_from(User)) or 0 + except Exception as exc: + logger.warning("SAC users bootstrap skipped (run alembic upgrade head): %s", exc) + db.rollback() + return + if count > 0: + return + settings = get_settings() + if not settings.sac_admin_password: + logger.warning("SAC users table empty and SAC_ADMIN_PASSWORD not set — UI login disabled until a user is created") + return + username = (settings.sac_admin_username or "admin").strip() or "admin" + create_user(db, username=username, password=settings.sac_admin_password, role=USER_ROLE_ADMIN) + db.commit() + logger.info("SAC bootstrap: created admin user %r from SAC_ADMIN_* env", username) diff --git a/backend/app/services/webhook_notify.py b/backend/app/services/webhook_notify.py new file mode 100644 index 0000000..c877ead --- /dev/null +++ b/backend/app/services/webhook_notify.py @@ -0,0 +1,136 @@ +"""Generic JSON webhook notifications.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from sqlalchemy.orm import Session + +from app.models import Event, Problem +from app.services.notification_severity import severity_meets_minimum +from app.services.notification_settings import WebhookConfig, get_effective_webhook_config + +logger = logging.getLogger(__name__) + + +class WebhookNotConfiguredError(Exception): + """Webhook disabled or missing URL.""" + + +class WebhookSendError(Exception): + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +def _host_payload(entity: Event | Problem) -> dict[str, Any]: + host = entity.host + if host is None: + return {"hostname": "unknown", "display_name": None} + return {"hostname": host.hostname, "display_name": host.display_name} + + +def build_event_payload(event: Event) -> dict[str, Any]: + return { + "kind": "event", + "event_id": event.event_id, + "type": event.type, + "severity": event.severity, + "category": event.category, + "title": event.title, + "summary": event.summary, + "host": _host_payload(event), + "occurred_at": event.occurred_at.isoformat() if event.occurred_at else None, + } + + +def build_problem_payload(problem: Problem, event: Event | None = None) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": "problem", + "problem_id": problem.id, + "rule_id": problem.rule_id, + "severity": problem.severity, + "status": problem.status, + "title": problem.title, + "summary": problem.summary, + "host": _host_payload(problem), + "opened_at": problem.opened_at.isoformat() if problem.opened_at else None, + } + if event is not None: + payload["trigger_event"] = { + "event_id": event.event_id, + "type": event.type, + "severity": event.severity, + } + return payload + + +def send_webhook_payload(payload: dict[str, Any], *, config: WebhookConfig | None = None, force: bool = False) -> None: + cfg = config or get_effective_webhook_config() + if not force and not cfg.active: + return + if not cfg.url: + if force: + raise WebhookNotConfiguredError("Webhook: не задан URL") + return + + headers = {"Content-Type": "application/json", "User-Agent": "SecurityAlertCenter/1.0"} + if cfg.secret_header and cfg.secret: + headers[cfg.secret_header] = cfg.secret + + try: + with httpx.Client(timeout=10.0) as client: + response = client.post(cfg.url, json=payload, headers=headers) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:200] if exc.response is not None else str(exc) + raise WebhookSendError( + f"Webhook HTTP {exc.response.status_code}: {detail}", + status_code=exc.response.status_code, + ) from exc + except Exception as exc: + logger.exception("webhook send failed") + raise WebhookSendError(str(exc)) from exc + + +def send_webhook_test_message(*, config: WebhookConfig | None = None) -> None: + cfg = config or get_effective_webhook_config() + if not cfg.enabled: + raise WebhookNotConfiguredError("Webhook отключён (enabled=false)") + if not cfg.configured: + raise WebhookNotConfiguredError("Не задан webhook URL") + send_webhook_payload( + { + "kind": "test", + "message": "SAC webhook test", + "source": "security-alert-center", + }, + config=cfg, + force=True, + ) + + +def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: + cfg = get_effective_webhook_config(db) + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity): + return + try: + send_webhook_payload(build_event_payload(event), config=cfg) + except WebhookSendError: + logger.exception("notify_event webhook failed event_id=%s", event.event_id) + + +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: + cfg = get_effective_webhook_config(db) + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity): + return + try: + send_webhook_payload(build_problem_payload(problem, event), config=cfg) + except WebhookSendError: + logger.exception("notify_problem webhook failed problem_id=%s", problem.id) diff --git a/backend/app/services/win_admin_settings.py b/backend/app/services/win_admin_settings.py new file mode 100644 index 0000000..a7d3096 --- /dev/null +++ b/backend/app/services/win_admin_settings.py @@ -0,0 +1,109 @@ +"""Effective Windows domain admin credentials (host → DB → env).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.host import Host +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +@dataclass(frozen=True) +class WinAdminConfig: + user: str + password: str + source: str # host | env | db + + @property + def configured(self) -> bool: + return bool(self.user.strip() and self.password.strip()) + + +def normalize_win_admin_user(user: str) -> str: + """DOMAIN\\user — один обратный слэш; убрать лишнее экранирование из env/UI.""" + text = user.strip() + if not text: + return "" + text = text.replace("\\\\", "\\") + if "\\" not in text and "@" not in text and "/" not in text: + # NETBIOS\user без слэша — не трогаем (оператор допишет в UI) + return text + return text + + +def _win_admin_from_env() -> WinAdminConfig: + settings = get_settings() + return WinAdminConfig( + user=normalize_win_admin_user(settings.sac_win_admin_user), + password=settings.sac_win_admin_password, + source="env", + ) + + +def get_effective_win_admin_config(db: Session | None = None) -> WinAdminConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_win_admin_config(session) + finally: + session.close() + + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _win_admin_from_env() + if row is None: + return env_cfg + + user = normalize_win_admin_user((row.win_admin_user or "").strip() or env_cfg.user) + password = (row.win_admin_password or "") or env_cfg.password + has_db_values = bool((row.win_admin_user or "").strip() or (row.win_admin_password or "").strip()) + return WinAdminConfig( + user=user, + password=password, + source="db" if has_db_values else env_cfg.source, + ) + + +def upsert_win_admin_settings( + db: Session, + *, + user: str | None = None, + password: str | None = None, +) -> WinAdminConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _win_admin_from_env() + if row is None: + row = UiSettings( + id=UI_SETTINGS_ROW_ID, + show_sidebar_system_stats=True, + win_admin_user=env_cfg.user or None, + win_admin_password=env_cfg.password or None, + ) + db.add(row) + + if user is not None and user.strip(): + row.win_admin_user = normalize_win_admin_user(user) + if password is not None and password.strip(): + row.win_admin_password = password + + db.commit() + db.refresh(row) + return get_effective_win_admin_config(db) + + +def get_effective_win_admin_for_host(db: Session, host: Host) -> WinAdminConfig: + """Prefer per-host mgmt_* when both user and password are set; else global Settings/env.""" + host_user = normalize_win_admin_user((host.mgmt_user or "").strip()) + host_password = (host.mgmt_password or "").strip() + if host_user and host_password: + return WinAdminConfig(user=host_user, password=host_password, source="host") + + global_cfg = get_effective_win_admin_config(db) + # Allow host to override only the username, still using global password (rare). + if host_user and global_cfg.password.strip(): + return WinAdminConfig(user=host_user, password=global_cfg.password, source="host") + return global_cfg diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py new file mode 100644 index 0000000..5e455f0 --- /dev/null +++ b/backend/app/services/winrm_connect.py @@ -0,0 +1,692 @@ +"""WinRM connectivity test for Windows hosts using domain admin credentials.""" + +from __future__ import annotations + +import re +import socket +from dataclasses import dataclass +from pathlib import Path + +from app.config import get_settings +from app.models import Host +from app.services.agent_git_release import _cache_dir, clone_or_update_repo +from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, register_rdp_bundle_zip + +_CLIXML_PROGRESS_NOISE = frozenset( + { + "Preparing modules for first use.", + "Preparing modules fo", + } +) +_CLIXML_MARKER = "#< CLIXML" +_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]") +RDP_REMOTE_STAGING_DIRNAME = "sac-rdp-staging" +RDP_LEGACY_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging" +RDP_BUNDLE_FILES = ( + "Login_Monitor.ps1", + "Sac-Client.ps1", + "RdpMonitor-TaskQuery.ps1", + "version.txt", + "Deploy-LoginMonitor.ps1", + "Restart-RdpLoginMonitor.ps1", + "login_monitor.settings.example.ps1", +) +RDP_BUNDLE_REQUIRED = frozenset( + { + "Login_Monitor.ps1", + "Sac-Client.ps1", + "version.txt", + "Deploy-LoginMonitor.ps1", + } +) + + +class WinAdminNotConfiguredError(Exception): + pass + + +class HostNotWindowsError(Exception): + pass + + +class HostTargetMissingError(Exception): + pass + + +@dataclass(frozen=True) +class WinRmTestResult: + ok: bool + message: str + target: str + hostname: str | None = None + + +@dataclass(frozen=True) +class WinRmCmdResult: + ok: bool + message: str + target: str + stdout: str = "" + stderr: str = "" + exit_code: int | None = None + + +def _winrm_session( + target: str, + user: str, + password: str, + *, + timeout_sec: int = 15, +): + from app.services.win_admin_settings import normalize_win_admin_user + + user = normalize_win_admin_user(user.strip()) + target = target.strip() + if not target or not user or not password: + raise WinAdminNotConfiguredError("Windows admin credentials or target missing") + + try: + import winrm + except ImportError as exc: + raise RuntimeError("pywinrm is not installed on SAC server") from exc + + operation_timeout_sec = max(5, timeout_sec) + read_timeout_sec = operation_timeout_sec + 15 + settings = get_settings() + scheme = "https" if settings.sac_winrm_use_https else "http" + port = 5986 if settings.sac_winrm_use_https else 5985 + endpoint = f"{scheme}://{target}:{port}/wsman" + cert_validation = (settings.sac_winrm_server_cert_validation or "validate").strip().lower() + if cert_validation not in {"validate", "ignore"}: + cert_validation = "validate" + session = winrm.Session( + endpoint, + auth=(user, password), + transport="ntlm", + read_timeout_sec=read_timeout_sec, + operation_timeout_sec=operation_timeout_sec, + server_cert_validation=cert_validation, + ) + return session, winrm + + +def _clixml_to_plain(text: str) -> str: + """Turn WinRM/PowerShell CLIXML blobs into readable text; drop progress noise.""" + raw = text or "" + if _CLIXML_MARKER not in raw: + return raw.strip() + + messages = re.findall(r'([^<]*)', raw, flags=re.IGNORECASE) + messages = [ + msg.strip() + for msg in messages + if msg.strip() and msg.strip() not in _CLIXML_PROGRESS_NOISE + ] + to_strings = re.findall(r"([^<]*)", raw, flags=re.IGNORECASE) + to_strings = [ + text.strip() + for text in to_strings + if text.strip() and "Preparing modules" not in text + ] + parts = [*messages, *to_strings] + if parts: + return "\n".join(parts) + + without = re.sub(r"#< CLIXML.*", "", raw, flags=re.DOTALL).strip() + return without + + +def _decode_text_score(text: str) -> int: + cyrillic = len(_CYRILLIC_RE.findall(text)) + suspicious = len(re.findall(r"\?{3,}", text)) + return cyrillic * 5 - text.count("\ufffd") * 20 - suspicious * 15 - max(0, text.count("?") - cyrillic) * 2 + + +def _decode_winrm_bytes(data: bytes) -> str: + if not data: + return "" + for encoding in ("utf-8-sig", "utf-8"): + try: + text = data.decode(encoding) + except UnicodeDecodeError: + break + score = _decode_text_score(text) + if text.isascii() or score > 0: + return text + best_score = -10_000 + best_text = "" + for encoding in ("cp866", "cp1251"): + try: + text = data.decode(encoding) + except UnicodeDecodeError: + continue + score = _decode_text_score(text) + if score > best_score: + best_score = score + best_text = text + if best_text: + return best_text + return data.decode("utf-8", errors="replace") + + +def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str: + stdout_plain = _clixml_to_plain(stdout) + stderr_plain = _clixml_to_plain(stderr) + + for text in (stderr_plain, stdout_plain): + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("ERROR:"): + return stripped[6:].strip() or stripped + + for candidate in (stderr_plain, stdout_plain): + if candidate and _CLIXML_MARKER not in candidate: + lines = [line.strip() for line in candidate.splitlines() if line.strip()] + if lines: + return lines[-1][:2000] + + if stdout_plain: + return stdout_plain[:2000] + if stderr_plain: + return stderr_plain[:2000] + return ( + f"PowerShell exit code {exit_code} " + "(CLIXML без текста ошибки — проверьте Deploy-LoginMonitor.ps1 на ПК)" + ) + + +def _sanitize_winrm_streams(stdout: str, stderr: str) -> tuple[str, str]: + return _clixml_to_plain(stdout), _clixml_to_plain(stderr) + + +def _finalize_winrm_run( + *, + target: str, + stdout: str, + stderr: str, + exit_code: int, +) -> WinRmCmdResult: + ok = exit_code == 0 + if ok: + message = f"WinRM OK ({target}), exit 0" + if stdout.strip(): + message = f"{message}\n{stdout.strip().splitlines()[0][:200]}" + else: + detail = _winrm_failure_detail(stdout, stderr, exit_code) + message = f"WinRM command failed ({target}): {detail}" + return WinRmCmdResult( + ok=ok, + message=message, + target=target, + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + +_SYSTEM32_CMDS = { + "hostname": "hostname.exe", + "qwinsta": "qwinsta.exe", + "logoff": "logoff.exe", +} + + +def _resolve_winrm_cmd(remote_cmd: str) -> str: + """WinRM cmd shell may have minimal PATH — use explicit System32 for built-ins.""" + text = remote_cmd.strip() + if not text: + return text + parts = text.split(None, 1) + name = parts[0].lower() + rest = parts[1] if len(parts) > 1 else "" + exe = _SYSTEM32_CMDS.get(name) + if exe: + suffix = f" {rest}" if rest else "" + return f"%SystemRoot%\\System32\\{exe}{suffix}" + return text + + +def run_winrm_ps( + *, + target: str, + user: str, + password: str, + script: str, + timeout_sec: int = 30, +) -> WinRmCmdResult: + target = target.strip() + try: + session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec) + result = session.run_ps(script) + except winrm.exceptions.WinRMTransportError as exc: + detail = str(exc) + hint = "" + if "credentials were rejected" in detail.lower(): + hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК." + return WinRmCmdResult( + ok=False, + message=f"WinRM transport error ({target}): {detail}{hint}", + target=target, + ) + except (socket.timeout, TimeoutError): + return WinRmCmdResult( + ok=False, + message=f"WinRM timeout ({timeout_sec}s) to {target}:5985", + target=target, + ) + except WinAdminNotConfiguredError as exc: + return WinRmCmdResult(ok=False, message=str(exc), target=target) + except RuntimeError as exc: + return WinRmCmdResult(ok=False, message=str(exc), target=target) + except Exception as exc: + return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target) + + stdout = _decode_winrm_bytes(result.std_out or b"") + stderr = _decode_winrm_bytes(result.std_err or b"") + exit_code = int(result.status_code) + stdout, stderr = _sanitize_winrm_streams(stdout, stderr) + return _finalize_winrm_run( + target=target, + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + +def run_winrm_cmd( + *, + target: str, + user: str, + password: str, + remote_cmd: str, + timeout_sec: int = 30, +) -> WinRmCmdResult: + target = target.strip() + try: + session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec) + result = session.run_cmd(_resolve_winrm_cmd(remote_cmd)) + except winrm.exceptions.WinRMTransportError as exc: + detail = str(exc) + hint = "" + if "credentials were rejected" in detail.lower(): + hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК." + return WinRmCmdResult( + ok=False, + message=f"WinRM transport error ({target}): {detail}{hint}", + target=target, + ) + except (socket.timeout, TimeoutError): + return WinRmCmdResult( + ok=False, + message=f"WinRM timeout ({timeout_sec}s) to {target}:5985", + target=target, + ) + except WinAdminNotConfiguredError as exc: + return WinRmCmdResult(ok=False, message=str(exc), target=target) + except RuntimeError as exc: + return WinRmCmdResult(ok=False, message=str(exc), target=target) + except Exception as exc: + return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target) + + stdout = _decode_winrm_bytes(result.std_out or b"") + stderr = _decode_winrm_bytes(result.std_err or b"") + exit_code = int(result.status_code) + stdout, stderr = _sanitize_winrm_streams(stdout, stderr) + return _finalize_winrm_run( + target=target, + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + +def run_winrm_qwinsta(*, target: str, user: str, password: str) -> WinRmCmdResult: + return run_winrm_cmd(target=target, user=user, password=password, remote_cmd="qwinsta", timeout_sec=45) + + +def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) -> WinRmCmdResult: + if session_id < 0: + return WinRmCmdResult(ok=False, message="Invalid session_id", target=target) + return run_winrm_cmd( + target=target, + user=user, + password=password, + remote_cmd=f"logoff {session_id} /v", + timeout_sec=45, + ) + + +def _powershell_script_prelude() -> str: + return ( + "$ProgressPreference = 'SilentlyContinue'\n" + "$InformationPreference = 'SilentlyContinue'\n" + "$utf8NoBom = New-Object System.Text.UTF8Encoding $false\n" + "[Console]::OutputEncoding = $utf8NoBom\n" + "$OutputEncoding = $utf8NoBom\n" + ) + + +def _powershell_literal_path(path: str) -> str: + return path.replace("'", "''") + + +def _wrap_powershell_body(body: str) -> str: + indented = "\n".join(f" {line}" if line else "" for line in body.splitlines()) + return ( + _powershell_script_prelude() + + "try {\n" + + indented + + "\n} catch {\n" + + " Write-Output ('ERROR: ' + $_.Exception.Message)\n" + + " exit 1\n" + + "}\n" + ) + + +def _custom_deploy_body(script_path: str) -> str: + literal = _powershell_literal_path(script_path.strip()) + return ( + f"$p = '{literal}'\n" + "if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n" + "Write-Output \"Running: $p\"\n" + "& $p" + ) + + +def _rdp_staging_setup_ps() -> str: + dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME) + legacy = _powershell_literal_path(RDP_LEGACY_STAGING) + return ( + f"$staging = Join-Path $env:TEMP '{dirname}'\n" + f"$legacyStaging = '{legacy}'\n" + "if (Test-Path -LiteralPath $legacyStaging) {\n" + " Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\n" + "}\n" + ) + + +def _prepare_staging_body() -> str: + return ( + _rdp_staging_setup_ps() + + "if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n" + + "New-Item -ItemType Directory -Path $staging -Force | Out-Null\n" + + "Write-Output \"Staging ready: $staging\"\n" + ) + + +def _deploy_from_staging_body() -> str: + return ( + _rdp_staging_setup_ps() + + "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n" + + "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n" + + "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n" + + "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n" + + "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n" + + "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n" + + "if (Test-Path -LiteralPath $logPath) {\n" + + " Write-Output ''\n" + + " Write-Output '--- deploy.log ---'\n" + + " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n" + + "}\n" + ) + + +def _bundle_download_url(token: str) -> str: + settings = get_settings() + base = (settings.sac_agent_bundle_base_url or settings.sac_public_url).rstrip("/") + return f"{base}/api/v1/agent/rdp-bundle/{token}" + + +def _download_bundle_body(bundle_url: str) -> str: + url = _powershell_literal_path(bundle_url) + return ( + _rdp_staging_setup_ps() + + "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n" + + f"$url = '{url}'\n" + + "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n" + + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n" + + "Write-Output \"Downloading bundle: $url\"\n" + + "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n" + + "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n" + + "$zipSize = (Get-Item -LiteralPath $zip).Length\n" + + "if ($zipSize -lt 100) { throw \"Bundle too small ($zipSize bytes)\" }\n" + + "Add-Type -AssemblyName System.IO.Compression.FileSystem\n" + + "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n" + + "Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue\n" + + "Write-Output \"Bundle extracted to $staging\"\n" + ) + + +def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path: + settings = get_settings() + return clone_or_update_repo( + repo_url, + git_branch, + _cache_dir(), + token=(settings.sac_agent_git_token or "").strip(), + ) + + +def run_winrm_rdp_monitor_update( + *, + target: str, + user: str, + password: str, + script_path: str = "", + repo_url: str = "", + git_branch: str = "main", +) -> WinRmCmdResult: + target = target.strip() + if script_path.strip(): + return run_winrm_ps( + target=target, + user=user, + password=password, + script=_wrap_powershell_body(_custom_deploy_body(script_path)), + timeout_sec=900, + ) + + effective_repo = ( + repo_url or "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git" + ).strip() + effective_branch = (git_branch or "main").strip() or "main" + if not effective_repo: + return WinRmCmdResult( + ok=False, + message="rdp_git_repo_url is not configured in SAC", + target=target, + ) + + try: + repo_dir = _fetch_rdp_bundle_dir(effective_repo, effective_branch) + except Exception as exc: + return WinRmCmdResult( + ok=False, + message=f"SAC failed to fetch RDP bundle from git: {exc}", + target=target, + ) + + missing = [name for name in RDP_BUNDLE_REQUIRED if not (repo_dir / name).is_file()] + if missing: + return WinRmCmdResult( + ok=False, + message=f"RDP bundle incomplete on SAC: missing {', '.join(missing)}", + target=target, + ) + + zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES) + if not zip_bytes: + return WinRmCmdResult( + ok=False, + message="RDP bundle zip is empty on SAC", + target=target, + ) + bundle_token = register_rdp_bundle_zip(zip_bytes) + bundle_url = _bundle_download_url(bundle_token) + staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}" + + prep = run_winrm_ps( + target=target, + user=user, + password=password, + script=_wrap_powershell_body(_prepare_staging_body()), + timeout_sec=120, + ) + if not prep.ok: + return prep + + download = run_winrm_ps( + target=target, + user=user, + password=password, + script=_wrap_powershell_body(_download_bundle_body(bundle_url)), + timeout_sec=300, + ) + if not download.ok: + return WinRmCmdResult( + ok=False, + message=( + f"Failed to download RDP bundle on client: {download.message} " + "(bundle URL omitted from logs)" + ), + target=target, + stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()), + stderr=download.stderr, + exit_code=download.exit_code, + ) + + deploy = run_winrm_ps( + target=target, + user=user, + password=password, + script=_wrap_powershell_body(_deploy_from_staging_body()), + timeout_sec=900, + ) + header = f"SAC served bundle from git; client staging {staging_label}" + stdout = "\n\n".join( + part.strip() + for part in [header, prep.stdout, download.stdout, deploy.stdout] + if part.strip() + ) + return WinRmCmdResult( + ok=deploy.ok, + message=deploy.message, + target=target, + stdout=stdout, + stderr=deploy.stderr, + exit_code=deploy.exit_code, + ) + + +def run_winrm_on_host_targets( + host: Host, + *, + user: str, + password: str, + action, +) -> tuple[WinRmCmdResult | None, list[str]]: + """Try WinRM targets in hostname-first order; action(target) -> WinRmCmdResult.""" + attempts: list[str] = [] + last: WinRmCmdResult | None = None + for target in iter_winrm_targets(host): + result = action(target=target) + last = result + attempts.append(f"{target}={'OK' if result.ok else 'fail'}") + if result.ok: + return result, attempts + return last, attempts + + +def resolve_windows_host_target(host: Host) -> str: + targets = iter_winrm_targets(host) + if not targets: + raise HostTargetMissingError("Host has no hostname or IPv4 for WinRM test") + return targets[0] + + +def _netbios_name_variants(name: str | None) -> list[str]: + """Короткое имя ПК (NetBIOS), как Enter-PSSession -ComputerName Andrisonova-PC.""" + text = (name or "").strip() + if not text: + return [] + short = text.split(".")[0].split("\\")[0].strip() + if not short: + return [] + if short.casefold() == text.casefold(): + return [short] + return [short, text] + + +def _looks_like_computer_name(value: str) -> bool: + text = value.strip() + if not text or " " in text: + return False + return len(text) <= 63 + + +def iter_winrm_targets(host: Host) -> list[str]: + """WinRM + NTLM: сначала имя хоста (как Enter-PSSession -ComputerName), IP — последним.""" + if not is_windows_host(host): + raise HostNotWindowsError("Host is not Windows") + + seen: set[str] = set() + ordered: list[str] = [] + + def add(value: str | None) -> None: + text = (value or "").strip() + if not text or text.casefold() in seen: + return + seen.add(text.casefold()) + ordered.append(text) + + for variant in _netbios_name_variants(host.hostname): + add(variant) + + inventory = host.inventory if isinstance(host.inventory, dict) else {} + computer_name = inventory.get("computer_name") + if isinstance(computer_name, str): + for variant in _netbios_name_variants(computer_name): + add(variant) + + if host.display_name and _looks_like_computer_name(host.display_name): + for variant in _netbios_name_variants(host.display_name): + add(variant) + + add(host.ipv4) + return ordered + + +def is_windows_host(host: Host) -> bool: + if (host.os_family or "").strip().lower() == "windows": + return True + return (host.product or "").strip() == "rdp-login-monitor" + + +def test_winrm_connection( + *, + target: str, + user: str, + password: str, + timeout_sec: int = 15, +) -> WinRmTestResult: + result = run_winrm_cmd( + target=target, + user=user, + password=password, + remote_cmd="hostname", + timeout_sec=timeout_sec, + ) + if result.ok and result.stdout.strip(): + host = result.stdout.strip().splitlines()[0] + return WinRmTestResult( + ok=True, + message=f"WinRM OK, hostname={host}", + target=result.target, + hostname=host, + ) + return WinRmTestResult( + ok=result.ok, + message=result.message, + target=result.target, + hostname=None, + ) diff --git a/backend/app/utils/sql_like.py b/backend/app/utils/sql_like.py new file mode 100644 index 0000000..6e22dbd --- /dev/null +++ b/backend/app/utils/sql_like.py @@ -0,0 +1,8 @@ +"""Escape user input for SQL ILIKE patterns.""" + + +def escape_ilike_pattern(value: str) -> str: + text = (value or "").strip() + if not text: + return text + return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/backend/app/version.py b/backend/app/version.py new file mode 100644 index 0000000..79933b3 --- /dev/null +++ b/backend/app/version.py @@ -0,0 +1,5 @@ +"""Единый источник версии SAC (API, health, логи, OpenAPI).""" + +APP_NAME = "Security Alert Center" +APP_VERSION = "0.5.17" +APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..30dfde0 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,21 @@ +fastapi>=0.115.0,<1 +uvicorn[standard]>=0.32.0,<1 +sqlalchemy>=2.0.36,<3 +alembic>=1.14.0,<2 +psycopg2-binary>=2.9.10,<3 +pydantic>=2.10.0,<3 +pydantic-settings>=2.6.0,<3 +python-multipart>=0.0.17,<1 +jsonschema>=4.23.0,<5 +passlib[bcrypt]>=1.7.4,<2 +python-jose[cryptography]>=3.3.0,<4 +httpx>=0.28.0,<1 +google-auth>=2.36.0,<3 +requests>=2.32.0,<3 +psutil>=6.1.0,<8 +pywinrm>=0.5.0,<1 +paramiko>=3.5.0,<4 + +# dev +pytest>=8.3.0,<9 +pytest-asyncio>=0.24.0,<1 diff --git a/backend/scripts/sac_manage_user.py b/backend/scripts/sac_manage_user.py new file mode 100644 index 0000000..1f1ad99 --- /dev/null +++ b/backend/scripts/sac_manage_user.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Manage SAC UI users (create admin/monitor accounts). + +Examples (from backend/ on prod server): + + python scripts/sac_manage_user.py create ivanov 'SecretPass123' --role monitor + python scripts/sac_manage_user.py list +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from sqlalchemy import select + +from app.database import SessionLocal +from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, User +from app.services.user_auth import create_user + + +def cmd_list() -> int: + db = SessionLocal() + try: + rows = db.scalars(select(User).order_by(User.username)).all() + if not rows: + print("No users.") + return 0 + for u in rows: + status = "active" if u.is_active else "inactive" + print(f"{u.id}\t{u.username}\t{u.role}\t{status}\t{u.created_at.isoformat()}") + return 0 + finally: + db.close() + + +def cmd_create(username: str, password: str, role: str) -> int: + db = SessionLocal() + try: + user = create_user(db, username=username, password=password, role=role) + db.commit() + print(f"Created user id={user.id} username={user.username!r} role={user.role}") + return 0 + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + finally: + db.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description="SAC UI user management") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("list", help="List UI users") + + create_p = sub.add_parser("create", help="Create a user") + create_p.add_argument("username") + create_p.add_argument("password") + create_p.add_argument( + "--role", + choices=[USER_ROLE_ADMIN, USER_ROLE_MONITOR], + default=USER_ROLE_MONITOR, + ) + + args = parser.parse_args() + if args.command == "list": + return cmd_list() + if args.command == "create": + return cmd_create(args.username, args.password, args.role) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9387650 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,159 @@ +"""SQLite in-memory fixtures for API tests.""" + +import os + +# Must be set before app.database imports create_engine +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SAC_BOOTSTRAP_API_KEY", "sac_test_key_for_pytest_only") +os.environ.setdefault("SAC_SECURITY_ENFORCE", "false") +os.environ.setdefault("JWT_SECRET", "pytest-jwt-secret-not-for-production-use") +os.environ.setdefault("SAC_SSH_AUTO_ADD_HOST_KEY", "true") +os.environ.setdefault("SAC_HOST_SILENCE_SCAN_ENABLED", "false") + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import JSON, create_engine, event +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.auth.api_key import hash_api_key +from app.auth.jwt_auth import create_access_token +from app.database import Base, get_db +from app.main import app as fastapi_app +import app.models # noqa: F401 — register all tables on Base.metadata +from app.models import ApiKey, User +from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR +from app.services.user_auth import hash_password + +TEST_API_KEY = os.environ["SAC_BOOTSTRAP_API_KEY"] + + +@event.listens_for(Base.metadata, "before_create") +def _sqlite_jsonb_as_json(metadata, connection, **_kwargs) -> None: + if connection.dialect.name != "sqlite": + return + for table in metadata.tables.values(): + for column in table.columns: + if isinstance(column.type, JSONB): + column.type = JSON() + + +@pytest.fixture +def db_engine(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture +def db_session(db_engine): + Session = sessionmaker(bind=db_engine, autocommit=False, autoflush=False) + session = Session() + session.add( + ApiKey( + name="test", + key_prefix=TEST_API_KEY[:12], + key_hash=hash_api_key(TEST_API_KEY), + is_active=True, + ) + ) + session.add( + User( + username="test-admin", + password_hash=hash_password("test-admin-password"), + role=USER_ROLE_ADMIN, + is_active=True, + ) + ) + session.add( + User( + username="test-monitor", + password_hash=hash_password("test-monitor-password"), + role=USER_ROLE_MONITOR, + is_active=True, + ) + ) + session.commit() + yield session + session.close() + + +@pytest.fixture(autouse=True) +def mock_agent_git_release(monkeypatch): + """API tests must not clone real git repos.""" + from app.services.agent_git_release import GitReleaseVersions + + empty = GitReleaseVersions(versions={}, fetched_at=None, from_cache=True) + monkeypatch.setattr( + "app.services.agent_git_release.get_git_release_versions", + lambda cfg, **kwargs: empty, + ) + monkeypatch.setattr( + "app.api.v1.hosts.get_git_release_versions", + lambda cfg, **kwargs: empty, + ) + monkeypatch.setattr( + "app.api.v1.settings.get_git_release_versions", + lambda cfg, **kwargs: empty, + ) + monkeypatch.setattr( + "app.services.agent_update.get_git_release_versions", + lambda cfg, **kwargs: empty, + ) + + +@pytest.fixture(autouse=True) +def reset_remote_action_state(): + from app.services import host_remote_actions + + with host_remote_actions._lock: + host_remote_actions._running.clear() + yield + with host_remote_actions._lock: + host_remote_actions._running.clear() + + +@pytest.fixture +def client(db_session, db_engine, monkeypatch): + monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1") + monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None) + monkeypatch.setattr("app.main.bootstrap_users", lambda: None) + monkeypatch.setattr("app.main.bootstrap_stale_remote_actions", lambda: None) + + test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False) + monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local) + + def override_get_db(): + session = test_session_local() + try: + yield session + finally: + session.close() + + fastapi_app.dependency_overrides[get_db] = override_get_db + with TestClient(fastapi_app) as c: + yield c + fastapi_app.dependency_overrides.clear() + + +@pytest.fixture +def auth_headers(): + return {"Authorization": f"Bearer {TEST_API_KEY}"} + + +@pytest.fixture +def jwt_headers(): + token = create_access_token("test-admin", USER_ROLE_ADMIN) + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def jwt_monitor_headers(): + token = create_access_token("test-monitor", USER_ROLE_MONITOR) + return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/test_agent_git_release.py b/backend/tests/test_agent_git_release.py new file mode 100644 index 0000000..4310be8 --- /dev/null +++ b/backend/tests/test_agent_git_release.py @@ -0,0 +1,86 @@ +"""Tests for git-based agent release version resolution.""" + +from pathlib import Path + +from app.services.agent_git_release import ( + normalize_git_repo_url, + parse_product_version_from_dir, + parse_rdp_version_from_files, + parse_ssh_version_from_files, + recommended_from_git, +) +from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig +from app.services.agent_version import reference_agent_versions_by_product + + +def test_normalize_git_repo_url(): + assert normalize_git_repo_url("git.papatramp.ru/PapaTramp/ssh-monitor").endswith( + "ssh-monitor.git" + ) + assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git" + + +def test_parse_rdp_version_from_files(): + assert parse_rdp_version_from_files("2.1.2-SAC\n", None) == "2.1.2-SAC" + assert ( + parse_rdp_version_from_files(None, '$ScriptVersion = "2.0.35-SAC"') + == "2.0.35-SAC" + ) + + +def test_parse_ssh_version_from_files(): + assert parse_ssh_version_from_files('SSH_MONITOR_VERSION="2.1.0-SAC"\n', None) == "2.1.0-SAC" + assert parse_ssh_version_from_files(None, "2.0.6-SAC") == "2.0.6-SAC" + + +def test_parse_product_version_from_dir(tmp_path: Path): + (tmp_path / "version.txt").write_text("2.1.2-SAC\n", encoding="utf-8") + assert parse_product_version_from_dir(tmp_path, PRODUCT_RDP) == "2.1.2-SAC" + + ssh_dir = tmp_path / "ssh" + ssh_dir.mkdir() + (ssh_dir / "ssh-monitor").write_text('SSH_MONITOR_VERSION="2.1.0-SAC"\n', encoding="utf-8") + assert parse_product_version_from_dir(ssh_dir, PRODUCT_SSH) == "2.1.0-SAC" + + +def test_recommended_from_git_prefers_git(): + cfg = AgentUpdateConfig( + mode="sac", + fallback_enabled=True, + fallback_after_minutes=15, + recommended_rdp_version="2.0.0-SAC", + recommended_ssh_version="", + min_rdp_version="", + min_ssh_version="", + win_agent_update_script="", + rdp_git_repo_url="https://git.example/rdp.git", + ssh_git_repo_url="https://git.example/ssh.git", + git_branch="main", + source="env", + ) + git_versions = {PRODUCT_SSH: "2.1.0-SAC"} + assert recommended_from_git(cfg, git_versions, PRODUCT_SSH) == "2.1.0-SAC" + assert recommended_from_git(cfg, git_versions, PRODUCT_RDP) == "2.0.0-SAC" + + +def test_reference_agent_versions_uses_git(): + cfg = AgentUpdateConfig( + mode="gpo", + fallback_enabled=True, + fallback_after_minutes=15, + recommended_rdp_version="", + recommended_ssh_version="", + min_rdp_version="", + min_ssh_version="", + win_agent_update_script="", + rdp_git_repo_url="", + ssh_git_repo_url="", + git_branch="main", + source="env", + ) + refs = reference_agent_versions_by_product( + cfg, + {"ssh-monitor": "2.0.6-SAC"}, + git_versions={"ssh-monitor": "2.1.0-SAC"}, + ) + assert refs["ssh-monitor"] == "2.1.0-SAC" diff --git a/backend/tests/test_agent_update.py b/backend/tests/test_agent_update.py new file mode 100644 index 0000000..b14e844 --- /dev/null +++ b/backend/tests/test_agent_update.py @@ -0,0 +1,292 @@ +"""Tests for agent update settings, poll, request and ingest lifecycle.""" + +import time +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from app.models import Host +from app.services.agent_poll import build_agent_poll_payload +from app.services.agent_update import ( + AGENT_UPDATE_SUCCESS, + AgentUpdateNotAllowedError, + process_agent_update_ingest, + request_agent_update, +) +from app.services.agent_update_settings import upsert_agent_update_settings +from app.services.ingest import ingest_event +from tests.test_ingest import VALID_EVENT + + +def wait_remote_job(client, host_id, headers, timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + response = client.get( + f"/api/v1/hosts/{host_id}/actions/remote-job", + headers=headers, + ) + assert response.status_code == 200 + body = response.json() + if not body.get("active") and body.get("status") != "running": + return body + time.sleep(0.05) + raise AssertionError("remote job did not finish in time") + + +def test_agent_update_settings_sac_mode(db_session): + cfg = upsert_agent_update_settings( + db_session, + mode="sac", + recommended_ssh_version="2.1.0-SAC", + fallback_after_minutes=10, + ) + assert cfg.mode == "sac" + assert cfg.recommended_ssh_version == "2.1.0-SAC" + assert cfg.fallback_after_minutes == 10 + + +def test_request_agent_update_sets_pending(db_session): + upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC") + host = Host( + hostname="linux-1", + os_family="linux", + product="ssh-monitor", + product_version="2.0.0-SAC", + agent_instance_id="agent-uuid-1", + ) + db_session.add(host) + db_session.commit() + + request_agent_update(db_session, host) + assert host.pending_agent_update is True + assert host.pending_update_target_version == "2.1.0-SAC" + assert host.agent_update_state == "requested" + + +def test_request_agent_update_blocked_in_gpo_mode(db_session): + upsert_agent_update_settings(db_session, mode="gpo") + host = Host( + hostname="linux-2", + os_family="linux", + product="ssh-monitor", + agent_instance_id="agent-uuid-2", + ) + db_session.add(host) + db_session.commit() + + try: + request_agent_update(db_session, host) + raise AssertionError("expected AgentUpdateNotAllowedError") + except AgentUpdateNotAllowedError: + pass + + +def test_poll_payload_includes_update_and_config(db_session): + upsert_agent_update_settings(db_session, mode="sac") + host = Host( + hostname="win-1", + os_family="windows", + product="rdp-login-monitor", + agent_instance_id="agent-uuid-3", + pending_agent_update=True, + pending_update_target_version="2.1.0-SAC", + agent_config={"ServerDisplayName": "Test PC"}, + agent_config_revision=3, + ) + db_session.add(host) + db_session.commit() + + payload = build_agent_poll_payload(db_session, host) + assert payload["config_revision"] == 3 + assert payload["config"]["settings"]["ServerDisplayName"] == "Test PC" + assert payload["update"]["requested"] is True + assert payload["update"]["target_version"] == "2.1.0-SAC" + assert payload["update"]["source"] == "sac" + + +def test_ingest_agent_update_success_clears_pending(db_session): + host = Host( + hostname="linux-3", + os_family="linux", + product="ssh-monitor", + agent_instance_id="agent-uuid-4", + pending_agent_update=True, + pending_update_target_version="2.1.0-SAC", + ) + db_session.add(host) + db_session.commit() + + process_agent_update_ingest( + db_session, + host, + AGENT_UPDATE_SUCCESS, + {"product_version": "2.1.0-SAC"}, + ) + assert host.pending_agent_update is False + assert host.product_version == "2.1.0-SAC" + assert host.agent_update_state == "success" + + +def test_api_request_agent_update(jwt_headers, client, db_session): + upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC") + host = Host( + hostname="api-linux", + os_family="linux", + product="ssh-monitor", + agent_instance_id="agent-api-1", + ) + db_session.add(host) + db_session.commit() + + response = client.post(f"/api/v1/hosts/{host.id}/actions/request-agent-update", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["pending_agent_update"] is True + assert body["target_version"] == "2.1.0-SAC" + + +def test_api_agent_poll_extended(auth_headers, client, db_session): + upsert_agent_update_settings(db_session, mode="sac") + host = Host( + hostname="poll-host", + os_family="linux", + product="ssh-monitor", + agent_instance_id="poll-agent-id", + pending_agent_update=True, + pending_update_target_version="2.1.0-SAC", + ) + db_session.add(host) + db_session.commit() + + response = client.get( + "/api/v1/agent/commands", + params={"agent_instance_id": "poll-agent-id"}, + headers=auth_headers, + ) + assert response.status_code == 200 + body = response.json() + assert body["update"]["requested"] is True + assert body["commands"] == [] + + +def test_api_patch_agent_config(jwt_headers, client, db_session): + host = Host( + hostname="cfg-host", + os_family="windows", + product="rdp-login-monitor", + ) + db_session.add(host) + db_session.commit() + + response = client.patch( + f"/api/v1/hosts/{host.id}/agent-config", + json={"settings": {"ServerDisplayName": "UNMS Kalina", "GetInventory": True}}, + headers=jwt_headers, + ) + assert response.status_code == 200 + body = response.json() + assert body["config_revision"] == 1 + assert body["settings"]["ServerDisplayName"] == "UNMS Kalina" + + +def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + host = Host( + hostname="fb-linux", + os_family="linux", + product="ssh-monitor", + ipv4="10.10.36.9", + ) + db_session.add(host) + db_session.commit() + + with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update: + from app.services.ssh_connect import SshCommandResult + + mock_update.return_value = SshCommandResult( + ok=True, + message="updated", + target="fb-linux", + agent_version="2.1.0-SAC", + ) + response = client.post( + f"/api/v1/hosts/{host.id}/actions/agent-update-fallback", + headers=jwt_headers, + ) + + assert response.status_code == 202 + assert response.json()["status"] == "running" + job = wait_remote_job(client, host.id, jwt_headers) + assert job["ok"] is True + assert job["product_version"] == "2.1.0-SAC" + + +def test_clear_stale_running_remote_actions(db_session): + from app.services.host_remote_actions import clear_stale_running_remote_actions + + host = Host( + hostname="stale-win", + os_family="windows", + product="rdp-login-monitor", + agent_update_state="running", + remote_action={"status": "running", "message": "Подключение…"}, + ) + db_session.add(host) + db_session.commit() + + cleared = clear_stale_running_remote_actions(db_session, reason="test reset") + assert cleared == ["stale-win"] + db_session.refresh(host) + assert host.agent_update_state == "failed" + assert host.remote_action["status"] == "failed" + assert host.remote_action["ok"] is False + + +def test_get_remote_action_status_ignores_stale_running_payload(db_session): + from app.services.host_remote_actions import get_remote_action_status + + host = Host( + hostname="done-linux", + os_family="linux", + product="ssh-monitor", + agent_update_state="success", + remote_action={ + "status": "running", + "message": "Выполняется обновление… (лог с хоста)", + "output": "=== Script update completed successfully ===", + "ok": True, + "finished_at": "2026-07-08T02:21:11+00:00", + }, + ) + db_session.add(host) + db_session.commit() + + status = get_remote_action_status(host) + assert status["active"] is False + assert status["agent_update_state"] == "success" + + +def test_cancel_host_remote_job_api(jwt_headers, client, db_session): + host = Host( + hostname="cancel-me", + os_family="windows", + product="rdp-login-monitor", + agent_update_state="running", + remote_action={"status": "running", "title": "Обновление через WinRM"}, + ) + db_session.add(host) + db_session.commit() + + response = client.post( + f"/api/v1/hosts/{host.id}/actions/remote-job/cancel", + headers=jwt_headers, + ) + assert response.status_code == 200 + body = response.json() + assert body["active"] is False + assert body["status"] == "failed" diff --git a/backend/tests/test_agent_version.py b/backend/tests/test_agent_version.py new file mode 100644 index 0000000..f1f7de4 --- /dev/null +++ b/backend/tests/test_agent_version.py @@ -0,0 +1,129 @@ +"""Agent version parse/compare for outdated highlighting.""" + +from app.services.agent_version import ( + effective_reference_version, + host_version_outdated, + is_agent_version_outdated, + latest_agent_versions_by_product, + parse_agent_version, +) +from app.models import Host +from datetime import datetime, timezone + + +def test_parse_agent_version(): + assert parse_agent_version("2.0.25-SAC") == parse_agent_version("2.0.25") + assert parse_agent_version("bad") is None + assert parse_agent_version(None) is None + + +def test_outdated_same_minor_patch_lag(): + latest = "2.0.25-SAC" + assert is_agent_version_outdated("2.0.25-SAC", latest) is False + assert is_agent_version_outdated("2.0.24-SAC", latest) is False + assert is_agent_version_outdated("2.0.23-SAC", latest) is False + assert is_agent_version_outdated("2.0.22-SAC", latest) is True + assert is_agent_version_outdated("2.0.18-SAC", latest) is True + assert is_agent_version_outdated("2.0.20-SAC", latest) is True + + +def test_outdated_different_minor_or_major(): + latest = "2.0.1-SAC" + assert is_agent_version_outdated("1.2.5-SAC", latest) is True + assert is_agent_version_outdated("2.1.0-SAC", latest) is False + + +def test_effective_reference_version_picks_max(): + assert ( + effective_reference_version( + recommended="2.1.0-SAC", + fleet_latest="2.0.6-SAC", + min_version="", + ) + == "2.1.0-SAC" + ) + assert ( + effective_reference_version( + recommended="", + fleet_latest="2.0.6-SAC", + min_version="2.1.0-SAC", + ) + == "2.1.0-SAC" + ) + assert ( + effective_reference_version( + recommended="", + fleet_latest="2.0.35-SAC", + min_version="", + ) + == "2.0.35-SAC" + ) + + +def test_host_version_outdated_ssh_vs_git(): + assert ( + host_version_outdated( + "2.0.6-SAC", + recommended="2.1.0-SAC", + fleet_latest="2.0.6-SAC", + ) + is True + ) + assert ( + host_version_outdated( + "2.0.6-SAC", + recommended="", + fleet_latest="2.0.6-SAC", + ) + is False + ) + assert ( + host_version_outdated( + "2.0.6-SAC", + recommended="", + fleet_latest="2.0.6-SAC", + min_version="2.1.0-SAC", + ) + is True + ) + + +def test_latest_agent_versions_by_product(db_session): + now = datetime.now(timezone.utc) + db_session.add_all( + [ + Host( + hostname="linux-a", + os_family="linux", + product="ssh-monitor", + product_version="1.2.5-SAC", + last_seen_at=now, + ), + Host( + hostname="linux-b", + os_family="linux", + product="ssh-monitor", + product_version="2.0.1-SAC", + last_seen_at=now, + ), + Host( + hostname="win-a", + os_family="windows", + product="rdp-login-monitor", + product_version="2.0.18-SAC", + last_seen_at=now, + ), + Host( + hostname="win-b", + os_family="windows", + product="rdp-login-monitor", + product_version="2.0.25-SAC", + last_seen_at=now, + ), + ] + ) + db_session.commit() + + latest = latest_agent_versions_by_product(db_session) + assert latest["ssh-monitor"] == "2.0.1-SAC" + assert latest["rdp-login-monitor"] == "2.0.25-SAC" diff --git a/backend/tests/test_client_ip.py b/backend/tests/test_client_ip.py new file mode 100644 index 0000000..718eb1e --- /dev/null +++ b/backend/tests/test_client_ip.py @@ -0,0 +1,26 @@ +"""Tests for client IP resolution behind reverse proxy.""" + +from types import SimpleNamespace + +from app.services.client_ip import client_ip_from_request + + +def _request(*, client_host: str = "10.0.0.5", xff: str | None = None): + headers = {} + if xff is not None: + headers["x-forwarded-for"] = xff + return SimpleNamespace(client=SimpleNamespace(host=client_host), headers=headers) + + +def test_client_ip_without_forwarded_header(): + assert client_ip_from_request(_request(client_host="203.0.113.10")) == "203.0.113.10" + + +def test_client_ip_uses_last_forwarded_hop(): + req = _request(client_host="127.0.0.1", xff="203.0.113.99, 198.51.100.20") + assert client_ip_from_request(req) == "198.51.100.20" + + +def test_client_ip_ignores_spoofed_first_hop(): + req = _request(client_host="127.0.0.1", xff="1.2.3.4, 203.0.113.50") + assert client_ip_from_request(req) == "203.0.113.50" diff --git a/backend/tests/test_daily_report.py b/backend/tests/test_daily_report.py new file mode 100644 index 0000000..bc5c45c --- /dev/null +++ b/backend/tests/test_daily_report.py @@ -0,0 +1,258 @@ +"""SAC daily report aggregation (F-NOT-05 / notif-32).""" + +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from sqlalchemy import select + +from app.config import Settings +from app.models import Event, Host +from app.services import notify_dispatch +from app.services.daily_report import ( + _aggregate_ssh, + generate_daily_report_for_host, + host_has_report_today, + run_daily_reports, +) +from app.services.notification_policy import NotificationPolicyConfig +from app.services.telegram_templates import format_event_telegram_html, sanitize_telegram_html + + +def _ssh_host(db) -> Host: + h = Host( + hostname="pilot-ssh", + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db.add(h) + db.flush() + return h + + +def _cfg(**overrides) -> Settings: + base = { + "sac_daily_report_enabled": True, + "sac_daily_report_hour": 9, + "sac_daily_report_timezone": "UTC", + "sac_daily_report_skip_if_agent_sent": False, + "sac_daily_report_require_activity": False, + } + base.update(overrides) + return Settings(**base) + + +def test_aggregate_ssh_counts_types(db_session): + now = datetime.now(timezone.utc) + events = [ + Event( + event_id=str(uuid.uuid4()), + host_id=1, + occurred_at=now, + category="auth", + type="ssh.login.success", + severity="info", + title="ok", + summary="", + payload={}, + ), + Event( + event_id=str(uuid.uuid4()), + host_id=1, + occurred_at=now, + category="auth", + type="ssh.login.failed", + severity="warning", + title="fail", + summary="", + details={"source_ip": "10.0.0.1"}, + payload={}, + ), + Event( + event_id=str(uuid.uuid4()), + host_id=1, + occurred_at=now, + category="privilege", + type="privilege.sudo.command", + severity="warning", + title="sudo", + summary="", + payload={}, + ), + ] + stats = _aggregate_ssh(events) + assert stats["successful_ssh"] == 1 + assert stats["failed_ssh"] == 1 + assert stats["sudo_commands"] == 1 + assert "10.0.0.1" in stats["top_failed_ips"][0] + + +def test_skip_when_agent_report_today(db_session): + h = _ssh_host(db_session) + cfg = _cfg(sac_daily_report_skip_if_agent_sent=True) + now = datetime.now(timezone.utc) + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=h.id, + occurred_at=now, + received_at=now, + category="report", + type="report.daily.ssh", + severity="info", + title="agent", + summary="from agent", + details={"generated_by": "agent"}, + payload={}, + ) + ) + db_session.commit() + assert host_has_report_today(db_session, h.id, "report.daily.ssh", cfg) is True + res = generate_daily_report_for_host(db_session, h, cfg) + assert res is not None + assert res.created is False + assert res.skipped_reason == "agent_report_exists_today" + + +def test_generate_creates_report_and_notifies(db_session): + h = _ssh_host(db_session) + now = datetime.now(timezone.utc) + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=h.id, + occurred_at=now - timedelta(hours=1), + category="auth", + type="ssh.login.failed", + severity="warning", + title="fail", + summary="x", + details={"source_ip": "1.2.3.4"}, + payload={}, + ) + ) + db_session.commit() + cfg = _cfg() + with patch("app.services.daily_report.notify_daily_report") as mock_notify: + res = generate_daily_report_for_host(db_session, h, cfg) + assert res is not None + assert res.created is True + assert res.report_type == "report.daily.ssh" + mock_notify.assert_called_once() + ev = db_session.scalar(select(Event).where(Event.type == "report.daily.ssh")) + assert ev is not None + assert ev.details.get("generated_by") == "sac" + + +def test_generate_rdp_report_unified_format(db_session): + h = Host( + hostname="win-srv", + display_name="RDCB", + ipv4="10.0.0.5", + os_family="windows", + product="rdp-login-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.flush() + now = datetime.now(timezone.utc) + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=h.id, + occurred_at=now, + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="", + details={"user": "admin"}, + payload={}, + ) + ) + db_session.commit() + cfg = _cfg() + with patch("app.services.daily_report.notify_daily_report"): + res = generate_daily_report_for_host(db_session, h, cfg) + assert res and res.created + ev = db_session.scalar(select(Event).where(Event.type == "report.daily.rdp")) + body = ev.details.get("report_body", "") + assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body + assert "RDCB (10.0.0.5)" in body + assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ" in body + + +def test_run_daily_reports_respects_hour(db_session): + h = _ssh_host(db_session) + db_session.commit() + cfg = _cfg(sac_daily_report_hour=23) + early = datetime(2026, 5, 29, 8, 0, tzinfo=timezone.utc) + with patch("app.services.daily_report._now_in_tz", return_value=early): + out = run_daily_reports(db_session, cfg, force=False) + assert out == [] + + +def test_run_daily_reports_force_bypasses_hour(db_session): + _ssh_host(db_session) + db_session.commit() + cfg = _cfg(sac_daily_report_hour=23) + early = datetime(2026, 5, 29, 8, 0, tzinfo=timezone.utc) + with patch("app.services.daily_report._now_in_tz", return_value=early): + with patch("app.services.daily_report.generate_daily_report_for_host") as mock_gen: + mock_gen.return_value = None + run_daily_reports(db_session, cfg, force=True) + mock_gen.assert_called() + + +def test_notify_daily_report_bypasses_min_severity(): + event = Event( + event_id="00000000-0000-4000-8000-000000000601", + host_id=1, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="report", + type="report.daily.ssh", + severity="info", + title="report", + summary="s", + dedup_key="sac|1|report.daily.ssh|2026-05-29", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_daily_report(event) + mock_tg.notify_event.assert_called_once() + + +def test_daily_report_telegram_html_uses_report_html(db_session): + h = Host(hostname="h1", display_name="H1", os_family="linux") + event = Event( + event_id=str(uuid.uuid4()), + host_id=1, + host=h, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="report", + type="report.daily.ssh", + severity="info", + title="Отчёт", + summary="short", + details={"report_html": "📊 OK
line"}, + payload={}, + ) + assert format_event_telegram_html(event) == ( + "📊 OK\nline\n📡 Оповещение: SAC (Security Alert Center)" + ) + + +def test_sanitize_telegram_html_strips_div_and_br(): + raw = '
a
b
' + assert sanitize_telegram_html(raw) == "a\nb" diff --git a/backend/tests/test_daily_report_format.py b/backend/tests/test_daily_report_format.py new file mode 100644 index 0000000..81edb95 --- /dev/null +++ b/backend/tests/test_daily_report_format.py @@ -0,0 +1,192 @@ +"""Unified daily report text format.""" + +from datetime import datetime, timezone + +from app.models import Host +from app.services.daily_report_format import ( + append_notification_source_plain, + build_report_body, + enrich_stats_for_storage, + format_notification_source_plain, + normalize_active_users_list, + normalize_report_body, +) + + +def test_build_report_body_ssh_matches_agent_layout(): + host = Host( + hostname="haproxy", + display_name="HaProxy Kalina", + ipv4="192.168.160.117", + os_family="linux", + product="ssh-monitor", + ) + when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc) + stats = enrich_stats_for_storage( + "ssh", + { + "successful_ssh": 0, + "failed_ssh": 0, + "sudo_commands": 5, + "active_bans": 0, + "top_failed_ips": [], + "active_users": [ + "👤 papatramp | pts/0 | с 2026-05-27 12:28 | 🌐 192.168.160.3", + ], + }, + ) + body = build_report_body("ssh", host, stats, when, sac_generated=True) + assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" in body + assert "Agent version sac" in body + assert "🖥️ Сервер: HaProxy Kalina (192.168.160.117)" in body + assert " 📈 СТАТИСТИКА" in body + assert "Команд через sudo: 5" in body + assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1)" in body + assert "papatramp" in body + assert "\n\n\n" not in body + + +def test_build_report_body_windows_layout(): + host = Host( + hostname="WIN01", + display_name="K6A-DC3", + ipv4="10.0.0.10", + os_family="windows", + product="rdp-login-monitor", + ) + when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc) + stats = enrich_stats_for_storage( + "windows", + { + "rdp_success": 1, + "rdp_failed": 2, + "active_bans": 0, + "top_failed_ips": ["1.2.3.4 — 2"], + "active_users": ["👤 DOMAIN\\user1", "👤 user2"], + }, + ) + body = build_report_body("windows", host, stats, when, sac_generated=True) + assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body + assert "Agent version sac" in body + assert "K6A-DC3 (10.0.0.10)" in body + assert "Успешных RDP подключений: 1" in body + assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body + assert "user2" in body + lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")] + assert len(lines) == 2 + + +def test_build_report_body_includes_notification_source(): + host = Host(hostname="h1", display_name="H1", os_family="linux", product="ssh-monitor") + when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc) + stats = enrich_stats_for_storage("ssh", {"successful_ssh": 0, "failed_ssh": 0, "sudo_commands": 0, "active_bans": 0}) + sac_body = build_report_body("ssh", host, stats, when, sac_generated=True) + assert "📡 Оповещение: SAC (Security Alert Center)" in sac_body + agent_body = build_report_body( + "ssh", + host, + {**stats, "agent_version": "1.2.11-SAC"}, + when, + sac_generated=False, + ) + assert "📡 Оповещение: агент (ssh-monitor 1.2.11-SAC)" in agent_body + + +def test_append_notification_source_plain_dedupes(): + line = format_notification_source_plain(generated_by="sac") + body = append_notification_source_plain(f"title\n\n{line}", generated_by="sac") + assert body.count("📡 Оповещение:") == 1 + + +def test_normalize_active_users_list_splits_combined_line(): + users = normalize_active_users_list(["👤 k.khodasevich 👤 papatramp"]) + assert len(users) == 2 + assert users[0].strip().startswith("👤 k.khodasevich") + assert users[1].strip().startswith("👤 papatramp") + + +def test_normalize_report_body_adds_server_and_collapses_blanks(): + host = Host( + hostname="srv", + display_name="Unimus Kalina", + ipv4="192.168.160.17", + product_version="1.2.10-SAC", + ) + raw = "\n".join( + [ + "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА", + "", + "", + "🕐 Время отчета: 30.05.2026 09:00:00", + "", + "", + " 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", + " ✅ Успешных SSH подключений: 0", + "", + " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1):", + " 👤 u1 👤 u2", + ] + ) + body = normalize_report_body(raw, host, "ssh") + assert "Agent version 1.2.10-SAC" in body + assert "🖥️ Сервер: Unimus Kalina (192.168.160.17)" in body + assert "\n\n\n" not in body + user_lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")] + assert len(user_lines) == 2 + assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body + + +def test_normalize_ssh_active_users_mismatch_count_and_empty_body(): + host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.1", product="ssh-monitor") + raw = "\n".join( + [ + "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА", + "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2):", + " (нет данных)", + ] + ) + body = normalize_report_body(raw, host, "ssh") + assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body + assert "(нет данных)" in body + assert not any(ln.strip().startswith("👤") for ln in body.split("\n")) + + +def test_normalize_rdp_empty_active_users_placeholder(): + host = Host(hostname="gw", display_name="K6A-DC3", ipv4="192.168.160.40", product="rdp-login-monitor") + raw = "\n".join( + [ + "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS", + "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0):", + " (нет активных пользователей / RDP-сессий)", + ] + ) + body = normalize_report_body(raw, host, "windows") + assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body + assert "нет активных пользователей" in body + assert "👤 (нет активных" not in body + + +def test_normalize_daily_report_reconciles_stats_from_body(): + from app.services.daily_report_format import normalize_daily_report_details + + host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.2", product="ssh-monitor") + details = { + "report_body": "\n".join( + [ + "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА", + "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (9):", + " (нет данных)", + ] + ), + "stats": { + "successful_ssh": 1, + "active_sessions": 9, + "active_users": [], + "generated_by": "agent", + }, + } + out = normalize_daily_report_details(details, host, "report.daily.ssh") + assert out is not None + assert out["stats"]["active_sessions"] == 0 + assert out["stats"]["active_users"] == [] + assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in out["report_body"] diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..3f4dbba --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,122 @@ +"""Dashboard summary API.""" + +import uuid +from datetime import datetime, timedelta, timezone + +from app.models import Event, Host, Problem + + +def _host(db, name: str) -> Host: + h = Host( + hostname=name, + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db.add(h) + db.flush() + return h + + +def _event(db, host: Host, etype: str, *, received_at: datetime | None = None) -> Event: + now = received_at or datetime.now(timezone.utc) + e = Event( + event_id=str(uuid.uuid4()), + host_id=host.id, + occurred_at=now, + received_at=now, + category="security", + type=etype, + severity="info", + title=f"t {etype}", + summary="s", + payload={}, + ) + db.add(e) + db.flush() + return e + + +def test_dashboard_summary_top_and_problems_24h(client, db_session, jwt_headers): + h1 = _host(db_session, "web-01") + h2 = _host(db_session, "db-01") + now = datetime.now(timezone.utc) + for _ in range(3): + _event(db_session, h1, "ssh.login.failed") + _event(db_session, h2, "agent.heartbeat") + _event(db_session, h2, "agent.heartbeat") + + db_session.add( + Problem( + host_id=h1.id, + title="open now", + summary="s", + severity="high", + status="open", + rule_id="rule:test", + fingerprint="fp1", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(hours=2), + updated_at=now - timedelta(hours=2), + ) + ) + db_session.add( + Problem( + host_id=h2.id, + title="resolved today", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp2", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(hours=5), + updated_at=now - timedelta(hours=1), + ) + ) + db_session.commit() + + r = client.get("/api/v1/dashboards/summary", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["events_last_24h"] == 5 + assert body["problems_open"] == 1 + assert body["problems_opened_24h"] == 1 + assert body["problems_resolved_24h"] == 1 + assert body["top_hosts"][0]["hostname"] == "web-01" + assert body["top_hosts"][0]["count"] == 3 + types = {row["type"]: row["count"] for row in body["top_event_types"]} + assert types["ssh.login.failed"] == 3 + assert types["agent.heartbeat"] == 2 + + +def test_dashboard_daily_reports_24h_includes_ssh_and_rdp(client, db_session, jwt_headers): + h_ssh = _host(db_session, "ssh-pilot") + h_rdp = _host(db_session, "rdp-pilot") + h_rdp.product = "rdp-login-monitor" + now = datetime.now(timezone.utc) + _event(db_session, h_ssh, "report.daily.ssh", received_at=now) + _event(db_session, h_rdp, "report.daily.rdp", received_at=now) + db_session.commit() + + r = client.get("/api/v1/dashboards/summary", headers=jwt_headers) + assert r.status_code == 200 + assert r.json()["daily_reports_24h"] == 2 + + +def test_dashboard_recent_events_ordered_by_received_at(client, db_session, jwt_headers): + h = _host(db_session, "live-host") + now = datetime.now(timezone.utc) + older = _event(db_session, h, "agent.lifecycle", received_at=now - timedelta(minutes=5)) + newer = _event(db_session, h, "agent.heartbeat", received_at=now) + db_session.commit() + + r = client.get("/api/v1/dashboards/recent-events?limit=8", headers=jwt_headers) + assert r.status_code == 200 + rows = r.json() + assert len(rows) >= 2 + assert rows[0]["id"] == newer.id + assert rows[0]["type"] == "agent.heartbeat" + assert rows[1]["id"] == older.id diff --git a/backend/tests/test_email_notify.py b/backend/tests/test_email_notify.py new file mode 100644 index 0000000..d92ad5b --- /dev/null +++ b/backend/tests/test_email_notify.py @@ -0,0 +1,71 @@ +"""Tests for SAC email notification helpers.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from app.models import Event +from app.services import email_notify +from app.services.notification_settings import EmailConfig + + +def test_notify_event_respects_min_severity(): + sent: list[tuple[str, str]] = [] + + def fake_send(subject: str, body: str, **kwargs) -> None: + sent.append((subject, body)) + + event = Event( + event_id="00000000-0000-4000-8000-000000000301", + host_id=1, + occurred_at=datetime(2026, 5, 29, 14, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="failed", + summary="smtp test", + payload={}, + ) + + cfg = EmailConfig( + enabled=True, + smtp_host="smtp.example.com", + smtp_port=587, + smtp_user="u", + smtp_password="p", + mail_from="sac@example.com", + mail_to="admin@example.com", + smtp_starttls=True, + smtp_ssl=False, + min_severity="high", + source="env", + ) + with patch.object(email_notify, "get_effective_email_config", return_value=cfg): + with patch.object(email_notify, "send_email_message", side_effect=fake_send): + email_notify.notify_event(event) + assert sent == [] + + cfg_warn = EmailConfig( + enabled=True, + smtp_host="smtp.example.com", + smtp_port=587, + smtp_user="", + smtp_password="", + mail_from="sac@example.com", + mail_to="admin@example.com", + smtp_starttls=True, + smtp_ssl=False, + min_severity="warning", + source="env", + ) + with patch.object(email_notify, "get_effective_email_config", return_value=cfg_warn): + with patch.object(email_notify, "send_email_message", side_effect=fake_send): + email_notify.notify_event(event) + assert len(sent) == 1 + + +def test_parse_mail_recipients(): + assert email_notify.parse_mail_recipients("a@x.com; b@x.com, c@x.com") == [ + "a@x.com", + "b@x.com", + "c@x.com", + ] diff --git a/backend/tests/test_event_actor_user.py b/backend/tests/test_event_actor_user.py new file mode 100644 index 0000000..3ad0629 --- /dev/null +++ b/backend/tests/test_event_actor_user.py @@ -0,0 +1,67 @@ +"""Actor user extraction for event list UI.""" + +import uuid + +from app.services.event_actor_user import extract_event_actor_user + + +def test_ssh_login_user(): + assert extract_event_actor_user( + "ssh.login.success", + {"user": "deploy", "source_ip": "10.0.0.1"}, + ) == "deploy" + + +def test_rdp_login_username_fallback(): + assert extract_event_actor_user( + "rdp.login.failed", + {"username": "DOMAIN\\admin"}, + ) == "DOMAIN\\admin" + + +def test_sudo_user(): + assert extract_event_actor_user( + "privilege.sudo.command", + {"user": "root", "command": "apt update"}, + ) == "root" + + +def test_shadow_shadower_user(): + assert extract_event_actor_user( + "rdp.shadow.control.started", + {"shadower_user": "admin", "target_user": "user1"}, + ) == "admin" + + +def test_heartbeat_no_user(): + assert extract_event_actor_user("agent.heartbeat", {"user": "ignored"}) is None + + +def test_auth_event_missing_user(): + assert extract_event_actor_user("ssh.login.failed", {}) is None + + +def test_events_list_includes_actor_user(client, auth_headers, jwt_headers): + event_id = str(uuid.uuid4()) + payload = { + "schema_version": "1.0", + "event_id": event_id, + "occurred_at": "2026-05-27T10:00:00+03:00", + "source": {"product": "ssh-monitor", "product_version": "1.2.3-SAC"}, + "host": {"hostname": "actor-host", "os_family": "linux"}, + "category": "auth", + "type": "ssh.login.success", + "severity": "info", + "title": "SSH ok", + "summary": "login", + "details": {"user": "alice", "source_ip": "192.0.2.1"}, + } + assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201 + + listed = client.get("/api/v1/events?type=ssh.login.success&hostname=actor", headers=jwt_headers) + assert listed.status_code == 200 + items = listed.json()["items"] + assert any(i["actor_user"] == "alice" for i in items) + + dash = client.get("/api/v1/dashboards/recent-events?limit=5", headers=jwt_headers) + assert any(i.get("actor_user") == "alice" for i in dash.json()) diff --git a/backend/tests/test_event_severity_overrides.py b/backend/tests/test_event_severity_overrides.py new file mode 100644 index 0000000..ea1bdd0 --- /dev/null +++ b/backend/tests/test_event_severity_overrides.py @@ -0,0 +1,53 @@ +"""Tests for per-event-type severity overrides.""" + +import uuid + +from app.models.event_severity_override import EventSeverityOverride + +VALID_EVENT = { + "schema_version": "1.0", + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "occurred_at": "2026-05-27T10:00:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"}, + "host": {"hostname": "test-host", "os_family": "windows"}, + "category": "auth", + "type": "rdp.login.success", + "severity": "info", + "title": "RDP login", + "summary": "pytest", +} + + +def test_severity_override_applied_on_ingest(client, auth_headers, jwt_headers, db_session): + db_session.add(EventSeverityOverride(event_type="rdp.login.success", severity="warning")) + db_session.commit() + + event_id = str(uuid.uuid4()) + payload = {**VALID_EVENT, "event_id": event_id} + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + + listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "rdp.login.success"}) + row = next(i for i in listed.json()["items"] if i["event_id"] == event_id) + assert row["severity"] == "warning" + + +def test_severity_overrides_settings_api(client, jwt_headers, db_session): + db_session.add(EventSeverityOverride(event_type="ssh.login.failed", severity="high")) + db_session.commit() + + r = client.get("/api/v1/settings/notifications/severity-overrides", headers=jwt_headers) + assert r.status_code == 200 + items = {i["event_type"]: i for i in r.json()["items"]} + assert items["ssh.login.failed"]["override_severity"] == "high" + assert items["rdp.login.success"]["default_severity"] == "info" + + put = client.put( + "/api/v1/settings/notifications/severity-overrides", + headers=jwt_headers, + json={"overrides": {"rdp.login.success": "warning", "ssh.login.failed": None}}, + ) + assert put.status_code == 200 + updated = {i["event_type"]: i for i in put.json()["items"]} + assert updated["rdp.login.success"]["override_severity"] == "warning" + assert updated["ssh.login.failed"]["override_severity"] is None diff --git a/backend/tests/test_event_type_visibility.py b/backend/tests/test_event_type_visibility.py new file mode 100644 index 0000000..c16b293 --- /dev/null +++ b/backend/tests/test_event_type_visibility.py @@ -0,0 +1,117 @@ +"""Tests for hiding event types from SAC UI.""" + +import uuid + +from app.models.event_type_visibility import EventTypeVisibility + +VALID_EVENT = { + "schema_version": "1.0", + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "occurred_at": "2026-05-27T10:00:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"}, + "host": {"hostname": "test-host", "os_family": "windows"}, + "category": "auth", + "type": "agent.heartbeat", + "severity": "info", + "title": "Heartbeat", + "summary": "pytest", +} + +LOGIN_EVENT = { + **VALID_EVENT, + "type": "rdp.login.success", + "title": "RDP login", + "summary": "login pytest", +} + + +def _ingest(client, auth_headers, payload): + event_id = str(uuid.uuid4()) + r = client.post("/api/v1/events", json={**payload, "event_id": event_id}, headers=auth_headers) + assert r.status_code == 201 + return event_id + + +def test_hidden_event_type_excluded_from_list(client, auth_headers, jwt_headers, db_session): + db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False)) + db_session.commit() + + hb_id = _ingest(client, auth_headers, VALID_EVENT) + login_id = _ingest(client, auth_headers, LOGIN_EVENT) + + listed = client.get("/api/v1/events", headers=jwt_headers) + assert listed.status_code == 200 + ids = {item["event_id"] for item in listed.json()["items"]} + assert hb_id not in ids + assert login_id in ids + + +def test_hidden_event_type_returns_404_on_detail(client, auth_headers, jwt_headers, db_session): + db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False)) + db_session.commit() + + _ingest(client, auth_headers, VALID_EVENT) + listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.heartbeat"}) + assert listed.json()["total"] == 0 + + +def test_hidden_event_type_visible_on_host_detail_list(client, auth_headers, jwt_headers, db_session): + from app.models import Host + + host = Host(hostname="inv-pc", os_family="windows", product="rdp-login-monitor") + db_session.add(host) + db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False)) + db_session.commit() + + payload = { + **VALID_EVENT, + "type": "agent.inventory", + "category": "agent", + "title": "Inventory", + "summary": "hw", + "host": {"hostname": "inv-pc", "os_family": "windows"}, + "details": {"inventory": {"memory_gb": 16}}, + } + _ingest(client, auth_headers, payload) + + global_list = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.inventory"}) + assert global_list.json()["total"] == 0 + + host_list = client.get( + "/api/v1/events", + headers=jwt_headers, + params={"host_id": host.id, "include_hidden": "true"}, + ) + assert host_list.status_code == 200 + assert host_list.json()["total"] == 1 + + event_db_id = host_list.json()["items"][0]["id"] + detail = client.get(f"/api/v1/events/{event_db_id}", headers=jwt_headers) + assert detail.status_code == 200 + assert detail.json()["type"] == "agent.inventory" + + +def test_include_hidden_requires_host_id(client, jwt_headers): + r = client.get("/api/v1/events", headers=jwt_headers, params={"include_hidden": "true"}) + assert r.status_code == 400 + + +def test_visibility_settings_api(client, jwt_headers, db_session): + put = client.put( + "/api/v1/settings/notifications/severity-overrides", + headers=jwt_headers, + json={"visibility": {"agent.heartbeat": False}}, + ) + assert put.status_code == 200 + items = {i["event_type"]: i for i in put.json()["items"]} + assert items["agent.heartbeat"]["show_in_events"] is False + assert items["rdp.login.success"]["show_in_events"] is True + + reset = client.put( + "/api/v1/settings/notifications/severity-overrides", + headers=jwt_headers, + json={"visibility": {"agent.heartbeat": None}}, + ) + assert reset.status_code == 200 + items = {i["event_type"]: i for i in reset.json()["items"]} + assert items["agent.heartbeat"]["show_in_events"] is True diff --git a/backend/tests/test_events_list_filter.py b/backend/tests/test_events_list_filter.py new file mode 100644 index 0000000..f9d48d1 --- /dev/null +++ b/backend/tests/test_events_list_filter.py @@ -0,0 +1,54 @@ +"""Tests for GET /api/v1/events filters.""" + +import uuid + +VALID_RDG = { + "schema_version": "1.0", + "occurred_at": "2026-05-27T10:00:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"}, + "host": {"hostname": "gw-filter-test", "os_family": "windows"}, + "category": "auth", + "severity": "info", + "title": "RDG", + "summary": "pytest", +} + + +def _ingest(client, auth_headers, *, event_type: str) -> str: + event_id = str(uuid.uuid4()) + payload = {**VALID_RDG, "event_id": event_id, "type": event_type} + assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201 + return event_id + + +def test_events_type_filter_prefix(client, auth_headers, jwt_headers): + success_id = _ingest(client, auth_headers, event_type="rdg.connection.success") + disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected") + other_id = _ingest(client, auth_headers, event_type="rdp.login.success") + + listed = client.get( + "/api/v1/events", + headers=jwt_headers, + params={"type": "rdg.connection", "hostname": "gw-filter-test", "page_size": 50}, + ) + assert listed.status_code == 200 + body = listed.json() + ids = {row["event_id"] for row in body["items"]} + assert success_id in ids + assert disconnected_id in ids + assert other_id not in ids + + +def test_events_type_filter_exact_still_works(client, auth_headers, jwt_headers): + success_id = _ingest(client, auth_headers, event_type="rdg.connection.success") + disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected") + + listed = client.get( + "/api/v1/events", + headers=jwt_headers, + params={"type": "rdg.connection.success", "hostname": "gw-filter-test", "page_size": 50}, + ) + assert listed.status_code == 200 + ids = {row["event_id"] for row in listed.json()["items"]} + assert success_id in ids + assert disconnected_id not in ids diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..2d381fd --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,9 @@ +"""Version and health contract smoke tests (no DB required).""" + +from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL + + +def test_version_constants(): + assert APP_VERSION == "0.5.0" + assert APP_NAME == "Security Alert Center" + assert APP_VERSION_LABEL == "Security Alert Center v.0.5.0" diff --git a/backend/tests/test_health_ops.py b/backend/tests/test_health_ops.py new file mode 100644 index 0000000..17eef59 --- /dev/null +++ b/backend/tests/test_health_ops.py @@ -0,0 +1,11 @@ +"""Extended /health payload for ops monitoring.""" + + +def test_health_includes_stale_and_last_event(client, db_session, auth_headers): + r = client.get("/health") + assert r.status_code == 200 + body = r.json() + assert body["database"] == "ok" + assert "hosts_stale" in body + assert "last_event_received_at" in body + assert body["service"] == "security-alert-center" diff --git a/backend/tests/test_host_inventory.py b/backend/tests/test_host_inventory.py new file mode 100644 index 0000000..e8f0d8a --- /dev/null +++ b/backend/tests/test_host_inventory.py @@ -0,0 +1,132 @@ +"""Host inventory ingest and API.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select + +from app.models import Event, Host +from app.services.host_inventory import diff_hardware, hardware_snapshot, inventory_fingerprint + + +def test_hardware_diff_detects_memory_change(): + old = {"memory_gb": 16.0, "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}]} + new = {"memory_gb": 32.0, "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}]} + changes = diff_hardware(old, new) + assert len(changes) == 1 + assert changes[0]["field"] == "memory_gb" + + +def test_hardware_diff_ignores_first_snapshot(): + new = {"memory_gb": 16.0} + assert diff_hardware(None, new) == [] + + +def test_ingest_inventory_stores_host_and_info(client, auth_headers, db_session): + event_id = str(uuid.uuid4()) + payload = { + "schema_version": "1.0", + "event_id": event_id, + "occurred_at": "2026-06-04T10:00:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "2.0.21-SAC"}, + "host": {"hostname": "inv-pc", "os_family": "windows"}, + "category": "agent", + "type": "agent.inventory", + "severity": "info", + "title": "Host inventory", + "summary": "Inventory snapshot", + "details": { + "inventory": { + "schema_version": 1, + "computer_name": "INV-PC", + "memory_gb": 32.0, + "processor": [{"name": "Intel Xeon", "cores": 8, "logical_processors": 16}], + "windows": {"product_name": "Windows Server 2019", "version": "1809"}, + } + }, + } + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + + host = db_session.scalar(select(Host).where(Host.hostname == "inv-pc")) + assert host is not None + assert host.inventory is not None + assert host.inventory["memory_gb"] == 32.0 + assert host.inventory_updated_at is not None + assert host.os_version == "Windows Server 2019 (1809)" + + +def test_ingest_inventory_hardware_change_warning(client, auth_headers, db_session): + base = { + "schema_version": "1.0", + "occurred_at": "2026-06-04T10:00:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "2.0.21-SAC"}, + "host": {"hostname": "inv-change", "os_family": "windows"}, + "category": "agent", + "type": "agent.inventory", + "severity": "info", + "title": "Host inventory", + "summary": "Inventory snapshot", + } + inv_v1 = { + "inventory": { + "memory_gb": 16.0, + "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}], + "disks": [{"friendly_name": "Disk0", "media_type": "SSD", "size_gb": 500.0}], + } + } + assert ( + client.post( + "/api/v1/events", + json={**base, "event_id": str(uuid.uuid4()), "details": inv_v1}, + headers=auth_headers, + ).status_code + == 201 + ) + + inv_v2 = { + "inventory": { + "memory_gb": 32.0, + "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}], + "disks": [{"friendly_name": "Disk0", "media_type": "SSD", "size_gb": 500.0}], + } + } + r = client.post( + "/api/v1/events", + json={**base, "event_id": str(uuid.uuid4()), "details": inv_v2}, + headers=auth_headers, + ) + assert r.status_code == 201 + + ev = db_session.scalar(select(Event).order_by(Event.id.desc())) + assert ev is not None + assert ev.severity == "warning" + assert ev.details.get("hardware_changes") + + +def test_get_host_detail(client, db_session, jwt_headers): + h = Host( + hostname="detail-host", + display_name="Detail Host", + os_family="windows", + product="rdp-login-monitor", + product_version="2.0.21-SAC", + inventory={"memory_gb": 64.0}, + inventory_updated_at=datetime.now(timezone.utc), + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.commit() + + r = client.get(f"/api/v1/hosts/{h.id}", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["hostname"] == "detail-host" + assert body["inventory"]["memory_gb"] == 64.0 + assert body["last_inventory_at"] is not None + + +def test_inventory_fingerprint_stable(): + inv = {"memory_gb": 16.0, "processor": [{"name": "X", "cores": 2, "logical_processors": 4}]} + assert inventory_fingerprint(inv) == inventory_fingerprint(inv) + assert hardware_snapshot(inv)["memory_gb"] == 16.0 diff --git a/backend/tests/test_host_manual_add.py b/backend/tests/test_host_manual_add.py new file mode 100644 index 0000000..0147c4e --- /dev/null +++ b/backend/tests/test_host_manual_add.py @@ -0,0 +1,141 @@ +"""Manual host add: validation, probe, deploy kick-off.""" + +from unittest.mock import patch + +import pytest + +from app.models import Host +from app.services.host_manual_add import ( + ManualHostAddError, + prepare_manual_host_add, + validate_linux_target, + validate_windows_target, +) +from app.services.winrm_connect import WinRmTestResult +from tests.test_agent_update import wait_remote_job + + +def test_validate_windows_rejects_ip(): + with pytest.raises(ManualHostAddError, match="не IP"): + validate_windows_target("10.10.36.9") + + +def test_validate_windows_accepts_hostname(): + assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01" + assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01" + + +def test_validate_linux_accepts_ip(): + assert validate_linux_target("10.10.36.9") == "10.10.36.9" + + +def test_prepare_manual_host_windows(db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + with patch("app.services.host_manual_add.test_winrm_connection") as mock_win: + mock_win.return_value = WinRmTestResult( + ok=True, + message="WinRM OK, hostname=WORKSTATION-01", + target="WORKSTATION-01", + hostname="WORKSTATION-01", + ) + host = prepare_manual_host_add( + db_session, + platform="windows", + target="WORKSTATION-01", + ) + assert host.hostname == "WORKSTATION-01" + assert host.product == "rdp-login-monitor" + assert host.os_family == "windows" + + +def test_api_manual_add_windows(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + with patch("app.services.host_manual_add.test_winrm_connection") as mock_win: + mock_win.return_value = WinRmTestResult( + ok=True, + message="WinRM OK, hostname=NEW-PC", + target="NEW-PC", + hostname="NEW-PC", + ) + with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy: + from app.services.winrm_connect import WinRmCmdResult + + mock_deploy.return_value = WinRmCmdResult( + ok=True, + message="deploy ok", + target="NEW-PC", + stdout="2.1.8-SAC", + ) + response = client.post( + "/api/v1/hosts/manual-add", + json={"platform": "windows", "target": "NEW-PC"}, + headers=jwt_headers, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "running" + host_id = body["host_id"] + host = db_session.get(Host, host_id) + assert host is not None + assert host.hostname == "NEW-PC" + job = wait_remote_job(client, host_id, jwt_headers) + assert job["ok"] is True + + +def test_api_manual_add_rejects_windows_ip(jwt_headers, client): + response = client.post( + "/api/v1/hosts/manual-add", + json={"platform": "windows", "target": "192.168.1.10"}, + headers=jwt_headers, + ) + assert response.status_code == 400 + assert "не IP" in response.json()["detail"] + + +def test_api_manual_add_linux(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh: + from app.services.ssh_connect import SshCommandResult + + mock_ssh.return_value = SshCommandResult( + ok=True, + message="SSH OK, hostname=linux-srv", + target="10.10.36.9", + stdout="linux-srv\n", + ) + with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update: + mock_update.return_value = SshCommandResult( + ok=True, + message="updated", + target="10.10.36.9", + agent_version="2.1.5-SAC", + ) + response = client.post( + "/api/v1/hosts/manual-add", + json={"platform": "linux", "target": "10.10.36.9"}, + headers=jwt_headers, + ) + + assert response.status_code == 202 + host_id = response.json()["host_id"] + host = db_session.get(Host, host_id) + assert host.hostname == "linux-srv" + assert host.ipv4 == "10.10.36.9" + job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0) + assert job["ok"] is True diff --git a/backend/tests/test_host_mgmt_credentials.py b/backend/tests/test_host_mgmt_credentials.py new file mode 100644 index 0000000..247234e --- /dev/null +++ b/backend/tests/test_host_mgmt_credentials.py @@ -0,0 +1,64 @@ +"""Per-host management credential override.""" + +from app.models.host import Host +from app.services.linux_admin_settings import get_effective_linux_admin_for_host +from app.services.win_admin_settings import get_effective_win_admin_for_host +from app.services.host_mgmt_credentials import get_host_mgmt_access_view, upsert_host_mgmt_credentials + + +def test_win_admin_for_host_prefers_override(db_session): + host = Host( + hostname="HOME-PC", + os_family="windows", + product="rdp-login-monitor", + mgmt_user=".\\Admin", + mgmt_password="local-secret", + ) + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + cfg = get_effective_win_admin_for_host(db_session, host) + assert cfg.configured is True + assert cfg.source == "host" + assert cfg.user == ".\\Admin" + assert cfg.password == "local-secret" + + +def test_linux_admin_for_host_prefers_override(db_session): + host = Host( + hostname="homeserver", + os_family="linux", + product="ssh-monitor", + mgmt_user="deploy", + mgmt_password="ssh-pass", + ) + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + cfg = get_effective_linux_admin_for_host(db_session, host) + assert cfg.source == "host" + assert cfg.user == "deploy" + + +def test_upsert_and_clear_host_access(db_session): + host = Host(hostname="box", os_family="windows", product="rdp-login-monitor") + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + view = upsert_host_mgmt_credentials( + db_session, + host, + user="HOME\\user", + password="secret123", + ) + assert view.has_override is True + assert view.password_set is True + assert view.user == "HOME\\user" + + cleared = upsert_host_mgmt_credentials(db_session, host, clear=True) + assert cleared.has_override is False + assert cleared.password_set is False + assert get_host_mgmt_access_view(db_session, host).user is None diff --git a/backend/tests/test_host_sessions.py b/backend/tests/test_host_sessions.py new file mode 100644 index 0000000..c68bd7a --- /dev/null +++ b/backend/tests/test_host_sessions.py @@ -0,0 +1,218 @@ +"""Tests for host session list/parse helpers.""" + +from types import SimpleNamespace + +from app.services.host_sessions import ( + HostSessionRow, + _event_login_user, + event_session_terminated, + event_supports_session_terminate, + filter_logind_session_rows, + filter_windows_sessions_for_user, + mark_event_session_terminated, + parse_loginctl_sessions, + parse_loginctl_sessions_json, + parse_qwinsta_sessions, + terminate_session_for_event, +) +from app.services.linux_admin_settings import LinuxAdminConfig +from app.services.win_admin_settings import WinAdminConfig +from app.services.winrm_connect import WinRmCmdResult + + +def test_parse_loginctl_sessions(): + stdout = "c1 1000 alice seat0 pts/0 active -\n 2 1001 bob - tty2 active -\n" + rows = parse_loginctl_sessions(stdout) + assert len(rows) == 2 + assert rows[0].session_id == "c1" + assert rows[0].user == "alice" + assert rows[0].tty == "pts/0" + + +def test_parse_loginctl_sessions_prefers_pts_over_ephemeral_duplicate(): + stdout = """21166 1000 papatramp - pts/0 active no - +21169 1000 papatramp - - active no - +""" + rows = parse_loginctl_sessions(stdout) + assert len(rows) == 1 + assert rows[0].session_id == "21166" + assert rows[0].tty == "pts/0" + + +def test_parse_loginctl_sessions_json(): + stdout = """[ + {"session":"21166","uid":1000,"user":"papatramp","seat":null,"tty":"pts/0","state":"active","idle":false,"since":null}, + {"session":"21169","uid":1000,"user":"papatramp","seat":null,"tty":null,"state":"active","idle":false,"since":null} +]""" + rows = parse_loginctl_sessions_json(stdout) + assert len(rows) == 1 + assert rows[0].session_id == "21166" + assert rows[0].tty == "pts/0" + + +def test_filter_logind_session_rows_keeps_distinct_users(): + from app.services.host_sessions import HostSessionRow + + rows = filter_logind_session_rows( + [ + HostSessionRow(session_id="1", user="alice", tty="pts/0", state="active"), + HostSessionRow(session_id="2", user="bob", tty=None, state="active"), + ] + ) + assert len(rows) == 2 + + +def test_parse_qwinsta_sessions_filters_user(): + stdout = """SESSIONNAME USERNAME ID STATE + console Administrator 1 Active + rdp-tcp#0 B26\\alice 2 Active +""" + all_rows = parse_qwinsta_sessions(stdout) + assert len(all_rows) == 2 + filtered = parse_qwinsta_sessions(stdout, filter_user="alice") + assert len(filtered) == 1 + assert filtered[0].session_id == "2" + + +def test_parse_qwinsta_sessions_disconnected_without_sessionname(): + """Disc rows often omit SESSIONNAME; auto flap logoff relies on these.""" + stdout = """SESSIONNAME USERNAME ID STATE + rdp-tcp#0 B26\\s.shelkovaya 2 Active + B26\\s.shelkovaya 5 Disc + rdp-tcp 65536 Listen + services 0 Disc +""" + rows = parse_qwinsta_sessions(stdout) + assert {(r.session_id, r.state.split()[0], r.session_name) for r in rows} == { + ("2", "Active", "rdp-tcp#0"), + ("5", "Disc", ""), + } + filtered = parse_qwinsta_sessions(stdout, filter_user=r"B26\s.shelkovaya") + assert [r.session_id for r in filtered] == ["2", "5"] + + +def test_parse_qwinsta_sessions_filters_domain_user(): + stdout = """SESSIONNAME USERNAME ID STATE + rdp-tcp#1 B26\\bob 3 Active +""" + rows = parse_qwinsta_sessions(stdout, filter_user=r"B26\bob") + assert len(rows) == 1 + assert rows[0].session_id == "3" + + +def test_event_supports_session_terminate_types(): + class HostStub: + os_family = "linux" + product = "ssh-monitor" + + class EventStub: + def __init__(self, event_type: str): + self.type = event_type + self.host = HostStub() + + assert event_supports_session_terminate(EventStub("ssh.login.success")) is True + + +def test_event_login_user_without_orm_actor_user_attr(): + event = SimpleNamespace( + type="rdp.login.success", + details={"user": "papatramp"}, + ) + assert _event_login_user(event) == "papatramp" + + +def test_filter_windows_sessions_for_user_matches_domain(): + rows = [ + HostSessionRow(session_id="2", user="B26\\papatramp", state="Active"), + HostSessionRow(session_id="3", user="B26\\alice", state="Active"), + ] + matched = filter_windows_sessions_for_user(rows, "papatramp") + assert len(matched) == 1 + assert matched[0].session_id == "2" + + +def test_terminate_session_for_event_windows_already_logged_off(monkeypatch): + host = SimpleNamespace( + os_family="windows", + product="rdp-login-monitor", + hostname="BIV-PC", + ipv4="192.168.165.39", + display_name=None, + inventory={}, + ) + event = SimpleNamespace( + type="rdp.login.success", + details={"user": "papatramp"}, + host=host, + ) + + def fake_list(_host, _cfg): + return [], WinRmCmdResult(ok=True, message="ok", target="BIV-PC", stdout="SESSIONNAME USERNAME ID STATE") + + monkeypatch.setattr( + "app.services.host_sessions.list_windows_sessions", + fake_list, + ) + + result = terminate_session_for_event( + event, + linux_cfg=LinuxAdminConfig(user="", password="", source="test"), + win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"), + ) + assert result.ok is True + assert "уже вышел" in result.message + + +def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch): + host = SimpleNamespace( + os_family="windows", + product="rdp-login-monitor", + hostname="srv01", + ipv4="10.0.0.1", + display_name=None, + inventory={}, + ) + event = SimpleNamespace( + type="rdp.login.success", + details={"user": "papatramp"}, + host=host, + ) + rows = [HostSessionRow(session_id="2", user="B26\\papatramp", state="Active")] + + def fake_list(_host, _cfg): + return rows, WinRmCmdResult(ok=True, message="ok", target="srv01") + + def fake_term(_host, _cfg, sid): + assert sid == "2" + return WinRmCmdResult(ok=True, message="logged off", target="srv01") + + monkeypatch.setattr( + "app.services.host_sessions.list_windows_sessions", + fake_list, + ) + monkeypatch.setattr( + "app.services.host_sessions.terminate_windows_session", + fake_term, + ) + + result = terminate_session_for_event( + event, + linux_cfg=LinuxAdminConfig(user="", password="", source="test"), + win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"), + ) + assert result.ok is True + + +def test_event_session_terminated_flag(): + event = SimpleNamespace( + type="rdp.login.success", + details={"session_terminated_at": "2026-06-24T10:00:00+00:00"}, + ) + assert event_session_terminated(event) is True + + fresh = SimpleNamespace(type="rdp.login.success", details={"user": "alice"}) + assert event_session_terminated(fresh) is False + + mark_event_session_terminated(fresh, by_username="admin") + assert event_session_terminated(fresh) is True + assert fresh.details["session_terminated_by"] == "admin" diff --git a/backend/tests/test_host_silence_scan.py b/backend/tests/test_host_silence_scan.py new file mode 100644 index 0000000..3c8a3be --- /dev/null +++ b/backend/tests/test_host_silence_scan.py @@ -0,0 +1,355 @@ +"""Proactive host_silence scan (stale heartbeat without waiting for ingest).""" + +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from sqlalchemy import select + +from app.config import get_settings +from app.models import Problem +from app.services.host_silence_scan import run_host_silence_scan +from app.services.problem_rules import RULE_HOST_SILENCE +from tests.test_problem_rules import _ingest + + +@pytest.fixture +def scan_settings(monkeypatch): + monkeypatch.setenv("SAC_HEARTBEAT_STALE_MINUTES", "60") + monkeypatch.setenv("SAC_HOST_SILENCE_SCAN_ENABLED", "true") + monkeypatch.setenv("SAC_HOST_SILENCE_SCAN_INTERVAL_MINUTES", "5") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def test_scan_creates_problem_for_stale_host(db_session, scan_settings): + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + now = datetime.now(timezone.utc) + results = run_host_silence_scan(db_session, now=now) + assert len(results) == 1 + assert results[0].created is True + assert results[0].problem.rule_id == RULE_HOST_SILENCE + + problem = db_session.scalar( + select(Problem).where( + Problem.host_id == hb.host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status == "open", + ) + ) + assert problem is not None + + +def test_scan_does_not_duplicate_open_problem(db_session, scan_settings): + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + now = datetime.now(timezone.utc) + first = run_host_silence_scan(db_session, now=now) + assert first[0].created is True + + second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5)) + assert len(second) == 1 + assert second[0].created is False + + problems = db_session.scalars( + select(Problem).where( + Problem.host_id == hb.host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status == "open", + ) + ).all() + assert len(problems) == 1 + + +def test_scan_does_not_duplicate_after_correlation_window(db_session, scan_settings, monkeypatch): + """host_silence — одна open-проблема на хост, даже если прошло > correlation window.""" + monkeypatch.setenv("SAC_PROBLEM_CORRELATION_WINDOW_MINUTES", "60") + get_settings.cache_clear() + + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + t0 = datetime.now(timezone.utc) + first = run_host_silence_scan(db_session, now=t0) + assert first[0].created is True + first[0].problem.last_seen_at = t0 - timedelta(hours=2) + db_session.flush() + + later = run_host_silence_scan(db_session, now=t0 + timedelta(hours=3)) + assert len(later) == 1 + assert later[0].created is False + assert later[0].problem.id == first[0].problem.id + + problems = db_session.scalars( + select(Problem).where( + Problem.host_id == hb.host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status == "open", + ) + ).all() + assert len(problems) == 1 + + +def test_scan_skips_host_without_heartbeat(db_session, scan_settings): + _ingest( + db_session, + event_id=str(uuid.uuid4()), + type="ssh.login.success", + severity="info", + title="login", + summary="ok", + ) + results = run_host_silence_scan(db_session) + assert results == [] + + +def test_scan_suppresses_after_manual_resolve_within_cooldown(db_session, scan_settings, monkeypatch): + monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12") + get_settings.cache_clear() + + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + now = datetime.now(timezone.utc) + first = run_host_silence_scan(db_session, now=now) + assert first[0].created is True + problem = first[0].problem + problem.status = "resolved" + problem.resolved_by = "manual" + problem.updated_at = now + db_session.flush() + + second = run_host_silence_scan(db_session, now=now + timedelta(minutes=10)) + assert second == [] + + open_count = db_session.scalar( + select(Problem).where( + Problem.host_id == hb.host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status == "open", + ) + ) + assert open_count is None + + resolved_count = len( + db_session.scalars( + select(Problem).where( + Problem.host_id == hb.host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status == "resolved", + ) + ).all() + ) + assert resolved_count == 1 + + +def test_scan_reopens_after_manual_cooldown_expires(db_session, scan_settings, monkeypatch): + monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12") + get_settings.cache_clear() + + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + now = datetime.now(timezone.utc) + first = run_host_silence_scan(db_session, now=now) + problem = first[0].problem + problem.status = "resolved" + problem.resolved_by = "manual" + problem.updated_at = now - timedelta(hours=13) + db_session.flush() + + later = run_host_silence_scan(db_session, now=now) + assert len(later) == 1 + assert later[0].created is True + assert later[0].problem.id == problem.id + assert later[0].problem.status == "open" + assert later[0].problem.resolved_by is None + + +def test_scan_does_not_duplicate_acknowledged_problem(db_session, scan_settings): + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + now = datetime.now(timezone.utc) + first = run_host_silence_scan(db_session, now=now) + assert first[0].created is True + first[0].problem.status = "acknowledged" + db_session.flush() + + second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5)) + assert len(second) == 1 + assert second[0].created is False + assert second[0].problem.id == first[0].problem.id + + problems = db_session.scalars( + select(Problem).where( + Problem.host_id == hb.host_id, + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status.in_(("open", "acknowledged")), + ) + ).all() + assert len(problems) == 1 + + +def test_scan_dedupes_by_hostname_across_duplicate_hosts(db_session, scan_settings): + from app.models import Host + + now = datetime.now(timezone.utc) + host_a = Host( + hostname="COMM-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.162.33", + ) + host_b = Host( + hostname="COMM-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.162.33", + ) + db_session.add_all([host_a, host_b]) + db_session.flush() + + hb = _ingest( + db_session, + host={"hostname": host_a.hostname, "os_family": "windows", "ipv4": host_a.ipv4}, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.host_id = host_a.id + hb.received_at = now - timedelta(hours=2) + db_session.flush() + + first = run_host_silence_scan(db_session, now=now) + assert len(first) == 1 + assert first[0].created is True + + hb_b = _ingest( + db_session, + event_id=str(uuid.uuid4()), + host={"hostname": host_b.hostname, "os_family": "windows", "ipv4": host_b.ipv4}, + source={"product": "rdp-login-monitor", "product_version": "2.0.0-SAC"}, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb2", + summary="heartbeat", + ) + hb_b.host_id = host_b.id + hb_b.received_at = now - timedelta(hours=2) + db_session.flush() + + second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5)) + assert len(second) == 2 + assert all(not item.created for item in second) + assert {item.problem.id for item in second} == {first[0].problem.id} + + active = db_session.scalars( + select(Problem).where( + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status.in_(("open", "acknowledged")), + ) + ).all() + assert len(active) == 1 + + +def test_scan_skipped_when_advisory_lock_not_acquired(db_session, scan_settings, monkeypatch): + monkeypatch.setattr( + "app.services.host_silence_scan._host_silence_scan_lock", + lambda _db: False, + ) + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + assert run_host_silence_scan(db_session) == [] + + +def test_scan_notifies_only_on_create(db_session, scan_settings): + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + from app.services.host_silence_scan import dispatch_host_silence_notifications + + with patch("app.services.notify_dispatch.notify_problem") as mock_notify: + results = run_host_silence_scan(db_session) + notified = dispatch_host_silence_notifications(db_session, results) + assert notified == 1 + mock_notify.assert_called_once() + + with patch("app.services.notify_dispatch.notify_problem") as mock_notify: + results2 = run_host_silence_scan(db_session) + notified2 = dispatch_host_silence_notifications(db_session, results2) + assert notified2 == 0 + mock_notify.assert_not_called() diff --git a/backend/tests/test_hosts.py b/backend/tests/test_hosts.py new file mode 100644 index 0000000..499cbe4 --- /dev/null +++ b/backend/tests/test_hosts.py @@ -0,0 +1,183 @@ +"""Hosts list API.""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select + +from app.config import get_settings +from app.models import Event, Host, Problem + + +@pytest.fixture +def host_stale_settings(monkeypatch): + monkeypatch.setenv("SAC_HEARTBEAT_STALE_MINUTES", "60") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def test_hosts_filter_agent_status_stale(client, db_session, jwt_headers, host_stale_settings): + now = datetime.now(timezone.utc) + stale_host = Host( + hostname="stale-pc", + os_family="windows", + product="rdp-login-monitor", + last_seen_at=now, + ) + fresh_host = Host( + hostname="fresh-pc", + os_family="windows", + product="rdp-login-monitor", + last_seen_at=now, + ) + unknown_host = Host( + hostname="unknown-pc", + os_family="linux", + product="ssh-monitor", + last_seen_at=now, + ) + db_session.add_all([stale_host, fresh_host, unknown_host]) + db_session.flush() + + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=stale_host.id, + occurred_at=now - timedelta(hours=2), + received_at=now - timedelta(hours=2), + category="agent", + type="agent.heartbeat", + severity="info", + title="hb", + summary="s", + payload={}, + ) + ) + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=fresh_host.id, + occurred_at=now - timedelta(minutes=5), + received_at=now - timedelta(minutes=5), + category="agent", + type="agent.heartbeat", + severity="info", + title="hb", + summary="s", + payload={}, + ) + ) + db_session.commit() + + r = client.get("/api/v1/hosts?agent_status=stale", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["hostname"] == "stale-pc" + assert body["items"][0]["agent_status"] == "stale" + + r_all = client.get("/api/v1/hosts", headers=jwt_headers) + assert r_all.json()["total"] == 3 + + +def test_hosts_search_by_display_name(client, db_session, jwt_headers): + h = Host( + hostname="pc-01", + display_name="UNMS Kalina", + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.commit() + + r = client.get("/api/v1/hosts?hostname=UNMS", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["display_name"] == "UNMS Kalina" + + r2 = client.get("/api/v1/hosts?hostname=nomatch", headers=jwt_headers) + assert r2.json()["total"] == 0 + + +def test_delete_host_removes_events_and_problems(client, db_session, jwt_headers): + h = Host( + hostname="test-host", + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.flush() + ev = Event( + event_id=str(uuid.uuid4()), + host_id=h.id, + occurred_at=datetime.now(timezone.utc), + category="agent", + type="agent.test", + severity="info", + title="t", + summary="s", + payload={}, + ) + db_session.add(ev) + db_session.add( + Problem( + host_id=h.id, + title="p", + summary="s", + severity="warning", + status="open", + rule_id="rule:test", + fingerprint="fp-del-host", + event_count=1, + last_seen_at=datetime.now(timezone.utc), + ) + ) + db_session.commit() + + host_id = h.id + r = client.delete(f"/api/v1/hosts/{host_id}", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["hostname"] == "test-host" + assert body["deleted_events"] == 1 + assert body["deleted_problems"] == 1 + assert db_session.scalar(select(Host.id).where(Host.id == host_id)) is None + + +def test_delete_host_404(client, jwt_headers): + r = client.delete("/api/v1/hosts/999999", headers=jwt_headers) + assert r.status_code == 404 + + +def test_delete_host_forbidden_for_monitor(client, db_session, jwt_monitor_headers): + h = Host( + hostname="monitor-blocked", + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.commit() + + r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_monitor_headers) + assert r.status_code == 403 + assert db_session.get(Host, h.id) is not None + + +def test_delete_host_requires_jwt(client, db_session): + h = Host( + hostname="no-auth", + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.commit() + r = client.delete(f"/api/v1/hosts/{h.id}") + assert r.status_code == 401 + diff --git a/backend/tests/test_ingest.py b/backend/tests/test_ingest.py new file mode 100644 index 0000000..9b5d5fc --- /dev/null +++ b/backend/tests/test_ingest.py @@ -0,0 +1,180 @@ +"""Ingest idempotency and HTTP status contract.""" + +import uuid + +from app.services.schema_validate import validate_event_payload + +VALID_EVENT = { + "schema_version": "1.0", + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "occurred_at": "2026-05-27T10:00:00+03:00", + "source": {"product": "ssh-monitor", "product_version": "1.2.3-SAC"}, + "host": {"hostname": "test-host", "os_family": "linux"}, + "category": "agent", + "type": "agent.test", + "severity": "info", + "title": "Test", + "summary": "pytest ingest", +} + + +def test_schema_rejects_non_uuid_event_id(): + bad = {**VALID_EVENT, "event_id": "not-a-uuid"} + errors = validate_event_payload(bad) + assert errors + + +def test_schema_rejects_missing_event_id(): + payload = {k: v for k, v in VALID_EVENT.items() if k != "event_id"} + errors = validate_event_payload(payload) + assert errors + + +def test_ingest_created_201(client, auth_headers): + event_id = str(uuid.uuid4()) + payload = {**VALID_EVENT, "event_id": event_id} + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + data = r.json() + assert data["created"] is True + assert data["status"] == "created" + assert data["event_id"] == event_id + + +def test_ingest_duplicate_409(client, auth_headers): + event_id = str(uuid.uuid4()) + payload = {**VALID_EVENT, "event_id": event_id} + assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201 + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 409 + data = r.json() + assert data["created"] is False + assert data["status"] == "duplicate" + assert data["event_id"] == event_id + + +def test_ingest_rdp_lifecycle_201(client, auth_headers): + event_id = str(uuid.uuid4()) + payload = { + **VALID_EVENT, + "event_id": event_id, + "source": {"product": "rdp-login-monitor", "product_version": "1.2.11-SAC"}, + "host": {"hostname": "K6A-DC3", "os_family": "windows"}, + "type": "agent.lifecycle", + "title": "RDP login monitor started", + "summary": "Мониторинг запущен на K6A-DC3, версия 1.2.11-SAC", + "details": {"lifecycle": "started"}, + } + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + assert r.json()["event_id"] == event_id + + +def test_ingest_host_display_name_updated(client, auth_headers, db_session): + from sqlalchemy import select + + from app.models import Host + + event_id = str(uuid.uuid4()) + payload = { + **VALID_EVENT, + "event_id": event_id, + "host": { + "hostname": "short-name", + "display_name": "UNMS Kalina", + "os_family": "linux", + }, + } + assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201 + host = db_session.scalar(select(Host).where(Host.hostname == "short-name")) + assert host is not None + assert host.display_name == "UNMS Kalina" + + payload2 = { + **payload, + "event_id": str(uuid.uuid4()), + "host": {"hostname": "short-name", "display_name": "Kalina UNMS", "os_family": "linux"}, + } + assert client.post("/api/v1/events", json=payload2, headers=auth_headers).status_code == 201 + db_session.refresh(host) + assert host.display_name == "Kalina UNMS" + + +def test_ingest_invalid_payload_422(client, auth_headers): + r = client.post( + "/api/v1/events", + json={"event_id": "bad", "summary": "x"}, + headers=auth_headers, + ) + assert r.status_code == 422 + assert "schema_errors" in r.json()["detail"] + + +def test_ingest_daily_report_calls_notify_daily_report(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": "report", + "type": "report.daily.ssh", + "severity": "info", + "title": "Daily SSH report", + "summary": "stats", + "details": {"generated_by": "agent", "report_body": "line1"}, + } + with patch.object(events_api, "schedule_notify_daily_report") as mock_daily: + with patch.object(events_api, "notify_event") as mock_event: + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + mock_daily.assert_called_once() + mock_event.assert_not_called() + + +def test_ingest_lifecycle_defers_notify(client, auth_headers): + from unittest.mock import patch + + from app.api.v1 import events as events_api + + event_id = str(uuid.uuid4()) + payload = { + **VALID_EVENT, + "event_id": event_id, + "type": "agent.lifecycle", + "title": "started", + "summary": "agent started", + "details": {"lifecycle": "started"}, + } + with patch.object(events_api, "schedule_notify_lifecycle") as mock_defer: + with patch.object(events_api, "notify_lifecycle") as mock_sync: + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + mock_defer.assert_called_once() + mock_sync.assert_not_called() + + +def test_ingest_auth_login_defers_notify(client, auth_headers): + from unittest.mock import patch + + from app.api.v1 import events as events_api + + event_id = str(uuid.uuid4()) + payload = { + **VALID_EVENT, + "event_id": event_id, + "category": "auth", + "type": "ssh.login.success", + "severity": "info", + "title": "SSH login", + "summary": "user@host", + } + with patch.object(events_api, "schedule_notify_auth_login") as mock_defer: + with patch.object(events_api, "notify_auth_login") as mock_sync: + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + mock_defer.assert_called_once() + mock_sync.assert_not_called() + diff --git a/backend/tests/test_ingest_body_limit.py b/backend/tests/test_ingest_body_limit.py new file mode 100644 index 0000000..816d1c5 --- /dev/null +++ b/backend/tests/test_ingest_body_limit.py @@ -0,0 +1,23 @@ +"""Ingest body size limit middleware.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware + + +def test_ingest_body_size_limit_rejects_large_content_length(): + app = FastAPI() + app.add_middleware(IngestBodySizeLimitMiddleware, max_bytes=128) + + @app.post("/api/v1/events") + def ingest(): + return {"ok": True} + + client = TestClient(app) + response = client.post( + "/api/v1/events", + content=b"x" * 10, + headers={"content-length": "256"}, + ) + assert response.status_code == 413 diff --git a/backend/tests/test_linux_admin_settings.py b/backend/tests/test_linux_admin_settings.py new file mode 100644 index 0000000..cc7e716 --- /dev/null +++ b/backend/tests/test_linux_admin_settings.py @@ -0,0 +1,224 @@ +"""Additional Linux admin API tests.""" + +import time +from unittest.mock import patch + + +def wait_remote_job(client, host_id, headers, timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + response = client.get( + f"/api/v1/hosts/{host_id}/actions/remote-job", + headers=headers, + ) + assert response.status_code == 200 + body = response.json() + if not body.get("active") and body.get("status") != "running": + return body + time.sleep(0.05) + raise AssertionError("remote job did not finish in time") + + +def test_get_linux_admin_settings_env_default(jwt_headers, client, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "") + from app.config import get_settings + + get_settings.cache_clear() + + response = client.get("/api/v1/settings/linux-admin", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["configured"] is False + assert body["source"] == "env" + + +def test_host_ssh_test_requires_admin(jwt_monitor_headers, client): + response = client.post("/api/v1/hosts/1/actions/ssh-test", headers=jwt_monitor_headers) + assert response.status_code == 403 + + +def test_host_ssh_test_not_linux(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw") + from app.config import get_settings + from app.models import Host + + get_settings.cache_clear() + + host = Host(hostname="PC", os_family="windows", product="rdp-login-monitor", ipv4="10.0.0.1") + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + response = client.post(f"/api/v1/hosts/{host.id}/actions/ssh-test", headers=jwt_headers) + assert response.status_code == 400 + assert "not Linux" in response.json()["detail"] + + +def test_host_agent_update_not_configured(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "") + from app.config import get_settings + from app.models import Host + + get_settings.cache_clear() + + host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5") + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + response = client.post(f"/api/v1/hosts/{host.id}/actions/agent-update", headers=jwt_headers) + assert response.status_code == 400 + assert "not configured" in response.json()["detail"].lower() + + +def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "") + from app.config import get_settings + from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/linux-admin", + headers=jwt_headers, + json={"user": "root", "password": "secret-pass"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["configured"] is True + assert body["user"] == "root" + assert body["source"] == "db" + assert body["password_hint"] + + row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID) + assert row is not None + assert row.linux_admin_user == "root" + assert row.linux_admin_password == "secret-pass" + + +def test_host_ssh_test_success(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 + + get_settings.cache_clear() + + host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5") + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + with patch("app.api.v1.hosts.test_ssh_connection") as mock_test: + mock_test.return_value = SshCommandResult( + ok=True, + message="SSH OK, hostname=ubabuba", + target="ubabuba", + stdout="ubabuba\n", + exit_code=0, + ) + response = client.post( + f"/api/v1/hosts/{host.id}/actions/ssh-test", + headers=jwt_headers, + ) + + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert "hostname=ubabuba" in body["message"] + assert body["target"] == "ubabuba" + assert body["ssh_admin_ok"] is True + + db_session.refresh(host) + assert host.ssh_admin_ok is True + assert host.ssh_admin_checked_at is not None + + +def test_host_agent_update_success(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 + + get_settings.cache_clear() + + host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5") + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + with patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update: + mock_update.return_value = SshCommandResult( + ok=True, + message="SSH OK (ubabuba), exit 0\nSUMMARY updated", + target="ubabuba", + stdout="SUMMARY updated\n", + exit_code=0, + ) + response = client.post( + f"/api/v1/hosts/{host.id}/actions/agent-update", + headers=jwt_headers, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "running" + job = wait_remote_job(client, host.id, jwt_headers) + assert job["ok"] is True + 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() diff --git a/backend/tests/test_login_rate_limit.py b/backend/tests/test_login_rate_limit.py new file mode 100644 index 0000000..fc0b5a2 --- /dev/null +++ b/backend/tests/test_login_rate_limit.py @@ -0,0 +1,65 @@ +"""SAC UI login rate limit and Telegram alert on brute-force.""" + + +def _failed_login(client, username: str = "test-admin") -> int: + return client.post( + "/api/v1/auth/login", + json={"username": username, "password": "wrong-password"}, + ).status_code + + +def test_login_blocked_after_max_failures(client, monkeypatch): + monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3") + from app.config import get_settings + + get_settings.cache_clear() + + assert _failed_login(client) == 401 + assert _failed_login(client) == 401 + assert _failed_login(client) == 401 + assert _failed_login(client) == 429 + + ok = client.post( + "/api/v1/auth/login", + json={"username": "test-admin", "password": "test-admin-password"}, + ) + assert ok.status_code == 429 + + get_settings.cache_clear() + + +def test_login_telegram_alert_on_threshold(client, monkeypatch): + monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3") + monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "true") + from app.config import get_settings + + get_settings.cache_clear() + + sent: list[str] = [] + monkeypatch.setattr( + "app.services.login_rate_limit.send_telegram_text", + lambda text, **kwargs: sent.append(text), + ) + + for _ in range(3): + assert _failed_login(client, username="attacker") == 401 + + assert len(sent) == 1 + assert "SAC: подбор пароля UI" in sent[0] + assert "attacker" in sent[0] + + assert _failed_login(client, username="attacker") == 429 + assert len(sent) == 1 + + get_settings.cache_clear() + + +def test_deactivated_user_token_rejected_on_me(client, jwt_monitor_headers, db_session): + from app.models.user import User + + user = db_session.query(User).filter(User.username == "test-monitor").one() + user.is_active = False + db_session.commit() + + response = client.get("/api/v1/auth/me", headers=jwt_monitor_headers) + assert response.status_code == 401 diff --git a/backend/tests/test_login_security_settings.py b/backend/tests/test_login_security_settings.py new file mode 100644 index 0000000..4a164de --- /dev/null +++ b/backend/tests/test_login_security_settings.py @@ -0,0 +1,88 @@ +"""Login security whitelist and unblock.""" + +from datetime import datetime, timezone + +from sqlalchemy import delete + +from app.models.login_attempt import LoginAttempt +from app.services.login_security_settings import ( + clear_web_login_block, + get_effective_login_security_config, + is_ip_login_whitelisted, + list_web_login_blocks, + parse_ip_whitelist_text, + upsert_login_security_settings, +) + + +def test_parse_ip_whitelist_text(): + assert parse_ip_whitelist_text("192.168.160.3\n192.168.160.4") == [ + "192.168.160.3", + "192.168.160.4", + ] + assert parse_ip_whitelist_text("192.168.160.3, 10.0.0.1") == [ + "192.168.160.3", + "10.0.0.1", + ] + assert parse_ip_whitelist_text("not-an-ip\n192.168.1.1") == ["192.168.1.1"] + + +def test_whitelisted_ip_not_blocked(client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2") + monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "false") + monkeypatch.setattr( + "app.services.login_rate_limit.client_ip_from_request", + lambda _request: "192.168.160.3", + ) + from app.config import get_settings + + get_settings.cache_clear() + db_session.execute(delete(LoginAttempt)) + db_session.commit() + + upsert_login_security_settings( + db_session, + ip_whitelist_text="192.168.160.3", + sync_fail2ban=False, + ) + + for _ in range(5): + r = client.post( + "/api/v1/auth/login", + json={"username": "test-admin", "password": "wrong"}, + ) + assert r.status_code == 401 + + get_settings.cache_clear() + + +def test_list_web_blocks_and_clear(db_session, monkeypatch): + monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2") + monkeypatch.setenv("SAC_LOGIN_FAILURE_WINDOW_MINUTES", "15") + from app.config import get_settings + + get_settings.cache_clear() + + now = datetime.now(timezone.utc) + db_session.add( + LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now) + ) + db_session.add( + LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now) + ) + db_session.commit() + + blocks = list_web_login_blocks(db_session) + assert any(b.ip_address == "203.0.113.50" for b in blocks) + + deleted = clear_web_login_block(db_session, "203.0.113.50") + assert deleted >= 2 + assert not list_web_login_blocks(db_session) + + get_settings.cache_clear() + + +def test_is_ip_login_whitelisted(): + wl = ("192.168.160.3",) + assert is_ip_login_whitelisted("192.168.160.3", wl) + assert not is_ip_login_whitelisted("192.168.160.4", wl) diff --git a/backend/tests/test_mobile_api.py b/backend/tests/test_mobile_api.py new file mode 100644 index 0000000..023e324 --- /dev/null +++ b/backend/tests/test_mobile_api.py @@ -0,0 +1,227 @@ +"""Mobile / Seaca API.""" + +from datetime import datetime, timedelta, timezone + +from app.models.mobile_device import MobileDevice +from app.models.mobile_enrollment_code import MobileEnrollmentCode +from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings +from app.services.mobile_tokens import hash_mobile_secret + + +def _enable_mobile(db_session): + row = db_session.get(MobileSettings, MOBILE_SETTINGS_ROW_ID) + if row is None: + row = MobileSettings(id=MOBILE_SETTINGS_ROW_ID, devices_allowed=True, max_devices_per_user=3) + db_session.add(row) + else: + row.devices_allowed = True + db_session.commit() + + +def test_mobile_settings_admin(jwt_headers, client, db_session): + _enable_mobile(db_session) + response = client.get("/api/v1/mobile/settings", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["devices_allowed"] is True + assert body["max_devices_per_user"] == 3 + + +def test_create_enrollment_code_and_enroll(jwt_headers, client, db_session): + _enable_mobile(db_session) + create = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"label": "test phone", "login_mode": "password", "expires_in_hours": 1}, + ) + assert create.status_code == 200 + created = create.json() + assert created["enrollment_code"].startswith("sacmob_") + assert created["code_prefix"] + + enroll = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": created["enrollment_code"], + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": "uuid-test-device-001", + "display_name": "Pixel Test", + "fcm_token": "fcm-token-test", + }, + ) + assert enroll.status_code == 200 + body = enroll.json() + assert body["username"] == "test-monitor" + assert body["role"] == "monitor" + assert body["device_id"] > 0 + assert body["access_token"] + assert body["refresh_token"] + + devices = client.get("/api/v1/mobile/devices", headers=jwt_headers) + assert devices.status_code == 200 + assert len(devices.json()) == 1 + assert devices.json()[0]["display_name"] == "Pixel Test" + + +def test_enroll_blocked_when_disabled(client, db_session, jwt_headers): + code_plain = "sacmob_testcode123456" + db_session.add( + MobileEnrollmentCode( + label="x", + code_hash=hash_mobile_secret(code_plain), + code_prefix=code_plain[:12], + login_mode="password", + max_uses=1, + use_count=0, + expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + ) + ) + db_session.commit() + + response = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": code_plain, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": "uuid-disabled-001", + }, + ) + assert response.status_code == 403 + + +def test_refresh_token_rotation(client, db_session, jwt_headers): + _enable_mobile(db_session) + create = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ) + code = create.json()["enrollment_code"] + enroll = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": code, + "username": "test-admin", + "password": "test-admin-password", + "device_uuid": "uuid-refresh-001", + }, + ) + data = enroll.json() + refresh = client.post( + "/api/v1/mobile/auth/refresh", + json={"refresh_token": data["refresh_token"], "device_uuid": "uuid-refresh-001"}, + ) + assert refresh.status_code == 200 + refreshed = refresh.json() + assert refreshed["refresh_token"] != data["refresh_token"] + assert refreshed["device_id"] == data["device_id"] + + +def test_revoke_device_blocks_token(client, db_session, jwt_headers): + _enable_mobile(db_session) + create = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ) + code = create.json()["enrollment_code"] + enroll = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": code, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": "uuid-revoke-001", + }, + ) + data = enroll.json() + headers = {"Authorization": f"Bearer {data['access_token']}"} + + revoke = client.post(f"/api/v1/mobile/devices/{data['device_id']}/revoke", headers=jwt_headers) + assert revoke.status_code == 200 + + me = client.put( + "/api/v1/mobile/devices/me/fcm", + headers=headers, + json={"fcm_token": "new-token"}, + ) + assert me.status_code == 401 + + +def test_reenroll_same_device_after_revoke(client, db_session, jwt_headers): + _enable_mobile(db_session) + device_uuid = "uuid-reenroll-001" + + first_code = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ).json()["enrollment_code"] + first = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": first_code, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": device_uuid, + "display_name": "Phone A", + }, + ) + assert first.status_code == 200 + first_device_id = first.json()["device_id"] + + revoke = client.post(f"/api/v1/mobile/devices/{first_device_id}/revoke", headers=jwt_headers) + assert revoke.status_code == 200 + + second_code = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ).json()["enrollment_code"] + second = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": second_code, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": device_uuid, + "display_name": "Phone B", + }, + ) + assert second.status_code == 200 + assert second.json()["device_id"] == first_device_id + + devices = client.get("/api/v1/mobile/devices", headers=jwt_headers) + assert devices.status_code == 200 + assert len(devices.json()) == 1 + assert devices.json()[0]["display_name"] == "Phone B" + assert devices.json()[0]["is_active"] is True + + +def test_delete_device_record(client, db_session, jwt_headers): + _enable_mobile(db_session) + create = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ) + code = create.json()["enrollment_code"] + enroll = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": code, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": "uuid-delete-001", + }, + ) + device_id = enroll.json()["device_id"] + + deleted = client.delete(f"/api/v1/mobile/devices/{device_id}", headers=jwt_headers) + assert deleted.status_code == 204 + + devices = client.get("/api/v1/mobile/devices", headers=jwt_headers) + assert devices.status_code == 200 + assert devices.json() == [] diff --git a/backend/tests/test_mobile_notify.py b/backend/tests/test_mobile_notify.py new file mode 100644 index 0000000..8aca1ac --- /dev/null +++ b/backend/tests/test_mobile_notify.py @@ -0,0 +1,99 @@ +"""FCM push must not break event ingest.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from app.models import Event, Host +from app.services.mobile_notify import MobileNotConfiguredError, _event_payload, _send_fcm_data + + +def test_event_payload_lifecycle_uses_telegram_wording(): + host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000511", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="short", + details={"lifecycle": "started", "trigger": "settings_reload", "telegram_via": "sac"}, + payload={"source": {"product": "ssh-monitor", "product_version": "1.4.0-SAC"}}, + ) + title, body, data = _event_payload(event) + assert title == "✅ Агент запущен" + assert "graceful restart" in body + assert data["type"] == "agent.lifecycle" + + +def test_send_fcm_data_swallows_token_errors_by_default(): + with patch( + "app.services.mobile_notify._fcm_access_token", + side_effect=MobileNotConfiguredError("FCM: install requests"), + ): + with patch("app.services.mobile_notify.get_effective_fcm_config") as mock_cfg: + mock_cfg.return_value.enabled = True + mock_cfg.return_value.project_id = "proj" + mock_cfg.return_value.service_account_path = "/etc/fcm.json" + mock_cfg.return_value.configured = True + mock_cfg.return_value.active = True + _send_fcm_data(["token"], {"kind": "test"}, title="t", body="b") + + +def test_send_fcm_data_raises_token_errors_when_required(): + with patch( + "app.services.mobile_notify._fcm_access_token", + side_effect=MobileNotConfiguredError("FCM: install requests"), + ): + with patch("app.services.mobile_notify.get_effective_fcm_config") as mock_cfg: + mock_cfg.return_value.enabled = True + mock_cfg.return_value.project_id = "proj" + mock_cfg.return_value.service_account_path = "/etc/fcm.json" + mock_cfg.return_value.configured = True + mock_cfg.return_value.active = True + with pytest.raises(MobileNotConfiguredError): + _send_fcm_data( + ["token"], + {"kind": "test"}, + title="t", + body="b", + require_success=True, + ) + + +def test_send_fcm_data_payload_is_data_only(): + posted: list[dict] = [] + + def capture_post(_url, *, headers, json): + posted.append(json) + response = MagicMock() + response.raise_for_status.return_value = None + return response + + with patch("app.services.mobile_notify._fcm_access_token", return_value="token"): + with patch("app.services.mobile_notify.get_effective_fcm_config") as mock_cfg: + mock_cfg.return_value.enabled = True + mock_cfg.return_value.project_id = "proj" + mock_cfg.return_value.service_account_path = "/etc/fcm.json" + mock_cfg.return_value.configured = True + mock_cfg.return_value.active = True + with patch("httpx.Client") as mock_client: + mock_client.return_value.__enter__.return_value.post = capture_post + _send_fcm_data( + ["device-token"], + {"kind": "event", "id": "42", "severity": "high"}, + title="Alert", + body="Summary text", + ) + + assert len(posted) == 1 + message = posted[0]["message"] + assert "notification" not in message + assert message["data"]["title"] == "Alert" + assert message["data"]["body"] == "Summary text" + assert message["data"]["kind"] == "event" + assert message["data"]["id"] == "42" diff --git a/backend/tests/test_notification_cooldown.py b/backend/tests/test_notification_cooldown.py new file mode 100644 index 0000000..75bab28 --- /dev/null +++ b/backend/tests/test_notification_cooldown.py @@ -0,0 +1,85 @@ +"""Notification cooldown / dedup (notif-31).""" + +from datetime import datetime, timedelta, timezone + +from app.config import get_settings +from app.models import Event +from app.models.notification_cooldown import NotificationCooldown +from app.services.notification_cooldown import ( + allow_notify, + build_event_cooldown_key, + build_problem_cooldown_key, + should_notify_event, +) +from app.models import Problem + + +def test_build_event_cooldown_key_prefers_dedup_key(): + event = Event( + event_id="00000000-0000-4000-8000-000000000601", + host_id=1, + occurred_at=datetime(2026, 5, 29, 16, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="t", + summary="s", + dedup_key="rdp|host|failed|10.0.0.1|admin", + payload={}, + ) + assert build_event_cooldown_key(event) == "event:rdp|host|failed|10.0.0.1|admin" + + +def test_allow_notify_blocks_within_cooldown(db_session, monkeypatch): + monkeypatch.setenv("SAC_NOTIFY_COOLDOWN_ENABLED", "true") + monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "90") + get_settings.cache_clear() + + key = "event:test-key" + past = datetime.now(timezone.utc) - timedelta(seconds=30) + db_session.add(NotificationCooldown(cooldown_key=key, kind="event", last_notified_at=past)) + db_session.commit() + + assert allow_notify(db_session, key=key, kind="event") is False + + +def test_allow_notify_allows_after_cooldown(db_session, monkeypatch): + monkeypatch.setenv("SAC_NOTIFY_COOLDOWN_ENABLED", "true") + monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "60") + get_settings.cache_clear() + + key = "event:old" + past = datetime.now(timezone.utc) - timedelta(seconds=120) + db_session.add(NotificationCooldown(cooldown_key=key, kind="event", last_notified_at=past)) + db_session.commit() + + assert allow_notify(db_session, key=key, kind="event") is True + + +def test_should_notify_event_exempt_agent_test(db_session, monkeypatch): + monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "90") + get_settings.cache_clear() + + event = Event( + event_id="00000000-0000-4000-8000-000000000602", + host_id=1, + occurred_at=datetime(2026, 5, 29, 16, 0, tzinfo=timezone.utc), + category="agent", + type="agent.test", + severity="info", + title="t", + summary="s", + payload={}, + ) + assert should_notify_event(event, db_session) is True + + +def test_build_problem_cooldown_key(): + problem = Problem( + title="brute", + summary="x", + severity="high", + status="open", + fingerprint="host:1|rdp.login.failed|rule:brute", + ) + assert build_problem_cooldown_key(problem).startswith("problem:") diff --git a/backend/tests/test_notification_policy.py b/backend/tests/test_notification_policy.py new file mode 100644 index 0000000..dd28629 --- /dev/null +++ b/backend/tests/test_notification_policy.py @@ -0,0 +1,65 @@ +"""Global notification policy (notif-22).""" + +from app.config import get_settings +from app.models.notification_policy import NotificationPolicy +from app.services.notification_policy import get_effective_notification_policy, upsert_notification_policy +from app.services.notification_severity import severity_meets_minimum + + +def test_severity_meets_minimum(): + assert severity_meets_minimum("warning", "warning") + assert severity_meets_minimum("high", "warning") + assert not severity_meets_minimum("info", "warning") + + +def test_put_policy_persists_db(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("NOTIFY_MIN_SEVERITY", "high") + monkeypatch.setenv("NOTIFY_CHANNELS", "telegram") + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/notifications/policy", + headers=jwt_headers, + json={ + "min_severity": "warning", + "use_telegram": True, + "use_webhook": True, + "use_email": False, + "use_mobile": False, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["min_severity"] == "warning" + assert body["use_webhook"] is True + assert body["source"] == "db" + + row = db_session.get(NotificationPolicy, 1) + assert row is not None + assert row.use_webhook is True + + cfg = get_effective_notification_policy(db_session) + assert cfg.min_severity == "warning" + + +def test_upsert_policy_syncs_channel_min_severity(db_session, monkeypatch): + monkeypatch.setenv("NOTIFY_MIN_SEVERITY", "high") + get_settings.cache_clear() + + from app.models.notification_channel import NotificationChannel + + db_session.add( + NotificationChannel(channel="telegram", enabled=False, min_severity="high") + ) + db_session.commit() + + upsert_notification_policy( + db_session, + min_severity="critical", + use_telegram=True, + use_webhook=False, + use_email=False, + use_mobile=False, + ) + row = db_session.get(NotificationChannel, "telegram") + assert row.min_severity == "critical" diff --git a/backend/tests/test_notify_dispatch.py b/backend/tests/test_notify_dispatch.py new file mode 100644 index 0000000..c03e59a --- /dev/null +++ b/backend/tests/test_notify_dispatch.py @@ -0,0 +1,366 @@ +"""notify_dispatch respects global policy.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from app.models import Event +from app.services import notify_dispatch +from app.services.notification_policy import NotificationPolicyConfig + + +def test_notify_event_calls_selected_channels(): + event = Event( + event_id="00000000-0000-4000-8000-000000000401", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="skip", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_not_called() + + +def test_notify_event_calls_selected_channels(): + event = Event( + event_id="00000000-0000-4000-8000-000000000402", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="fail", + summary="send", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + with patch.object(notify_dispatch, "email_notify") as mock_em: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_called_once() + mock_wh.notify_event.assert_called_once() + mock_em.notify_event.assert_not_called() + + +def test_notify_event_skips_agent_heartbeat(): + event = Event( + event_id="00000000-0000-4000-8000-000000000404", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="agent", + type="agent.heartbeat", + severity="info", + title="heartbeat", + summary="skip", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="info", + use_telegram=True, + use_webhook=True, + use_email=True, + use_mobile=True, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + with patch.object(notify_dispatch, "email_notify") as mock_em: + with patch.object(notify_dispatch, "mobile_notify") as mock_mob: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_not_called() + mock_em.notify_event.assert_not_called() + mock_mob.notify_event.assert_not_called() + + +def test_notify_event_skipped_by_cooldown(): + event = Event( + event_id="00000000-0000-4000-8000-000000000403", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="fail", + summary="send", + dedup_key="same-key", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=False): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_not_called() + + +def test_notify_event_skips_hidden_event_type(db_session): + from app.models.event_type_visibility import EventTypeVisibility + + db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False)) + db_session.commit() + + event = Event( + event_id="00000000-0000-4000-8000-000000000701", + host_id=1, + occurred_at=datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc), + category="agent", + type="agent.inventory", + severity="warning", + title="Hardware changed", + summary="memory", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=True, + use_mobile=True, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + with patch.object(notify_dispatch, "email_notify") as mock_em: + with patch.object(notify_dispatch, "mobile_notify") as mock_mob: + notify_dispatch.notify_event(event, db=db_session) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_not_called() + mock_em.notify_event.assert_not_called() + mock_mob.notify_event.assert_not_called() + + +def test_notify_auth_login_bypasses_min_severity(): + event = Event( + event_id="00000000-0000-4000-8000-000000000601", + host_id=1, + occurred_at=datetime(2026, 5, 31, 18, 36, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="RDP login event 4624", + summary="papatramp from 192.168.160.3", + details={"telegram_via": "sac"}, + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_auth_login(event) + mock_tg.notify_event.assert_called_once() + + +def test_notify_rdg_connection_bypasses_min_severity(): + event = Event( + event_id="00000000-0000-4000-8000-000000000603", + host_id=1, + occurred_at=datetime(2026, 5, 31, 18, 36, tzinfo=timezone.utc), + category="auth", + type="rdg.connection.success", + severity="info", + title="RD Gateway event 302", + summary="RDG 302 papatramp -> 192.168.160.3", + details={"telegram_via": "sac"}, + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_rdg_connection(event) + mock_tg.notify_event.assert_called_once() + + +def test_notify_auth_login_skips_telegram_when_via_agent(): + event = Event( + event_id="00000000-0000-4000-8000-000000000602", + host_id=1, + occurred_at=datetime(2026, 5, 31, 18, 36, tzinfo=timezone.utc), + category="auth", + type="ssh.login.success", + severity="info", + title="SSH login", + summary="user from 10.0.0.1", + details={"telegram_via": "agent"}, + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + notify_dispatch.notify_auth_login(event) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_called_once() + + +def test_notify_lifecycle_bypasses_min_severity(): + event = Event( + event_id="00000000-0000-4000-8000-000000000501", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="agent started", + details={"lifecycle": "started", "telegram_via": "sac"}, + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_lifecycle(event) + mock_tg.notify_event.assert_called_once() + + +def test_notify_lifecycle_skips_telegram_when_via_agent(): + event = Event( + event_id="00000000-0000-4000-8000-000000000502", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="agent started", + details={"lifecycle": "started", "telegram_via": "agent"}, + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + notify_dispatch.notify_lifecycle(event) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_called_once() + + +def test_notify_daily_report_skips_telegram_when_via_agent(): + event = Event( + event_id="00000000-0000-4000-8000-000000000604", + host_id=1, + occurred_at=datetime(2026, 6, 3, 9, 0, tzinfo=timezone.utc), + category="report", + type="report.daily.rdp", + severity="info", + title="Ежедневный отчёт Windows", + summary="RDP 24ч: успех 0, неудач 0, банов 0", + details={"telegram_via": "agent", "report_body": "📊 body"}, + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + notify_dispatch.notify_daily_report(event) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_called_once() + + +def test_notify_daily_report_skips_telegram_when_via_agent(): + event = Event( + event_id="00000000-0000-4000-8000-000000000604", + host_id=1, + occurred_at=datetime(2026, 6, 3, 9, 0, tzinfo=timezone.utc), + category="report", + type="report.daily.rdp", + severity="info", + title="Ежедневный отчёт Windows", + summary="RDP 24ч: успех 0, неудач 0, банов 0", + details={"telegram_via": "agent", "report_body": "📊 body"}, + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=False, + use_mobile=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + notify_dispatch.notify_daily_report(event) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_called_once() diff --git a/backend/tests/test_problem_rules.py b/backend/tests/test_problem_rules.py new file mode 100644 index 0000000..187ab07 --- /dev/null +++ b/backend/tests/test_problem_rules.py @@ -0,0 +1,280 @@ +"""Unit tests for problem rules v1.""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select + +from app.config import get_settings +from app.models import Event, Problem +from app.services.ingest import ingest_event +from app.services.problem_rules import ( + RULE_BRUTE_FORCE, + RULE_HOST_SILENCE, + RULE_PRIVILEGE_SPIKE, + evaluate_brute_force_burst, + evaluate_host_silence, + evaluate_privilege_spike, + last_heartbeat_at, +) +from app.services.problems import maybe_create_problem +from tests.test_ingest import VALID_EVENT + + +def _payload(**overrides): + now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + base = { + **VALID_EVENT, + "event_id": str(uuid.uuid4()), + "occurred_at": now, + } + base.update(overrides) + return base + + +def _ingest(db, **overrides): + event, _ = ingest_event(db, _payload(**overrides)) + db.flush() + return event + + +@pytest.fixture +def rule_settings(monkeypatch): + monkeypatch.setenv("SAC_BRUTE_FORCE_THRESHOLD", "3") + monkeypatch.setenv("SAC_BRUTE_FORCE_WINDOW_MINUTES", "15") + monkeypatch.setenv("SAC_PRIVILEGE_SPIKE_THRESHOLD", "3") + monkeypatch.setenv("SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES", "10") + monkeypatch.setenv("SAC_HEARTBEAT_STALE_MINUTES", "60") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def test_brute_force_burst_at_threshold(db_session, rule_settings): + for _ in range(3): + _ingest( + db_session, + type="ssh.login.failed", + severity="warning", + title="failed", + summary="fail", + details={"source_ip": "10.0.0.9"}, + ) + event = _ingest( + db_session, + type="ssh.login.failed", + severity="warning", + title="failed", + summary="fail", + details={"source_ip": "10.0.0.9"}, + ) + match = evaluate_brute_force_burst(db_session, event) + assert match is not None + assert match.rule_id == RULE_BRUTE_FORCE + problem, created = maybe_create_problem(db_session, event) + assert created is True + assert problem.rule_id == RULE_BRUTE_FORCE + + +def test_brute_force_below_threshold(db_session, rule_settings): + event = _ingest( + db_session, + type="ssh.login.failed", + severity="warning", + title="failed", + summary="fail", + details={"source_ip": "10.0.0.1"}, + ) + assert evaluate_brute_force_burst(db_session, event) is None + + +def test_privilege_spike_at_threshold(db_session, rule_settings): + for _ in range(2): + _ingest( + db_session, + type="privilege.sudo.command", + severity="warning", + title="sudo", + summary="sudo cmd", + ) + event = _ingest( + db_session, + type="privilege.sudo.command", + severity="warning", + title="sudo", + summary="sudo cmd", + ) + match = evaluate_privilege_spike(db_session, event) + assert match is not None + assert match.rule_id == RULE_PRIVILEGE_SPIKE + problem, created = maybe_create_problem(db_session, event) + assert created is True + assert problem.rule_id == RULE_PRIVILEGE_SPIKE + + +def test_host_silence_when_heartbeat_stale(db_session, rule_settings): + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + assert last_heartbeat_at(db_session, hb.host_id) is not None + event = _ingest( + db_session, + type="ssh.login.success", + severity="info", + title="ok", + summary="login", + ) + match = evaluate_host_silence(db_session, event) + assert match is not None + assert match.rule_id == RULE_HOST_SILENCE + problem, created = maybe_create_problem(db_session, event) + assert created is True + assert problem.rule_id == RULE_HOST_SILENCE + + +def test_heartbeat_resolves_host_silence(db_session, rule_settings): + hb_old = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb_old.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + trigger = _ingest( + db_session, + type="ssh.login.success", + severity="info", + title="ok", + summary="login", + ) + maybe_create_problem(db_session, trigger) + db_session.flush() + + open_silence = db_session.scalar( + select(Problem).where( + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status == "open", + ) + ) + assert open_silence is not None + + fresh_hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb fresh", + summary="heartbeat ok", + ) + maybe_create_problem(db_session, fresh_hb) + db_session.refresh(open_silence) + assert open_silence.status == "resolved" + assert open_silence.resolved_by == "auto" + + +def test_host_silence_suppressed_after_manual_resolve_on_ingest(db_session, rule_settings, monkeypatch): + monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12") + get_settings.cache_clear() + + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + trigger = _ingest( + db_session, + type="ssh.login.success", + severity="info", + title="ok", + summary="login", + ) + problem, created = maybe_create_problem(db_session, trigger) + assert created is True + problem.status = "resolved" + problem.resolved_by = "manual" + problem.updated_at = datetime.now(timezone.utc) + db_session.flush() + + trigger2 = _ingest( + db_session, + type="ssh.login.success", + severity="info", + title="ok2", + summary="login2", + ) + problem2, created2 = maybe_create_problem(db_session, trigger2) + assert problem2 is None + assert created2 is False + + open_silence = db_session.scalar( + select(Problem).where( + Problem.rule_id == RULE_HOST_SILENCE, + Problem.status == "open", + ) + ) + assert open_silence is None + + +def test_host_silence_reopens_after_auto_resolve_when_still_stale(db_session, rule_settings, monkeypatch): + monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12") + get_settings.cache_clear() + + hb = _ingest( + db_session, + type="agent.heartbeat", + category="agent", + severity="info", + title="hb", + summary="heartbeat", + ) + hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.flush() + + trigger = _ingest( + db_session, + type="ssh.login.success", + severity="info", + title="ok", + summary="login", + ) + problem, created = maybe_create_problem(db_session, trigger) + assert created is True + + from app.services.problem_rules import resolve_host_silence_problems + + resolve_host_silence_problems(db_session, hb.host_id) + db_session.refresh(problem) + assert problem.status == "resolved" + assert problem.resolved_by == "auto" + + trigger2 = _ingest( + db_session, + type="ssh.login.success", + severity="info", + title="ok2", + summary="login2", + ) + problem2, created2 = maybe_create_problem(db_session, trigger2) + assert created2 is True + assert problem2 is not None + assert problem2.status == "open" + assert problem2.id != problem.id diff --git a/backend/tests/test_problems.py b/backend/tests/test_problems.py new file mode 100644 index 0000000..62c862b --- /dev/null +++ b/backend/tests/test_problems.py @@ -0,0 +1,200 @@ +"""Problems correlation and API smoke tests.""" + +import uuid +from datetime import datetime, timedelta, timezone + +from app.models import Host, Problem +from tests.test_ingest import VALID_EVENT + + +def _event_payload(**overrides): + now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + base = { + **VALID_EVENT, + "event_id": str(uuid.uuid4()), + "occurred_at": now, + "severity": "high", + "type": "ssh.ip.banned", + "title": "Ban test", + "summary": "test problem correlation", + } + base.update(overrides) + return base + + +def test_problem_correlates_two_events(client, auth_headers, jwt_headers): + e1 = _event_payload() + e2 = _event_payload(title="Ban test 2") + assert client.post("/api/v1/events", json=e1, headers=auth_headers).status_code == 201 + assert client.post("/api/v1/events", json=e2, headers=auth_headers).status_code == 201 + + r = client.get("/api/v1/problems?status=open", headers=jwt_headers) + assert r.status_code == 200 + data = r.json() + assert data["total"] == 1 + problem = data["items"][0] + assert problem["event_count"] == 2 + assert problem["fingerprint"].startswith("h") + assert ":tssh.ip.banned:" in problem["fingerprint"] + + detail = client.get(f"/api/v1/problems/{problem['id']}", headers=jwt_headers) + assert detail.status_code == 200 + assert len(detail.json()["events"]) == 2 + + +def test_problem_ack_and_resolve(client, auth_headers, jwt_headers): + payload = _event_payload() + client.post("/api/v1/events", json=payload, headers=auth_headers) + pid = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"][0]["id"] + + ack = client.post(f"/api/v1/problems/{pid}/ack", headers=jwt_headers) + assert ack.status_code == 200 + assert ack.json()["status"] == "acknowledged" + + res = client.post(f"/api/v1/problems/{pid}/resolve", headers=jwt_headers) + assert res.status_code == 200 + assert res.json()["status"] == "resolved" + + +def test_new_problem_after_resolve(client, auth_headers, jwt_headers): + p1 = _event_payload() + client.post("/api/v1/events", json=p1, headers=auth_headers) + pid = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"][0]["id"] + client.post(f"/api/v1/problems/{pid}/resolve", headers=jwt_headers) + + p2 = _event_payload(summary="after resolve") + client.post("/api/v1/events", json=p2, headers=auth_headers) + open_items = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"] + assert len(open_items) == 1 + assert open_items[0]["event_count"] == 1 + assert open_items[0]["id"] != pid + + +def test_problems_created_within_hours(client, db_session, jwt_headers): + now = datetime.now(timezone.utc) + h = Host( + hostname="p-host", + os_family="linux", + product="ssh-monitor", + last_seen_at=now, + ) + db_session.add(h) + db_session.flush() + db_session.add( + Problem( + host_id=h.id, + title="recent", + summary="s", + severity="warning", + status="open", + rule_id="rule:test", + fingerprint="fp-recent", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(hours=2), + updated_at=now - timedelta(hours=2), + ) + ) + db_session.add( + Problem( + host_id=h.id, + title="old", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp-old", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(days=3), + updated_at=now - timedelta(days=2), + ) + ) + db_session.commit() + + r = client.get("/api/v1/problems?created_within_hours=24", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["title"] == "recent" + + +def test_problems_created_within_hours_excludes_resolved(client, db_session, jwt_headers): + now = datetime.now(timezone.utc) + h = Host( + hostname="p-host2", + os_family="linux", + product="ssh-monitor", + last_seen_at=now, + ) + db_session.add(h) + db_session.flush() + db_session.add( + Problem( + host_id=h.id, + title="closed recently", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp-closed-recent", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(hours=1), + updated_at=now - timedelta(minutes=30), + ) + ) + db_session.commit() + + r = client.get("/api/v1/problems?created_within_hours=24", headers=jwt_headers) + assert r.status_code == 200 + assert r.json()["total"] == 0 + + +def test_problems_resolved_within_hours(client, db_session, jwt_headers): + now = datetime.now(timezone.utc) + h = Host( + hostname="r-host", + os_family="linux", + product="ssh-monitor", + last_seen_at=now, + ) + db_session.add(h) + db_session.flush() + db_session.add( + Problem( + host_id=h.id, + title="closed today", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp-closed-today", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(days=2), + updated_at=now - timedelta(hours=1), + ) + ) + db_session.add( + Problem( + host_id=h.id, + title="closed long ago", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp-closed-old", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(days=10), + updated_at=now - timedelta(days=5), + ) + ) + db_session.commit() + + r = client.get("/api/v1/problems?resolved_within_hours=24", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["title"] == "closed today" diff --git a/backend/tests/test_rdg_client_host.py b/backend/tests/test_rdg_client_host.py new file mode 100644 index 0000000..caacd7e --- /dev/null +++ b/backend/tests/test_rdg_client_host.py @@ -0,0 +1,55 @@ +"""Tests for RDG client workstation lookup.""" + +from datetime import datetime, timezone + +from app.models import Host +from app.services.rdg_client_host import find_windows_host_by_ipv4, resolve_client_workstation + + +def test_find_windows_host_by_ipv4(db_session): + host = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.113", + ) + db_session.add(host) + db_session.commit() + + found = find_windows_host_by_ipv4(db_session, "192.168.160.113") + assert found is not None + assert found.hostname == "Andrisonova-PC" + + +def test_resolve_client_workstation_from_event(db_session): + from app.models import Event + + ws = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.113", + ) + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add_all([ws, gw]) + db_session.commit() + + event = Event( + event_id="ev-1", + host_id=gw.id, + occurred_at=datetime(2026, 6, 20, tzinfo=timezone.utc), + received_at=datetime(2026, 6, 20, tzinfo=timezone.utc), + category="auth", + type="rdg.connection.disconnected", + severity="info", + title="303", + summary="", + payload={}, + details={"user": r"B26\user", "internal_ip": "192.168.160.113", "rdg_flap": True}, + ) + db_session.add(event) + db_session.commit() + + client = resolve_client_workstation(db_session, event) + assert client.id == ws.id + assert client.hostname == "Andrisonova-PC" diff --git a/backend/tests/test_rdg_display.py b/backend/tests/test_rdg_display.py new file mode 100644 index 0000000..42a3beb --- /dev/null +++ b/backend/tests/test_rdg_display.py @@ -0,0 +1,136 @@ +"""Tests for RDG event display (access path, title/summary).""" + +from datetime import datetime, timezone + +import pytest + +from app.models import Event, Host +from app.services.event_summary import event_to_summary +from app.services.rdg_display import ( + ACCESS_PATH_DIRECT, + ACCESS_PATH_HAPROXY, + build_rdg_display, + classify_rdg_access_path, + event_supports_rdg_client_qwinsta, +) + + +def _rdg_event( + db_session, + *, + gw: Host, + event_type: str = "rdg.connection.success", + external_ip: str = "10.0.0.5", + internal_ip: str = "192.168.160.3", + win_id: int = 302, +) -> Event: + event = Event( + event_id=f"ev-rdg-{win_id}", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type=event_type, + severity="info", + title="RD Gateway event 302", + summary="", + payload={}, + details={ + "user": r"B26\papatramp", + "external_ip": external_ip, + "internal_ip": internal_ip, + "event_id_windows": win_id, + }, + ) + db_session.add(event) + db_session.commit() + db_session.refresh(event) + return event + + +def test_classify_rdg_access_path_direct(monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50") + from app.config import get_settings + + get_settings.cache_clear() + assert classify_rdg_access_path("10.0.0.5") == ACCESS_PATH_DIRECT + + +def test_classify_rdg_access_path_haproxy(monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50,10.0.0.100") + from app.config import get_settings + + get_settings.cache_clear() + assert classify_rdg_access_path("10.0.0.100") == ACCESS_PATH_HAPROXY + + +def test_build_rdg_display_title_and_path(db_session, monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "") + from app.config import get_settings + + get_settings.cache_clear() + + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + ws = Host(hostname="WS-PC", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.3") + db_session.add_all([gw, ws]) + db_session.commit() + + event = _rdg_event(db_session, gw=gw) + display = build_rdg_display(event, db_session) + assert display is not None + assert ACCESS_PATH_DIRECT in display.title + assert "WS-PC" in display.title + assert "192.168.160.3" in display.title + assert display.access_path == ACCESS_PATH_DIRECT + assert display.qwinsta_enabled is True + assert "papatramp" in display.summary + assert "шлюз K6A-DC3" in display.summary + assert "event 302" in display.title + + +def test_event_to_summary_enriches_rdg(db_session, monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "10.0.0.5") + from app.config import get_settings + + get_settings.cache_clear() + + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + + event = _rdg_event(db_session, gw=gw, external_ip="10.0.0.5") + summary = event_to_summary(event, db_session) + assert summary.rdg_access_path == ACCESS_PATH_HAPROXY + assert summary.rdg_qwinsta_enabled is True + assert "RDS подключение" in summary.title + assert ACCESS_PATH_HAPROXY in summary.title + + +def test_event_supports_rdg_client_qwinsta_without_flap(db_session): + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + event = _rdg_event(db_session, gw=gw) + assert event_supports_rdg_client_qwinsta(event, db_session) is True + + +def test_event_supports_rdg_client_qwinsta_not_on_disconnect(db_session): + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + event = _rdg_event(db_session, gw=gw, event_type="rdg.connection.disconnected", win_id=303) + assert event_supports_rdg_client_qwinsta(event, db_session) is False + + +@pytest.mark.parametrize( + "internal_ip", + ["", None], +) +def test_event_supports_rdg_client_qwinsta_requires_internal_ip(db_session, internal_ip): + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + event = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "") + if internal_ip is None: + event.details = {k: v for k, v in event.details.items() if k != "internal_ip"} + assert event_supports_rdg_client_qwinsta(event, db_session) is False diff --git a/backend/tests/test_rdg_session_flap.py b/backend/tests/test_rdg_session_flap.py new file mode 100644 index 0000000..1d2c8e7 --- /dev/null +++ b/backend/tests/test_rdg_session_flap.py @@ -0,0 +1,339 @@ +"""Tests for RDG 302→303 session flap rule.""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from app.config import get_settings +from app.models import Problem +from app.services.ingest import ingest_event +from app.services.problems import maybe_create_problem +from app.services.problem_rules import RULE_RDG_SESSION_FLAP +from app.services.rdg_session_flap import ( + evaluate_rdg_session_flap, + event_has_rdg_flap, + find_rdg_success_before_end, + resolve_rdg_flap_summary, + resolve_rdg_qwinsta_enabled, +) +from app.services.event_summary import event_to_summary +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 + + +@pytest.fixture +def rdg_settings(monkeypatch): + monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1") + monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10") + monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def test_rdg_flap_detects_302_then_303(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = "B26\\test.user" + details = {"user": user, "internal_ip": "192.168.163.32"} + + _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="info", + title="RD Gateway event 302", + summary="302", + details=details, + ) + end = _ingest( + db_session, + t0 + timedelta(seconds=4), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="RD Gateway event 303", + summary="303", + details=details, + ) + + match = evaluate_rdg_session_flap(db_session, end) + assert match is not None + assert match.rule_id == RULE_RDG_SESSION_FLAP + assert event_has_rdg_flap(end) is True + + problem, created = maybe_create_problem(db_session, end) + assert created is True + assert problem.rule_id == RULE_RDG_SESSION_FLAP + + +def test_rdg_flap_ignores_gap_over_10_sec(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = "B26\\slow.user" + details = {"user": user} + + _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + end = _ingest( + db_session, + t0 + timedelta(seconds=15), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + + assert find_rdg_success_before_end(db_session, end) is None + assert evaluate_rdg_session_flap(db_session, end) is None + + +def test_rdg_flap_dedup_within_30_sec(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = "B26\\dup.user" + details = {"user": user} + + def pair(at: datetime): + _ingest( + db_session, + at, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + return _ingest( + db_session, + at + timedelta(seconds=5), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + + end1 = pair(t0) + maybe_create_problem(db_session, end1) + db_session.flush() + + end2 = pair(t0 + timedelta(seconds=10)) + maybe_create_problem(db_session, end2) + db_session.flush() + + from sqlalchemy import func, select + + open_count = db_session.scalar( + select(func.count()).select_from(Problem).where( + Problem.rule_id == RULE_RDG_SESSION_FLAP, + Problem.status == "open", + ) + ) + assert open_count == 1 + + +def test_resolve_rdg_flap_summary_for_302_and_303(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = "B26\\pair.user" + details = {"user": user, "internal_ip": "192.168.163.48"} + + start = _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="warning", + title="RD Gateway event 302", + summary="302", + details=details, + ) + end = _ingest( + db_session, + t0 + timedelta(seconds=5), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="RD Gateway event 303", + summary="303", + details=details, + ) + evaluate_rdg_session_flap(db_session, end) + db_session.flush() + + end_flap, end_pair, end_qwinsta = resolve_rdg_flap_summary(db_session, end) + start_flap, start_pair, start_qwinsta = resolve_rdg_flap_summary(db_session, start) + + assert end_flap is True + assert end_pair == start.id + assert end_qwinsta == start.id + + assert start_flap is True + assert start_pair == end.id + assert start_qwinsta == start.id + + end_summary = event_to_summary(end, db_session) + start_summary = event_to_summary(start, db_session) + assert end_summary.rdg_flap is True + assert start_summary.rdg_flap is True + assert start_summary.rdg_flap_qwinsta_event_id == start.id + assert start_summary.rdg_qwinsta_enabled is True + assert end_summary.rdg_qwinsta_enabled is False + + +def test_rdg_qwinsta_disabled_after_normal_session_end(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = "B26\\normal.user" + details = {"user": user, "internal_ip": "192.168.163.49"} + + start = _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + end = _ingest( + db_session, + t0 + timedelta(minutes=20), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + + assert resolve_rdg_qwinsta_enabled(db_session, start) is False + assert resolve_rdg_qwinsta_enabled(db_session, end) is False + + start_summary = event_to_summary(start, db_session) + end_summary = event_to_summary(end, db_session) + assert start_summary.rdg_qwinsta_enabled is False + assert end_summary.rdg_qwinsta_enabled is False + + +def test_rdg_qwinsta_enabled_while_session_open(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + details = {"user": r"B26\active.user", "internal_ip": "192.168.163.50"} + + start = _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + + assert resolve_rdg_qwinsta_enabled(db_session, start) is True + assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True + + +def test_rdg_qwinsta_disabled_on_flap_302_after_later_success(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = r"B26\m.semenova" + details = {"user": user, "internal_ip": "192.168.164.45"} + + flap_start = _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="warning", + title="302", + summary="302", + details=details, + ) + flap_end = _ingest( + db_session, + t0 + timedelta(seconds=5), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + evaluate_rdg_session_flap(db_session, flap_end) + db_session.flush() + + assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is True + + _ingest( + db_session, + t0 + timedelta(minutes=2, seconds=44), + type="rdg.connection.success", + category="auth", + severity="warning", + title="302", + summary="302", + details=details, + ) + + assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is False + assert event_to_summary(flap_start, db_session).rdg_qwinsta_enabled is False + + +def test_rdg_qwinsta_stays_enabled_when_unrelated_303_without_internal_ip(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = "B26\\khodasevich" + details_302 = {"user": user, "internal_ip": "192.168.160.209"} + details_303_other = {"user": user} + + start = _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="warning", + title="302", + summary="302", + details=details_302, + ) + _ingest( + db_session, + t0 + timedelta(minutes=5), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details_303_other, + ) + + assert resolve_rdg_qwinsta_enabled(db_session, start) is True + assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True diff --git a/backend/tests/test_rdg_winrm_actions.py b/backend/tests/test_rdg_winrm_actions.py new file mode 100644 index 0000000..e274046 --- /dev/null +++ b/backend/tests/test_rdg_winrm_actions.py @@ -0,0 +1,223 @@ +"""API tests for RDG qwinsta/logoff via WinRM on client workstation.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from sqlalchemy import inspect as sa_inspect + +from app.models import Event, Host +from app.services.winrm_connect import WinRmCmdResult + + +def _rdg_success_event(db_session, *, gw: Host, ws: Host) -> Event: + event = Event( + event_id="ev-rdg-302", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.success", + severity="info", + title="RD Gateway event 302", + summary="", + payload={}, + details={ + "user": r"B26\papatramp", + "internal_ip": ws.ipv4, + }, + ) + db_session.add(event) + db_session.commit() + db_session.refresh(event) + return event + + +def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + ws = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.113", + ) + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add_all([ws, gw]) + db_session.commit() + ws_id = ws.id + event = _rdg_success_event(db_session, gw=gw, ws=ws) + + qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n" + + with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run: + mock_run.return_value = ( + WinRmCmdResult( + ok=True, + message="WinRM OK (Andrisonova-PC), exit 0", + target="Andrisonova-PC", + stdout=qwinsta_out, + exit_code=0, + ), + ["Andrisonova-PC=OK"], + ) + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + mock_run.assert_called_once() + called_identity = sa_inspect(mock_run.call_args.args[0]).identity + assert called_identity is not None + assert called_identity[0] == ws_id + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "completed" + assert body["client_hostname"] == "Andrisonova-PC" + assert body["target"] == "Andrisonova-PC" + assert "papatramp" in (body["result_stdout"] or "") + + +def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + + event = Event( + event_id="ev-flap-2", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.success", + severity="info", + title="302", + summary="", + payload={}, + details={"user": r"B26\user", "internal_ip": "192.168.160.999"}, + ) + db_session.add(event) + db_session.commit() + + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +def test_qwinsta_without_rdg_flap(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + ws = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.113", + ) + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add_all([ws, gw]) + db_session.commit() + + event = Event( + event_id="ev-rdg-plain", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.success", + severity="info", + title="302", + summary="", + payload={}, + details={"user": r"B26\papatramp", "internal_ip": ws.ipv4}, + ) + db_session.add(event) + db_session.commit() + + with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run: + mock_run.return_value = ( + WinRmCmdResult(ok=True, message="OK", target="Andrisonova-PC", stdout="ok", exit_code=0), + ["Andrisonova-PC=OK"], + ) + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + + assert response.status_code == 200 + mock_run.assert_called_once() + + +def test_qwinsta_rejects_rdg_without_internal_ip(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + + event = Event( + event_id="ev-rdg-no-ip", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.success", + severity="info", + title="302", + summary="", + payload={}, + details={"user": r"B26\user"}, + ) + db_session.add(event) + db_session.commit() + + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + assert response.status_code == 400 + assert "internal_ip" in response.json()["detail"].lower() + + +def test_qwinsta_rejects_rdg_303_disconnect(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + ws = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.113", + ) + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add_all([ws, gw]) + db_session.commit() + + event = Event( + event_id="ev-rdg-303", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.disconnected", + severity="info", + title="303", + summary="", + payload={}, + details={"user": r"B26\papatramp", "internal_ip": ws.ipv4}, + ) + db_session.add(event) + db_session.commit() + + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + assert response.status_code == 400 diff --git a/backend/tests/test_rdg_workstation_session.py b/backend/tests/test_rdg_workstation_session.py new file mode 100644 index 0000000..421f173 --- /dev/null +++ b/backend/tests/test_rdg_workstation_session.py @@ -0,0 +1,378 @@ +"""Tests for RDG 303 → workstation rdp.login.success correlation.""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from app.config import get_settings +from app.models import Host +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.rdg_workstation_session import ( + SESSION_CLOSED_BY_RDG_AT_KEY, + SESSION_CLOSED_BY_RDG_EVENT_ID_KEY, + close_workstation_session_for_rdg_end, + find_rdg_end_after_workstation_login, + resolve_workstation_login_closed, +) +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 + + +@pytest.fixture +def rdg_settings(monkeypatch): + monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1") + monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10") + monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture +def rdg_hosts(db_session): + ws = Host( + hostname="TSA-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.163.100", + ) + gw = Host( + hostname="K6A-DC3", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.40", + ) + db_session.add_all([ws, gw]) + db_session.commit() + return ws, gw + + +def test_rdg_end_marks_workstation_login_closed_on_ingest(db_session, rdg_settings, rdg_hosts): + ws, _gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\TSA" + 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.8-SAC"}, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="1149", + details={"user": user}, + ) + end = _ingest( + db_session, + t0 + timedelta(hours=2), + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="RD Gateway event 303", + summary="303", + details={"user": user, "internal_ip": internal_ip}, + ) + + assert login.details[SESSION_CLOSED_BY_RDG_AT_KEY] == end.occurred_at.isoformat() + assert login.details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] == end.id + assert event_session_terminated(login, db=db_session) is True + + summary = event_to_summary(login, db_session) + assert summary.session_terminated is True + + +def test_rdg_flap_does_not_close_workstation_login(db_session, rdg_settings, rdg_hosts): + ws, _gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\TSA" + internal_ip = ws.ipv4 + details = {"user": user, "internal_ip": internal_ip} + + 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.8-SAC"}, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="1149", + details={"user": user}, + ) + _ingest( + db_session, + t0, + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + _ingest( + db_session, + t0 + timedelta(seconds=4), + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + db_session.refresh(login) + + assert SESSION_CLOSED_BY_RDG_AT_KEY not in (login.details or {}) + assert event_session_terminated(login, db=db_session) is False + assert event_to_summary(login, db_session).session_terminated is False + + +def test_runtime_resolve_for_historical_login_without_flag(db_session, rdg_settings, rdg_hosts): + ws, gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\TSA" + internal_ip = ws.ipv4 + + from app.models import Event + + login = Event( + event_id=str(uuid.uuid4()), + host_id=ws.id, + occurred_at=t0 + timedelta(seconds=1), + received_at=t0, + category="auth", + type="rdp.login.success", + severity="info", + title="RDP login", + summary="1149", + payload={}, + details={"user": "TSA"}, + ) + end = Event( + event_id=str(uuid.uuid4()), + host_id=gw.id, + occurred_at=t0 + timedelta(hours=2), + received_at=t0, + category="auth", + type="rdg.connection.disconnected", + severity="info", + title="303", + summary="303", + payload={}, + details={"user": user, "internal_ip": internal_ip}, + ) + db_session.add_all([login, end]) + db_session.commit() + + assert resolve_workstation_login_closed(db_session, login) is True + assert find_rdg_end_after_workstation_login(db_session, login) is not None + assert event_to_summary(login, db_session).session_terminated is True + + +def test_user_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts): + ws, _gw = rdg_hosts + t0 = datetime.now(timezone.utc) + 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.8-SAC"}, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="1149", + details={"user": r"B26\Alice"}, + ) + end = _ingest( + db_session, + t0 + timedelta(hours=1), + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details={"user": r"B26\Bob", "internal_ip": internal_ip}, + ) + db_session.refresh(login) + + assert close_workstation_session_for_rdg_end(db_session, end) is None + assert event_session_terminated(login, db=db_session) is False + + +def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts): + ws, _gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\TSA" + + login = _ingest( + db_session, + t0, + host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4}, + source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="1149", + details={"user": user}, + ) + end = _ingest( + db_session, + t0 + timedelta(hours=1), + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details={"user": user, "internal_ip": "192.168.163.200"}, + ) + db_session.refresh(login) + + 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 diff --git a/backend/tests/test_rdp_flap_auto_disconnect.py b/backend/tests/test_rdp_flap_auto_disconnect.py new file mode 100644 index 0000000..218f60b --- /dev/null +++ b/backend/tests/test_rdp_flap_auto_disconnect.py @@ -0,0 +1,242 @@ +"""Tests for RDP flap auto-disconnect setting and service.""" + +import uuid +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.config import get_settings +from app.models import Host +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings +from app.services.ingest import ingest_event +from app.services.problems import maybe_create_problem +from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session +from app.services.rdp_flap_settings import get_effective_rdp_flap_settings, upsert_rdp_flap_settings +from app.services.winrm_connect import WinRmCmdResult +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 + + +@pytest.fixture +def rdg_settings(monkeypatch): + monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1") + monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10") + monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30") + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "secret") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture +def rdg_hosts(db_session): + ws = Host( + hostname="TSA-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.163.100", + ) + gw = Host( + hostname="K6A-DC3", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.40", + ) + db_session.add_all([ws, gw]) + db_session.commit() + return ws, gw + + +def test_rdp_flap_settings_default_disabled(db_session): + cfg = get_effective_rdp_flap_settings(db_session) + assert cfg.auto_disconnect is False + assert cfg.source == "default" + + +def test_rdp_flap_settings_upsert(db_session): + upsert_rdp_flap_settings(db_session, auto_disconnect=True) + cfg = get_effective_rdp_flap_settings(db_session) + assert cfg.auto_disconnect is True + row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID) + assert row is not None + assert row.auto_rdp_flap_disconnect is True + + +def test_auto_disconnect_skipped_when_disabled(db_session, rdg_settings, rdg_hosts, monkeypatch): + ws, gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\TSA" + details = {"user": user, "internal_ip": ws.ipv4} + gw_payload = { + "host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4}, + "source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + } + + _ingest( + db_session, + t0, + **gw_payload, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + end = _ingest( + db_session, + t0 + timedelta(seconds=4), + **gw_payload, + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + maybe_create_problem(db_session, end) + + mock_logoff = MagicMock() + monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff) + + result = maybe_auto_disconnect_stuck_rdp_session(db_session, end) + assert result is None + mock_logoff.assert_not_called() + + +def test_auto_disconnect_rdg_flap_calls_logoff(db_session, rdg_settings, rdg_hosts, monkeypatch): + ws, gw = rdg_hosts + upsert_rdp_flap_settings(db_session, auto_disconnect=True) + t0 = datetime.now(timezone.utc) + user = r"B26\TSA" + details = {"user": user, "internal_ip": ws.ipv4} + gw_payload = { + "host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4}, + "source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + } + + _ingest( + db_session, + t0, + **gw_payload, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + _ingest( + db_session, + t0 + timedelta(seconds=1), + host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4}, + source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + type="rdp.login.success", + category="auth", + severity="info", + title="login", + summary="login", + details={"user": user}, + ) + end = _ingest( + db_session, + t0 + timedelta(seconds=4), + **gw_payload, + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + maybe_create_problem(db_session, end) + + qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 5 Active\n" + monkeypatch.setattr( + "app.services.rdp_flap_auto_disconnect.list_windows_sessions", + lambda host, cfg: ( + [], + WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout), + ), + ) + mock_logoff = MagicMock(return_value=SimpleNamespace(status="completed", result_stderr=None, result_stdout="ok")) + monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff) + + result = maybe_auto_disconnect_stuck_rdp_session(db_session, end) + assert result is not None + assert result.ok is True + assert result.session_ids == (5,) + mock_logoff.assert_called_once() + assert end.details["rdp_flap_auto_disconnect"]["ok"] is True + + +def test_auto_disconnect_direct_rdp_failed(db_session, rdg_settings, rdg_hosts, monkeypatch): + ws, _gw = rdg_hosts + upsert_rdp_flap_settings(db_session, auto_disconnect=True) + t0 = datetime.now(timezone.utc) + user = r"B26\TSA" + ws_payload = { + "host": {"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4}, + "source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"}, + } + + _ingest( + db_session, + t0, + **ws_payload, + type="rdp.login.success", + category="auth", + severity="info", + title="login", + summary="login", + details={"user": user}, + ) + failed = _ingest( + db_session, + t0 + timedelta(seconds=30), + **ws_payload, + type="rdp.login.failed", + category="auth", + severity="warning", + title="failed", + summary="failed", + details={"user": user}, + ) + + qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 3 Active\n" + monkeypatch.setattr( + "app.services.rdp_flap_auto_disconnect.list_windows_sessions", + lambda host, cfg: ( + [], + WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout), + ), + ) + monkeypatch.setattr( + "app.services.rdp_flap_auto_disconnect.terminate_windows_session", + lambda host, cfg, sid: WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or ""), + ) + + result = maybe_auto_disconnect_stuck_rdp_session(db_session, failed) + assert result is not None + assert result.ok is True + assert result.session_ids == (3,) diff --git a/backend/tests/test_rdp_session_logoff.py b/backend/tests/test_rdp_session_logoff.py new file mode 100644 index 0000000..6752ddf --- /dev/null +++ b/backend/tests/test_rdp_session_logoff.py @@ -0,0 +1,229 @@ +"""Tests for direct RDP logoff → workstation rdp.login.success correlation.""" + +import uuid +from datetime import datetime, timedelta, timezone + +from app.services.event_summary import event_to_summary +from app.services.host_sessions import event_session_terminated +from app.services.ingest import ingest_event +from app.services.rdp_session_logoff import ( + SESSION_CLOSED_BY_LOGOFF_AT_KEY, + SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY, + close_workstation_session_for_rdp_logoff, + find_logoff_after_workstation_login, + resolve_workstation_login_closed_by_logoff, +) +from tests.test_ingest import VALID_EVENT + + +def _payload(**overrides): + base = { + **VALID_EVENT, + "event_id": str(uuid.uuid4()), + "occurred_at": datetime.now(timezone.utc).isoformat(), + } + base.update(overrides) + return base + + +def _ingest(db, occurred_at: datetime, **overrides): + payload = _payload(**overrides) + payload["occurred_at"] = occurred_at.isoformat() + event, _ = ingest_event(db, payload) + db.flush() + return event + + +def test_logoff_marks_workstation_login_closed_on_ingest(db_session): + t0 = datetime.now(timezone.utc) + user = r"B26\papatramp" + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0 + timedelta(seconds=1), + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10}, + ) + logoff = _ingest( + db_session, + t0 + timedelta(hours=1), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4634", + details={ + "user": user, + "ip_address": "192.168.160.3", + "logon_type": 10, + "event_id_windows": 4634, + }, + ) + + assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat() + assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id + assert event_session_terminated(login, db=db_session) is True + assert event_to_summary(login, db_session).session_terminated is True + + +def test_user_mismatch_does_not_close_login(db_session): + t0 = datetime.now(timezone.utc) + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0, + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": r"B26\Alice"}, + ) + logoff = _ingest( + db_session, + t0 + timedelta(hours=1), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4634", + details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634}, + ) + db_session.refresh(login) + + assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None + assert event_session_terminated(login, db=db_session) is False + + +def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session): + t0 = datetime.now(timezone.utc) + user = r"B26\papatramp" + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0, + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10}, + ) + logoff = _ingest( + db_session, + t0 + timedelta(hours=1), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4634", + details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634}, + ) + db_session.refresh(login) + + assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None + assert event_session_terminated(login, db=db_session) is False + + +def test_runtime_resolve_for_historical_login_without_flag(db_session): + from app.models import Event, Host + + t0 = datetime.now(timezone.utc) + user = r"B26\papatramp" + host = Host( + hostname="BIV-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.165.39", + ) + db_session.add(host) + db_session.commit() + + login = Event( + event_id=str(uuid.uuid4()), + host_id=host.id, + occurred_at=t0 + timedelta(seconds=1), + received_at=t0, + category="auth", + type="rdp.login.success", + severity="info", + title="RDP login", + summary="4624", + payload={}, + details={"user": "papatramp", "ip_address": "192.168.160.3"}, + ) + logoff = Event( + event_id=str(uuid.uuid4()), + host_id=host.id, + occurred_at=t0 + timedelta(hours=2), + received_at=t0, + category="auth", + type="rdp.session.logoff", + severity="info", + title="4634", + summary="4634", + payload={}, + details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634}, + ) + db_session.add_all([login, logoff]) + db_session.commit() + + assert resolve_workstation_login_closed_by_logoff(db_session, login) is True + assert find_logoff_after_workstation_login(db_session, login) is not None + assert event_to_summary(login, db_session).session_terminated is True + + +def test_sam_domain_user_match_on_logoff(db_session): + t0 = datetime.now(timezone.utc) + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0, + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": r"B26\papatramp", "logon_type": 10}, + ) + _ingest( + db_session, + t0 + timedelta(minutes=30), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4647", + details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647}, + ) + db_session.refresh(login) + + assert event_session_terminated(login, db=db_session) is True diff --git a/backend/tests/test_retention.py b/backend/tests/test_retention.py new file mode 100644 index 0000000..2ca0c2d --- /dev/null +++ b/backend/tests/test_retention.py @@ -0,0 +1,78 @@ +"""Retention purge service.""" + +import uuid +from datetime import datetime, timedelta, timezone + +from app.config import Settings +from app.models import Event, Host, Problem +from app.services.retention import purge_old_data + + +def _host(db, name: str) -> Host: + h = Host( + hostname=name, + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db.add(h) + db.flush() + return h + + +def test_purge_old_events_and_resolved_problems(db_session): + h = _host(db_session, "retention-host") + now = datetime.now(timezone.utc) + old_event = now - timedelta(days=120) + old_problem = now - timedelta(days=200) + _ev = Event( + event_id=str(uuid.uuid4()), + host_id=h.id, + occurred_at=old_event, + received_at=old_event, + category="agent", + type="agent.heartbeat", + severity="info", + title="old", + summary="s", + payload={}, + ) + db_session.add(_ev) + db_session.add( + Problem( + host_id=h.id, + title="resolved old", + summary="s", + severity="info", + status="resolved", + rule_id="rule:test", + fingerprint="fp-ret-old", + event_count=1, + last_seen_at=old_problem, + created_at=old_problem, + updated_at=old_problem, + ) + ) + db_session.add( + Problem( + host_id=h.id, + title="open stays", + summary="s", + severity="high", + status="open", + rule_id="rule:test", + fingerprint="fp-ret-open", + event_count=1, + last_seen_at=now, + created_at=now, + updated_at=now, + ) + ) + db_session.commit() + + settings = Settings(sac_events_retention_days=90, sac_problems_retention_days=180) + stats = purge_old_data(db_session, settings) + assert stats["events_deleted"] == 1 + assert stats["problems_deleted"] == 1 + assert db_session.query(Event).count() == 0 + assert db_session.query(Problem).filter(Problem.status == "open").count() == 1 diff --git a/backend/tests/test_security_hardening.py b/backend/tests/test_security_hardening.py new file mode 100644 index 0000000..4bc1e21 --- /dev/null +++ b/backend/tests/test_security_hardening.py @@ -0,0 +1,52 @@ +"""Security bootstrap and API key hashing.""" + +import hashlib +import pytest + +from app.auth.api_key import _legacy_hash_api_key, hash_api_key, verify_api_key_hash +from app.security_bootstrap import validate_security_settings +from app.config import Settings + + +def test_hash_api_key_uses_hmac(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "unit-test-secret") + from app.config import get_settings + + get_settings.cache_clear() + raw = "sac_example_key" + assert hash_api_key(raw) != _legacy_hash_api_key(raw) + assert verify_api_key_hash(raw, hash_api_key(raw)) + get_settings.cache_clear() + + +def test_verify_api_key_hash_accepts_legacy_sha256(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "unit-test-secret") + from app.config import get_settings + + get_settings.cache_clear() + raw = "sac_legacy_key" + legacy = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert verify_api_key_hash(raw, legacy) + get_settings.cache_clear() + + +def test_validate_security_settings_rejects_weak_jwt(): + settings = Settings( + jwt_secret="change-me-in-production", + sac_public_url="https://sac.example.com", + cors_origins="https://sac.example.com", + sac_security_enforce=True, + ) + with pytest.raises(RuntimeError, match="JWT_SECRET"): + validate_security_settings(settings) + + +def test_validate_security_settings_rejects_wildcard_cors(monkeypatch): + settings = Settings( + jwt_secret="strong-secret-value", + sac_public_url="https://sac.example.com", + cors_origins="*", + sac_security_enforce=True, + ) + with pytest.raises(RuntimeError, match="CORS_ORIGINS"): + validate_security_settings(settings) diff --git a/backend/tests/test_session_duration.py b/backend/tests/test_session_duration.py new file mode 100644 index 0000000..9827450 --- /dev/null +++ b/backend/tests/test_session_duration.py @@ -0,0 +1,20 @@ +"""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" diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py new file mode 100644 index 0000000..d47d36f --- /dev/null +++ b/backend/tests/test_settings_api.py @@ -0,0 +1,197 @@ +"""GET/PUT/POST /api/v1/settings/notifications""" + +from unittest.mock import patch + +from app.config import get_settings +from app.models.notification_channel import NotificationChannel +from app.services.notification_settings import get_effective_telegram_config + + +def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatch): + monkeypatch.setenv("TELEGRAM_ENABLED", "true") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567890:ABCDEFghij") + monkeypatch.setenv("TELEGRAM_CHAT_ID", "2843230") + monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "warning") + get_settings.cache_clear() + + response = client.get("/api/v1/settings/notifications", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["telegram"]["enabled"] is True + assert body["telegram"]["configured"] is True + assert body["telegram"]["min_severity"] == "warning" + assert body["telegram"]["source"] == "env" + assert body["telegram"]["bot_token_hint"].endswith("ghij") + assert "policy" in body + assert body["policy"]["min_severity"] == "warning" + assert "webhook" in body + assert body["webhook"]["enabled"] is False + assert "email" in body + assert body["email"]["enabled"] is False + + +def test_put_email_settings_persists_db(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SMTP_ENABLED", "false") + monkeypatch.setenv("SMTP_HOST", "") + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/notifications/email", + headers=jwt_headers, + json={ + "enabled": True, + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_user": "monitor", + "smtp_password": "secret", + "mail_from": "sac@example.com", + "mail_to": "admin@example.com,ops@example.com", + "smtp_starttls": True, + "smtp_ssl": False, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["source"] == "db" + assert body["configured"] is True + assert body["smtp_port"] == 587 + + row = db_session.get(NotificationChannel, "email") + assert row is not None + assert row.smtp_host == "smtp.example.com" + assert row.mail_from == "sac@example.com" + + +def test_post_email_test_success(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SMTP_ENABLED", "false") + get_settings.cache_clear() + + db_session.add( + NotificationChannel( + channel="email", + enabled=True, + min_severity="warning", + smtp_host="smtp.example.com", + smtp_port=587, + mail_from="sac@example.com", + mail_to="admin@example.com", + ) + ) + db_session.commit() + + with patch("app.api.v1.settings.send_email_test_message") as mock_test: + response = client.post("/api/v1/settings/notifications/email/test", headers=jwt_headers) + assert response.status_code == 200 + assert response.json()["ok"] is True + mock_test.assert_called_once() + + +def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("WEBHOOK_ENABLED", "false") + monkeypatch.setenv("WEBHOOK_URL", "") + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/notifications/webhook", + headers=jwt_headers, + json={ + "enabled": True, + "url": "https://example.com/hooks/sac", + "secret_header": "X-SAC-Token", + "secret": "supersecret", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["source"] == "db" + assert body["configured"] is True + + row = db_session.get(NotificationChannel, "webhook") + assert row is not None + assert row.webhook_url == "https://example.com/hooks/sac" + assert row.webhook_secret_header == "X-SAC-Token" + + +def test_post_webhook_test_success(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("WEBHOOK_ENABLED", "false") + get_settings.cache_clear() + + db_session.add( + NotificationChannel( + channel="webhook", + enabled=True, + min_severity="warning", + webhook_url="https://example.com/hook", + ) + ) + db_session.commit() + + with patch("app.api.v1.settings.send_webhook_test_message") as mock_test: + response = client.post("/api/v1/settings/notifications/webhook/test", headers=jwt_headers) + assert response.status_code == 200 + assert response.json()["ok"] is True + mock_test.assert_called_once() + + +def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("TELEGRAM_ENABLED", "false") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "") + monkeypatch.setenv("TELEGRAM_CHAT_ID", "") + monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "high") + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/notifications/telegram", + headers=jwt_headers, + json={ + "enabled": True, + "bot_token": "1234567890:TESTTOKEN", + "chat_id": "999001", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["source"] == "db" + assert body["enabled"] is True + + row = db_session.get(NotificationChannel, "telegram") + assert row is not None + assert row.bot_token == "1234567890:TESTTOKEN" + assert row.chat_id == "999001" + + cfg = get_effective_telegram_config(db_session) + assert cfg.source == "db" + + +def test_post_telegram_test_success(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("TELEGRAM_ENABLED", "true") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "tok") + monkeypatch.setenv("TELEGRAM_CHAT_ID", "1") + get_settings.cache_clear() + + db_session.add( + NotificationChannel( + channel="telegram", + enabled=True, + min_severity="warning", + bot_token="1234567890:ABCDEF", + chat_id="2843230", + ) + ) + db_session.commit() + + with patch("app.api.v1.settings.send_telegram_test_message") as mock_test: + response = client.post("/api/v1/settings/notifications/telegram/test", headers=jwt_headers) + assert response.status_code == 200 + assert response.json()["ok"] is True + mock_test.assert_called_once() + + +def test_post_telegram_test_not_configured(jwt_headers, client, monkeypatch): + monkeypatch.setenv("TELEGRAM_ENABLED", "false") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "") + monkeypatch.setenv("TELEGRAM_CHAT_ID", "") + get_settings.cache_clear() + + response = client.post("/api/v1/settings/notifications/telegram/test", headers=jwt_headers) + assert response.status_code == 400 diff --git a/backend/tests/test_ssh_connect.py b/backend/tests/test_ssh_connect.py new file mode 100644 index 0000000..88a59a3 --- /dev/null +++ b/backend/tests/test_ssh_connect.py @@ -0,0 +1,336 @@ +"""SSH connect service tests (paramiko mocked via sys.modules).""" + +import sys +from unittest.mock import MagicMock + +from app.models import Host +from app.services import ssh_connect +from app.services.ssh_connect import ( + HostNotLinuxError, + HostTargetMissingError, + _remote_shell_command, + iter_ssh_targets, + run_ssh_command, + run_ssh_monitor_update, +) + + +class _AuthError(Exception): + pass + + +def _install_fake_paramiko(monkeypatch, *, connect_side_effect=None, exec_setup=None): + mock_client_cls = MagicMock() + client = MagicMock() + if connect_side_effect is not None: + client.connect.side_effect = connect_side_effect + if exec_setup is not None: + exec_setup(client) + mock_client_cls.return_value = client + + fake = MagicMock() + fake.SSHClient = mock_client_cls + fake.AutoAddPolicy = MagicMock() + fake.RejectPolicy = MagicMock() + fake.AuthenticationException = _AuthError + monkeypatch.setitem(sys.modules, "paramiko", fake) + return client + + +def test_ssh_output_hostname_ignores_motd(): + stdout = "Welcome!\nFASTPANEL\n\ncz-server\n" + assert ssh_connect._ssh_output_hostname(stdout) == "cz-server" + + +def test_remote_shell_command_non_login_for_sessions(): + cmd, needs_pw = _remote_shell_command("root", "loginctl list-sessions", login_shell=False) + assert cmd == "bash -c 'loginctl list-sessions'" + assert needs_pw is False + + +def test_remote_shell_command_non_root_probe_has_no_sudo(): + cmd, needs_pw = _remote_shell_command("deploy", "hostname", need_root=False) + assert "sudo" not in cmd + assert "hostname" in cmd + assert needs_pw is False + + +def test_remote_shell_command_non_root_privileged_uses_sudo_s(): + cmd, needs_pw = _remote_shell_command("deploy", "/opt/scripts/update_ssh_monitor.sh", need_root=True) + assert "sudo -S" in cmd + assert "update_ssh_monitor.sh" in cmd + assert needs_pw is True + assert "secret" not in cmd + + +def test_remote_shell_command_root_skips_sudo_even_when_need_root(): + cmd, needs_pw = _remote_shell_command("root", "/opt/scripts/update_ssh_monitor.sh", need_root=True) + assert "sudo" not in cmd + assert needs_pw is False + + +def test_probe_ssh_connection_non_root_does_not_use_sudo(monkeypatch): + captured: list[str] = [] + + def setup(client): + stdout = MagicMock() + stdout.read.return_value = b"ubabuba\n" + stdout.channel.recv_exit_status.return_value = 0 + stderr = MagicMock() + stderr.read.return_value = b"" + + def capture_exec(cmd, **kwargs): + captured.append(cmd) + return (None, stdout, stderr) + + client.exec_command.side_effect = capture_exec + + _install_fake_paramiko(monkeypatch, exec_setup=setup) + + result = ssh_connect.test_ssh_connection(target="10.10.36.9", user="deploy", password="pw") + assert result.ok is True + assert captured + assert "sudo" not in captured[0] + + +def test_iter_ssh_targets_skips_human_display_name(monkeypatch): + monkeypatch.setattr(ssh_connect, "_ssh_name_resolves", lambda name: True) + host = Host( + hostname="ubabuba", + display_name="Ubabuba Kalina (10.10.36.9)", + os_family="linux", + product="ssh-monitor", + ipv4="10.10.36.9", + ) + targets = iter_ssh_targets(host) + assert "Ubabuba Kalina (10.10.36.9)" not in targets + assert "10.10.36.9" in targets + + +def test_iter_ssh_targets_skips_unresolvable_hostname(monkeypatch): + def resolves(name: str) -> bool: + return ssh_connect._is_ipv4(name) + + monkeypatch.setattr(ssh_connect, "_ssh_name_resolves", resolves) + host = Host( + hostname="ubabuba", + os_family="linux", + product="ssh-monitor", + ipv4="10.10.36.9", + ) + targets = iter_ssh_targets(host) + assert targets == ["10.10.36.9"] + + +def test_iter_ssh_targets_rejects_windows(): + host = Host(hostname="PC", os_family="windows", product="rdp-login-monitor", ipv4="1.2.3.4") + try: + iter_ssh_targets(host) + raise AssertionError("expected HostNotLinuxError") + except HostNotLinuxError: + pass + + +def test_iter_ssh_targets_requires_address(): + host = Host(hostname="", os_family="linux", product="ssh-monitor", ipv4=None) + try: + iter_ssh_targets(host) + raise AssertionError("expected HostTargetMissingError") + except HostTargetMissingError: + pass + + +def test_run_ssh_command_retries_transient_no_existing_session(monkeypatch): + attempts = {"count": 0} + + class _NoSessionError(Exception): + pass + + def connect_side_effect(*args, **kwargs): + attempts["count"] += 1 + if attempts["count"] == 1: + raise _NoSessionError("No existing session") + + def setup(client): + stdout = MagicMock() + stdout.read.return_value = b"ready\n" + stdout.channel.recv_exit_status.return_value = 0 + stderr = MagicMock() + stderr.read.return_value = b"" + + def exec_ok(cmd, **kwargs): + return (None, stdout, stderr) + + client.exec_command.side_effect = exec_ok + + fake = MagicMock() + fake.SSHClient = MagicMock() + fake.AutoAddPolicy = MagicMock() + fake.RejectPolicy = MagicMock() + fake.AuthenticationException = _AuthError + + def make_client(): + client = MagicMock() + client.connect.side_effect = connect_side_effect + setup(client) + return client + + fake.SSHClient.side_effect = make_client + monkeypatch.setitem(sys.modules, "paramiko", fake) + + result = run_ssh_command( + target="185.87.149.9", + user="root", + password="pw", + remote_cmd="hostname", + ) + assert result.ok is True + assert attempts["count"] == 2 + + +def test_probe_ssh_connection_success(monkeypatch): + def setup(client): + stdout = MagicMock() + stdout.read.return_value = b"ubabuba\n" + stdout.channel.recv_exit_status.return_value = 0 + stderr = MagicMock() + stderr.read.return_value = b"" + client.exec_command.return_value = (None, stdout, stderr) + + client = _install_fake_paramiko(monkeypatch, exec_setup=setup) + + result = ssh_connect.test_ssh_connection(target="ubabuba", user="root", password="pw") + assert result.ok is True + assert "hostname=ubabuba" in result.message + client.connect.assert_called_once() + client.close.assert_called_once() + + +def test_run_ssh_command_auth_failure(monkeypatch): + client = _install_fake_paramiko(monkeypatch, connect_side_effect=_AuthError("bad creds")) + + result = run_ssh_command(target="10.0.0.1", user="root", password="wrong", remote_cmd="hostname") + assert result.ok is False + assert "auth failed" in result.message.lower() + client.close.assert_called_once() + + +def test_run_ssh_monitor_update_missing_script_bootstraps(monkeypatch): + calls = 0 + + def fake_run(**kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return ssh_connect.SshCommandResult( + ok=True, + message="ok", + target="h", + stdout="missing\n", + ) + if calls == 2: + cmd = kwargs["remote_cmd"] + assert "git clone" in cmd + assert "update_ssh_monitor.sh" in cmd + assert kwargs.get("need_root") is True + return ssh_connect.SshCommandResult( + ok=True, + message="SSH OK", + target="185.87.149.9", + stdout="SUMMARY updated 2.1.0-SAC\n", + exit_code=0, + ) + return ssh_connect.SshCommandResult( + ok=True, + message="ok", + target="185.87.149.9", + stdout="2.1.0-SAC\n", + exit_code=0, + ) + + monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run) + + result = run_ssh_monitor_update(target="185.87.149.9", user="root", password="pw") + assert result.ok is True + assert result.agent_version == "2.1.0-SAC" + assert "Bootstrap" in result.message + assert calls == 3 + + +def test_run_ssh_monitor_update_missing_script(monkeypatch): + def fake_run(**kwargs): + if kwargs["remote_cmd"].startswith("test -x"): + return ssh_connect.SshCommandResult( + ok=True, + message="ok", + target="h", + stdout="missing\n", + ) + return ssh_connect.SshCommandResult( + ok=False, + message="git clone failed", + target="h", + stderr="fatal: could not read", + exit_code=128, + ) + + monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run) + + result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw") + assert result.ok is False + assert "bootstrap failed" in result.message.lower() + + +def test_run_ssh_monitor_update_runs_script(monkeypatch): + calls = 0 + + def fake_run(**kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return ssh_connect.SshCommandResult( + ok=True, + message="ok", + target="h", + stdout="ready\n", + ) + if calls == 2: + cmd = kwargs["remote_cmd"] + assert "git -C" in cmd + assert "kalinamall" in cmd + assert "REPO_URL=" in cmd + assert "git.kalinamall.ru" in cmd + assert "update_ssh_monitor.sh" in cmd + assert kwargs.get("need_root") is True + return ssh_connect.SshCommandResult( + ok=True, + message="SSH OK", + target="ubabuba", + stdout="SUMMARY updated 2.1.0-SAC\n", + exit_code=0, + ) + return ssh_connect.SshCommandResult( + ok=True, + message="ok", + target="ubabuba", + stdout='2.1.0-SAC\n', + exit_code=0, + ) + + monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run) + + result = run_ssh_monitor_update( + target="ubabuba", + user="root", + password="pw", + repo_url="https://git.papatramp.ru/PapaTramp/ssh-monitor.git", + ) + assert result.ok is True + assert result.agent_version == "2.1.0-SAC" + assert calls == 3 + + +def test_parse_ssh_monitor_version_text(): + assert ssh_connect.parse_ssh_monitor_version_text("SSH_MONITOR_VERSION=2.1.0-SAC") == "2.1.0-SAC" + assert ssh_connect.parse_ssh_monitor_version_text("SUMMARY updated 2.1.1-SAC done") == "2.1.1-SAC" + assert ssh_connect.parse_ssh_monitor_version_text("no version") is None diff --git a/backend/tests/test_system_stats.py b/backend/tests/test_system_stats.py new file mode 100644 index 0000000..b9d3b55 --- /dev/null +++ b/backend/tests/test_system_stats.py @@ -0,0 +1,30 @@ +"""GET /api/v1/system/stats""" + +from app.services.ui_settings import upsert_ui_settings + + +def test_system_stats_requires_auth(client): + response = client.get("/api/v1/system/stats") + assert response.status_code == 401 + + +def test_system_stats_visible_default(jwt_headers, client): + response = client.get("/api/v1/system/stats", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["visible"] is True + assert "collected_at" in body + assert "database" in body + assert body["database"]["status"] in ("ok", "error") + assert "app" in body + assert "events_last_hour" in body["app"] + assert "problems_open" in body["app"] + + +def test_system_stats_respects_ui_setting(jwt_headers, client, db_session): + upsert_ui_settings(db_session, show_sidebar_system_stats=False) + db_session.commit() + + response = client.get("/api/v1/system/stats", headers=jwt_headers) + assert response.status_code == 200 + assert response.json()["visible"] is False diff --git a/backend/tests/test_telegram_notify.py b/backend/tests/test_telegram_notify.py new file mode 100644 index 0000000..7c655b5 --- /dev/null +++ b/backend/tests/test_telegram_notify.py @@ -0,0 +1,76 @@ +"""Tests for SAC Telegram notification helpers.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from app.models import Event, Problem +from app.services import telegram_notify +from app.services.notification_settings import TelegramConfig + + +def _telegram_cfg(*, min_severity: str) -> TelegramConfig: + return TelegramConfig( + enabled=True, + bot_token="test-token", + chat_id="123", + min_severity=min_severity, + source="env", + ) + + +def test_notify_event_respects_min_severity(): + sent: list[str] = [] + + def fake_send(message: str, **kwargs) -> None: + sent.append(message) + + event = Event( + event_id="00000000-0000-4000-8000-000000000001", + host_id=1, + occurred_at=datetime(2026, 5, 28, 12, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="info event", + payload={}, + ) + + cfg = _telegram_cfg(min_severity="warning") + with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg): + with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send): + telegram_notify.notify_event(event) + assert sent == [] + + event.severity = "warning" + with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg): + with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send): + telegram_notify.notify_event(event) + assert len(sent) == 1 + + +def test_notify_problem_respects_min_severity(): + sent: list[str] = [] + + def fake_send(message: str, **kwargs) -> None: + sent.append(message) + + problem = Problem( + title="brute", + summary="burst", + severity="warning", + status="open", + fingerprint="fp1", + ) + + cfg = _telegram_cfg(min_severity="high") + with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg): + with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send): + telegram_notify.notify_problem(problem) + assert sent == [] + + problem.severity = "high" + with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg): + with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send): + telegram_notify.notify_problem(problem) + assert len(sent) == 1 diff --git a/backend/tests/test_telegram_templates.py b/backend/tests/test_telegram_templates.py new file mode 100644 index 0000000..c7b5834 --- /dev/null +++ b/backend/tests/test_telegram_templates.py @@ -0,0 +1,418 @@ +"""Telegram HTML templates (notif-30).""" + +from datetime import datetime, timezone + +from app.models import Event, Host +from app.services.telegram_templates import ( + format_event_mobile_push, + format_event_telegram_html, + format_rdp_login_html, + html_escape, + telegram_html_to_plaintext, +) + + +def test_html_escape_special_chars(): + assert html_escape("a&") == "a<b>&" + + +def test_sanitize_telegram_html_keeps_bold(): + from app.services.telegram_templates import sanitize_telegram_html + + assert sanitize_telegram_html("title
line") == "title\nline" + + +def test_rdp_failed_template_includes_user_ip_logon(): + host = Host(hostname="K6A-DC3", display_name="UNMS Kalina", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000501", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 10, 30, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="RDP login failed", + summary="4625 user from 10.0.0.5", + details={ + "user": "DOMAIN\\admin", + "ip_address": "10.0.0.5", + "logon_type": 10, + "event_id_windows": 4625, + "workstation_name": "CLIENT01", + }, + payload={}, + ) + text = format_rdp_login_html(event) + assert "НЕУДАЧНАЯ" in text + assert "DOMAIN\\admin" in text or "DOMAIN" in text + assert "10.0.0.5" in text + assert "Удаленный интерактивный" in text + assert "(10)" in text + assert "UNMS Kalina" in text + assert "CLIENT01" in text + assert "4625" in text + assert "" in text + + +def test_rdp_login_hides_redundant_workstation_when_matches_hostname(): + host = Host( + hostname="ITIS198", + display_name="ITIS (192.168.160.198)", + os_family="windows", + ) + event = Event( + event_id="00000000-0000-4000-8000-000000000508", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 31, 18, 48, 52, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="RDP login", + summary="papatramp", + details={ + "user": "papatramp", + "ip_address": "192.168.160.3", + "logon_type": 10, + "workstation_name": "ITIS198", + "event_id_windows": 4624, + }, + payload={}, + ) + text = format_rdp_login_html(event) + assert "ITIS" in text + assert "192.168.160.3" in text + assert "Рабочая станция" not in text + assert "ITIS198" not in text + + +def test_rdp_login_shows_workstation_when_client_differs(): + host = Host(hostname="ITIS198", display_name="ITIS", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000509", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 31, 18, 48, 52, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="RDP login", + summary="papatramp", + details={ + "user": "papatramp", + "ip_address": "192.168.160.3", + "logon_type": 10, + "workstation_name": "NEW-ADMIN-PC", + "event_id_windows": 4624, + }, + payload={}, + ) + text = format_rdp_login_html(event) + assert "Рабочая станция" in text + assert "NEW-ADMIN-PC" in text + + +def test_rdp_success_template(): + event = Event( + event_id="00000000-0000-4000-8000-000000000502", + host_id=1, + occurred_at=datetime(2026, 5, 29, 11, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="ok", + details={"user": "u1", "ip_address": "1.2.3.4", "logon_type": 3}, + payload={}, + ) + text = format_event_telegram_html(event) + assert "УСПЕШНЫЙ" in text + assert "Сеть/RDP" in text + assert "📡 Оповещение: SAC (Security Alert Center)" in text + + +def test_event_telegram_includes_sac_source_for_sac_daily_report(): + host = Host(hostname="h1", display_name="H1", os_family="linux", product="ssh-monitor") + event = Event( + event_id="00000000-0000-4000-8000-000000000504", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="report", + type="report.daily.ssh", + severity="info", + title="Отчёт", + summary="short", + details={"generated_by": "sac", "report_body": "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА\nline"}, + payload={}, + ) + text = format_event_telegram_html(event) + assert "📡 Оповещение: SAC (Security Alert Center)" in text + + +def test_telegram_html_to_plaintext_strips_tags(): + assert telegram_html_to_plaintext("✅ Агент запущен\n🏢 Хост: WIN01") == ( + "✅ Агент запущен\n🏢 Хост: WIN01" + ) + + +def test_format_event_mobile_push_lifecycle_includes_trigger(): + host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000509", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="Мониторинг запущен", + details={"lifecycle": "started", "trigger": "deploy_recycle", "telegram_via": "sac"}, + payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}}, + ) + title, body = format_event_mobile_push(event) + assert title == "✅ Агент запущен" + assert "K6A-DC3" in body + assert "1.2.30-SAC" in body + assert "обновление скрипта" in body + assert "📡 Оповещение: SAC" in body + + +def test_format_event_mobile_push_uses_notification_body(): + host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows") + body_text = "✅ Мониторинг логинов ЗАПУЩЕН\n🖥️ Сервер: K6A-DC3 (10.0.0.1)" + event = Event( + event_id="00000000-0000-4000-8000-000000000510", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="Мониторинг запущен", + details={ + "lifecycle": "started", + "trigger": "boot", + "notification_body": body_text, + "telegram_via": "sac", + }, + payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.31-SAC"}}, + ) + title, body = format_event_mobile_push(event) + assert title == "✅ Мониторинг логинов ЗАПУЩЕН" + assert "K6A-DC3 (10.0.0.1)" in body + + +def test_lifecycle_started_template(): + host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000505", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="Мониторинг запущен", + details={"lifecycle": "started", "trigger": "boot", "telegram_via": "sac"}, + payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}}, + ) + text = format_event_telegram_html(event) + assert "Агент запущен" in text + assert "K6A-DC3" in text + assert "1.2.30-SAC" in text + assert "загрузка ОС" in text + assert "📡 Оповещение: SAC (Security Alert Center)" in text + + +def test_lifecycle_notification_body_renders_full_text(): + host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows") + body = "✅ Мониторинг логинов ЗАПУЩЕН\n🖥️ Сервер: K6A-DC3 (10.0.0.1)\n🕐 Время запуска: 28.05.2026 12:00:00" + event = Event( + event_id="00000000-0000-4000-8000-000000000507", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="Мониторинг запущен", + details={"lifecycle": "started", "trigger": "boot", "notification_body": body, "telegram_via": "sac"}, + payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.31-SAC"}}, + ) + text = format_event_telegram_html(event) + assert "✅ Мониторинг логинов ЗАПУЩЕН" in text + assert "K6A-DC3 (10.0.0.1)" in text + assert "Агент запущен" not in text + assert "📡 Оповещение: SAC (Security Alert Center)" in text + + +def test_lifecycle_footer_uses_telegram_via_agent_when_present(): + host = Host(hostname="WIN01", display_name="H1", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000506", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="agent", + type="agent.lifecycle", + severity="info", + title="started", + summary="x", + details={"lifecycle": "started", "telegram_via": "agent"}, + payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}}, + ) + text = format_event_telegram_html(event) + assert "📡 Оповещение: агент (rdp-login-monitor 1.2.30-SAC)" in text + + +def test_daily_report_summary_fallback_includes_host(): + host = Host(hostname="srv1", display_name="K6A-DC5", ipv4="192.168.160.91", os_family="linux") + event = Event( + event_id="00000000-0000-4000-8000-000000000508", + host_id=1, + host=host, + occurred_at=datetime(2026, 6, 3, 9, 0, tzinfo=timezone.utc), + category="report", + type="report.daily.ssh", + severity="info", + title="Ежедневный отчёт SSH", + summary="SSH 24ч: успех 0, неудач 0, банов 0", + details={"generated_by": "sac"}, + payload={}, + ) + text = format_event_telegram_html(event) + assert "K6A-DC5" in text + assert "SSH 24ч: успех 0" in text + assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH" in text + + +def test_rdp_shadow_control_template(): + host = Host(hostname="RDS01", display_name="RDS Farm", ipv4="10.0.0.5", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000701", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 30, 10, 0, 0, tzinfo=timezone.utc), + category="session", + type="rdp.shadow.control.started", + severity="warning", + title="RDS Shadow Control 20506", + summary="Shadow admin -> user", + details={ + "event_id_windows": 20506, + "shadower_user": "DOMAIN\\admin", + "target_user": "DOMAIN\\user", + "session_id": "3", + "shadow_action": "control_started", + }, + payload={}, + ) + text = format_event_telegram_html(event) + assert "SHADOW CONTROL" in text + assert "DOMAIN" in text + assert "20506" in text + assert "RDS Farm" in text + + +def test_winrm_session_template(): + host = Host(hostname="SRV01", display_name="App Server", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000702", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 30, 11, 0, 0, tzinfo=timezone.utc), + category="session", + type="winrm.session.started", + severity="warning", + title="WinRM shell", + summary="WinRM 91", + details={ + "event_id_windows": 91, + "user": "DOMAIN\\admin", + "source_ip": "192.168.160.3", + "resource_uri": "http://schemas.microsoft.com/powershell/Microsoft.PowerShell", + }, + payload={}, + ) + text = format_event_telegram_html(event) + assert "Enter-PSSession" in text or "WinRM" in text + assert "192.168.160.3" in text + assert "91" in text + + +def test_ssh_failed_template(): + event = Event( + event_id="00000000-0000-4000-8000-000000000503", + host_id=1, + occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc), + category="auth", + type="ssh.login.failed", + severity="warning", + title="ssh fail", + summary="fail", + details={"user": "root", "source_ip": "10.10.36.9", "port": 22, "attempt_number": 3, "max_attempts": 5}, + payload={}, + ) + text = format_event_telegram_html(event) + assert "SSH" in text + assert "root" in text + assert "10.10.36.9" in text + assert "3 / 5" in text + + +def test_agent_inventory_hardware_change_template(): + event = Event( + event_id="00000000-0000-4000-8000-000000000504", + host_id=1, + occurred_at=datetime(2026, 6, 4, 12, 0, tzinfo=timezone.utc), + category="agent", + type="agent.inventory", + severity="warning", + title="Hardware changed", + summary="memory_gb changed", + details={ + "hardware_changes": [ + {"field": "memory_gb", "old": 16.0, "new": 32.0}, + ] + }, + payload={}, + ) + text = format_event_telegram_html(event) + assert "железо" in text.lower() or "Изменилось" in text + assert "memory_gb" in text + assert "32" in text + + +def test_format_problem_host_silence_scan_without_event(): + from app.models import Host, Problem + from app.services.problem_rules import RULE_HOST_SILENCE + from app.services.telegram_templates import format_problem_telegram_html + + host = Host( + hostname="YURKOV-PC", + os_family="windows", + product="rdp-login-monitor", + last_seen_at=datetime(2026, 6, 11, 9, 0, tzinfo=timezone.utc), + ) + problem = Problem( + host_id=1, + host=host, + title="Host silence: YURKOV-PC", + summary="Нет свежего agent.heartbeat более 300 мин", + severity="high", + status="open", + rule_id=RULE_HOST_SILENCE, + fingerprint="fp-silence", + event_count=0, + last_seen_at=datetime(2026, 6, 11, 9, 0, tzinfo=timezone.utc), + ) + text = format_problem_telegram_html(problem, event=None) + assert "периодическая проверка SAC" in text + assert "host_silence scan" in text + assert "YURKOV-PC" in text or "UNMS" not in text # host label uses hostname diff --git a/backend/tests/test_ui_settings.py b/backend/tests/test_ui_settings.py new file mode 100644 index 0000000..bbc0172 --- /dev/null +++ b/backend/tests/test_ui_settings.py @@ -0,0 +1,32 @@ +"""GET/PUT /api/v1/settings/ui""" + +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +def test_get_ui_settings_default(jwt_headers, client): + response = client.get("/api/v1/settings/ui", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["show_sidebar_system_stats"] is True + assert body["source"] == "default" + + +def test_put_ui_settings_persists(jwt_headers, client, db_session): + response = client.put( + "/api/v1/settings/ui", + headers=jwt_headers, + json={"show_sidebar_system_stats": False}, + ) + assert response.status_code == 200 + body = response.json() + assert body["show_sidebar_system_stats"] is False + assert body["source"] == "db" + + row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID) + assert row is not None + assert row.show_sidebar_system_stats is False + + +def test_ui_settings_requires_admin(jwt_monitor_headers, client): + response = client.get("/api/v1/settings/ui", headers=jwt_monitor_headers) + assert response.status_code == 403 diff --git a/backend/tests/test_users_auth.py b/backend/tests/test_users_auth.py new file mode 100644 index 0000000..fe11da9 --- /dev/null +++ b/backend/tests/test_users_auth.py @@ -0,0 +1,136 @@ +"""Multi-user UI auth and RBAC.""" + +from app.models.user import USER_ROLE_MONITOR + + +def test_login_success(client): + response = client.post( + "/api/v1/auth/login", + json={"username": "test-admin", "password": "test-admin-password"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["username"] == "test-admin" + assert body["role"] == "admin" + assert body["access_token"] + + +def test_login_monitor(client): + response = client.post( + "/api/v1/auth/login", + json={"username": "test-monitor", "password": "test-monitor-password"}, + ) + assert response.status_code == 200 + assert response.json()["role"] == USER_ROLE_MONITOR + + +def test_login_invalid_password(client): + response = client.post( + "/api/v1/auth/login", + json={"username": "test-admin", "password": "wrong"}, + ) + assert response.status_code == 401 + + +def test_auth_me(client, jwt_headers): + response = client.get("/api/v1/auth/me", headers=jwt_headers) + assert response.status_code == 200 + assert response.json() == {"username": "test-admin", "role": "admin"} + + +def test_settings_forbidden_for_monitor(client, jwt_monitor_headers): + response = client.get("/api/v1/settings/notifications", headers=jwt_monitor_headers) + assert response.status_code == 403 + + +def test_events_allowed_for_monitor(client, jwt_monitor_headers): + response = client.get("/api/v1/events", headers=jwt_monitor_headers) + assert response.status_code == 200 + + +def test_admin_create_monitor_user(client, jwt_headers): + response = client.post( + "/api/v1/users", + headers=jwt_headers, + json={"username": "new-monitor", "password": "longpassword1", "role": "monitor"}, + ) + assert response.status_code == 201 + assert response.json()["username"] == "new-monitor" + assert response.json()["role"] == "monitor" + + +def test_monitor_cannot_list_users(client, jwt_monitor_headers): + response = client.get("/api/v1/users", headers=jwt_monitor_headers) + assert response.status_code == 403 + + +def test_admin_update_user_role_and_password(client, jwt_headers): + create = client.post( + "/api/v1/users", + headers=jwt_headers, + json={"username": "patch-me", "password": "longpassword1", "role": "monitor"}, + ) + assert create.status_code == 201 + user_id = create.json()["id"] + + response = client.patch( + f"/api/v1/users/{user_id}", + headers=jwt_headers, + json={"role": "admin", "password": "newpassword9"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["role"] == "admin" + + login = client.post( + "/api/v1/auth/login", + json={"username": "patch-me", "password": "newpassword9"}, + ) + assert login.status_code == 200 + assert login.json()["role"] == "admin" + + +def test_admin_update_username(client, jwt_headers): + create = client.post( + "/api/v1/users", + headers=jwt_headers, + json={"username": "old-name", "password": "longpassword1", "role": "monitor"}, + ) + user_id = create.json()["id"] + + response = client.patch( + f"/api/v1/users/{user_id}", + headers=jwt_headers, + json={"username": "new-name"}, + ) + assert response.status_code == 200 + assert response.json()["username"] == "new-name" + + login = client.post( + "/api/v1/auth/login", + json={"username": "new-name", "password": "longpassword1"}, + ) + assert login.status_code == 200 + + +def test_admin_deactivate_user(client, jwt_headers): + create = client.post( + "/api/v1/users", + headers=jwt_headers, + json={"username": "to-disable", "password": "longpassword1", "role": "monitor"}, + ) + user_id = create.json()["id"] + + response = client.patch( + f"/api/v1/users/{user_id}", + headers=jwt_headers, + json={"is_active": False}, + ) + assert response.status_code == 200 + assert response.json()["is_active"] is False + + login = client.post( + "/api/v1/auth/login", + json={"username": "to-disable", "password": "longpassword1"}, + ) + assert login.status_code == 401 diff --git a/backend/tests/test_webhook_notify.py b/backend/tests/test_webhook_notify.py new file mode 100644 index 0000000..63bd729 --- /dev/null +++ b/backend/tests/test_webhook_notify.py @@ -0,0 +1,55 @@ +"""Tests for SAC webhook notification helpers.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from app.models import Event +from app.services import webhook_notify +from app.services.notification_settings import WebhookConfig + + +def test_notify_event_respects_min_severity(): + sent: list[dict] = [] + + def fake_send(payload: dict, **kwargs) -> None: + sent.append(payload) + + event = Event( + event_id="00000000-0000-4000-8000-000000000201", + host_id=1, + occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="failed", + summary="e2e", + payload={}, + ) + + cfg = WebhookConfig( + enabled=True, + url="https://example.com/hook", + secret_header="", + secret="", + min_severity="high", + source="env", + ) + with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg): + with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send): + webhook_notify.notify_event(event) + assert sent == [] + + cfg_high = WebhookConfig( + enabled=True, + url="https://example.com/hook", + secret_header="", + secret="", + min_severity="warning", + source="env", + ) + event.severity = "warning" + with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg_high): + with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send): + webhook_notify.notify_event(event) + assert len(sent) == 1 + assert sent[0]["kind"] == "event" diff --git a/backend/tests/test_win_admin_settings.py b/backend/tests/test_win_admin_settings.py new file mode 100644 index 0000000..3d50748 --- /dev/null +++ b/backend/tests/test_win_admin_settings.py @@ -0,0 +1,115 @@ +"""Win admin settings and WinRM host test.""" + +from unittest.mock import patch + +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings +from app.services.win_admin_settings import normalize_win_admin_user +from app.services.winrm_connect import iter_winrm_targets + + +def test_normalize_win_admin_user(): + assert normalize_win_admin_user(r"B26\\papatramp") == r"B26\papatramp" + assert normalize_win_admin_user(r"B26\papatramp") == r"B26\papatramp" + + +def test_iter_winrm_targets_hostname_before_ip(): + from app.models import Host + + host = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.1.50", + inventory={"computer_name": "Andrisonova-PC"}, + ) + targets = iter_winrm_targets(host) + assert targets[0] == "Andrisonova-PC" + assert targets[-1] == "192.168.1.50" + + +def test_get_win_admin_settings_env_default(jwt_headers, client, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", "") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "") + from app.config import get_settings + + get_settings.cache_clear() + + response = client.get("/api/v1/settings/win-admin", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["configured"] is False + assert body["source"] == "env" + + +def test_put_win_admin_settings_persists(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", "") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "") + from app.config import get_settings + + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/win-admin", + headers=jwt_headers, + json={"user": r"B26\papatramp", "password": "secret-pass"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["configured"] is True + assert body["user"] == r"B26\papatramp" + assert body["source"] == "db" + assert body["password_hint"] + + row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID) + assert row is not None + assert row.win_admin_user == r"B26\papatramp" + assert row.win_admin_password == "secret-pass" + + +def test_host_winrm_test_requires_admin(jwt_monitor_headers, client): + response = client.post("/api/v1/hosts/1/actions/winrm-test", headers=jwt_monitor_headers) + assert response.status_code == 403 + + +def test_host_winrm_test_not_found(jwt_headers, client): + response = client.post("/api/v1/hosts/99999/actions/winrm-test", headers=jwt_headers) + assert response.status_code == 404 + + +def test_host_winrm_test_success(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + from app.models import Host + from app.services.winrm_connect import WinRmTestResult + + host = Host( + hostname="PC01", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.1.10", + ) + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + with patch("app.api.v1.hosts.test_winrm_connection") as mock_test: + mock_test.return_value = WinRmTestResult( + ok=True, + message="WinRM OK, hostname=PC01", + target="192.168.1.10", + hostname="PC01", + ) + response = client.post( + f"/api/v1/hosts/{host.id}/actions/winrm-test", + headers=jwt_headers, + ) + + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert body["hostname"] == "PC01" + assert body["target"] == "192.168.1.10" diff --git a/backend/tests/test_winrm_connect.py b/backend/tests/test_winrm_connect.py new file mode 100644 index 0000000..9f0dceb --- /dev/null +++ b/backend/tests/test_winrm_connect.py @@ -0,0 +1,170 @@ +"""WinRM bundle delivery and CLIXML error parsing tests.""" + +from pathlib import Path +from unittest.mock import patch + +from app.services.rdp_bundle_delivery import ( + _UTF8_BOM, + build_rdp_bundle_zip, + get_rdp_bundle_zip, + register_rdp_bundle_zip, +) +from app.services.winrm_connect import ( + RDP_BUNDLE_REQUIRED, + RDP_LEGACY_STAGING, + RDP_REMOTE_STAGING_DIRNAME, + WinRmCmdResult, + _clixml_to_plain, + _custom_deploy_body, + _decode_winrm_bytes, + _deploy_from_staging_body, + _download_bundle_body, + _prepare_staging_body, + _winrm_failure_detail, + run_winrm_rdp_monitor_update, +) + + +def test_decode_winrm_bytes_reads_russian_cp866_console(): + sample = "Деплой: корень шары".encode("cp866") + decoded = _decode_winrm_bytes(sample) + assert "Деплой" in decoded + assert "шары" in decoded + + +def test_decode_winrm_bytes_handles_utf8_multibyte(): + text = "\u0414\u0435\u043f\u043b\u043e\u0439: \u043a\u043e\u0440\u0435\u043d\u044c \u0448\u0430\u0440\u044b" + assert _decode_winrm_bytes(text.encode("utf-8")) == text + + +def test_deploy_from_staging_includes_deploy_log_tail(): + script = _deploy_from_staging_body() + assert "deploy.log" in script + assert "Get-Content" in script + assert "-Encoding UTF8" in script + assert RDP_REMOTE_STAGING_DIRNAME in script + + +def test_prepare_staging_recreates_remote_dir(): + script = _prepare_staging_body() + assert "Remove-Item" in script + assert RDP_REMOTE_STAGING_DIRNAME in script + assert RDP_LEGACY_STAGING in script + + +def test_download_bundle_uses_invoke_webrequest(): + script = _download_bundle_body( + "https://sac.example/api/v1/agent/rdp-bundle/token", + ) + assert "Invoke-WebRequest" in script + assert "$env:TEMP" in script + assert RDP_REMOTE_STAGING_DIRNAME in script + assert "ZipFile]::ExtractToDirectory" in script + assert "_sac_staging" not in script or RDP_LEGACY_STAGING in script + assert "Expand-Archive" not in script + + +def test_winrm_failure_detail_prefers_error_line_over_progress_stdout(): + stdout = "Downloading bundle: https://sac.test/bundle\nERROR: destination not empty" + detail = _winrm_failure_detail(stdout, "", 1) + assert detail == "destination not empty" + + +def test_deploy_from_staging_runs_local_bundle(): + script = _deploy_from_staging_body() + assert "-SourceShareRoot" in script + assert "Deploy-LoginMonitor.ps1" in script + + +def test_custom_deploy_script_escapes_single_quotes(): + script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1") + assert "O''Brien" in script + + +def test_rdp_bundle_zip_adds_utf8_bom_for_ps1(tmp_path: Path, monkeypatch): + monkeypatch.setattr("app.services.rdp_bundle_delivery.BUNDLE_DIR", tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + (repo / "Deploy-LoginMonitor.ps1").write_text("# test", encoding="utf-8") + zip_bytes = build_rdp_bundle_zip(repo, ("Deploy-LoginMonitor.ps1",)) + import zipfile + import io + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: + data = archive.read("Deploy-LoginMonitor.ps1") + assert data.startswith(_UTF8_BOM) + + +def test_rdp_bundle_zip_roundtrip(tmp_path: Path, monkeypatch): + monkeypatch.setattr("app.services.rdp_bundle_delivery.BUNDLE_DIR", tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + for name in RDP_BUNDLE_REQUIRED: + (repo / name).write_text(name, encoding="utf-8") + zip_bytes = build_rdp_bundle_zip(repo, tuple(RDP_BUNDLE_REQUIRED)) + token = register_rdp_bundle_zip(zip_bytes) + assert get_rdp_bundle_zip(token) == zip_bytes + + +def test_resolve_winrm_cmd_uses_system32_for_builtins(): + from app.services.winrm_connect import _resolve_winrm_cmd + + assert _resolve_winrm_cmd("hostname") == "%SystemRoot%\\System32\\hostname.exe" + assert _resolve_winrm_cmd("qwinsta") == "%SystemRoot%\\System32\\qwinsta.exe" + assert _resolve_winrm_cmd("logoff 2 /v") == "%SystemRoot%\\System32\\logoff.exe 2 /v" + + +def test_run_winrm_rdp_monitor_update_downloads_bundle_from_sac(tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + for name in RDP_BUNDLE_REQUIRED: + (repo / name).write_text(f"content-{name}", encoding="utf-8") + + calls: list[str] = [] + + def fake_run_ps(*, script: str, **kwargs) -> WinRmCmdResult: + calls.append(script) + if "Remove-Item" in script: + return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready") + if "Invoke-WebRequest" in script: + return WinRmCmdResult(ok=True, message="download ok", target="pc", stdout="Bundle extracted") + if "powershell.exe -NoProfile" in script or "& $deploy" in script: + return WinRmCmdResult( + ok=True, + message="WinRM OK (pc), exit 0", + target="pc", + stdout="deployed 1.2.3", + exit_code=0, + ) + return WinRmCmdResult(ok=True, message="ok", target="pc", exit_code=0) + + with ( + patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo), + patch("app.services.winrm_connect._bundle_download_url", return_value="https://sac.test/bundle.zip"), + patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps), + ): + result = run_winrm_rdp_monitor_update( + target="pc", + user="B26\\admin", + password="pw", + repo_url="https://git.example.com/RDP-login-monitor.git", + ) + + assert result.ok is True + assert "SAC served bundle" in result.stdout + assert any("Invoke-WebRequest" in call for call in calls) + assert not any("FromBase64String" in call for call in calls) + + +def test_winrm_failure_detail_never_returns_raw_clixml_progress(): + clixml = ( + "#< CLIXML " + 'Preparing modules for first use.' + ) + detail = _winrm_failure_detail("", clixml, 1) + assert "#< CLIXML" not in detail + + +def test_clixml_to_plain_strips_progress_only_blob(): + clixml = "#< CLIXML Preparing modules for first use." + assert _clixml_to_plain(clixml) == "" diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..885e984 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,30 @@ +# Только для Docker Compose (см. docs/install-ubuntu-24.04-docker.md). +# Production native: deploy/env.native.example → /opt/security-alert-center/config/sac-api.env + +# PostgreSQL +POSTGRES_USER=sac +POSTGRES_PASSWORD=change-me-long-random +POSTGRES_DB=sac + +# API (публичный URL для ссылок в ответах ingest) +SAC_PUBLIC_URL=https://sac.kalinamall.ru +SAC_API_PORT=8000 + +# JWT (UI — позже) +JWT_SECRET=change-me-openssl-rand-hex-32 + +# Первый API key для агентов (заголовок: Authorization: Bearer <ключ>) +# Сгенерировать: python -c "import secrets; print('sac_'+secrets.token_urlsafe(32))" +SAC_BOOTSTRAP_API_KEY=sac_replace_with_generated_key + +# Admin UI (фаза 1C) +SAC_ADMIN_USERNAME=admin +SAC_ADMIN_PASSWORD= + +# Telegram notifications from SAC (phase 1C.4) +TELEGRAM_ENABLED=false +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +TELEGRAM_MIN_SEVERITY=high + +CORS_ORIGINS=* diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..77433fd --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,30 @@ +# Deploy + +## Production (native) — основной путь + +1. [docs/install-ubuntu-24.04-native.md](../docs/install-ubuntu-24.04-native.md) +2. `deploy/env.native.example` → `/opt/security-alert-center/config/sac-api.env` +3. `deploy/systemd/sac-api.service` → `/etc/systemd/system/` +4. `deploy/nginx/sac.conf.tls.example` → `/etc/nginx/sites-available/sac` (wildcard TLS; только `sac.kalinamall.ru`, чужие Host → 444) + или `sac.conf.example` — только HTTP / отладка +5. `deploy/sac-deploy.sh` → `/opt/sac-deploy.sh` — полное обновление после `git pull` + +```bash +sudo cp deploy/sac-deploy.sh /opt/sac-deploy.sh +sudo chmod 755 /opt/sac-deploy.sh +sudo /opt/sac-deploy.sh +``` + +Суточные отчёты: `sac-daily-report.service` + timer. Файл `sac-daily-report.timer` в git — шаблон; +на сервере timer **генерируется** из `SAC_DAILY_REPORT_HOUR` / `SAC_DAILY_REPORT_TIMEZONE` +(`deploy/systemd/render-sac-daily-report-timer.sh`, вызывается из `sac-deploy.sh`). + +## Альтернатива: Docker + +Только для стенда. См. [docs/install-ubuntu-24.04-docker.md](../docs/install-ubuntu-24.04-docker.md). + +```bash +cp .env.example .env # docker-only +docker compose up -d --build +docker compose run --rm migrate +``` diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..03e540e --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,51 @@ +services: + postgres: + image: postgres:16-bookworm + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-sac} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-sac} + volumes: + - sac_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sac} -d ${POSTGRES_DB:-sac}"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: .. + dockerfile: backend/Dockerfile + restart: unless-stopped + env_file: + - .env + environment: + DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-sac}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-sac} + SAC_PUBLIC_URL: ${SAC_PUBLIC_URL:-http://localhost:8000} + JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET} + SAC_BOOTSTRAP_API_KEY: ${SAC_BOOTSTRAP_API_KEY:?set SAC_BOOTSTRAP_API_KEY} + EVENT_SCHEMA_PATH: /app/schemas/event-schema-v1.json + CORS_ORIGINS: ${CORS_ORIGINS:-*} + ports: + - "${SAC_API_PORT:-8000}:8000" + depends_on: + postgres: + condition: service_healthy + + migrate: + build: + context: .. + dockerfile: backend/Dockerfile + env_file: + - .env + environment: + DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-sac}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-sac} + command: alembic upgrade head + depends_on: + postgres: + condition: service_healthy + +volumes: + sac_pgdata: diff --git a/deploy/env.native.example b/deploy/env.native.example new file mode 100644 index 0000000..36079b2 --- /dev/null +++ b/deploy/env.native.example @@ -0,0 +1,139 @@ +# Native: /opt/security-alert-center/config/sac-api.env +# sudo chown sac:sac ... && sudo chmod 600 ... +# Пароль в URL — в двойных кавычках. Символ # в пароле допустим только внутри кавычек. +# systemd НЕ парсит этот файл — читает приложение (SAC_CONFIG_FILE). + +DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:5432/sac +# 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). +# SAC_AGENT_BUNDLE_BASE_URL=https://192.168.x.x +JWT_SECRET=CHANGE_ME_openssl_rand_hex_32 +SAC_SECURITY_ENFORCE=true + +# API key для агентов: Authorization: Bearer <ключ> +# python3.12 -c "import secrets; print('sac_'+secrets.token_urlsafe(32))" +SAC_BOOTSTRAP_API_KEY=sac_replace_with_generated_key + +EVENT_SCHEMA_PATH=/opt/security-alert-center/schemas/event-schema-v1.json + +SAC_ADMIN_USERNAME=admin +SAC_ADMIN_PASSWORD= + +# Telegram notifications from SAC (phase 1C.4) +# При UseSAC=exclusive на агентах — основной канал для оператора; см. docs/agent-integration.md §1.3.1 +TELEGRAM_ENABLED=false +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +# exclusive: warning (failed login, problems); prod noise-only: high +TELEGRAM_MIN_SEVERITY=high + +# Generic webhook (JSON POST), F-NOT-01 +WEBHOOK_ENABLED=false +WEBHOOK_URL= +WEBHOOK_SECRET_HEADER= +WEBHOOK_SECRET= +WEBHOOK_MIN_SEVERITY=high + +# SMTP email (F-NOT-01) +SMTP_ENABLED=false +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_TO= +SMTP_STARTTLS=true +SMTP_SSL=false +SMTP_MIN_SEVERITY=high + +# Глобальное правило: severity ≥ NOTIFY_MIN_SEVERITY → каналы из NOTIFY_CHANNELS +NOTIFY_MIN_SEVERITY=warning +NOTIFY_CHANNELS=telegram,webhook,email + +# Cooldown оповещений SAC (секунды; 0 = выкл. для kind) +SAC_NOTIFY_COOLDOWN_ENABLED=true +SAC_NOTIFY_EVENT_COOLDOWN_SEC=90 +SAC_NOTIFY_PROBLEM_COOLDOWN_SEC=300 + +# Суточные отчёты из SAC (F-NOT-05; systemd sac-daily-report.timer) +SAC_DAILY_REPORT_ENABLED=true +SAC_DAILY_REPORT_HOUR=9 +SAC_DAILY_REPORT_TIMEZONE=Europe/Moscow +SAC_DAILY_REPORT_SKIP_IF_AGENT_SENT=true +SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true + +# Статус хоста по agent.heartbeat +# Порог stale для agent.heartbeat (мин). При интервале агента ~4 ч — 300 (5 ч) даёт запас. +SAC_HEARTBEAT_STALE_MINUTES=300 + +# Проактивный алерт host_silence (без ожидания нового события с хоста) +# Если включён systemd timer sac-host-silence-scan.timer — лучше SAC_HOST_SILENCE_SCAN_ENABLED=false +# (иначе scan идёт параллельно из каждого uvicorn worker + timer). +SAC_HOST_SILENCE_SCAN_ENABLED=true +SAC_HOST_SILENCE_SCAN_INTERVAL_MINUTES=5 + +# Корреляция Problems: host + type + rule в одном open Problem (минуты) +SAC_PROBLEM_CORRELATION_WINDOW_MINUTES=60 + +# rule:brute_force_burst (ssh.login.failed / rdp.login.failed) +SAC_BRUTE_FORCE_WINDOW_MINUTES=15 +SAC_BRUTE_FORCE_THRESHOLD=30 + +# rule:privilege_spike (privilege.sudo.command) +SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES=10 +SAC_PRIVILEGE_SPIKE_THRESHOLD=10 + +# rule:rdg_session_flap (302→303) +SAC_RDG_FLAP_WINDOW_MIN_SEC=1 +SAC_RDG_FLAP_WINDOW_MAX_SEC=10 +SAC_RDG_FLAP_DEDUP_SEC=30 + +# IP HAProxy (или другого RDG-прокси), через который виден вход: external_ip в событии 302/303 +# SAC_RDG_HAPROXY_EXTERNAL_IPS=192.168.160.50,10.0.0.100 + +# qwinsta/logoff на Windows-хостах (доменный admin) +# SAC_WIN_ADMIN_USER=B26\\Administrator +# SAC_WIN_ADMIN_PASSWORD= + +# SSH / update_ssh_monitor.sh на Linux-хостах +# SAC_LINUX_ADMIN_USER=root +# SAC_LINUX_ADMIN_PASSWORD= + +# Retention (systemd sac-retention.timer) +SAC_EVENTS_RETENTION_DAYS=90 +SAC_PROBLEMS_RETENTION_DAYS=180 + +# UI login: блокировка IP после N неудачных попыток (окно в минутах) + алерт в Telegram +SAC_LOGIN_MAX_FAILURES=3 +SAC_LOGIN_FAILURE_WINDOW_MINUTES=15 +SAC_LOGIN_ALERT_TELEGRAM=true +# IP через запятую — не блокируются в UI (дополняет список в Настройках SAC) +SAC_LOGIN_IP_WHITELIST=192.168.160.3 +# fail2ban: синхронизация белого списка из UI (нужны права записи + reload) +SAC_FAIL2BAN_SYNC_ENABLED=false +SAC_FAIL2BAN_SSH_JAIL=sshd +SAC_FAIL2BAN_IGNOREIP_FILE=/etc/fail2ban/jail.d/sac-login-whitelist.local + +# Seaca mobile push (FCM HTTP v1) +SAC_FCM_ENABLED=false +SAC_FCM_PROJECT_ID= +# Путь к JSON service account Firebase (chmod 600, владелец sac) +SAC_FCM_SERVICE_ACCOUNT_JSON= +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 + +# WinRM: enable HTTPS listener on clients (5986) before turning on. +# SAC_WINRM_USE_HTTPS=false +# SAC_WINRM_SERVER_CERT_VALIDATION=validate diff --git a/deploy/nginx/sac.conf.example b/deploy/nginx/sac.conf.example new file mode 100644 index 0000000..9a85086 --- /dev/null +++ b/deploy/nginx/sac.conf.example @@ -0,0 +1,69 @@ +# /etc/nginx/sites-available/sac +# Production FQDN: sac.kalinamall.ru +# API: 127.0.0.1:8000 (systemd sac-api) +# +# Hostname: только sac.kalinamall.ru; остальные Host → 444 (см. default_server ниже). +# +# Порядок: 1) этот конфиг (только :80) → nginx -t → reload +# 2) certbot --nginx -d sac.kalinamall.ru (добавит :443 и сертификаты) + +upstream sac_api { + server 127.0.0.1:8000; + keepalive 8; +} + +# --- default: отклонить чужие Host (не sac.kalinamall.ru) --- +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 444; +} + +server { + listen 80; + listen [::]:80; + server_name sac.kalinamall.ru; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + client_max_body_size 2m; + + # SSH/WinRM и git-probe: backend ждёт до 900s + location ~ ^/api/v1/(hosts/[0-9]+/actions/(agent-update|agent-update-fallback|ssh-test|winrm-test)|settings/agent-updates/test-git) { + 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 960s; + proxy_read_timeout 960s; + } + + # Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem. + location = /api/v1/events { + proxy_pass http://sac_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 30s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + + location / { + 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_read_timeout 60s; + } +} diff --git a/deploy/nginx/sac.conf.tls.example b/deploy/nginx/sac.conf.tls.example new file mode 100644 index 0000000..6ff6e66 --- /dev/null +++ b/deploy/nginx/sac.conf.tls.example @@ -0,0 +1,94 @@ +# /etc/nginx/sites-available/sac +# HTTPS с корпоративным wildcard-сертификатом (например VeriSign / DigiCert). +# Покрывает sac.kalinamall.ru при CN/SAN *.kalinamall.ru или sac.kalinamall.ru +# +# Hostname: только sac.kalinamall.ru. Любой другой Host (например ext.kalinamall.ru +# при ошибке DNS/маршрутизации на IP SAC) получает 444 / reject handshake — +# форма входа SAC не отдаётся на «чужом» имени. +# +# Перед включением: +# 1) Положите fullchain и ключ в /etc/ssl/sac/ (см. docs/ssl-certificate.md) +# 2) Подставьте пути в ssl_certificate / ssl_certificate_key ниже +# 3) sudo nginx -t && sudo systemctl reload nginx + +upstream sac_api { + server 127.0.0.1:8000; + keepalive 8; +} + +# --- default: отклонить чужие Host на :80 --- +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 444; +} + +# --- default: отклонить чужой SNI/Host на :443 (nginx >= 1.19.5) --- +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + server_name _; + ssl_reject_handshake on; +} + +# Редирект HTTP → HTTPS (только sac.kalinamall.ru) +server { + listen 80; + listen [::]:80; + server_name sac.kalinamall.ru; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name sac.kalinamall.ru; + + # --- замените пути на ваши файлы wildcard --- + ssl_certificate /etc/ssl/sac/fullchain.pem; + ssl_certificate_key /etc/ssl/sac/privkey.pem; + + ssl_session_timeout 1d; + ssl_session_cache shared:SAC_SSL:10m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + client_max_body_size 2m; + + # SSH/WinRM и git-probe: backend ждёт до 900s + location ~ ^/api/v1/(hosts/[0-9]+/actions/(agent-update|agent-update-fallback|ssh-test|winrm-test)|settings/agent-updates/test-git) { + 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 960s; + proxy_read_timeout 960s; + } + + # Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem. + location = /api/v1/events { + proxy_pass http://sac_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 30s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + + location / { + 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_read_timeout 60s; + } +} diff --git a/deploy/sac-deploy.sh b/deploy/sac-deploy.sh new file mode 100644 index 0000000..7024254 --- /dev/null +++ b/deploy/sac-deploy.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Полное обновление SAC на сервере (native). +# Установка: sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh +# sudo chmod 755 /opt/sac-deploy.sh +# Запуск: sudo /opt/sac-deploy.sh +# +# Выполняет от пользователя sac: git pull, pip, alembic, npm build. +# От root: systemctl restart sac-api. + +set -euo pipefail + +APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}" +APP_USER="${SAC_APP_USER:-sac}" +SERVICE_NAME="${SAC_SERVICE:-sac-api}" +CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}" + +log() { printf '[sac-deploy] %s\n' "$*"; } +die() { printf '[sac-deploy] ERROR: %s\n' "$*" >&2; exit 1; } + +if [ "$(id -u)" -ne 0 ]; then + die "Запускайте от root: sudo /opt/sac-deploy.sh" +fi + +id "$APP_USER" &>/dev/null || die "Пользователь ${APP_USER} не существует" + +if [ ! -d "${APP_ROOT}/.git" ]; then + die "Не найден git-репозиторий: ${APP_ROOT}" +fi + +log "Выравнивание владельца ${APP_ROOT} → ${APP_USER}:${APP_USER}" +chown -R "${APP_USER}:${APP_USER}" "${APP_ROOT}" + +log "git fetch + sync" +sudo -u "${APP_USER}" git -C "${APP_ROOT}" fetch --prune + +if sudo -u "${APP_USER}" git -C "${APP_ROOT}" pull --ff-only 2>/dev/null; then + log "git: fast-forward OK" +else + UPSTREAM="$(sudo -u "${APP_USER}" git -C "${APP_ROOT}" rev-parse --abbrev-ref '@{u}' 2>/dev/null || true)" + if [ -z "${UPSTREAM}" ]; then + UPSTREAM="origin/main" + fi + log "WARN: fast-forward невозможен (часто после force-push на kalinamall) — reset --hard ${UPSTREAM}" + sudo -u "${APP_USER}" git -C "${APP_ROOT}" reset --hard "${UPSTREAM}" +fi + +if [ ! -f "${CONFIG_FILE}" ]; then + die "Нет конфига: ${CONFIG_FILE}" +fi + +VENV="${APP_ROOT}/backend/.venv" +if [ ! -x "${VENV}/bin/pip" ]; then + die "Нет venv: ${VENV} — сначала установите по install-ubuntu-24.04-native.md" +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}' + cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head +" + +if [ -f "${APP_ROOT}/frontend/package.json" ]; then + if ! command -v npm >/dev/null 2>&1; then + log "npm не найден — пропуск сборки UI (apt install nodejs npm)" + else + log "frontend: npm ci / build" + sudo -u "${APP_USER}" bash -c " + cd '${APP_ROOT}/frontend' + if [ -f package-lock.json ]; then npm ci; else npm install; fi + npm run build + " + if [ ! -f "${APP_ROOT}/frontend/dist/index.html" ]; then + die "Сборка UI не создала frontend/dist/index.html" + fi + fi +fi + +if [ -f "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" ]; then + SYSTEMD_DST="/etc/systemd/system/${SERVICE_NAME}.service" + if [ ! -f "${SYSTEMD_DST}" ] || ! cmp -s "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" "${SYSTEMD_DST}"; then + log "Обновление systemd unit → ${SYSTEMD_DST}" + cp "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" "${SYSTEMD_DST}" + 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 " + export SAC_CONFIG_FILE='${CONFIG_FILE}' + cd '${APP_ROOT}/backend' + '${VENV}/bin/python' -c \" +from sqlalchemy import create_engine, text +from app.config import get_settings +engine = create_engine(get_settings().database_url, pool_pre_ping=True) +with engine.connect() as conn: + conn.execute(text('SELECT 1')) +print('db: OK') +\" +" || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql" + +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}' + cd '${APP_ROOT}/backend' + '${VENV}/bin/python' -c \" +from app.database import SessionLocal +from app.services.host_remote_actions import clear_stale_running_remote_actions +session = SessionLocal() +try: + names = clear_stale_running_remote_actions(session) + for name in names: + print(f'stale remote_action reset: {name}') +finally: + session.close() +\" +" +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" + +HEALTH_OK=0 +for _ in 1 2 3 4 5 6; do + sleep 2 + if curl -sf "http://127.0.0.1:8000/health" >/dev/null 2>&1; then + HEALTH_OK=1 + break + fi +done +if [ "${HEALTH_OK}" -eq 1 ]; then + log "health: OK (http://127.0.0.1:8000/health)" +else + die "${SERVICE_NAME} запущен, но /health не ответил — journalctl -u ${SERVICE_NAME} -n 80 --no-pager" +fi + +RENDER_DAILY_TIMER="${APP_ROOT}/deploy/systemd/render-sac-daily-report-timer.sh" +if [ -f "${RENDER_DAILY_TIMER}" ]; then + chmod +x "${RENDER_DAILY_TIMER}" 2>/dev/null || true +fi + +for aux in sac-retention sac-daily-report; do + for suffix in service timer; do + src="${APP_ROOT}/deploy/systemd/${aux}.${suffix}" + dst="/etc/systemd/system/${aux}.${suffix}" + if [ "${aux}" = "sac-daily-report" ] && [ "${suffix}" = "timer" ]; then + if [ -x "${RENDER_DAILY_TIMER}" ]; then + log "Генерация ${dst} из ${CONFIG_FILE} (SAC_DAILY_REPORT_HOUR/TIMEZONE)" + bash "${RENDER_DAILY_TIMER}" "${CONFIG_FILE}" "${dst}" + systemctl daemon-reload + systemctl restart "${aux}.timer" 2>/dev/null || systemctl start "${aux}.timer" 2>/dev/null || true + elif [ -f "${src}" ]; then + log "WARN: ${RENDER_DAILY_TIMER} не найден — копируем статический timer" + cp "${src}" "${dst}" + systemctl daemon-reload + fi + continue + fi + if [ -f "${src}" ]; then + if [ ! -f "${dst}" ] || ! cmp -s "${src}" "${dst}"; then + log "Обновление ${dst}" + cp "${src}" "${dst}" + systemctl daemon-reload + fi + fi + done + if [ -f "/etc/systemd/system/${aux}.timer" ]; then + systemctl enable "${aux}.timer" 2>/dev/null || true + systemctl start "${aux}.timer" 2>/dev/null || true + fi +done + +log "Готово." diff --git a/deploy/systemd/render-sac-daily-report-timer.sh b/deploy/systemd/render-sac-daily-report-timer.sh new file mode 100644 index 0000000..cdd06db --- /dev/null +++ b/deploy/systemd/render-sac-daily-report-timer.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Генерирует sac-daily-report.timer из SAC_DAILY_REPORT_HOUR и SAC_DAILY_REPORT_TIMEZONE. +# Usage: sudo deploy/systemd/render-sac-daily-report-timer.sh [config.env] [output.timer] + +set -euo pipefail + +CONFIG_FILE="${1:-/opt/security-alert-center/config/sac-api.env}" +OUTPUT="${2:-/etc/systemd/system/sac-daily-report.timer}" + +HOUR=9 +TZ_NAME="Europe/Moscow" + +if [ -f "${CONFIG_FILE}" ]; then + set -a + # shellcheck source=/dev/null + source "${CONFIG_FILE}" + set +a +fi + +HOUR="${SAC_DAILY_REPORT_HOUR:-9}" +TZ_NAME="${SAC_DAILY_REPORT_TIMEZONE:-Europe/Moscow}" + +if ! [[ "${HOUR}" =~ ^[0-9]+$ ]] || [ "${HOUR}" -lt 0 ] || [ "${HOUR}" -gt 23 ]; then + printf 'render-sac-daily-report-timer: invalid SAC_DAILY_REPORT_HOUR=%s\n' "${HOUR}" >&2 + exit 1 +fi + +HOUR_PADDED="$(printf '%02d' "${HOUR}")" + +cat >"${OUTPUT}" </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 diff --git a/deploy/systemd/sac-api.service b/deploy/systemd/sac-api.service new file mode 100644 index 0000000..3ffd4b8 --- /dev/null +++ b/deploy/systemd/sac-api.service @@ -0,0 +1,24 @@ +[Unit] +Description=Security Alert Center API (FastAPI) +Documentation=https://git.papatramp.ru/PapaTramp/security-alert-center +After=network-online.target postgresql.service +Wants=network-online.target +Requires=postgresql.service + +[Service] +Type=simple +User=sac +Group=sac +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 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=20 +TimeoutStartSec=30 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/sac-daily-report.service b/deploy/systemd/sac-daily-report.service new file mode 100644 index 0000000..135bc88 --- /dev/null +++ b/deploy/systemd/sac-daily-report.service @@ -0,0 +1,16 @@ +[Unit] +Description=Security Alert Center daily report generation (F-NOT-05) +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +User=sac +Group=sac +WorkingDirectory=/opt/security-alert-center/backend +EnvironmentFile=/opt/security-alert-center/config/sac-api.env +ExecStart=/opt/security-alert-center/backend/.venv/bin/python -m app.jobs.daily_report +Nice=10 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/sac-daily-report.timer b/deploy/systemd/sac-daily-report.timer new file mode 100644 index 0000000..251b657 --- /dev/null +++ b/deploy/systemd/sac-daily-report.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Daily SAC report aggregation and notify + +[Timer] +# Шаблон по умолчанию (hour=9, Europe/Moscow). На prod timer перегенерируется +# из config/sac-api.env: deploy/systemd/render-sac-daily-report-timer.sh или sac-deploy.sh +OnCalendar=*-*-* 09:00:00 +Timezone=Europe/Moscow +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/sac-host-silence-scan.service b/deploy/systemd/sac-host-silence-scan.service new file mode 100644 index 0000000..86d1fa8 --- /dev/null +++ b/deploy/systemd/sac-host-silence-scan.service @@ -0,0 +1,16 @@ +[Unit] +Description=Security Alert Center host silence scan (stale heartbeat) +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +User=sac +Group=sac +WorkingDirectory=/opt/security-alert-center/backend +EnvironmentFile=/opt/security-alert-center/config/sac-api.env +ExecStart=/opt/security-alert-center/backend/.venv/bin/python -m app.jobs.host_silence_scan +Nice=10 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/sac-host-silence-scan.timer b/deploy/systemd/sac-host-silence-scan.timer new file mode 100644 index 0000000..7801614 --- /dev/null +++ b/deploy/systemd/sac-host-silence-scan.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Periodic SAC host silence scan (stale agent.heartbeat) + +[Timer] +OnBootSec=3min +OnUnitActiveSec=5min +RandomizedDelaySec=30 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/sac-retention.service b/deploy/systemd/sac-retention.service new file mode 100644 index 0000000..74539cf --- /dev/null +++ b/deploy/systemd/sac-retention.service @@ -0,0 +1,16 @@ +[Unit] +Description=Security Alert Center data retention +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +User=sac +Group=sac +WorkingDirectory=/opt/security-alert-center/backend +EnvironmentFile=/opt/security-alert-center/config/sac-api.env +ExecStart=/opt/security-alert-center/backend/.venv/bin/python -m app.jobs.retention +Nice=10 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/sac-retention.timer b/deploy/systemd/sac-retention.timer new file mode 100644 index 0000000..1b500aa --- /dev/null +++ b/deploy/systemd/sac-retention.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily SAC retention purge + +[Timer] +OnCalendar=daily +RandomizedDelaySec=900 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..f97eedf --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,29 @@ +# Индекс документации SAC + +| Документ | Назначение | +|----------|------------| +| [TZ.md](TZ.md) | **Техническое задание** (главный документ) | +| [diagrams/data-flow.md](diagrams/data-flow.md) | Диаграмма «Как это работает» (Mermaid) | +| [architecture.md](architecture.md) | Компоненты, API, модель данных | +| [agent-integration.md](agent-integration.md) | UseSAC, протокол ingest, маппинг типов | +| [event-schema-v1.json](event-schema-v1.json) | JSON Schema событий | +| [work-plan.md](work-plan.md) | План работ по фазам | +| [roadmap.md](roadmap.md) | Дорожная карта версий | +| [install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md) | **Подготовка сервера (native)** | +| [install-ubuntu-24.04-docker.md](install-ubuntu-24.04-docker.md) | Подготовка сервера (Docker, альтернатива) | +| [install-ubuntu-24.04.md](install-ubuntu-24.04.md) | Указатель | +| [ssl-certificate.md](ssl-certificate.md) | Wildcard TLS: формат, scp, пути `/etc/ssl/sac/` | +| [deployment.md](deployment.md) | Эксплуатация, backup, TLS, `sac-deploy.sh` | +| [operations-prod-status.md](operations-prod-status.md) | **Статус prod** и следующие шаги | +| [seaca-mobile.md](seaca-mobile.md) | **Seaca**: коды, устройства, сессия, API | +| [seaca-fcm.md](seaca-fcm.md) | **Seaca push**: Firebase и `SAC_FCM_*` | +| [workspace-three-repos.md](workspace-three-repos.md) | Multi-root workspace (три репо) | +| [agent-control-plane.md](agent-control-plane.md) | **v0.7:** RDG flap, qwinsta/logoff, обновления агентов, config push | +| [agent-update-backlog.md](agent-update-backlog.md) | Ранний backlog (см. agent-control-plane.md) | + +## Порядок чтения + +1. TZ.md +2. diagrams/data-flow.md +3. agent-integration.md + event-schema-v1.json +4. work-plan.md diff --git a/docs/TZ.md b/docs/TZ.md new file mode 100644 index 0000000..2c0cf4c --- /dev/null +++ b/docs/TZ.md @@ -0,0 +1,310 @@ +# Техническое задание +# Security Alert Center (SAC) + +| Поле | Значение | +|------|----------| +| Версия документа | 1.0 | +| Дата | 2026-05-26 | +| Статус | Черновик на согласование | +| Целевая ОС сервера | **Ubuntu 24.04 LTS** | + +--- + +## 1. Назначение и цели + +### 1.1. Назначение + +**Security Alert Center (SAC)** — центральное веб-приложение, которое: + +1. Принимает структурированные события безопасности от агентов **ssh-monitor** (Linux) и **RDP-login-monitor** (Windows). +2. Хранит их в базе данных для поиска, аудита и аналитики. +3. Отображает ленту событий, инциденты (Problems) и дашборды в стиле **Zabbix** (Monitoring → Problems, графики, хосты). +4. По правилам доставляет оповещения операторам (Telegram, email, webhook) — **когда на агентах включён режим `UseSAC`**. + +### 1.2. Цели + +- Единая точка наблюдения за входами, неудачными попытками, sudo, банами IP, RD Gateway и т.д. +- Снижение «шума» в мессенджерах за счёт дедупликации, группировки и правил в SAC. +- Сохранение привычного поведения агентов при **`UseSAC=off`** (уведомления напрямую в Telegram/email, как сейчас). +- Эксплуатация на одном сервере **Ubuntu 24.04** без обязательного Kubernetes. + +### 1.3. Не входит в scope MVP (см. [roadmap.md](roadmap.md)) + +- Кластеризация SAC (active-active). +- Полноценный SIEM / ML-аномалии. +- Замена существующих агентов — они остаются, меняется только канал доставки при `UseSAC`. + +--- + +## 2. Контекст: три репозитория + +| № | Репозиторий | Роль | +|---|-------------|------| +| 1 | `ssh-monitor` | Агент на Linux-серверах | +| 2 | `RDP-login-monitor` | Агент на Windows Server | +| 3 | `security-alert-center` | Центральный сервер (этот проект) | + +Совместная разработка в multi-root workspace — см. [workspace-three-repos.md](workspace-three-repos.md). + +--- + +## 3. Как это работает (архитектура потоков) + +Ниже — целевая схема взаимодействия компонентов (рис. 1). + +```mermaid +flowchart LR + subgraph agents [Агенты на хостах] + SSH[ssh-monitor Linux] + RDP[RDP-login-monitor Windows] + end + + subgraph sac [Security Alert Center Ubuntu 24.04] + API[Ingest API HTTPS] + Q[Очередь / воркер] + DB[(PostgreSQL)] + RT[Realtime SSE/WebSocket] + UI[Web UI] + NOTIFY[Правила оповещений] + end + + subgraph channels [Каналы наружу] + TG[Telegram] + MAIL[Email] + WEBHOOK[Webhook / Slack] + end + + SSH -->|JSON события + API key| API + RDP -->|JSON события + API key| API + API --> Q --> DB + DB --> UI + DB --> RT --> UI + DB --> NOTIFY --> TG + NOTIFY --> MAIL + NOTIFY --> WEBHOOK +``` + +**Рис. 1.** Поток данных: агенты → SAC → БД/UI; оповещения пользователю при `UseSAC=exclusive` идут только из SAC. + +### 3.1. Жизненный цикл события + +1. На хосте возникает событие (успешный SSH, неудачный RDP, sudo, бан IP и т.д.). +2. Агент формирует **каноническое JSON-событие** (схема v1 — [event-schema-v1.json](event-schema-v1.json)). +3. `POST /api/v1/events` по HTTPS с API-ключом; при сбое — запись в локальный **spool** и повтор. +4. SAC: валидация → дедупликация → запись в PostgreSQL → оценка правил → при необходимости создание **Problem**. +5. Оператор просматривает UI; при `UseSAC=exclusive` Telegram/email отправляет **SAC**, не агент. + +### 3.2. Режим `UseSAC` на агентах + +| Режим | Значение | Поведение агента | Кто шлёт в Telegram/email | +|-------|----------|------------------|---------------------------| +| `off` | `0` | Как сейчас | Агент | +| `exclusive` | `1` | Только SAC (расширенный JSON) | **Только SAC** | +| `dual` | `2` | SAC + локальные каналы | Оба (миграция/отладка) | +| `fallback` | `3` | SAC; при N сбоях подряд — снова локально | SAC, при аварии SAC — агент | + +**Требования:** + +- При `UseSAC=exclusive` функции `notify_send` / `Send-TelegramMessage` для **оповещений о событиях** не вызываются. +- Локальные **лог-файлы** на агенте (`LOG_FILE`, `login_monitor.log` и т.д.) **сохраняются** всегда. +- При `UseSAC=exclusive` обязательны `SAC_URL`, `SAC_API_KEY`; при старте — проверка доступности SAC (`--check-sac` / аналог). +- Подробности — [agent-integration.md](agent-integration.md). + +--- + +## 4. Функциональные требования + +### 4.1. Приём событий (Ingest API) + +| ID | Требование | +|----|------------| +| F-ING-01 | `POST /api/v1/events` принимает одно событие JSON, соответствующее `event-schema-v1.json`. | +| F-ING-02 | Аутентификация: заголовок `Authorization: Bearer ` или `X-SAC-API-Key`. | +| F-ING-03 | Ответ `202 Accepted` с `event_id`, `sac_event_url`; при создании Problem — `problem_id` (опционально). | +| F-ING-04 | Идемпотентность: повтор с тем же `event_id` не создаёт дубликат. | +| F-ING-05 | `POST /api/v1/events/batch` — до 100 событий (фаза 1.5). | +| F-ING-06 | Rate limiting: настраиваемый лимит на ключ/хост. | +| F-ING-07 | `GET /health` — проверка БД и версии (для агентов и мониторинга). | + +### 4.2. Учёт хостов (Hosts) + +| ID | Требование | +|----|------------| +| F-HST-01 | Первый валидный ingest с API key регистрирует/обновляет карточку хоста. | +| F-HST-02 | Поля: hostname, display_name, os_family, os_version, ipv4/ipv6, версия агента, `use_sac_mode`, last_seen, tags. | +| F-HST-03 | Статус «жив/мёртв» по последнему `agent.heartbeat` (порог N минут — настраивается). | +| F-HST-04 | UI: список хостов, фильтр, переход к событиям хоста. | + +### 4.3. Хранение событий (Events) + +| ID | Требование | +|----|------------| +| F-EVT-01 | Хранение всех полей события + `received_at`, `host_id`. | +| F-EVT-02 | Поиск/фильтр: период, хост, `type`, `severity`, IP, user, текст в `summary`. | +| F-EVT-03 | Просмотр карточки события с `details`, `raw` (с лимитом отображения). | +| F-EVT-04 | Экспорт CSV за выбранный фильтр (фаза 1.5). | +| F-EVT-05 | Retention: настраиваемое хранение сырых событий (по умолчанию 90 дней). | + +### 4.4. Инциденты (Problems) + +| ID | Требование | +|----|------------| +| F-PRB-01 | Правила создают Problem из одного или нескольких событий (пример: ≥30 `ssh.login.failed` за 15 мин с одного IP). | +| F-PRB-02 | Статусы: `open`, `acknowledged`, `resolved`. | +| F-PRB-03 | UI: список Problems с severity, хостом, временем, действиями ack/resolve. | +| F-PRB-04 | Связь Problem ↔ Events (просмотр связанных событий). | + +### 4.5. Веб-интерфейс + +| ID | Требование | +|----|------------| +| F-UI-01 | Страница **Problems** — активные инциденты, счётчики по severity. | +| F-UI-02 | Страница **Events** — лента с пагинацией и фильтрами. | +| F-UI-03 | Страница **Hosts** — инвентарь агентов. | +| F-UI-04 | Страница **Dashboards** — графики: успешные/неудачные входы по времени, топ IP, sudo, баны (MVP: базовый набор виджетов). | +| F-UI-05 | Live-лента последних событий (SSE или WebSocket). | +| F-UI-06 | Аутентификация в UI: логин/пароль (MVP); LDAP — фаза 3. | +| F-UI-07 | Роли MVP: `admin`, `operator`, `viewer`. | + +### 4.6. Оповещения из SAC + +| ID | Требование | +|----|------------| +| F-NOT-01 | Каналы: Telegram, SMTP email, generic webhook (JSON). | +| F-NOT-02 | Правила: условие (тип, severity, хост, тег) → каналы + шаблон. | +| F-NOT-03 | Дедупликация и cooldown на уровне SAC (аналог `BRUTE_NOTIFY_COOLDOWN_SEC`, `SSH_ACCEPT_NOTIFY_DEDUP_SEC`). | +| F-NOT-04 | Расписание тишины (maintenance window) — фаза 2. | +| F-NOT-05 | Суточные отчёты (`report.daily.*`) формируются и отправляются **из SAC**, не с агента при `UseSAC=exclusive`. | + +### 4.7. Типы событий (минимальный перечень MVP) + +**ssh-monitor:** + +- `ssh.login.success`, `ssh.login.failed`, `ssh.session.disconnected` +- `privilege.sudo.command` +- `ssh.ip.banned`, `ssh.ip.bruteforce.threshold`, `ssh.bruteforce.mass` +- `session.logind.new`, `session.logind.removed`, `session.logind.failed` +- `report.daily.ssh`, `agent.heartbeat`, `agent.recovered`, `agent.test` + +**RDP-login-monitor:** + +- `rdp.login.success`, `rdp.login.failed`, `auth.explicit.credentials` (4648) +- `rdg.connection.success`, `rdg.connection.failed` +- `report.daily.rdp`, `agent.heartbeat`, `agent.lifecycle`, `agent.test` + +Полный контракт — [event-schema-v1.json](event-schema-v1.json) и [agent-integration.md](agent-integration.md). + +### 4.8. Расширенные поля событий (при `UseSAC=exclusive`) + +Агент передаёт структуру **сверх** текущего текста Telegram: + +- Идентификаторы: `event_id`, `correlation_id`, `dedup_key`, `fingerprint` +- Контекст хоста: FQDN, timezone, версия продукта, `agent.instance_id` +- Пороги: `enrichment.attempt_number`, `enrichment.threshold` +- SSH/RDP-специфика: port, logon_type, gateway target, sudo command, ban_until +- `raw`: обрезанный фрагмент journal / Event XML +- `filtered_out` + `filter_reason` (опционально, отдельный тип) + +Обогащение **в SAC** (не обязательно в MVP): GeoIP, «новый IP для user», корреляция SSH+RDP с одного IP. + +--- + +## 5. Нефункциональные требования + +| ID | Требование | +|----|------------| +| NF-01 | Сервер приложения: **Ubuntu 24.04 LTS** (единственная поддерживаемая платформа для SAC). | +| NF-02 | БД: **PostgreSQL 16+** (production); SQLite допустим только для dev. | +| NF-03 | Ingest: p95 < 2 с при нормальной нагрузке (до 50 событий/с мин суммарно). | +| NF-04 | Доступность UI по HTTPS (TLS). | +| NF-05 | API keys хранятся в БД как hash; секреты конфигурации — в `/etc/security-alert-center/` или env, не в git. | +| NF-06 | Резервное копирование БД: документированная процедура `pg_dump` (см. [deployment.md](deployment.md)). | +| NF-07 | Логи приложения: structured JSON, ротация logrotate. | +| NF-08 | Язык UI: русский (основной); i18n — фаза 3. | +| NF-09 | Время в БД: UTC; отображение — timezone пользователя или `Europe/Moscow` по умолчанию. | + +--- + +## 6. Технологический стек (целевой) + +| Слой | Технология | +|------|------------| +| Backend | Python 3.12, FastAPI, SQLAlchemy 2, Alembic | +| БД | PostgreSQL 16 | +| Очередь (фаза 1.5+) | Redis 7, воркер (ARQ или Celery) | +| Frontend | Vue 3 + Vite, UI-kit (Naive UI / PrimeVue), ECharts | +| Realtime | Server-Sent Events (приоритет MVP) | +| Reverse proxy | nginx | +| Развёртывание | **Native:** PostgreSQL + systemd + nginx (production). Docker Compose — альтернатива для стенда | + +Детали — [architecture.md](architecture.md). + +--- + +## 7. Безопасность + +- Только HTTPS для ingest и UI. +- Отдельные API keys на хост (ротация из UI). +- RBAC в UI (MVP: 3 роли). +- Аудит действий: ack/resolve, смена правил. +- PII в `raw` — ограничение размера; опция маскирования в логах SAC. +- Разделение сетей: агенты → исходящий 443 на SAC; SAC не требует входящих на агенты. + +--- + +## 8. Интеграция с существующими агентами + +Изменения в репозиториях `ssh-monitor` и `RDP-login-monitor` — **отдельные задачи**, не в этом репозитории. + +Обязательно: + +1. Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, `SAC_MODE` (`off|exclusive|dual|fallback`). +2. Функция отправки структурированного события + локальный spool. +3. Команда проверки `--test-sac` / `Test-SacConnection`. +4. Сохранение локальных логов при любом режиме. + +Спецификация — [agent-integration.md](agent-integration.md). + +--- + +## 9. Критерии приёмки MVP + +1. Развёрнут SAC на Ubuntu 24.04, доступны UI и `POST /api/v1/events`. +2. Тестовый агент (curl / `agent.test`) создаёт событие, видимое в UI < 10 с. +3. Реализованы режимы ingest для типов из п. 4.7 (тестовыми payload). +4. Страницы Problems (базовые правила), Events, Hosts, Dashboard (минимум 3 виджета). +5. Настроен канал Telegram из SAC; при `UseSAC=exclusive` на тестовом ssh-monitor Telegram **с агента** не приходит, с SAC — приходит. +6. Heartbeat: Problem «хост недоступен» при отсутствии heartbeat > N мин. +7. Документация развёртывания воспроизводима с нуля по [deployment.md](deployment.md). +8. Spool: при остановке SAC события буферизуются на агенте и доставляются после восстановления. + +--- + +## 10. Риски и митигация + +| Риск | Митигация | +|------|-----------| +| SAC недоступен, алерты потеряны | spool на агенте, режим `fallback` | +| Дубли SSH + logind | `dedup_key` на агенте и в SAC | +| Перегруз БД | партиции по месяцу, retention, агрегаты (фаза 2) | +| Двойные Telegram при миграции | явный `dual`, затем `exclusive` | + +--- + +## 11. Связанные документы + +- [architecture.md](architecture.md) +- [event-schema-v1.json](event-schema-v1.json) +- [agent-integration.md](agent-integration.md) +- [work-plan.md](work-plan.md) +- [roadmap.md](roadmap.md) +- [deployment.md](deployment.md) +- [workspace-three-repos.md](workspace-three-repos.md) + +--- + +## 12. История изменений + +| Версия | Дата | Изменения | +|--------|------|-----------| +| 1.0 | 2026-05-26 | Первоначальная версия ТЗ | diff --git a/docs/agent-control-plane.md b/docs/agent-control-plane.md new file mode 100644 index 0000000..bb0a418 --- /dev/null +++ b/docs/agent-control-plane.md @@ -0,0 +1,329 @@ +# Agent Control Plane — концепция SAC v0.7+ + +**Статус:** утверждено для реализации (2026-06). +**Связано:** [roadmap.md](roadmap.md) §v0.7, [agent-integration.md](agent-integration.md), [agent-update-backlog.md](agent-update-backlog.md). + +--- + +## 1. Зачем + +Сейчас SAC — **односторонний** канал: агент → ingest. Для задач из [roadmap.md](roadmap.md) (§ «Добавить») нужен **обратный канал** SAC ↔ агент и управление хостами: + +| # | Задача | Кратко | +|---|--------|--------| +| П.1 | RDG flap 302→303 | Детект, Problem, кнопка **qwinsta** / **logoff** в UI | +| П.2 | Обновления агентов | Режим GPO или SAC (pull + fallback SSH/WinRM) | +| П.3 | Настройки агента | Desired config в карточке хоста → poll агента | + +**Эталонный git для prod и агентов:** [git.kalinamall.ru/PapaTramp](https://git.kalinamall.ru/PapaTramp). GitHub — публичная sanitized-копия; mirror-скрипты (`push-mirror.sh`, `Rewrite-GitHostUrls.ps1`) живут на kalinamall и нужны только для синхронизации URL в docs между remotes, к runtime агентов не относятся. + +--- + +## 2. Общая архитектура + +```mermaid +flowchart TB + subgraph sac [SAC Ubuntu] + API[FastAPI] + UI[Vue SPA] + PG[(PostgreSQL)] + VAULT[Encrypted secrets] + end + + subgraph win [Windows host] + RDP[RDP-login-monitor] + end + + subgraph linux [Linux host] + SSH[ssh-monitor] + end + + RDP -->|POST events / heartbeat| API + SSH -->|POST events / heartbeat| API + RDP -->|GET agent/commands poll| API + SSH -->|GET agent/commands poll| API + UI --> API + API --> PG + API --> VAULT +``` + +**Принципы:** + +- Агент за NAT — только **исходящий HTTPS**; inbound на хост не требуется. +- Команды и конфиг — **poll** на heartbeat (или отдельный timer 30–60 с). +- Секреты (admin password, SSH private keys, WinRM password) — **encrypted at rest** в PostgreSQL; master key в `SAC_HOST_KEY_ENCRYPTION_KEY` (env). +- Audit: все команды и результаты — события ingest `agent.command.*`, `agent.update.*`. + +--- + +## 3. П.1 — RDG flap 302→303 и qwinsta / logoff + +### 3.1. Смысл проблемы + +При неудачном RDP через RD Gateway типична пара событий Windows **302 → 303** за несколько секунд. Клиент не удерживает сессию; **«зависшая» сессия обычно остаётся на компьютере пользователя** (session host / рабочая станция из `details.internal_ip`), а не на gateway. + +SAC уже принимает: + +| Windows | SAC `type` | Пример severity | +|---------|------------|-----------------| +| 302 | `rdg.connection.success` | info / warning | +| 303 | `rdg.connection.disconnected` | info | +| 303 (alt) | `rdg.connection.failed` | warning | + +Правило должно учитывать **оба** варианта 303. + +### 3.2. Правило детекта `rule:rdg_session_flap` + +``` +WHEN event_B.type IN (rdg.connection.disconnected, rdg.connection.failed) + AND exists event_A within 1..10 seconds BEFORE event_B + AND event_A.type = rdg.connection.success + AND same host_id + AND same details.user + AND (recommended) same details.internal_ip +OPTIONAL reduce false positives: + AND event_B.details.session_duration_sec <= 15 # если агент передаёт из 303 + AND/OR low bytes transferred in 303 payload +THEN: + mark event_B.details.rdg_flap = true + create Problem rule:rdg_session_flap (dedup 30 s, см. ниже) + send notification (Telegram / email / webhook по policy) +``` + +**Порядок:** только **302 → 303**, не наоборот. + +**Окно:** **1–10 секунд** между 302 и 303. + +**Dedup Problem:** **30 секунд** по ключу `{host_id}|{user}|{internal_ip}`. Пользователи часто пробуют 1–2 раза подряд и звонят админам — 60 с слишком долго. + +**Хост:** правило **универсальное** для любого хоста с RDG-событиями в домене (любой DC / gateway / session host, где стоит агент). + +**Маршрутизация команды qwinsta:** команда уходит на хост, где **установлен агент** и куда привязано событие (`host_id`). Если session host ≠ gateway, в перспективе — таблица «gateway → session hosts» или агент на session host; MVP — агент на том же сервере, что шлёт события RDG. + +### 3.3. UI — веб и Seaca + +Бейдж **RDG flap** и кнопка **qwinsta** на событиях **302 и 303** (пара вычисляется при чтении): + +- Веб: **Обзор**, **События**, карточка события +- **Seaca:** список событий + карточка → `POST .../actions/qwinsta` / `logoff` + +**Поток:** + +1. Оператор нажимает **qwinsta**. +2. `POST /api/v1/events/{id}/actions/qwinsta` → SAC выполняет **WinRM qwinsta** на **клиентский ПК** (`internal_ip` из события). +3. Результат — модалка: SESSION, USER, ID, STATE. +4. **logoff:** `POST .../actions/logoff { session_id }` → WinRM `logoff` → повторный qwinsta. +5. Нужен **Windows domain admin** в настройках SAC (`SAC_WIN_ADMIN_*` или UI). + +```mermaid +sequenceDiagram + participant Op as Оператор + participant UI as SAC UI + participant API as SAC API + participant Ag as RDP-agent + + Op->>UI: qwinsta + UI->>API: POST events/{id}/actions/qwinsta + API->>Ag: poll command qwinsta + Ag->>Ag: runas domain admin + Ag->>API: agent.command.result + API->>UI: modal + parsed sessions + Op->>UI: logoff session_id + UI->>API: POST actions/logoff + Ag->>API: agent.command.result +``` + +**Автоматический logoff без участия оператора — не делаем** в первых итерациях. + +--- + +## 4. П.2 — Обновления агентов (Windows + Linux) + +### 4.1. Настройки SAC → «Обновления агентов» + +| Режим | Описание | +|-------|----------| +| **A — GPO / ручной** | Как сейчас: NETLOGON, `Deploy-LoginMonitor.ps1`, `update_ssh_monitor.sh`. SAC только показывает устаревшие версии. | +| **B — SAC** | SAC инициирует обновление; агент тянет пакет сам (pull). | + +**Рекомендуемые версии** по продукту (`RDP-login-monitor`, `ssh-monitor`) — min / recommended; сверка с `host.product_version` (уже есть в UI «Хосты»). + +**Источник пакетов** (выбор в настройках): + +| Источник | Windows | Linux | +|----------|---------|-------| +| SMB / NETLOGON | `\\dc\NETLOGON\...` | — | +| Git | private repo kalinamall | `git pull` в `/opt/ssh-monitor` | +| HTTP(S) SAC | zip + checksum с SAC | zip + checksum | + +### 4.2. B1 — Self-update после «пинка» (основной путь) + +1. Admin: «Запросить обновление» на хосте / группе. +2. SAC: `host.pending_update = true` в ответе poll. +3. **Windows:** агент запускает `Deploy-LoginMonitor.ps1` (существующий скрипт). +4. **Linux:** агент запускает `update_ssh_monitor.sh`. +5. Ingest: `agent.update.started` → `agent.update.success` | `agent.update.failed`. + +### 4.3. B2 — Fallback (SSH / WinRM) + +Кнопка на карточке хоста: **«Обновить через WinRM»** / **«Fallback SSH»** (`POST .../actions/agent-update-fallback`). Лог операции — модалка сразу при старте (не ждёт конца). + +Если за **N минут** нет `agent.update.*` или статус `failed` — тот же fallback может сработать автоматически (если включён в настройках). + +| OS | Метод | Credentials | Как работает (SAC 0.20.18+) | +|----|--------|-------------|------------------------------| +| Linux | SSH | ключ после bootstrap (§5) | SAC передаёт `REPO_URL` / `GIT_BRANCH` из **Настройки → Обновления агентов** в `update_ssh_monitor.sh` (не дефолт GitHub на хосте) | +| Windows | WinRM | encrypted login/password в БД | 1) SAC `git fetch` RDP-login-monitor на сервере → zip с UTF-8 BOM для `.ps1`
2) Клиент по WinRM: `Invoke-WebRequest` → `https:///api/v1/agent/rdp-bundle/`
3) Распаковка в `C:\ProgramData\RDP-login-monitor\_sac_staging`
4) `Deploy-LoginMonitor.ps1 -SourceShareRoot` (файлы в `ProgramData\RDP-login-monitor\`) | + +**Windows — требования:** domain admin в **Настройки → Управление хостами → Windows**; `rdp_git_repo_url` в **Обновления агентов**; ПК должен открывать `SAC_PUBLIC_URL` (HTTPS). **Git на клиенте не нужен.** GPO/NETLOGON по-прежнему основной путь в режиме GPO. + +**nginx:** для `agent-update-fallback` и SSH/WinRM-тестов — `proxy_read_timeout 960s` (см. `deploy/nginx/sac.conf.tls.example`). + +--- + +## 5. П.2C — Доступ SAC к хостам + +### 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, не пишутся на диск агента). + +### 5.2. Linux — bootstrap password → SSH key → удалить password + +На карточке хоста → «Доступ для управления»: + +``` +1. Admin вводит login + password (временно) +2. SAC по SSH: генерирует ed25519, добавляет pubkey в authorized_keys +3. SAC проверяет вход по ключу +4. Успех → password DELETE из БД, status = key_ready +5. Ошибка → password остаётся, status = bootstrap_failed, алерт +6. Кнопка «Переустановить ключи» — повтор bootstrap +``` + +Private key — encrypted в PostgreSQL. + +### 5.3. Статусы доступа + +| Статус | Linux | Windows | +|--------|-------|---------| +| `no_access` | нет данных | нет WinRM | +| `bootstrap_pending` | идёт настройка ключа | — | +| `key_ready` | SSH по ключу | — | +| `winrm_ready` | — | WinRM настроен | + +--- + +## 6. П.3 — Настройки агента в карточке хоста + +**Desired state** в SAC; агент забирает на poll: + +```json +{ + "config_revision": 42, + "settings": { + "ServerDisplayName": "UNMS Kalina", + "EnableRcmShadowControlMonitoring": true, + "GetInventory": true, + "HEARTBEAT_INTERVAL": "300" + } +} +``` + +- Whitelist ключей — без секретов (`SAC_API_KEY` только локально). +- **Windows:** поля из `login_monitor.settings.ps1`. +- **Linux:** поля из `/etc/ssh-monitor.conf`. +- Merge: remote перекрывает локальное, revision монотонно растёт. + +--- + +## 7. API (черновик) + +### 7.1. Poll (агент) + +```http +GET /api/v1/agent/commands?since= +Authorization: Bearer sac_xxx +``` + +```json +{ + "config_revision": 42, + "config": { "settings": { } }, + "update": { "requested": false, "target_version": "2.0.38-SAC", "source": "smb://..." }, + "commands": [ + { "id": "uuid", "type": "qwinsta", "params": { "user": "B26\\user" } } + ] +} +``` + +### 7.2. UI actions (JWT: admin / monitor / mobile) + +| Метод | Путь | Назначение | +|-------|------|------------| +| POST | `/api/v1/events/{id}/actions/qwinsta` | qwinsta (WinRM на клиентский ПК) | +| 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) | +| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники | +| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows | + +### 7.3. Ingest (новые типы) + +| `type` | Назначение | +|--------|------------| +| `agent.command.result` | stdout/stderr qwinsta, logoff | +| `agent.update.started` | начало self-update | +| `agent.update.success` | успех | +| `agent.update.failed` | ошибка | + +--- + +## 8. Фазы реализации + +| Фаза | Содержание | Статус | +|------|------------|--------| +| **1** | `rule:rdg_session_flap`, Problem, notify, флаг `rdg_flap` | ✅ SAC | +| **2** | **qwinsta** / **logoff** — веб + Seaca | ✅ | +| **3** | Poll API, `agent_commands` (агент) | ✅ RDP ≥ 2.1.0-SAC | +| **4** | WinRM qwinsta с сервера SAC | ✅ | +| **5** | Settings «Обновления агентов», SSH bootstrap | частично ✅ | +| **6** | Self-update B1 | ⏳ | +| **7** | SSH bootstrap + WinRM fallback | частично ✅ (SSH/WinRM update из UI, git URL из настроек) | +| **8** | Desired config per host | ⏳ | + +**Миграция:** `016_agent_commands`. Env: `SAC_RDG_FLAP_*`, `SAC_WIN_ADMIN_USER`, `SAC_WIN_ADMIN_PASSWORD`. + +--- + +## 9. Безопасность + +- Команды qwinsta/logoff — JWT (веб и Seaca); `requested_by` в `agent_commands`. +- Rate limit на actions per host. +- logoff — confirm dialog; по умолчанию только matching user. +- Password bootstrap Linux — удаляется после успеха; re-bootstrap явной кнопкой. +- WinRM password — только encrypted storage, не в логах и не в Telegram. + +--- + +## 10. Git / remotes (справка) + +| Репозиторий | Prod (kalinamall) | Public (github) | +|-------------|-------------------|-----------------| +| security-alert-center | основной | sanitized | +| RDP-login-monitor | prod paths/secrets | sanitized settings | +| ssh-monitor | mirror-скрипты + URLs | без mirror-скриптов | + +Mirror-скрипты: временно переписывают URL в docs под целевой host, пушат на remote, **откатывают** локальный main — чтобы в одной ветке не смешивать kalinamall/github URLs. + +--- + +## См. также + +- [agent-integration.md](agent-integration.md) — ingest, типы RDG +- [agent-update-backlog.md](agent-update-backlog.md) — ранний backlog (superseded деталями этого документа) +- [roadmap.md](roadmap.md) — v0.7 diff --git a/docs/agent-integration.md b/docs/agent-integration.md new file mode 100644 index 0000000..7e143be --- /dev/null +++ b/docs/agent-integration.md @@ -0,0 +1,306 @@ +# Интеграция агентов с SAC + +Контракт между **ssh-monitor**, **RDP-login-monitor** и **Security Alert Center**. +Изменения вносятся в репозитории агентов отдельными задачами. + +--- + +## 1. Режим `UseSAC` + +### 1.1. Параметры конфигурации + +**ssh-monitor** (`/etc/ssh-monitor.conf`): + +```ini +# off | exclusive | dual | fallback +UseSAC="exclusive" +SAC_URL="https://sac.kalinamall.ru/api/v1/events" +SAC_API_KEY="sac_xxxxxxxx" +SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool" +SAC_SEND_HEARTBEAT="1" +SAC_FALLBACK_FAILURES="5" +SAC_TIMEOUT_SEC="12" +``` + +**RDP-login-monitor** (`Login_Monitor.ps1` или отдельный `.conf`): + +```powershell +$UseSAC = "exclusive" # off | exclusive | dual | fallback +$SacUrl = "https://sac.kalinamall.ru/api/v1/events" +$SacApiKey = "sac_xxxxxxxx" +$SacSpoolDir = "D:\Soft\Logs\sac-spool" +``` + +### 1.2. Матрица поведения + +| Событие | `off` | `exclusive` | `dual` | `fallback` | +|---------|-------|-------------|--------|------------| +| Auth / sudo / ban / RDP login | Telegram/email с агента | Только SAC JSON | SAC + Telegram | SAC; при сбоях → Telegram | +| Daily report | С агента | SAC формирует и шлёт | Оба | SAC, fallback как выше | +| Heartbeat | С агента (если включён) | Только SAC (`agent.heartbeat`) | Оба | SAC | +| Локальный LOG_FILE | Да | Да | Да | Да | + +### 1.3. Проверка при старте + +- `UseSAC=off` — без изменений: хотя бы один канал `NOTIFY_CHAIN` (как сейчас). +- `UseSAC≠off` — обязательны `SAC_URL`, `SAC_API_KEY`; HTTP `GET {base}/health` OK. +- `--check-sac` / `Test-SacConnection` — отправка `agent.test`, ожидание **HTTP 201** (повтор с тем же `event_id` — **409**, тоже успех для spool). + +### 1.3.1. Telegram и email при `UseSAC=exclusive` + +При **`exclusive`** агент **не** шлёт Telegram/email — оператору нужны оповещения **из SAC** (`backend/app/services/telegram_notify.py`). + +На сервере SAC — **`sac-api.env`** или UI **Настройки** → **Правило оповещений**: один порог severity и выбор каналов (Telegram / webhook / email). + +```env +NOTIFY_MIN_SEVERITY=warning +NOTIFY_CHANNELS=telegram,webhook,email +TELEGRAM_ENABLED=true +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +``` + +| Severity события | `NOTIFY_MIN_SEVERITY=warning` | `high` | +|------------------|-------------------------------|--------| +| `info` (успешный RDP/SSH login) | нет | нет | +| `warning` (`*.login.failed`, sudo) | **да** | нет | +| `high` / `critical` (ban, problem) | **да** | **да** | + +Events и problems используют **одно** глобальное правило (`notification_policy` в БД, миграция `007`). + +Telegram из SAC отправляется с **`parse_mode=HTML`** (как у RDP-login-monitor): для `rdp.login.*` — пользователь, IP, `logon_type`, рабочая станция, Event ID Windows; для `ssh.login.*` и `privilege.sudo.command` — поля из `details`. + +UI **Настройки** (`/settings`) — правило + параметры каналов (JWT admin). После деплоя: `alembic upgrade head`. + +### 1.4. Версии и доставка обновлений + +При **любом** изменении агента, влияющем на SAC или поведение на хосте, поднимайте версию и пушьте в **GitHub** (`github.com/PTah`); для зеркала kalinamall — `.\scripts\Push-Mirror.ps1 kalinamall`: + +| Агент | Маркер версии | Как хост узнаёт о новой версии | +|-------|---------------|--------------------------------| +| **ssh-monitor** | `SSH_MONITOR_VERSION` в `ssh-monitor`; `# SAC client release:` в `sac-client.sh` | `update_ssh_monitor.sh`: `git pull` в клоне → сравнение sha256 `ssh-monitor` и `sac-client.sh` | +| **RDP-login-monitor** | `$ScriptVersion` в `Login_Monitor.ps1` и **та же** строка в `version.txt` на шаре NETLOGON | `Deploy-LoginMonitor.ps1`: сверка `version.txt` и SHA256 пакета (`Login_Monitor.ps1`, `Sac-Client.ps1`); при отсутствии SAC в settings — **`UseSAC=dual`** из example; подсказка `# $ServerDisplayName` | + +**Кириллица в SAC (RDP):** до **1.2.8-SAC** `Invoke-WebRequest` мог слать JSON не в UTF-8 — в UI «Отчёты» summary вида `RDP 24?: ??????` вместо `RDP 24ч: сессий …`. Обновите `Sac-Client.ps1` на всех хостах; старые события в БД не пересчитываются, исправятся только новые ingest после деплоя. + +**HTTP 422 и spool (RDP):** до **1.2.9-SAC** при отклонении схемой (часто `title` длиннее 256 символов) JSON попадал в `sac-spool` и повторялся бесконечно. С **1.2.9-SAC**: обрезка `title`/`summary`, UTF-8 POST, при 422 файл уходит в `sac-spool/rejected/` (не ретраится). Старые `.json` в spool на хосте удалите или перенесите вручную после обновления. + +**HTTP 409 и spool (RDP):** в PowerShell `Invoke-WebRequest` часто **бросает исключение** на 409 (и иногда на 201), хотя событие уже в SAC. До **1.2.10-SAC** клиент считал это ошибкой и снова слал тот же `event_id` из spool каждые ~5 с (лог `WARN: SAC POST HTTP 409`). С **1.2.10-SAC** коды **201/409/202** обрабатываются как успех и файл spool удаляется. + +**HTTP 422 на `agent.lifecycle` (RDP):** часто из‑за битого JSON с кириллицей в `summary` (`ConvertTo-Json` + неверная кодировка POST). С **1.2.11-SAC** — сериализация через `JavaScriptSerializer` (Unicode `\uXXXX`), в лог пишется тело ответа SAC (`schema_errors`). Файлы в `sac-spool/rejected/` после 422 можно удалить. + +**422 `json_invalid` / `Extra data` (позиция 4):** тело POST было `null` + JSON (утечка в pipeline от `Write-Log`). С **1.2.12-SAC**: `[void](Write-Log)`, одна строка JSON, без `ConvertFrom-Json` перед POST. + +Только правки `Sac-Client.ps1` / `sac-client.sh`: для RDP достаточно поднять patch в `version.txt` (и при необходимости `$ScriptVersion`); для Linux `sac-client.sh` обновится по checksum даже без смены `SSH_MONITOR_VERSION`, но **рекомендуется** поднимать обе метки для логов. + +### 1.5. Правила Problems v1 (SAC backend) + +| `rule_id` | Условие | Пороги (env) | +|-----------|---------|--------------| +| `rule:brute_force_burst` | `ssh.login.failed` / `rdp.login.failed`, один `source_ip` | `SAC_BRUTE_FORCE_WINDOW_MINUTES=15`, `SAC_BRUTE_FORCE_THRESHOLD=30` | +| `rule:privilege_spike` | `privilege.sudo.command` на хосте | `SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES=10`, `SAC_PRIVILEGE_SPIKE_THRESHOLD=10` | +| `rule:host_silence` | был heartbeat, но устарел (`agent.heartbeat`) | `SAC_HEARTBEAT_STALE_MINUTES` (как для UI «Хосты») | + +**Proactive host_silence:** фоновый scan в `sac-api` (`SAC_HOST_SILENCE_SCAN_ENABLED`, интервал `SAC_HOST_SILENCE_SCAN_INTERVAL_MINUTES`, по умолчанию каждые 5 мин) создаёт Problem и шлёт Telegram **без ожидания** нового ingest с хоста. Альтернатива: `systemd sac-host-silence-scan.timer` + `python -m app.jobs.host_silence_scan` (отключите in-process scan при нескольких воркерах uvicorn). + +Свежий `agent.heartbeat` **автоматически resolve** open/ack проблему `rule:host_silence`. Одиночные `high`/`critical` (ban, mass bruteforce) — как раньше. + +--- + +## 2. Протокол ingest + +### 2.1. Запрос + +```http +POST /api/v1/events HTTP/1.1 +Host: sac.kalinamall.ru +Content-Type: application/json +Authorization: Bearer sac_xxxxxxxx +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 + +{ ... событие по event-schema-v1.json ... } +``` + +### 2.2. Ответ + +| HTTP | Значение | `status` в теле | `created` | +|------|----------|-----------------|-----------| +| **201** | Событие записано впервые | `created` | `true` | +| **409** | Тот же `event_id` уже есть (идемпотентность) | `duplicate` | `false` | +| **422** | Ошибка JSON Schema | — | — | + +Пример **201**: + +```json +{ + "status": "created", + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "created": true, + "sac_event_url": "https://sac.kalinamall.ru/api/v1/events/12345", + "problem_id": null +} +``` + +`event_id` — обязательный **UUID** (RFC 4122), уникален глобально; в БД индекс `UNIQUE (event_id)`. + +### 2.3. Spool при ошибке + +1. Записать JSON в `SAC_SPOOL_DIR/{event_id}.json`. +2. **ssh-monitor:** каждую итерацию цикла вызывается `sac_flush_spool` (до 20 файлов). +3. После успеха (**HTTP 201** или **409**) — удалить файл из spool. +4. Лимит размера spool (например 500 MB) — логировать WARN, не удалять без алерта. + +--- + +## 3. Маппинг уведомлений → типы событий + +### 3.1. ssh-monitor + +| Текущий текст (сокращённо) | `type` | `severity` | +|----------------------------|--------|------------| +| Успешное SSH | `ssh.login.success` | info | +| Неудачная SSH | `ssh.login.failed` | warning | +| IP заблокирован | `ssh.ip.banned` | high | +| Лимит без бана | `ssh.ip.bruteforce.threshold` | warning | +| Массовый брутфорс | `ssh.bruteforce.mass` | high | +| Sudo | `privilege.sudo.command` | warning–critical* | +| logind new/removed/failed | `session.logind.*` | info–warning | +| Ежедневный отчёт | `report.daily.ssh` | info | +| Heartbeat | `agent.heartbeat` | info | + +\* critical — по эвристике команды (`useradd`, `passwd`, `rm -rf`, …) в `details.risk_level`. + +**Дополнительные поля в `details` (exclusive):** + +- `user`, `source_ip`, `port`, `attempt_number`, `max_attempts` +- `sudo`: `run_as`, `command`, `pwd`, `risk_level` +- `ban`: `ban_until`, `enable_ip_ban` (SSH / ipset) +- RDP-login-monitor: автобан IP в схеме **пока не стандартизирован** (`rdp.ip.banned` зарезервирован в SAC для будущего); в отчёте Windows строка «Активных банов» = 0 или число событий `rdp.ip.banned`, если агент начнёт слать +- `brute`: `window_sec`, `fails_in_window` +- `whitelist_matched`: boolean + +### 3.2. RDP-login-monitor + +| Событие | `type` | `severity` | +|---------|--------|------------| +| 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** | +| WinRM **91** inbound shell (Enter-PSSession) | `winrm.session.started` | **warning** | +| Security **5140** admin share (`C$`, `ADMIN$`) | `smb.admin_share.access` | **warning** | +| 4648 | `auth.explicit.credentials` | warning | +| RD Gateway 302 | `rdg.connection.success` | info | +| RD Gateway 303 | `rdg.connection.failed` | warning | +| Старт/стоп | `agent.lifecycle` | info | +| Отчёт | `report.daily.rdp` | info | +| Инвентаризация железа/ПО | `agent.inventory` | info (SAC: **warning**, если изменилось железо) | + +**Инвентаризация (`agent.inventory`, RDP ≥ 2.0.21-SAC):** агент шлёт снимок в `details.inventory` (CPU, RAM, диски, GPU, Windows, IPv4). Интервал по умолчанию **12 ч**; отключение: `$GetInventory = $false` в `login_monitor.settings.ps1`. SAC хранит последний снимок в `hosts.inventory`; при изменении железа ingest повышает severity до **warning** и шлёт оповещение. + +**Дополнительные поля:** + +- `event_id_windows`, `logon_type`, `ip_address`, `workstation_name` +- Shadow: `shadower_user`, `target_user`, `session_id`, `shadow_mode`, `shadow_action` +- WinRM: `source_ip`, `resource_uri`, `transport` +- Admin share 5140: `share_name`, `share_path`, `relative_target`, `access_mask` +- `gateway_target`, `gateway_error_code` +- `filtered_out`, `filter_reason` + +**Переключатели в `login_monitor.settings.ps1` (≥ 1.2.23-SAC):** `$EnableRcmShadowControlMonitoring`, `$EnableWinRmInboundMonitoring`, `$EnableAdminShareMonitoring` (по умолчанию `1`; Security **5140**, audit File Share). **`$GetInventory`** (по умолчанию `$true`) — опрос железа/ПО для SAC. Подавление: `ignore.lst` с префиксами `shadow:`, `winrm:`, `smb:` / `5140:`. + +**Exchange (RDP ≥ 2.0.23-SAC):** на почтовом сервере `$WinRmExchangeStrictMode = 1` — WinRM **91** без user в EventData не уходит в SAC; корреляция **4624** только при `LogonProcess WinRM` (отсекает ложные связки с Outlook/LT3). + +--- + +## 3.3. Человекочитаемое имя хоста (`host.display_name`) + +В UI SAC (**Хосты**, фильтры Problems/Events) показывается `display_name`, если оно задано; иначе `hostname` из ОС. + +| Агент | Параметр | В ingest | +|-------|----------|--------| +| **ssh-monitor** | `SERVER_DISPLAY_NAME` в `/etc/ssh-monitor.conf` | `host.display_name` (пусто — поле не передаётся) | +| **RDP-login-monitor** | `$ServerDisplayName` в `login_monitor.settings.ps1` | `host.display_name`; `hostname` = `$env:COMPUTERNAME` | + +Пример фрагмента JSON: + +```json +"host": { + "hostname": "NEW-ADMIN-PC", + "display_name": "UNMS Kalina", + "os_family": "windows" +} +``` + +Telegram у ssh/RDP использует ту же подпись, что и `display_name`, когда параметр задан. + +### Версии агентов (единый номер) + +| Репозиторий | Источник версии | SAC `product_version` | +|-------------|-----------------|------------------------| +| **ssh-monitor** | `SSH_MONITOR_VERSION` в `ssh-monitor` + `version.txt` | из `SSH_MONITOR_VERSION` | +| **RDP-login-monitor** | `$ScriptVersion` в `Login_Monitor.ps1` + `version.txt` | из `$ScriptVersion` | + +Не задавайте разные номера для «скрипта» и «SAC-модуля» — в UI **Хосты** отображается одна версия с ingest. + +--- + +## 4. Поля `dedup_key` (рекомендации) + +| Тип | Формат dedup_key | +|-----|------------------| +| ssh.login.success | `{product}\|{host}\|ssh.login.success\|{user}\|{ip}` | +| ssh.login.failed | `{product}\|{host}\|ssh.login.failed\|{ip}` (окно в SAC) | +| privilege.sudo | `{product}\|{host}\|sudo\|{user}\|{hash(command)}` | +| rdp.login.failed | `{product}\|{host}\|rdp.failed\|{ip}\|{user}` | + +SAC применяет cooldown по `dedup_key` (события, по умолчанию **90 с**) и по `fingerprint` problem (**300 с**). Таблица `notification_cooldown`, env: `SAC_NOTIFY_EVENT_COOLDOWN_SEC`, `SAC_NOTIFY_PROBLEM_COOLDOWN_SEC`. В логах API: `notify cooldown skip`. + +--- + +## 5. Точки встраивания в код агентов + +### ssh-monitor + +- Новая функция `send_sac_event()` вызывается из мест, где сейчас `notify_send "$message"`. +- Обёртка `notify_or_sac()`: + - `UseSAC=off` → `notify_send` + - `exclusive` → только `send_sac_event` (в `summary` — тот же текст) + - `dual` → оба + - `fallback` → `send_sac_event`; при fail increment counter → при пороге `notify_send` + +### RDP-login-monitor + +- `Send-SacEvent` + замена вызовов `Send-TelegramMessage` для событий мониторинга. +- Heartbeat и daily report — отдельные типы в SAC. +- **Команды SAC (≥ 2.1.0-SAC):** poll `GET /api/v1/agent/commands?agent_instance_id=…` каждые `$SacCommandPollIntervalSec` (по умолчанию 60 с); типы `qwinsta`, `logoff`. Ответ: `POST /api/v1/agent/commands/{uuid}/result`. Учётные данные доменного admin приходят в `run_as` (на SAC — `SAC_WIN_ADMIN_*`). Подробнее: [agent-control-plane.md](agent-control-plane.md). +- При `UseSAC=exclusive` суточный отчёт может формироваться **на SAC** (`generated_by: sac` в `details`), если агент за день не прислал `report.daily.*` — см. `SAC_DAILY_REPORT_*`, job `python -m app.jobs.daily_report`, timer `sac-daily-report.timer`. +- **Единый шаблон отчёта** (SSH / Windows): агенты `ssh-monitor` ≥ 1.2.8-SAC, `RDP-login-monitor` ≥ 1.2.21-SAC и SAC используют одинаковые секции в `report_body` / `details.stats`. +- **Только SAC, без отчёта с агента:** на агенте `DAILY_REPORT_ENABLED=0` (ssh) или `$DailyReportEnabled = $false` (RDP); на SAC `SAC_DAILY_REPORT_ENABLED=true`, `SAC_DAILY_REPORT_SKIP_IF_AGENT_SENT=true`. + +--- + +## 6. Обратная совместимость + +- По умолчанию `UseSAC=off` — поведение 100% как сейчас. +- `BACKUP_WEBHOOK_URL` в ssh-monitor при `exclusive` не используется для обычных алертов (только emergency в `fallback` — опционально). + +--- + +## 7. Чеклист готовности агента + +- [x] Параметры конфига задокументированы в README агента (ssh-monitor, RDP-login-monitor) +- [x] `--check-sac` / `Test-SacConnection` / `-CheckSac` +- [x] Spool и повторная отправка +- [ ] Все типы из п. 3 покрыты (RDP: основные; 4648 — при появлении обработки) +- [ ] `event_id` UUID на каждое событие +- [ ] Секреты не в git + +--- + +## 8. См. также + +- [event-schema-v1.json](event-schema-v1.json) +- [TZ.md](TZ.md) §3.2, §4.8 diff --git a/docs/agent-update-backlog.md b/docs/agent-update-backlog.md new file mode 100644 index 0000000..9bb0562 --- /dev/null +++ b/docs/agent-update-backlog.md @@ -0,0 +1,58 @@ +# ToDo — управление версиями агентов и удалённое обновление + +**Статус:** superseded — основной документ [agent-control-plane.md](agent-control-plane.md). +**Связано:** [roadmap.md](roadmap.md) (v0.7), [agent-integration.md](agent-integration.md), UI «Хосты». + +--- + +## Цель + +SAC должен: + +1. **Отслеживать соответствие версий** агентов (`ssh-monitor`, `RDP-login-monitor`) ожидаемым/минимальным для текущей версии SAC. +2. **Инициировать обновление удалённо** с компьютеров/серверов из UI SAC (или по политике), без ручного захода на каждый хост. + +Сейчас версии видны по `host.product_version` и heartbeat; обновление — вручную (`update_ssh_monitor.sh`, `Deploy-LoginMonitor.ps1` / NETLOGON, GPO). + +--- + +## Вопросы для проработки + +| # | Тема | Варианты / заметки | +|---|------|-------------------| +| 1 | **Источник истины версии** | Матрица в SAC (min/recommended per `product`); теги в репо; отдельный manifest API | +| 2 | **Сигнал «устарел»** | Problem `agent.version.stale`; severity на карточке хоста; дашборд «не на версии» | +| 3 | **Канал команд** | Pull-only (агент сам тянет по расписанию) vs push-команда от SAC (нужен обратный канал) | +| 4 | **Linux** | Вызов `update_ssh_monitor.sh` по systemd timer + флаг «pending update» из SAC ingest response? Отдельный `agent.command` event? SSH/Ansible с SAC — вне scope агента | +| 5 | **Windows** | GPO/NETLOGON уже есть; remote = триггер `Deploy-LoginMonitor.ps1` через scheduled task / WinRM / SCCM — что допустимо по безопасности | +| 6 | **Авторизация** | Только admin SAC; audit log; подтверждение массового обновления; rate limit | +| 7 | **Отчёт о результате** | `agent.update.started` / `agent.update.success` / `agent.update.failed` в ingest; привязка к `host_id` | +| 8 | **Сеть** | Агент за NAT только исходящий HTTPS — предпочтителен **pull**, не inbound на хост | +| 9 | **Совместимость** | Связка с `schema_version`, откат, окно maintenance | + +--- + +## Черновые критерии приёмки (когда дойдём до реализации) + +- [ ] В UI «Хосты»: колонка/бейдж «версия OK / устарела / неизвестна». +- [ ] Настройка минимальной/рекомендуемой версии по продукту в SAC (или env). +- [ ] Problem или оповещение при расхождении (с cooldown). +- [ ] Действие admin: «Запросить обновление» на одном хосте / группе (MVP). +- [ ] Агент подтверждает попытку обновления событием в SAC. +- [ ] Документация: Linux + Windows runbook, ограничения безопасности. + +--- + +## Не в первой итерации (вероятно) + +- Полная замена Ansible/GPO/SCCM. +- Обновление SAC с агентов. +- macOS-агент. + +--- + +## См. также + +- [analytics-backlog.md](analytics-backlog.md) — отложенная аналитика +- [todo-2026-05-29.md](todo-2026-05-29.md) — оповещения и настройки +- Репозитории агентов: `update_ssh_monitor.sh`, `Deploy-LoginMonitor.ps1`, `update-rdp-monitor.ps1` diff --git a/docs/analytics-backlog.md b/docs/analytics-backlog.md new file mode 100644 index 0000000..f7ae7d5 --- /dev/null +++ b/docs/analytics-backlog.md @@ -0,0 +1,41 @@ +# Analytics backlog (SAC) + +Разработка отложена; задачи для `work-plan.md` / спринта. + +## Фаза A — API + +| ID | Задача | Готово когда | +|----|--------|--------------| +| analytics-a1 | `GET /api/v1/analytics/series` (`bucket=hour\|day\|week\|month`, `from`/`to`) | JSON series + тесты | +| analytics-a2 | `group_by=severity\|type\|product`, `exclude_types=agent.heartbeat` | heartbeat не в общем счётчике | +| analytics-a3 | Presets 24h / 7d / 30d / 90d | документация API | +| analytics-a4 | Dashboard: `daily_reports_24h` учитывает `report.daily.rdp` | ✅ test_dashboard | +| analytics-a5 | Индексы под агрегации | EXPLAIN без full scan | + +## Фаза B — UI + +| ID | Задача | +|----|--------| +| analytics-b1 | Страница «Аналитика» + сайдбар | +| analytics-b2 | Графики: события по дням; failed vs success login | +| analytics-b3 | Sparklines на «Обзоре» (опционально) | +| analytics-b4 | Chart.js / аналог в Vite bundle | + +## Фаза C — отчёты и SOC + +| ID | Задача | +|----|--------| +| analytics-c1 | Тренды из `report.daily.*` → `details.stats` | +| analytics-c2 | KPI: % хостов с отчётом за 25h | +| analytics-c3 | Problems opened/resolved, MTTR | +| analytics-c4 | Top `source_ip` для `*.login.failed` | + +## Фаза D — масштаб + +| ID | Задача | +|----|--------| +| analytics-d1 | `analytics_daily_rollups` + nightly job | +| analytics-d2 | Экспорт CSV | +| analytics-d3 | `docs/analytics.md` (метрики и лимиты retention) | + +**Приоритет:** A1 → B1 → C1 → остальное. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1ccf562 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,163 @@ +# Архитектура Security Alert Center + +Дополнение к [TZ.md](TZ.md). Описывает компоненты, границы и модель данных. + +--- + +## 1. Диаграмма компонентов + +```mermaid +flowchart TB + subgraph external [Внешние системы] + AG_SSH[ssh-monitor] + AG_RDP[RDP-login-monitor] + TG[Telegram API] + SMTP[SMTP] + end + + subgraph sac_host [Ubuntu 24.04 — хост SAC] + NGX[nginx TLS] + API[FastAPI — api] + WRK[Worker] + FE[Static SPA] + PG[(PostgreSQL)] + RD[(Redis — фаза 1.5+)] + end + + AG_SSH -->|HTTPS ingest| NGX + AG_RDP -->|HTTPS ingest| NGX + NGX --> API + NGX --> FE + API --> PG + API --> RD + WRK --> PG + WRK --> RD + WRK --> TG + WRK --> SMTP + FE -->|REST + SSE| NGX +``` + +--- + +## 2. Компоненты + +| Компонент | Ответственность | +|-----------|-----------------| +| **nginx** | TLS, rate limit, раздача static UI, прокси `/api` → FastAPI | +| **api** | Ingest, REST для UI, auth JWT, health | +| **worker** | Правила Problems, отправка уведомлений, суточные отчёты, retention | +| **frontend** | SPA: Problems, Events, Hosts, Dashboards, Settings | +| **PostgreSQL** | События, хосты, problems, пользователи, правила, audit | +| **Redis** | Очередь задач, pub/sub для SSE (опционально) | + +--- + +## 3. Границы контекстов + +### 3.1. Агент (вне SAC) + +- Чтение локальных журналов (journalctl, Security.evtx). +- Формирование JSON-события. +- Режим `UseSAC`: маршрутизация «локальные каналы» vs «только SAC». +- Локальный spool при недоступности SAC. + +### 3.2. SAC + +- Единственный источник доставки оповещений при `UseSAC=exclusive`. +- Дедупликация, корреляция, Problems. +- Долговременное хранение и UI. + +--- + +## 4. Логическая модель данных + +### 4.1. Сущности + +``` +tenants (опционально, фаза 3) + └── hosts + └── events (партиции по occurred_at) + └── problems + └── problem_events (M:N) + └── notification_rules + └── notification_log + └── users + └── audit_log + └── api_keys (hash) +``` + +### 4.2. Ключевые поля + +**hosts** + +- `id`, `agent_instance_id` (unique), `hostname`, `display_name` +- `os_family`, `os_version`, `product` (`ssh-monitor` | `rdp-login-monitor`) +- `use_sac_mode`, `last_seen_at`, `tags[]` + +**events** + +- `id`, `event_id` (UUID от агента, unique), `host_id` +- `occurred_at`, `received_at` +- `category`, `type`, `severity` +- `title`, `summary`, `details` (JSONB), `raw` (JSONB/text) +- `dedup_key`, `correlation_id` + +**problems** + +- `id`, `title`, `severity`, `status` +- `opened_at`, `acknowledged_at`, `resolved_at` +- `rule_id`, `dedup_key` + +--- + +## 5. API (черновой перечень) + +| Метод | Путь | Назначение | +|-------|------|------------| +| POST | `/api/v1/events` | Ingest одного события | +| POST | `/api/v1/events/batch` | Batch (фаза 1.5) | +| GET | `/health` | Healthcheck | +| GET | `/api/v1/events` | Список (UI, auth) | +| GET | `/api/v1/events/{id}` | Карточка | +| GET | `/api/v1/problems` | Список Problems | +| PATCH | `/api/v1/problems/{id}` | ack / resolve | +| GET | `/api/v1/hosts` | Хосты | +| GET | `/api/v1/dashboards/summary` | Агрегаты для виджетов | +| GET | `/api/v1/stream/events` | SSE live | +| POST | `/api/v1/auth/login` | JWT | +| CRUD | `/api/v1/notification-rules` | Правила (admin) | + +OpenAPI — генерируется FastAPI при реализации. + +--- + +## 6. Каталоги репозитория (целевая структура) + +``` +security-alert-center/ + backend/ # FastAPI, models, services + frontend/ # Vue SPA + deploy/ + docker-compose.yml + nginx/ + systemd/ + docs/ # ТЗ, планы (текущая фаза) + schemas/ # Копия/ссылка JSON Schema +``` + +На фазе документации каталоги `backend/` и `frontend/` — заглушки (см. README внутри). + +--- + +## 7. Наблюдаемость SAC + +- `GET /health` — для агентов и внешнего мониторинга. +- Метрики (фаза 2): Prometheus endpoint или textfile. +- Логирование: каждый ingest (без полного `raw` в info-логах). + +--- + +## 8. См. также + +- [TZ.md](TZ.md) — требования +- [deployment.md](deployment.md) — инфраструктура Ubuntu 24.04 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..24340ca --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,138 @@ +# Развёртывание на Ubuntu 24.04 + +Эксплуатация SAC: **native** (PostgreSQL + systemd + nginx). Docker — только альтернатива. + +## Руководства по установке + +| Документ | Назначение | +|----------|------------| +| **[install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md)** | **Production** — пошаговая подготовка сервера | +| [install-ubuntu-24.04-docker.md](install-ubuntu-24.04-docker.md) | Стенд / отладка (Compose) | +| [install-ubuntu-24.04.md](install-ubuntu-24.04.md) | Указатель | + +## Артефакты native в репозитории + +``` +deploy/ + env.native.example → /opt/security-alert-center/config/sac-api.env + systemd/sac-api.service → /etc/systemd/system/ + nginx/sac.conf.example → /etc/nginx/sites-available/sac (HTTP) + nginx/sac.conf.tls.example → HTTPS + wildcard + sac-deploy.sh → /opt/sac-deploy.sh (полное обновление) +``` + +**Production URL:** `https://sac.kalinamall.ru` +**Конфиг:** `/opt/security-alert-center/config/sac-api.env` (владелец `sac:sac`, chmod 600). +**UI:** статика `frontend/dist`, раздаётся через FastAPI (после `npm run build`). + +--- + +## 1. Требования к серверу + +| Параметр | Минимум | Рекомендуется | +|----------|---------|---------------| +| ОС | Ubuntu 24.04 LTS | Ubuntu 24.04 LTS | +| CPU | 2 vCPU | 4 vCPU | +| RAM | 4 GB | 8 GB | +| Диск | 50 GB SSD | 100+ GB SSD | + +**Порты снаружи:** 443 (nginx), 80 (редирект). API `8000` — только `127.0.0.1`. PostgreSQL — только localhost. + +--- + +## 2. Сетевые правила + +**Агенты →** `https://sac.kalinamall.ru/api/v1/events` +**SAC →** Telegram, SMTP (исходящие) +**Админы →** 443 (UI/API через nginx) + +--- + +## 3. Вариант A: Native (production) + +Компоненты на хосте: + +| Компонент | Как | +|-----------|-----| +| PostgreSQL 16 | `apt`, БД `sac` | +| SAC API | Python 3.12 venv, **systemd** `sac-api` | +| TLS / proxy | **nginx** | +| UI | `frontend/dist`, сборка `npm run build`, раздача из API | + +Полная инструкция: **[install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md)**. + +Кратко: + +```bash +systemctl status sac-api +curl -sS http://127.0.0.1:8000/health +curl -sS https://sac.kalinamall.ru/health +``` + +--- + +## 4. Вариант B: Docker Compose (альтернатива) + +См. [install-ubuntu-24.04-docker.md](install-ubuntu-24.04-docker.md) и `deploy/docker-compose.yml`. +**Не используется** для production KalinaMall. + +--- + +## 5. TLS + +- **Production (KalinaMall):** корпоративный **wildcard** (VeriSign) — [ssl-certificate.md](ssl-certificate.md) (копирование, форматы, `/etc/ssl/sac/`), [install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md) §9, `deploy/nginx/sac.conf.tls.example` +- Альтернатива: **certbot** + Let's Encrypt (`deploy/nginx/sac.conf.example`) + +--- + +## 6. Резервное копирование (native) + +```bash +# /etc/cron.d/sac-backup +0 3 * * * postgres pg_dump -Fc -U sac sac > /var/backups/sac/sac_$(date +\%Y\%m\%d).dump +``` + +Восстановление: `pg_restore -d sac_restored /var/backups/sac/sac_YYYYMMDD.dump` + +--- + +## 7. Обслуживание + +| Задача | Периодичность | +|--------|---------------| +| `unattended-upgrades` | автоматически | +| `journalctl -u sac-api` | по инцидентам | +| `/health` | каждые 5 мин | +| Тест restore БД | ежемесячно | + +--- + +## 8. Обновление (native) + +```bash +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`). + +Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh` + +После **force-push** на upstream remote обычный `git pull` на сервере падает с «diverging branches». Скрипт `sac-deploy.sh` (актуальная версия) делает `fetch` и при необходимости `reset --hard` к upstream. **Один раз вручную**, если на сервере ещё старый скрипт: + +```bash +sudo -u sac git -C /opt/security-alert-center fetch --prune +sudo -u sac git -C /opt/security-alert-center reset --hard origin/main +sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh +sudo /opt/sac-deploy.sh +``` + +Подробности: [install-ubuntu-24.04-native.md §13](install-ubuntu-24.04-native.md#13-обновление-sac-деплой). + +--- + +## См. также + +- [TZ.md](TZ.md) +- [agent-integration.md](agent-integration.md) diff --git a/docs/diagrams/data-flow.md b/docs/diagrams/data-flow.md new file mode 100644 index 0000000..473c246 --- /dev/null +++ b/docs/diagrams/data-flow.md @@ -0,0 +1,45 @@ +# Диаграмма потоков данных (рис. 1 к ТЗ) + +Используется в [TZ.md](../TZ.md), раздел 3. +В GitLab/GitHub и в VS Code с поддержкой Mermaid диаграмма рендерится автоматически. + +## Экспорт в PNG/SVG + +1. Открыть этот файл в preview редактора или на https://mermaid.live +2. Экспортировать как изображение для презентаций + +```mermaid +flowchart LR + subgraph agents [Агенты на хостах] + SSH[ssh-monitor Linux] + RDP[RDP-login-monitor Windows] + end + + subgraph sac [Security Alert Center Ubuntu 24.04] + API[Ingest API HTTPS] + Q[Очередь / воркер] + DB[(PostgreSQL)] + RT[Realtime SSE/WebSocket] + UI[Web UI] + NOTIFY[Правила оповещений] + end + + subgraph channels [Каналы наружу] + TG[Telegram] + MAIL[Email] + WEBHOOK[Webhook / Slack] + end + + SSH -->|JSON события + API key| API + RDP -->|JSON события + API key| API + API --> Q --> DB + DB --> UI + DB --> RT --> UI + DB --> NOTIFY --> TG + NOTIFY --> MAIL + NOTIFY --> WEBHOOK +``` + +## Режим UseSAC=exclusive + +При включённом `UseSAC` стрелки от агентов к Telegram/email **отсутствуют**; доставка пользователю только через блок `NOTIFY` в SAC. diff --git a/docs/event-schema-v1.json b/docs/event-schema-v1.json new file mode 100644 index 0000000..7eb022a --- /dev/null +++ b/docs/event-schema-v1.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json", + "title": "Security Alert Center Event v1", + "description": "Каноническое событие от ssh-monitor или RDP-login-monitor", + "type": "object", + "required": [ + "schema_version", + "event_id", + "occurred_at", + "source", + "host", + "category", + "type", + "severity", + "title", + "summary" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "const": "1.0" + }, + "event_id": { + "type": "string", + "format": "uuid", + "description": "UUID v4, уникален глобально, для идемпотентности" + }, + "occurred_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 с offset, время события на хосте" + }, + "source": { + "type": "object", + "required": ["product", "product_version"], + "additionalProperties": false, + "properties": { + "product": { + "type": "string", + "enum": ["ssh-monitor", "rdp-login-monitor"] + }, + "product_version": { + "type": "string" + }, + "agent_instance_id": { + "type": "string", + "description": "Стабильный ID установки агента" + } + } + }, + "host": { + "type": "object", + "required": ["hostname", "os_family"], + "additionalProperties": false, + "properties": { + "hostname": { "type": "string" }, + "display_name": { "type": "string" }, + "fqdn": { "type": "string" }, + "os_family": { + "type": "string", + "enum": ["linux", "windows"] + }, + "os_version": { "type": "string" }, + "ipv4": { "type": "string" }, + "ipv6": { "type": "string" }, + "timezone": { + "type": "string", + "description": "IANA, например Europe/Moscow" + } + } + }, + "category": { + "type": "string", + "enum": [ + "auth", + "privilege", + "network", + "session", + "report", + "agent" + ] + }, + "type": { + "type": "string", + "description": "Машиночитаемый тип, см. docs/agent-integration.md", + "examples": [ + "ssh.login.success", + "ssh.login.failed", + "privilege.sudo.command", + "ssh.ip.banned", + "ssh.bruteforce.mass", + "session.logind.new", + "rdp.login.success", + "rdp.login.failed", + "rdp.session.logoff", + "rdp.shadow.control.started", + "rdp.shadow.control.stopped", + "rdp.shadow.control.permission", + "winrm.session.started", + "smb.admin_share.access", + "rdg.connection.success", + "report.daily.ssh", + "agent.heartbeat", + "agent.inventory", + "agent.test" + ] + }, + "severity": { + "type": "string", + "enum": ["info", "warning", "high", "critical"] + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "summary": { + "type": "string", + "description": "Человекочитаемый текст (как в Telegram сейчас)", + "maxLength": 8192 + }, + "details": { + "type": "object", + "description": "Структурированные поля по типу события", + "additionalProperties": true + }, + "raw": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": ["journal", "windows_event_xml", "text"] + }, + "payload": { + "type": "string", + "maxLength": 16384 + } + }, + "additionalProperties": false + }, + "tags": { + "type": "array", + "items": { "type": "string", "maxLength": 64 }, + "maxItems": 32 + }, + "dedup_key": { + "type": "string", + "maxLength": 512 + }, + "correlation_id": { + "type": "string", + "format": "uuid" + }, + "fingerprint": { + "type": "string", + "maxLength": 128 + }, + "enrichment": { + "type": "object", + "additionalProperties": true, + "properties": { + "attempt_number": { "type": "integer", "minimum": 0 }, + "threshold": { "type": "integer", "minimum": 0 }, + "risk_level": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + }, + "filtered_out": { "type": "boolean" }, + "filter_reason": { "type": "string" } + } + } + } +} diff --git a/docs/ingest-mass-update-backlog.ru.md b/docs/ingest-mass-update-backlog.ru.md new file mode 100644 index 0000000..5546fd7 --- /dev/null +++ b/docs/ingest-mass-update-backlog.ru.md @@ -0,0 +1,50 @@ +# Backlog: ingest и массовое обновление агентов + +**Статус:** ToDo (не в текущем релизе). +**Контекст:** массовый SSH-update через SAC (10+ хостов) + `sac-deploy` в одно окно → часть POST в ingest «теряется» с точки зрения агента (`SAC POST HTTP :`), flood в Telegram (fallback + watchdog). +**Связано:** [runbook-ops.md](runbook-ops.md), [agent-integration.md](agent-integration.md), ssh-monitor [security-roadmap.ru.md](https://git.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-хосты **пачками по 2–3**, не все подряд. +- [ ] На зрелых хостах перевести **`UseSAC=exclusive`** (нет дубля в Telegram с агента при fallback); watchdog по-прежнему шлёт в Telegram сам. +- [ ] Перед первым SSH-update с SAC: **`ssh-keyscan`** в `config/ssh_known_hosts` (см. runbook). +- [ ] В `sac-api.env`: только `KEY=value` на строку, комментарии отдельной строкой с `#`. + +--- + +## SAC (backend / deploy) + +- [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*` → `schedule_notify_daily_report`), чтобы ingest не ждал Telegram API — **0.5.5** +- [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh` — **0.5.5** +- [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` — **0.5.5** +- [ ] Опционально: метрики/лог длительности ingest и очереди при burst. +- [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками») — **0.5.5** + +--- + +## ssh-monitor (агент) + +- [x] При **shutdown** / `SIGTERM` не увеличивать `sac-fail.count` — **2.3.2-SAC** +- [x] После успешного heartbeat сбрасывать fail counter (успешный POST ingest) — уже было +- [x] Watchdog: не слать Telegram при штатном restart во время SAC-update (state file `/var/lib/ssh-monitor/agent-update-in-progress` от updater) — **2.2.1-SAC** +- [x] Sudo bootstrap/update через SAC: не слать Telegram (`ssh_monitor_sudo_is_sac_maintenance`, state-file раньше) — **2.2.3-SAC** +- [x] SAC: suppress `privilege.sudo.command` при `agent_update_state=running` / maintenance command — **0.5.3** +- [x] Документировать рекомендуемый `SAC_TIMEOUT_SEC` при тяжёлом ingest — **2.3.2-SAC** (`docs/sac-ingest.ru.md`) + +--- + +## Принято / сделано + +- [x] Pool БД 15+25, defer daily push — SAC **0.5.0** +- [x] `sac-deploy.sh` без `source` конфига, preflight pydantic — SAC `fa1bd41` +- [x] Watchdog: строка `🖥️ Сервер:` в Telegram — ssh-monitor **2.1.9-SAC** + +--- + +*После реализации пунктов SAC — bump `APP_VERSION` и runbook.* diff --git a/docs/install-ubuntu-24.04-docker.md b/docs/install-ubuntu-24.04-docker.md new file mode 100644 index 0000000..96727a8 --- /dev/null +++ b/docs/install-ubuntu-24.04-docker.md @@ -0,0 +1,105 @@ +# Ubuntu 24.04 — установка SAC (Docker Compose) + +**Альтернативный** способ. В KalinaMall для production используется **[native](install-ubuntu-24.04-native.md)**. + +Docker удобен для локальной отладки или изолированного стенда. + +--- + +## 0. Исходные данные + +| Параметр | Пример | +|----------|--------| +| Каталог | `/opt/security-alert-center` | + +--- + +## 1. Базовая ОС и ufw + +Как в [native-руководстве](install-ubuntu-24.04-native.md) §1 (без PostgreSQL/nginx на хосте, если всё в контейнерах — nginx можно на хосте для TLS). + +--- + +## 2. Git и клон + +```bash +sudo apt install -y git +sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center +sudo chown -R "$USER:$USER" /opt/security-alert-center +``` + +--- + +## 3. Docker Engine + Compose + +```bash +sudo apt install -y ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu noble stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin +sudo usermod -aG docker "$USER" +# перелогиниться +docker compose version +``` + +--- + +## 4. Конфигурация + +```bash +sudo mkdir -p /etc/security-alert-center +cp /opt/security-alert-center/deploy/.env.example /etc/security-alert-center/.env +chmod 600 /etc/security-alert-center/.env +nano /etc/security-alert-center/.env +ln -sf /etc/security-alert-center/.env /opt/security-alert-center/deploy/.env +``` + +Заполнить: `POSTGRES_PASSWORD`, `JWT_SECRET`, `SAC_PUBLIC_URL`, `SAC_BOOTSTRAP_API_KEY`. + +--- + +## 5. Запуск + +```bash +cd /opt/security-alert-center/deploy +docker compose up -d --build +docker compose run --rm migrate +docker compose ps +curl -sS http://127.0.0.1:8000/health +``` + +--- + +## 6. Бэкап (Docker) + +```bash +sudo tee /etc/cron.d/sac-backup <<'EOF' +0 3 * * * root docker compose -f /opt/security-alert-center/deploy/docker-compose.yml exec -T postgres pg_dump -U sac -Fc sac > /var/backups/sac/sac_$(date +\%Y\%m\%d).dump +EOF +``` + +--- + +## 7. Обновление + +```bash +cd /opt/security-alert-center +git pull +cd deploy +docker compose build +docker compose up -d +docker compose run --rm migrate +``` + +--- + +## См. также + +- [install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md) — основной путь +- [../deploy/docker-compose.yml](../deploy/docker-compose.yml) diff --git a/docs/install-ubuntu-24.04-native.md b/docs/install-ubuntu-24.04-native.md new file mode 100644 index 0000000..bb5e71b --- /dev/null +++ b/docs/install-ubuntu-24.04-native.md @@ -0,0 +1,465 @@ +# Ubuntu 24.04 — установка SAC (native, без Docker) + +Пошаговое руководство: **PostgreSQL + Python venv + systemd + nginx**. +Рекомендуемый путь для production. + +--- + +## 0. Исходные данные + +| Параметр | Пример | +|----------|--------| +| FQDN | `sac.kalinamall.ru` | +| IP | `10.0.0.50` | +| Каталог | `/opt/security-alert-center` | +| Пользователь сервиса | `sac` | +| API только на localhost | `127.0.0.1:8000` | +| Снаружи | nginx → 443 → API | + +--- + +## 1. Базовая настройка ОС + +```bash +sudo apt update +sudo apt upgrade -y +sudo timedatectl set-timezone Europe/Moscow +``` + +### Firewall (ufw) + +```bash +sudo apt install -y ufw +sudo ufw default deny incoming +sudo ufw default allow outgoing +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +sudo ufw status verbose +``` + +--- + +## 2. Пакеты + +```bash +sudo apt install -y \ + git \ + postgresql \ + postgresql-contrib \ + nginx \ + python3.12 \ + python3.12-venv \ + python3-pip \ + jq \ + certbot \ + python3-certbot-nginx +``` + +Проверка: + +```bash +python3.12 --version +psql --version +nginx -v +``` + +--- + +## 3. Клонирование репозитория + +```bash +sudo mkdir -p /opt +sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center +``` + +--- + +## 4. PostgreSQL + +Создать пользователя и БД (пароль сохраните для `.env`): + +```bash +sudo -u postgres psql <<'SQL' +CREATE USER sac WITH PASSWORD 'ЗАМЕНИТЕ_НА_ДЛИННЫЙ_ПАРОЛЬ'; +CREATE DATABASE sac OWNER sac; +GRANT ALL PRIVILEGES ON DATABASE sac TO sac; +SQL +``` + +PostgreSQL по умолчанию слушает `127.0.0.1` — для SAC этого достаточно. +Проверка: + +```bash +psql "postgresql://sac@127.0.0.1/sac" -c 'SELECT 1' +``` + +Каталог бэкапов: + +```bash +sudo mkdir -p /var/backups/sac +sudo chown postgres:postgres /var/backups/sac +``` + +--- + +## 5. Пользователь `sac` и Python venv + +```bash +sudo useradd --system --home /opt/security-alert-center --shell /usr/sbin/nologin sac || true +sudo chown -R sac:sac /opt/security-alert-center + +cd /opt/security-alert-center/backend +sudo -u sac python3.12 -m venv .venv +sudo -u sac .venv/bin/pip install --upgrade pip +sudo -u sac .venv/bin/pip install -r requirements.txt +``` + +--- + +## 6. Конфигурация + +Файл лежит **в каталоге приложения** (владелец `sac`) — так проще, чем права на `/etc/...`. + +```bash +sudo mkdir -p /opt/security-alert-center/config +sudo cp /opt/security-alert-center/deploy/env.native.example \ + /opt/security-alert-center/config/sac-api.env +sudo nano /opt/security-alert-center/config/sac-api.env +sudo chown sac:sac /opt/security-alert-center/config/sac-api.env +sudo chmod 600 /opt/security-alert-center/config/sac-api.env +``` + +Если уже создавали `/etc/security-alert-center/sac-api.env` — перенесите: + +```bash +sudo mkdir -p /opt/security-alert-center/config +sudo cp /etc/security-alert-center/sac-api.env /opt/security-alert-center/config/sac-api.env +sudo chown sac:sac /opt/security-alert-center/config/sac-api.env +sudo chmod 600 /opt/security-alert-center/config/sac-api.env +``` + +Обязательно задать: + +- `DATABASE_URL` — с паролем PostgreSQL из шага 4 (если в пароле есть `#`, `@`, `%` — возьмите значение в **кавычки**: `DATABASE_URL="postgresql+psycopg2://..."`) +- `JWT_SECRET` — `openssl rand -hex 32` +- `SAC_PUBLIC_URL` — `https://sac.kalinamall.ru` +- `SAC_BOOTSTRAP_API_KEY` — `python3.12 -c "import secrets; print('sac_'+secrets.token_urlsafe(32))"` +- `SAC_ADMIN_PASSWORD` — пароль входа в веб-UI (отдельно от API key агентов) +- `EVENT_SCHEMA_PATH=/opt/security-alert-center/schemas/event-schema-v1.json` +- (опц.) Telegram из SAC: `TELEGRAM_ENABLED=true`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_MIN_SEVERITY=high` + +--- + +## 7. Миграции БД + +Проверка, что `sac` читает конфиг (должно быть без `Permission denied`): + +```bash +ls -la /opt/security-alert-center/config/sac-api.env +# должно быть: -rw------- sac sac +sudo -u sac test -r /opt/security-alert-center/config/sac-api.env && echo OK +``` + +Миграции: + +```bash +cd /opt/security-alert-center/backend +sudo -u sac bash -c 'set -a; source /opt/security-alert-center/config/sac-api.env; set +a; .venv/bin/alembic upgrade head' +``` + +--- + +## 8. systemd + +Приложение загружает `config/sac-api.env` **само** (не через `EnvironmentFile` systemd) — так же, как при `alembic` с `source`. + +```bash +cd /opt/security-alert-center && sudo git pull +sudo cp /opt/security-alert-center/deploy/systemd/sac-api.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable sac-api +sudo systemctl start sac-api +sudo systemctl status sac-api --no-pager +``` + +Логи: + +```bash +journalctl -u sac-api -f +``` + +--- + +## 9. nginx и TLS + +Для KalinaMall: **корпоративный wildcard VeriSign** (или DigiCert), не Let's Encrypt. +Хост `sac.kalinamall.ru` должен попадать под `*.kalinamall.ru`. + +**Подробно:** форматы, scp с Windows/Linux, `.pfx` → PEM, права — **[ssl-certificate.md](ssl-certificate.md)**. + +### 9.1. Кратко: куда положить файлы + +nginx ожидает **два PEM-файла**: + +| Файл на сервере | Содержимое | +|-----------------|------------| +| `/etc/ssl/sac/fullchain.pem` | Wildcard-сертификат + промежуточные CA | +| `/etc/ssl/sac/privkey.pem` | Закрытый ключ | + +```bash +# каталог: группа ssl-cert, чтобы www-data мог зайти в каталог (chmod 750) +sudo mkdir -p /etc/ssl/sac +sudo chown root:ssl-cert /etc/ssl/sac +sudo chmod 750 /etc/ssl/sac + +# nginx читает ключ через группу ssl-cert (на чистой Ubuntu в группе часто только postgres) +sudo groupadd -f ssl-cert +sudo usermod -aG ssl-cert www-data +``` + +Скопировать с рабочего ПК (пример): + +```bash +# с вашего компьютера: +scp fullchain.pem privkey.pem papatramp@:/tmp/ + +# на сервере: +sudo mv /tmp/fullchain.pem /tmp/privkey.pem /etc/ssl/sac/ +sudo chmod 644 /etc/ssl/sac/fullchain.pem +sudo chmod 640 /etc/ssl/sac/privkey.pem +sudo chown root:root /etc/ssl/sac/fullchain.pem +sudo chown root:ssl-cert /etc/ssl/sac/privkey.pem +# каталог снова root:ssl-cert (если меняли владельца при mv) +sudo chown root:ssl-cert /etc/ssl/sac +sudo chmod 750 /etc/ssl/sac + +# worker nginx должен подхватить группу www-data +sudo systemctl restart nginx + +sudo -u www-data test -x /etc/ssl/sac && echo dir OK || echo dir FAIL +sudo -u www-data test -r /etc/ssl/sac/fullchain.pem && echo fullchain OK || echo fullchain FAIL +sudo -u www-data test -r /etc/ssl/sac/privkey.pem && echo key OK || echo key FAIL +``` + +Если `test -r privkey.pem` молчит: каталог был `root:root` при `750` — у `www-data` нет права `x` на `/etc/ssl/sac/`; либо `www-data` не в `ssl-cert`. См. [ssl-certificate.md §3](ssl-certificate.md#3-каталог-и-права-на-сервере). + +Если PFX с другого Linux — `scp` на SAC: [ssl-certificate.md §4.3](ssl-certificate.md#43-забрать-файл-с-другого-linux-сервера-scp). +Распаковка PFX на Ubuntu 24.04: **`openssl pkcs12 -legacy`** — [§4.4](ssl-certificate.md#44-распаковка-pfx--p12-на-сервере-ubuntu-2404). + +### 9.2. Конфиг nginx (HTTPS) + +```bash +cd /opt/security-alert-center +sudo git pull +sudo cp deploy/nginx/sac.conf.tls.example /etc/nginx/sites-available/sac +sudo nano /etc/nginx/sites-available/sac +# Проверьте пути ssl_certificate и ssl_certificate_key → /etc/ssl/sac/... + +sudo ln -sf /etc/nginx/sites-available/sac /etc/nginx/sites-enabled/sac +sudo rm -f /etc/nginx/sites-enabled/default +sudo nginx -t +sudo systemctl reload nginx +``` + +Шаблон `sac.conf.tls.example` содержит **default_server** с `return 444` (:80) и `ssl_reject_handshake` (:443) для любого Host, кроме `sac.kalinamall.ru`. Это защищает от ситуации, когда на IP SAC (например `192.168.160.145`) по ошибке DNS попадает `ext.kalinamall.ru` и открывается форма входа SAC. + +Проверка после reload: + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' --resolve sac.kalinamall.ru:443:127.0.0.1 https://sac.kalinamall.ru/health +# ожидается 200 + +curl -sS -o /dev/null -w '%{http_code}\n' --resolve ext.kalinamall.ru:443:127.0.0.1 https://ext.kalinamall.ru/ 2>/dev/null || echo "connection closed (444/reject OK)" +``` + +Проверка: + +```bash +curl -sS https://sac.kalinamall.ru/health | jq . +``` + +В `config/sac-api.env` должно быть: `SAC_PUBLIC_URL=https://sac.kalinamall.ru` + +### 9.3. Агенты (ssh-monitor, RDP) и доверие к CA + +- Агенты ходят на `https://sac.kalinamall.ru/api/v1/events`. +- Если используется **внутренний корневой CA**, а не публичный VeriSign/DigiCert в системном store — на Linux/Windows может понадобиться установка корневого сертификата в доверенные (или корпоративный прокси с подменой TLS). +- Для публичного коммерческого wildcard от известного УЦ обычно **дополнительных действий на агентах не нужно**. + +### 9.4. Альтернатива: только HTTP (отладка) + +Шаблон `deploy/nginx/sac.conf.example` — порт 80 без TLS. Не для production. + +### 9.5. Альтернатива: Let's Encrypt + +Если понадобится публичный бесплатный сертификат только на этот хост: + +```bash +sudo cp deploy/nginx/sac.conf.example /etc/nginx/sites-available/sac +# ... nginx -t, reload, затем: +sudo certbot --nginx -d sac.kalinamall.ru +``` + +--- + +## 10. Сборка веб-UI (фаза 1C) + +```bash +sudo apt install -y nodejs npm +cd /opt/security-alert-center +sudo -u sac git pull +cd frontend +sudo -u sac npm ci +sudo -u sac npm run build +ls -la dist/index.html +sudo systemctl restart sac-api +``` + +В `config/sac-api.env` задайте `SAC_ADMIN_PASSWORD`, затем откройте `https://sac.kalinamall.ru/` (логин `SAC_ADMIN_USERNAME`, по умолчанию `admin`). + +После обновления с multi-user (миграция `009_sac_users`): выполните `alembic upgrade head` в каталоге `backend/`. При первом старте API создаст пользователя **admin** из `SAC_ADMIN_*`, если таблица `sac_users` пуста. + +Добавить сотрудника с ролью **monitor** (просмотр без «Настройки»): + +```bash +cd /opt/security-alert-center/backend +sudo -u sac python scripts/sac_manage_user.py create ivanov 'StrongPass123' --role monitor +``` + +Роли: **admin** — полный доступ; **monitor** — события, хосты, проблемы, отчёты (без `/settings` и `/users`). + +--- + +## 11. Проверка + +Локально на сервере: + +```bash +curl -sS http://127.0.0.1:8000/health | jq . +``` + +Через nginx (после TLS): + +```bash +curl -sS https://sac.kalinamall.ru/health | jq . +``` + +Тест ingest (подставьте `SAC_BOOTSTRAP_API_KEY`): + +```bash +API_KEY="ваш_ключ_из_sac-api.env" +curl -sS -X POST http://127.0.0.1:8000/api/v1/events \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "schema_version": "1.0", + "event_id": "00000000-0000-4000-8000-000000000002", + "occurred_at": "2026-05-26T12:00:00+03:00", + "source": {"product": "ssh-monitor", "product_version": "test"}, + "host": {"hostname": "test-host", "os_family": "linux"}, + "category": "agent", + "type": "agent.test", + "severity": "info", + "title": "Test", + "summary": "Native install check" + }' +``` + +Ожидается HTTP **202** и `"status": "accepted"`. + +--- + +## 12. Резервное копирование + +```bash +sudo tee /etc/cron.d/sac-backup <<'EOF' +0 3 * * * postgres pg_dump -Fc -U sac sac > /var/backups/sac/sac_$(date +\%Y\%m\%d).dump +EOF +``` + +Для `pg_dump` без пароля в cron настройте `~postgres/.pgpass` (см. документацию PostgreSQL). + +--- + +## 13. Обновление SAC (деплой) + +### Рекомендуется: скрипт `/opt/sac-deploy.sh` + +После первой установки один раз: + +```bash +sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh +sudo chmod 755 /opt/sac-deploy.sh +``` + +Каждое обновление с git: + +```bash +sudo /opt/sac-deploy.sh +``` + +Скрипт: `chown sac:sac` → `git pull` (от **sac**) → `pip` → `alembic` → `npm run build` → `systemctl restart sac-api` → проверка `/health`. + +### Вручную (если без скрипта) + +```bash +sudo chown -R sac:sac /opt/security-alert-center +sudo -u sac git -C /opt/security-alert-center pull +cd /opt/security-alert-center/frontend && sudo -u sac npm ci && sudo -u sac npm run build +sudo -u sac /opt/security-alert-center/backend/.venv/bin/pip install -r /opt/security-alert-center/backend/requirements.txt +sudo -u sac bash -c 'set -a; source /opt/security-alert-center/config/sac-api.env; set +a; cd /opt/security-alert-center/backend && .venv/bin/alembic upgrade head' +sudo systemctl restart sac-api +``` + +### Git: только от пользователя `sac` + +Не выполняйте `sudo git pull` в `/opt/security-alert-center` — `.git` станет root и появится: + +`error: cannot open '.git/FETCH_HEAD': Permission denied` + +Исправление: `sudo chown -R sac:sac /opt/security-alert-center`. + +--- + +## 14. Чеклист + +- [ ] Ubuntu 24.04, timezone +- [ ] ufw: 22, 80, 443 +- [ ] PostgreSQL: БД `sac`, пользователь `sac` +- [ ] venv, зависимости установлены +- [ ] `/opt/security-alert-center/config/sac-api.env`, владелец `sac:sac`, chmod 600 +- [ ] `alembic upgrade head` без ошибок +- [ ] `sac-api` active (systemd) +- [ ] nginx + TLS +- [ ] `/health` ok, ingest 202 +- [ ] `frontend/dist` собран, UI открывается, вход admin OK +- [ ] cron backup + +--- + +## 15. Устранение неполадок + +| Симптом | Действие | +|---------|----------| +| `sac-api.env: Permission denied` | Конфиг в `/opt/security-alert-center/config/`, `chown sac:sac`, `chmod 600`. Не используйте `/etc/...` с каталогом `root:root` | +| `password authentication failed` в **sac-api**, но alembic OK | Старый unit с `EnvironmentFile=` — `git pull`, обновить `sac-api.service`, `daemon-reload`, `restart`. Либо пароль в `DATABASE_URL` ≠ PostgreSQL | +| `password authentication failed` везде | Пароль в `DATABASE_URL` в кавычках; `ALTER USER sac WITH PASSWORD`; тот же пароль в URL | +| `database: error` / SQLAlchemy connect | `systemctl status postgresql`; проверить `DATABASE_URL` после успешного `source` | +| 401 ingest | ключ в `Authorization: Bearer`; проверить `SAC_BOOTSTRAP_API_KEY` | +| 502 от nginx | API не слушает: `ss -lntp \| grep 8000`, `systemctl restart sac-api` | +| Schema errors 422 | путь `EVENT_SCHEMA_PATH`, наличие файла schema | +| `FETCH_HEAD: Permission denied` | `sudo chown -R sac:sac /opt/security-alert-center`; далее `sudo -u sac git pull` | +| UI 404 / пустая главная | `cd frontend && sudo -u sac npm run build`; `restart sac-api` | +| 503 на `/auth/login` | задать `SAC_ADMIN_PASSWORD` в `config/sac-api.env` | +| `test -r privkey.pem` без вывода | каталог `/etc/ssl/sac` → `root:ssl-cert`, `www-data` в группе `ssl-cert` — [ssl-certificate.md](ssl-certificate.md) §3 | +| PFX `RC2-40-CBC unsupported` | `openssl pkcs12 -legacy` — [ssl-certificate.md](ssl-certificate.md) §4.4 | + +--- + +## См. также + +- [install-ubuntu-24.04.md](install-ubuntu-24.04.md) — указатель +- [install-ubuntu-24.04-docker.md](install-ubuntu-24.04-docker.md) — альтернатива (не prod) +- [deployment.md](deployment.md) diff --git a/docs/install-ubuntu-24.04.md b/docs/install-ubuntu-24.04.md new file mode 100644 index 0000000..33f9ca8 --- /dev/null +++ b/docs/install-ubuntu-24.04.md @@ -0,0 +1,24 @@ +# Подготовка сервера Ubuntu 24.04 для SAC + +В эксплуатации KalinaMall используется **только native** (без Docker): PostgreSQL, Python venv, systemd, nginx. + +## Руководства + +| Путь | Когда использовать | +|------|-------------------| +| **[install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md)** | **Основной** — подготовка и запуск SAC | +| [install-ubuntu-24.04-docker.md](install-ubuntu-24.04-docker.md) | Альтернатива (Docker Compose), не для prod | + +## Быстрый старт (native) + +```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 +# … PostgreSQL, venv, config/sac-api.env, systemd, nginx +``` + +## См. также + +- [deployment.md](deployment.md) +- [../deploy/README.md](../deploy/README.md) diff --git a/docs/operations-prod-status.md b/docs/operations-prod-status.md new file mode 100644 index 0000000..4194bfb --- /dev/null +++ b/docs/operations-prod-status.md @@ -0,0 +1,66 @@ +# SAC production — текущий статус (KalinaMall) + +Ориентир для продолжения работ. Обновляйте при смене этапа. + +**Сервер:** Ubuntu 24.04, FQDN `sac.kalinamall.ru` +**Каталог:** `/opt/security-alert-center` +**Пользователь сервиса:** `sac` (git, venv, npm — только от него) +**Конфиг:** `/opt/security-alert-center/config/sac-api.env` + +--- + +## Что уже работает (2026-05-26) + +| Компонент | Статус | +|-----------|--------| +| PostgreSQL + Alembic | ✅ | +| `POST /api/v1/events` (ingest 202) | ✅ | +| `GET /health` | ✅ | +| nginx + wildcard TLS (`/etc/ssl/sac/`) | ✅ | +| JWT UI: Events, Hosts, **Problems** | ✅ (Problems после деплоя) | +| `SAC_ADMIN_PASSWORD` + вход в браузере | ✅ | + +--- + +## Деплой обновлений + +```bash +sudo /opt/sac-deploy.sh +``` + +См. `deploy/sac-deploy.sh`, [install-ubuntu-24.04-native.md §13](install-ubuntu-24.04-native.md#13-обновление-sac-деплой). + +**Не делать:** `sudo git pull` в каталоге приложения (ломает права `.git` для `sac`). + +--- + +## Тестовый агент (ubabuba, 10.10.36.9) + +| Параметр | Значение | +|----------|----------| +| Хост | `ubabuba` / `10.10.36.9` | +| Репозиторий | `git.papatramp.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) | + +Переключить на `dual` после настройки Telegram в `/etc/ssh-monitor.conf`. + +--- + +## Следующие шаги (2026-05-29) + +1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (миграции **`004`–`008`**: каналы, правило, cooldown) +2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod +3. **Timer** — `sac-daily-report.timer` + `SAC_DAILY_REPORT_*` (миграций нет; см. runbook § F-NOT-05) +4. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram» +5. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах +6. Пилот **UseSAC=exclusive** на Windows — ~16.06 (см. [todo-2026-05-29.md](todo-2026-05-29.md)) + +--- + +## Ссылки + +- [agent-integration.md](agent-integration.md) +- [ssl-certificate.md](ssl-certificate.md) +- [work-plan.md](work-plan.md) diff --git a/docs/pilot-2.1-exclusive.md b/docs/pilot-2.1-exclusive.md new file mode 100644 index 0000000..c968162 --- /dev/null +++ b/docs/pilot-2.1-exclusive.md @@ -0,0 +1,92 @@ +# Пилот 2.1 — UseSAC=exclusive + +Цель: один Linux-хост и (позже) один Windows-хост шлют **только в SAC**, без дублирования в Telegram с агента. + +--- + +## Linux (ssh-monitor) — ubabuba / новые хосты + +### 1. Обновить агент + +```bash +sudo /opt/scripts/update_ssh_monitor.sh +# или первично: sudo ./first_deploy.sh +``` + +### 2. Конфиг `/etc/ssh-monitor.conf` + +```ini +UseSAC=exclusive +SAC_URL=https://sac.kalinamall.ru +SAC_API_KEY=sac_xxxxxxxx +SAC_TLS_SKIP_VERIFY=0 +SAC_SPOOL_DIR=/var/lib/ssh-monitor/sac-spool +SAC_TIMEOUT_SEC=12 +``` + +Telegram/email **можно оставить** в конфиге — в `exclusive` они **не вызываются** для событий (`notify_or_sac` → только SAC). + +### 3. Проверки на агенте + +```bash +sudo /usr/local/bin/ssh-monitor --check-sac +sudo bash /opt/scripts/update/ssh-monitor/scripts/pilot-verify-exclusive.sh +# или из клона: +sudo bash /opt/scripts/update/ssh-monitor/scripts/pilot-verify-exclusive.sh + +sudo systemctl restart ssh-monitor.service +sudo systemctl status ssh-monitor.service --no-pager +``` + +### 4. Проверки в SAC + +1. UI → **Хосты** — появился/обновился хост пилота. +2. Сгенерировать: успешный SSH, `sudo` (успешный), при необходимости неудачный SSH. +3. UI → **События** — типы `ssh.login.success`, `privilege.sudo.command`, … +4. **Telegram бота агента** — новых сообщений от этого хоста **нет** (только SAC, если включён Telegram **сервера SAC** — это отдельно). +5. При `high`/`critical` — **Проблемы** и опционально Telegram из `TELEGRAM_*` в `sac-api.env`. + +### 5. Spool (сбой SAC) + +При недоступности SAC события пишутся в `SAC_SPOOL_DIR`. После восстановления SAC цикл монитора вызывает `sac_flush_spool` (до 20 файлов за итерацию). + +```bash +ls -la /var/lib/ssh-monitor/sac-spool/ +``` + +### 6. Откат на dual + +```ini +UseSAC=dual +``` + +```bash +sudo systemctl restart ssh-monitor.service +``` + +--- + +## Windows (RDP-login-monitor) + +**Статус:** интеграция UseSAC в RDP-monitor **ещё не реализована** — пилот 2.1 сейчас только **Linux**. + +После появления `UseSAC` в RDP — повторить сценарий с `Test-SacConnection` и событиями `rdp.login.*`. + +--- + +## Критерии приёмки 2.1 (Linux) + +| # | Критерий | +|---|----------| +| 1 | `UseSAC=exclusive`, сервис `active` | +| 2 | `--check-sac` → ingest OK | +| 3 | События видны в SAC UI | +| 4 | Telegram **агента** молчит | +| 5 | Spool очищается после восстановления SAC | + +--- + +## См. также + +- [agent-integration.md](agent-integration.md) +- [operations-prod-status.md](operations-prod-status.md) diff --git a/docs/pilot-2.2-heartbeat-reports.md b/docs/pilot-2.2-heartbeat-reports.md new file mode 100644 index 0000000..f59777a --- /dev/null +++ b/docs/pilot-2.2-heartbeat-reports.md @@ -0,0 +1,93 @@ +# Фаза 2.2 — Heartbeat и daily report через SAC + +Агент **уже** шлёт в SAC (в т.ч. в `exclusive`): + +| Тип | Интервал (ssh-monitor) | +|-----|-------------------------| +| `agent.heartbeat` | каждые **12 ч** | +| `report.daily.ssh` | раз в сутки после `DAILY_REPORT_HOUR` | + +SAC отображает их в **События** и на **Dashboard / Хосты**. + +--- + +## UI SAC + +- **Dashboard:** счётчики heartbeat/отчётов за 24 ч, хосты с устаревшим heartbeat. +- **Хосты:** колонки *Агент* (`online` / `stale` / `unknown`), время последнего heartbeat и отчёта (ссылка на **Отчёты** по hostname). +- **Отчёты** (`/reports`): список `report.daily.*` по узлам; клик по строке — карточка с метриками и полным текстом (HTML/`report_body` в `details`). +- Старые события без `details.report_body` показывают подсказку обновить агент. + +--- + +## Настройка порога «живости» (сервер SAC) + +В `config/sac-api.env`: + +```bash +# По умолчанию 780 мин (13 ч) — чуть больше интервала heartbeat агента (12 ч) +SAC_HEARTBEAT_STALE_MINUTES=780 +``` + +После изменения: `sudo systemctl restart sac-api`. + +--- + +## Проверка на пилотном хосте (exclusive) + +1. Дождаться heartbeat или ускорить тест: на агенте временно уменьшить интервал нельзя без правки кода — проще подождать или вручную: + ```bash + sudo /usr/local/bin/ssh-monitor --dry-run + ``` + (не шлёт реально; для реального теста — дождаться цикла 12 ч или смотреть уже пришедшие события). + +2. В SAC UI → **События**, фильтр `type=agent.heartbeat`. + +3. **Хосты** → статус `online` после свежего heartbeat. + +4. Ежедневный отчёт: после `DAILY_REPORT_HOUR` — событие `report.daily.ssh` (от агента) **или** от SAC при `SAC_DAILY_REPORT_ENABLED=true` и отсутствии отчёта агента за сутки. + +--- + +## SAC: отчёт без агента (exclusive / пропуск агента) + +В `config/sac-api.env`: + +```bash +SAC_DAILY_REPORT_ENABLED=true +SAC_DAILY_REPORT_HOUR=9 +SAC_DAILY_REPORT_TIMEZONE=Europe/Moscow +SAC_DAILY_REPORT_SKIP_IF_AGENT_SENT=true +SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true +``` + +- Job: `cd backend && .venv/bin/python -m app.jobs.daily_report` (ручной прогон: `--force`). +- Timer: `deploy/systemd/sac-daily-report.service` + `.timer` — **09:00** в `SAC_DAILY_REPORT_TIMEZONE` (генерируется из `SAC_DAILY_REPORT_HOUR`, без `RandomizedDelaySec`). +- Оповещение: `notify_daily_report` — **не** режется `NOTIFY_MIN_SEVERITY=warning` (severity отчёта `info`). +- UI **Отчёты** и Telegram используют `details.report_html` / `report_body` как у агента. + +Установка timer (один раз на сервере): + +```bash +sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.service /etc/systemd/system/ +sudo bash /opt/security-alert-center/deploy/systemd/render-sac-daily-report-timer.sh \ + /opt/security-alert-center/config/sac-api.env \ + /etc/systemd/system/sac-daily-report.timer +sudo systemctl daemon-reload +sudo systemctl enable --now sac-daily-report.timer +``` + +--- + +## Деплой + +```bash +sudo /opt/sac-deploy.sh +``` + +--- + +## См. также + +- [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) +- [agent-integration.md](agent-integration.md) diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..fa22b88 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,132 @@ +# Дорожная карта Security Alert Center + +Продуктовые фазы после MVP. Сроки ориентировочные, уточняются после фазы 1. + +--- + +## v0.1 — MVP (ядро SAC) + +**Цель:** ingest на сервере; агенты в `dual` по мере готовности фазы 1A. + +- Ingest API + PostgreSQL (**prod**) +- Events, Hosts, базовый UI (**prod**) +- Problems (2 правила) +- Telegram из SAC +- 3 виджета dashboard +- Ubuntu 24.04 deploy + +--- + +## v0.2 — Агенты + UseSAC + +**Цель:** production-пилот с `UseSAC=exclusive` на части хостов. + +- ssh-monitor: UseSAC, spool, все типы событий +- RDP-login-monitor: аналог +- Режимы `dual`, `fallback` +- Daily reports из SAC +- Документация миграции с Telegram-only + +--- + +## v0.3 — Операционная зрелость + +- `POST /events/batch` +- Email SMTP из SAC +- UI: редактор правил уведомлений +- Maintenance windows (тишина) +- Retention job + агрегаты по часам +- Экспорт CSV +- Redis + выделенный worker + +--- + +## v0.4 — Аналитика и корреляция + +- GeoIP / ASN по source IP +- «Новый IP для пользователя» +- Корреляция: один IP → SSH + RDP на разных хостах → один Problem +- Расширенные дашборды (RD Gateway vs RDP, sudo heatmap) +- Тёмная тема UI + +--- + +## v0.5 — Enterprise + +- LDAP / OIDC +- Multi-tenant (несколько организаций) +- RBAC расширенный, audit export +- Prometheus metrics endpoint +- Webhook CEF/JSON для SIEM +- HA: документированный cold standby (второй сервер, restore БД) + +--- + +## v0.6 — Mobile (Seaca) + +**Цель:** нативный Android-клиент и push по тем же правилам оповещений, что Telegram/email/webhook. + +Репозиторий клиента: [seaca](https://git.papatramp.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM). + +**Сервер SAC:** + +- Настройки → **Мобильные устройства**: вкл/выкл подключений, коды регистрации, список устройств, отзыв, способ логина +- API: enrollment codes, `mobile_devices`, refresh JWT, регистрация FCM-токена +- Worker: канал `push_mobile` в notification policy (severity, cooldown, dedup — как у остальных каналов) +- FCM HTTP v1 (service account на сервере; FCM — бесплатный транспорт доставки) + +**Не в мобильном клиенте:** пользователи SAC, каналы Telegram/SMTP/webhook, админ-настройки. + +Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.papatramp.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md). + +--- + +## v0.7 — Версии агентов и удалённое обновление (ToDo) + +**Цель:** SAC контролирует актуальность `ssh-monitor` и `RDP-login-monitor` на хостах и может **инициировать обновление** без обхода всех машин вручную. + +- Сверка `product_version` с политикой SAC (min / recommended) +- UI: статус версии на карточке хоста, список отстающих +- Problems / оповещения при устаревании агента +- Команда обновления (предпочтительно **pull** с хоста: агент видит «нужно обновиться» и запускает свой updater) +- События `agent.update.*` в ingest для аудита + +**Проработка:** [agent-control-plane.md](agent-control-plane.md) (основной документ), [agent-update-backlog.md](agent-update-backlog.md). + +--- + +## Добавить + +См. [agent-control-plane.md](agent-control-plane.md): + +- RDG flap 302→303 (1–10 с): Problem, оповещение, кнопки **qwinsta** / **logoff** на «Обзоре» +- Обновления агентов: режим GPO или SAC (pull + fallback SSH/WinRM), Windows + Linux +- Настройки агента в карточке хоста (desired config → poll) + +--- + +## Зависимости от внешних репо + +| Версия SAC | Минимальная версия ssh-monitor | Минимальная версия RDP-monitor | +|------------|-------------------------------|--------------------------------| +| v0.1 | — (не требуется) | — | +| v0.2 | TBD (тег с UseSAC) | TBD | +| v0.3+ | совместимость schema 1.0 | совместимость schema 1.0 | +| v0.6 | — | — (мобильный клиент [seaca](https://git.papatramp.ru/PapaTramp/seaca), не агент) | + +При breaking change схемы — `schema_version: 1.1` с поддержкой 1.0 на ingest. + +--- + +## Метрики успеха (KPI) + +- Среднее время от события до Telegram < 30 с (p95) +- 0 потерянных событий при 1 ч недоступности SAC (spool) +- Снижение сообщений в Telegram на 40%+ за счёт dedup (через 1 мес. exclusive) + +--- + +## См. также + +- [work-plan.md](work-plan.md) +- [TZ.md](TZ.md) diff --git a/docs/runbook-ops.md b/docs/runbook-ops.md new file mode 100644 index 0000000..dcc11b8 --- /dev/null +++ b/docs/runbook-ops.md @@ -0,0 +1,130 @@ +# Runbook: эксплуатация SAC + +Краткие процедуры для prod (`sac.kalinamall.ru`, native install в `/opt/security-alert-center`). + +## Деплой приложения + +```bash +sudo /opt/sac-deploy.sh +``` + +Скрипт: `git pull` / `reset --hard`, `pip install`, `alembic upgrade head`, `npm run build`, `systemctl restart sac-api`. + +Проверка: + +```bash +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). + +После деплоя 0.9.0+: в веб-SAC включить мобильные устройства, выдать код оператору. Push — после настройки FCM и сборки APK с `google-services.json`. + +## Резервное копирование PostgreSQL + +```bash +sudo -u postgres pg_dump -Fc sac > /var/backups/sac-$(date +%Y%m%d).dump +``` + +Восстановление (на тестовый инстанс): + +```bash +sudo -u postgres pg_restore -d sac_test --clean /var/backups/sac-YYYYMMDD.dump +``` + +Перед restore на prod — остановить API: `sudo systemctl stop sac-api`. + +## Retention (очистка БД) + +Политика по умолчанию (`config/sac-api.env`): + +| Данные | Срок | +|--------|------| +| `events` | 90 дней (`SAC_EVENTS_RETENTION_DAYS`) | +| `problems` со статусом `resolved` | 180 дней (`SAC_PROBLEMS_RETENTION_DAYS`) | + +Ручной прогон: + +```bash +sudo -u sac bash -c ' + set -a; source /opt/security-alert-center/config/sac-api.env; set +a + cd /opt/security-alert-center/backend + .venv/bin/python -m app.jobs.retention +' +``` + +Установка ежедневного timer (один раз): + +```bash +sudo cp /opt/security-alert-center/deploy/systemd/sac-retention.service /etc/systemd/system/ +sudo cp /opt/security-alert-center/deploy/systemd/sac-retention.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now sac-retention.timer +systemctl list-timers sac-retention.timer +``` + +## Суточные отчёты (F-NOT-05) + +```bash +sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.service /etc/systemd/system/ +sudo bash /opt/security-alert-center/deploy/systemd/render-sac-daily-report-timer.sh \ + /opt/security-alert-center/config/sac-api.env \ + /etc/systemd/system/sac-daily-report.timer +sudo systemctl daemon-reload +sudo systemctl enable --now sac-daily-report.timer +systemctl list-timers sac-daily-report.timer + +# Ручной прогон (игнор часа) +sudo -u sac bash -lc 'cd /opt/security-alert-center/backend && .venv/bin/python -m app.jobs.daily_report --force' +``` + +Переменные: `SAC_DAILY_REPORT_*` в `config/sac-api.env` (см. `deploy/env.native.example`). + +## Health и мониторинг + +| Endpoint | Назначение | +|----------|------------| +| `GET /health` | БД, версия, `hosts_stale`, `last_event_received_at` | +| `GET /api/v1/stream/events` | SSE для UI (счётчики + `last_event_id` каждые 5 с) | + +Агенты: `GET /health` без JWT. UI Dashboard: блок «Последние события» подгружается при смене `last_event_id` в SSE. + +## Типовые проблемы + +**События в Telegram есть, в UI нет** — проверить ingest (лог агента `SAC: accepted`), режим `UseSAC`, обновить Dashboard (live ~5 с). + +**422 на ingest** — см. `Logs/sac-last-post.json` на Windows-агенте; обновить `Sac-Client.ps1` (RDP) или `sac-client.sh` (Linux). + +**Устаревший heartbeat** — увеличить `SAC_HEARTBEAT_STALE_MINUTES` или проверить `agent.heartbeat` на хосте. + +**Чужой Host на IP SAC (форма SAC на ext.kalinamall.ru)** — в nginx только `server_name sac.kalinamall.ru`; default_server → 444 / `ssl_reject_handshake`. Обновить конфиг из `deploy/nginx/sac.conf.tls.example`, `sudo nginx -t && sudo systemctl reload nginx`. HAProxy на этом не решается — правка на **192.168.160.145** (nginx SAC). diff --git a/docs/screenshots/ui-events.png b/docs/screenshots/ui-events.png new file mode 100644 index 0000000000000000000000000000000000000000..c73e6bcfee61cd7d4e3b3ee1a351ab91e9834169 GIT binary patch literal 90246 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

%0;k$I)29AJ+*pi)OF{T2TdW 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 z

+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

d%G;3j>Uuy{>{TAWM}LH=tUL4k4WQ4mu(dpK zjOYY>;WYc#gy%ca_uWKPleeV^LLUc4$oHtxr1Y*S^eIx-Wmns;bMsnT2i&lu`pM}t z3Tk>D{#A@i!&XS6*&RqrZDH%3PQR8Qhd(FC2v0jsJMTdZ@z4~?Og<0=Z%_~w1ZKwH zb`vBc08+1ZncBV~8<*i`Dv^{sa=q=B7(A*~`w=mxIA#UgEjE&Wx&0u=(?`|GJ9L(g zeuRJzQ# z$>p#7wQqHPuUcFCUDKrUmH`Gwy C?bEFQ literal 0 HcmV?d00001 diff --git a/docs/screenshots/ui-problems.png b/docs/screenshots/ui-problems.png new file mode 100644 index 0000000000000000000000000000000000000000..c33b239667e2a6cf90874a5b5d4e57407c7309bf GIT binary patch literal 19592 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 kX_xY}7@PLf`sD3:/tmp/certs/ +``` + +Нужен SSH-доступ и право **читать** файл на источнике. + +### 4.4. Распаковка `.pfx` / `.p12` на сервере (Ubuntu 24.04) + +На Ubuntu 24.04 стоит **OpenSSL 3.x**. Старые PFX от VeriSign/DigiCert часто используют **RC2-40-CBC** — без флага `-legacy` будет ошибка: + +```text +Algorithm (RC2-40-CBC : 0), Properties (), unsupported +``` + +**Всегда добавляйте `-legacy`** к `openssl pkcs12`: + +```bash +sudo apt install -y openssl + +sudo mkdir -p /tmp/certs +cd /tmp/certs +# замените имя PFX; Enter Import Password: — пароль от PFX (не sudo) + +sudo openssl pkcs12 -legacy -in wildcard_kalinamall_ru.pfx -nocerts -nodes \ + -out privkey.pem + +sudo openssl pkcs12 -legacy -in wildcard_kalinamall_ru.pfx -clcerts -nokeys \ + -out server.crt + +sudo openssl pkcs12 -legacy -in wildcard_kalinamall_ru.pfx -cacerts -nokeys \ + -out ca-chain.crt + +# fullchain = сервер + промежуточные (порядок важен) +cat server.crt ca-chain.crt | sudo tee /etc/ssl/sac/fullchain.pem > /dev/null +sudo cp privkey.pem /etc/ssl/sac/privkey.pem +sudo chmod 644 /etc/ssl/sac/fullchain.pem +sudo chmod 640 /etc/ssl/sac/privkey.pem +sudo chown root:root /etc/ssl/sac/fullchain.pem +sudo chown root:ssl-cert /etc/ssl/sac/privkey.pem +sudo chown root:ssl-cert /etc/ssl/sac +sudo chmod 750 /etc/ssl/sac + +# очистка временных файлов +sudo shred -u server.crt ca-chain.crt privkey.pem 2>/dev/null +sudo rm -f /tmp/certs/wildcard_kalinamall_ru.pfx +``` + +Если `-legacy` недостаточно: + +```bash +sudo openssl pkcs12 -provider legacy -provider default \ + -in wildcard_kalinamall_ru.pfx -nocerts -nodes -out privkey.pem +``` + +Проверить содержимое PFX: + +```bash +sudo openssl pkcs12 -legacy -in wildcard_kalinamall_ru.pfx -info -noout +``` + +Пароль PFX вводится интерактивно (`Enter Import Password`). **Не** коммитьте PFX и ключ в git. + +--- + +## 5. Сборка fullchain из отдельных `.crt` + +Типичный набор от VeriSign / DigiCert: + +- `star_kalinamall_ru.crt` — ваш wildcard +- `DigiCertCA.crt` / `intermediate.crt` — промежуточный +- (иногда) `TrustedRoot.crt` — корневой **не** добавляйте в fullchain для nginx + +```bash +# на ПК или на сервере, в каталоге с исходниками: +cat star_kalinamall_ru.crt intermediate.crt > fullchain.pem +# ключ переименовать: +cp private.key privkey.pem +``` + +Проверка порядка (первая строка сертификата — ваш сайт): + +```bash +openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout | grep subject= +``` + +Скопировать `fullchain.pem` и `privkey.pem` на сервер (§4). + +--- + +## 6. Проверка сертификата + +На сервере: + +```bash +# кому выдан, срок действия +sudo openssl x509 -in /etc/ssl/sac/fullchain.pem -noout -subject -issuer -dates + +# wildcard / SAN +sudo openssl x509 -in /etc/ssl/sac/fullchain.pem -noout -ext subjectAltName + +# ключ соответствует сертификату +CERT_MD5=$(sudo openssl x509 -in /etc/ssl/sac/fullchain.pem -noout -modulus | openssl md5) +KEY_MD5=$(sudo openssl rsa -in /etc/ssl/sac/privkey.pem -noout -modulus 2>/dev/null | openssl md5) +# для EC-ключа: +# KEY_MD5=$(sudo openssl pkey -in /etc/ssl/sac/privkey.pem -noout -modulus | openssl md5) +echo "cert $CERT_MD5" +echo "key $KEY_MD5" +# хеши должны совпадать +``` + +--- + +## 7. Подключение в nginx + +```bash +sudo cp /opt/security-alert-center/deploy/nginx/sac.conf.tls.example \ + /etc/nginx/sites-available/sac +``` + +В файле должны быть (по умолчанию в шаблоне): + +```nginx +ssl_certificate /etc/ssl/sac/fullchain.pem; +ssl_certificate_key /etc/ssl/sac/privkey.pem; +``` + +```bash +sudo ln -sf /etc/nginx/sites-available/sac /etc/nginx/sites-enabled/sac +sudo nginx -t +sudo systemctl reload nginx +curl -sS https://sac.kalinamall.ru/health | jq . +``` + +В приложении SAC (`/opt/security-alert-center/config/sac-api.env`): + +```ini +SAC_PUBLIC_URL=https://sac.kalinamall.ru +``` + +--- + +## 8. Обновление сертификата (продление) + +1. Получить новые файлы от УЦ. +2. Заменить `/etc/ssl/sac/fullchain.pem` и при необходимости `privkey.pem`. +3. `sudo nginx -t && sudo systemctl reload nginx`. +4. Проверить срок: `openssl x509 -in /etc/ssl/sac/fullchain.pem -noout -dates`. + +Перезапуск `sac-api` **не** обязателен — TLS терминируется на nginx. + +--- + +## 9. Безопасность + +- Закрытый ключ только на сервере SAC, права `640`, группа `ssl-cert`. +- Не хранить PFX/ключ в репозитории, почте, мессенджерах. +- После распаковки PFX удалить `/tmp/*.pfx` и промежуточные копии. + +--- + +## 10. Устранение неполадок + +| Симптом | Решение | +|---------|---------| +| `RC2-40-CBC` / `unsupported` при `pkcs12` | OpenSSL 3 на Ubuntu 24.04: добавить **`-legacy`** (см. §4.4) | +| `mac verify failure` | Неверный пароль PFX | +| `nginx: PEM_read_bio_X509_AUX() failed` | Неверный PEM, проверьте `fullchain.pem` текстом, нет ли `.der` без конвертации | +| `key values mismatch` | Ключ не от этой пары сертификатов | +| `test -r privkey.pem` без вывода / key FAIL | Каталог `chown root:ssl-cert`, `750`; `usermod -aG ssl-cert www-data`; `systemctl restart nginx` | +| `permission denied` на key | То же + `chmod 640` на ключ; `test -x /etc/ssl/sac` | +| Браузер/агент не доверяет | Неполная цепочка — добавьте intermediate в fullchain | +| `certificate has expired` | Продлить у УЦ, заменить файлы | diff --git a/docs/testing-e2e-checklist.md b/docs/testing-e2e-checklist.md new file mode 100644 index 0000000..eeff8ed --- /dev/null +++ b/docs/testing-e2e-checklist.md @@ -0,0 +1,109 @@ +# E2E и нагрузочное тестирование SAC + агенты + +Чеклист после freeze MVP (`work-plan.md`). Отмечайте дату и хост. + +## Дни 1–2: функциональный E2E + +### Linux (ssh-monitor, `UseSAC=dual` или `exclusive`) + +- [ ] `ssh-monitor --check-sac` → HTTP 201/202, событие `agent.test` в UI +- [ ] Успешный SSH login → `ssh.login.success` в SAC + Telegram (dual) +- [ ] Неудачный SSH → `ssh.login.failed` +- [ ] `SERVER_DISPLAY_NAME` задан → в SAC **Хосты** отображается display name +- [ ] Heartbeat → `agent.heartbeat`, хост не `stale` в пределах `SAC_HEARTBEAT_STALE_MINUTES` +- [ ] Daily report → `report.daily.ssh` (агент или SAC: `python -m app.jobs.daily_report --force`, `details.generated_by`) + +### Windows (RDP-login-monitor, `UseSAC=dual`) + +- [ ] `-CheckSac` → ingest OK +- [ ] Старт монитора → `agent.lifecycle` в SAC (HTTP 201), Telegram «ЗАПУЩЕН» +- [ ] Версия в UI **Хосты** = `$ScriptVersion` после деплоя +- [ ] `$ServerDisplayName` → display name в SAC +- [ ] 4624/4625 (тестовый вход) → `rdp.login.*` при наличии событий +- [ ] Dashboard: новое событие появляется в «Последние события» без F5 (~5 с) + +### SAC UI + +- [ ] Problems: open → ack → resolve +- [ ] Дубликат `event_id` → HTTP 409, одна строка в БД +- [ ] Правило brute-force (порог событий) → Problem + +### SAC Telegram — severity и exclusive (`notif-02`) + +**Предусловие:** на prod после `79a78dc+` выполнен деплой (`sudo /opt/sac-deploy.sh` → миграция **`004_notification_channels`**). + +| # | Шаг | Ожидание | +|---|-----|----------| +| 1 | UI → **Настройки** → «Проверить Telegram» | Сообщение «✅ SAC: тестовое сообщение…» в чате | +| 2 | Включить Telegram, `min severity` = **warning**, сохранить (token/chat из env или форма) | GET: `source=db`, `configured=true` | +| 3 | Ingest **`rdp.login.failed`** (severity `warning`) — см. curl ниже | Telegram: «🚨 SAC событие», severity warning | +| 4 | Ingest **`rdp.login.success`** (severity `info`) — новый `event_id` | Событие в UI, **нет** сообщения в Telegram | +| 5 | Сменить `min severity` на **high**, сохранить | Повтор `failed` (warning) → **нет** TG; `high` problem → TG | +| 6 | Агент **`UseSAC=exclusive`**: неудачный вход | TG только из SAC, **не** из бота агента | + +**Синтетический ingest (без RDP-хоста):** подставьте `SAC_API_KEY` и уникальный `event_id`. + +```bash +API_KEY="sac_…" +BASE="https://sac.kalinamall.ru" + +# warning → должен уйти в TG при min=warning +curl -sS -X POST "$BASE/api/v1/events" \ + -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ + -d '{ + "schema_version": "1.0", + "event_id": "00000000-0000-4000-8000-000000000101", + "occurred_at": "2026-05-29T12:00:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "1.2.20-SAC"}, + "host": {"hostname": "E2E-RDP", "os_family": "windows", "display_name": "E2E RDP test"}, + "category": "auth", + "type": "rdp.login.failed", + "severity": "warning", + "title": "RDP login failed (E2E)", + "summary": "notif-02 synthetic failed login" + }' + +# info → не должен уйти в TG при min=warning +curl -sS -X POST "$BASE/api/v1/events" \ + -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ + -d '{ + "schema_version": "1.0", + "event_id": "00000000-0000-4000-8000-000000000102", + "occurred_at": "2026-05-29T12:01:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "1.2.20-SAC"}, + "host": {"hostname": "E2E-RDP", "os_family": "windows"}, + "category": "auth", + "type": "rdp.login.success", + "severity": "info", + "title": "RDP login success (E2E)", + "summary": "notif-02 synthetic success — no TG expected" + }' +``` + +Отметка: дата ______, оператор ______, результат TG шагов 3–4: ☐ OK / ☐ fail + +## Дни 3–4: нагрузка и устойчивость + +- [ ] Пакет 50+ `ssh.login.failed` с одного IP → агрегация/Problem, без зависания UI +- [ ] Повтор ingest с тем же `event_id` → 409 +- [ ] Остановка SAC API → spool на агенте, восстановление после `/health` +- [ ] Flapping heartbeat: stale → warning в health / хостах + +## День 5: регресс + +- [ ] Обновление RDP через Deploy: одна версия в `deployed_version.txt`, ingest после 1.2.14+ JSON +- [ ] `sac-retention.timer` active; ручной `systemctl start sac-retention.service` — exit 0 +- [ ] Известные issues занесены в issue tracker / `known-issues.md` + +## Дни 6–7: soak + +- [ ] 48–72 ч dual mode: нет роста spool без причины, диск БД в норме +- [ ] Только bugfix по результатам выше + +## Версии на момент чеклиста + +| Компонент | Версия | +|-----------|--------| +| ssh-monitor | 1.2.6-SAC | +| RDP-login-monitor | 1.2.20-SAC | +| security-alert-center | 0.7.0 (`backend/app/version.py`) | diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md new file mode 100644 index 0000000..4139d91 --- /dev/null +++ b/docs/todo-2026-05-29.md @@ -0,0 +1,89 @@ +# ToDo — 29.05.2026 (оповещения SAC + «Настройки») + +Контекст: при **`UseSAC=exclusive`** на агентах Telegram/email с хоста **не идут** — оператору нужны оповещения **из SAC**. Аналитика и прочее — отдельно ([analytics-backlog.md](analytics-backlog.md)). + +--- + +## Уже есть в коде (не путать с «ещё не сделано») + +| Что | Где | Ограничение | +|-----|-----|-------------| +| Telegram из SAC | `backend/app/services/telegram_notify.py` | Только если в `sac-api.env`: `TELEGRAM_ENABLED=true`, токен, chat_id | +| Порог severity | `telegram_min_severity` (по умолчанию **`high`**) | События **`info`** (успешный RDP `rdp.login.success`) **не** уходят в TG, пока порог не снизить до `warning` | +| Вызов при ingest | `events.py` → `notify_event` / `notify_problem` | Шаблон короткий («🚨 SAC событие»), не как у агента | +| UI «Настройки» | `/settings`, GET/PUT Telegram | БД `notification_channels` (миграция `004`) + fallback на `sac-api.env` | +| Email / webhook | TZ F-NOT-01 | Email ✅, Webhook ✅ | + +**Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems. + +**Замечание по коду:** `notify_problem()` сейчас **не проверяет** `telegram_min_severity` (в отличие от `notify_event`) — исправить в рамках `notif-03`. + +--- + +## Задачи (приоритет) + +### P0 — exclusive + Telegram + +| ID | Задача | Критерий | +|----|--------|----------| +| `notif-01` | Зафиксировать в `agent-integration.md` / runbook: при **exclusive** канал оповещений = SAC; рекомендуемый `TELEGRAM_MIN_SEVERITY=warning` | ✅ docs + `env.native.example` | +| `notif-02` | Проверить на staging/prod: ingest `rdp.login.failed` (warning) → TG; `rdp.login.success` (info) → нет TG при `min=warning` | ✅ чеклист [testing-e2e-checklist.md](testing-e2e-checklist.md) § SAC Telegram | +| `notif-03` | `notify_problem`: учитывать `telegram_min_severity` | ✅ unit-тест | + +### P1 — UI «Настройки» (каналы) + +| ID | Задача | Критерий | +|----|--------|----------| +| `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | ✅ страница + GET API | +| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity | ✅ форма + GET/PUT | +| `notif-12` | Хранение: **вариант A** — запись в БД `notification_channels` + fallback на env | ✅ миграция `004`, `PUT /notifications/telegram` | +| `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`) | ✅ GET settings | +| `notif-14` | Кнопка «Проверить Telegram» → тестовое сообщение | ✅ `POST …/telegram/test` + UI | + +### P2 — Email и webhook (после Telegram) + +| ID | Задача | Критерий | +|----|--------|----------| +| `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | ✅ PUT/test API + UI + ingest | +| `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | ✅ PUT/test API + UI + ingest | +| `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | ✅ `notification_policy` + UI «Правило оповещений» | + +### P3 — Качество сообщений (можно сдвинуть) + +| ID | Задача | +|----|--------| +| `notif-30` | Шаблон TG ближе к агенту (HTML, поля user/ip/logon_type из `details`) | ✅ `telegram_templates.py`, `parse_mode=HTML` | +| `notif-31` | Cooldown/dedup на уровне SAC (F-NOT-03) — не дублировать problem при burst | ✅ `notification_cooldown`, env 90/300 с | +| `notif-32` | F-NOT-05: daily report из SAC при exclusive (отдельный эпик) | ✅ `daily_report.py`, timer `sac-daily-report` | + +--- + +## Связь с агентами + +```text +UseSAC=off → только агент (Telegram на хосте) +UseSAC=dual → агент + SAC ingest (+ опционально SAC TG, риск дублей) +UseSAC=exclusive→ только SAC ingest; TG/email = настройки SAC (UI/env) +``` + +--- + +## Оценка на день + +| Блок | Часы (оценка) | +|------|----------------| +| P0 док + notif-03 + smoke | 2–3 | +| P1 UI Settings + API Telegram | 4–6 | +| P2 webhook/email | +1 день | + +**Минимум на завтра:** `notif-01` … `notif-03` + черновик `notif-10` … `notif-14` (хотя бы API без полировки UI). + +--- + +## См. также + +- [TZ.md](TZ.md) §4.6 F-NOT-01 … F-NOT-05 +- [agent-integration.md](agent-integration.md) — режимы UseSAC +- [agent-update-backlog.md](agent-update-backlog.md) — **ToDo:** версии агентов и удалённое обновление из SAC +- `backend/app/config.py` — `telegram_*` +- `backend/app/services/telegram_notify.py` diff --git a/docs/work-plan.md b/docs/work-plan.md new file mode 100644 index 0000000..1683122 --- /dev/null +++ b/docs/work-plan.md @@ -0,0 +1,171 @@ +# План работ — Security Alert Center + +**Актуальный порядок:** сначала подготовка агентов (параллельно с минимальным SAC ingest), затем полный SAC MVP, затем prod rollout `UseSAC=exclusive`. + +--- + +## Фаза 0. Документация ✅ + +| # | Задача | Статус | +|---|--------|--------| +| 0.1–0.3 | ТЗ, архитектура, схема, deployment | ✅ | +| 0.5 | Репозиторий GitHub + зеркало kalinamall | ✅ | +| 0.6 | Multi-root workspace | ✅ | + +--- + +## Фаза 1A. Агенты — контракт SAC (параллельно с 1B) + +**Репозитории:** `ssh-monitor`, `RDP-login-monitor` +**Прод:** везде `UseSAC=off` по умолчанию. + +| # | Задача | Статус | +|---|--------|--------| +| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | ✅ ssh-monitor | +| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | ✅ `sac-client.sh` | +| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | ✅ + маппинг событий | +| 1A.4 | `--check-sac` / `Test-SacConnection` | ✅ ssh-monitor `--check-sac` | +| 1A.5 | README агентов, first deploy | ✅ `first_deploy.sh`, `--deploy` | + +**Выход:** на тестовом хосте `dual` шлёт JSON в SAC + Telegram. + +--- + +## Фаза 1B. SAC — минимальный ingest ✅ (prod) + +| # | Задача | Статус | +|---|--------|--------| +| 1B.1 | Scaffold backend FastAPI | ✅ | +| 1B.2 | PostgreSQL + Alembic (hosts, events, api_keys) | ✅ | +| 1B.3 | `POST /api/v1/events`, `GET /health` | ✅ prod `sac.kalinamall.ru` | +| 1B.4 | systemd + nginx + TLS | ✅ | +| 1B.5 | Валидация JSON Schema v1 | ✅ | + +**Выход:** ingest с curl и UI на prod — достигнут. + +--- + +## Фаза 1C. SAC — MVP UI и оповещения + +| # | Задача | Статус | +|---|--------|--------| +| 1C.1 | Frontend Vue: Events, Hosts | ✅ prod | +| 1C.2 | Auth JWT, admin bootstrap | ✅ prod | +| 1C.3 | Problems (базовые правила) | ✅ API + UI (деплой ⏳) | +| 1C.4 | Telegram из SAC | ✅ backend (деплой + TELEGRAM_* в env) | +| 1C.5 | Dashboard (3 виджета), SSE | ✅ v0.2.0 | + +**Выход:** тег `v0.2.0-mvp`. + +--- + +## Фаза 2. Пилот и exclusive + +| # | Задача | +|---|--------| +| 2.1 | E2E: 1 Linux + 1 Windows, `UseSAC=exclusive` | ✅ Linux [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) | +| 2.2 | Daily report / heartbeat через SAC | ✅ [pilot-2.2-heartbeat-reports.md](pilot-2.2-heartbeat-reports.md) | +| 2.3 | fallback на агентах | ✅ счётчик `SAC_FALLBACK_FAILURES` в sac-client | + +**Выход:** `v0.2.0`. + +--- + +## Фаза 3. Эксплуатация + +См. [roadmap.md](roadmap.md). + +--- + +## Ближайшие 2 дня (чеклист исполнения) + +### День 1 + +- [x] `d1-1` Ingest: `event_id` UUID обязателен, UNIQUE индекс, коды duplicate, логи, docs/schema +- [x] `d1-2` MVP Problems: модель, корреляция `host+type+окно`, API `GET/ack/resolve` +- [x] `d1-3` Правила v1: `brute-force burst`, `privilege spike`, `host silence` + +### День 2 + +- [x] `d2-1` UI Problems: список, фильтры, `Ack/Resolve`, карточка с таймлайном +- [x] `d2-2` Dashboard: top hosts/types, `open vs resolved 24h`, drill-down +- [x] `d2-3` Ops: retention, health checks, runbook backup/restore/deploy +- [x] `dod` DoD: нет дублей `event_id`, Problems e2e, 3 правила, UI MVP, docs, push в kalinamall + +### Отображение хостов (`display_name`, как `SERVER_DISPLAY_NAME` у ssh) + +Сейчас: **ssh** — `SERVER_DISPLAY_NAME` только в Telegram; в SAC уходит `socket.gethostname()`. **RDP** — параметра нет, в SAC только `$env:COMPUTERNAME`. В SAC UI «Хосты» уже показывает `display_name || hostname`, если поле пришло в ingest. + +- [x] `agent-display-rdp` **RDP-login-monitor:** `$ServerDisplayName` в `login_monitor.settings.ps1` / example; подпись в Telegram; в `Sac-Client.ps1` — `host.display_name` (и при необходимости согласовать `hostname`) +- [x] `agent-display-ssh` **ssh-monitor:** в `sac-client.sh` передавать `SERVER_DISPLAY_NAME` как `host.display_name` в JSON ingest +- [x] `sac-display-hosts` **SAC:** проверить ingest/обновление `Host.display_name`; колонка «Хосты» = человекочитаемое имя; поиск по `display_name`; `docs/agent-integration.md` + schema `host.display_name` + +**Приёмка:** хост с `SERVER_DISPLAY_NAME="UNMS Kalina"` (или RDP `$ServerDisplayName`) в SAC → **Хосты** отображается как **UNMS Kalina**, а не только короткое имя ОС. + +### Почасовой чеклист + +#### День 1 + +- [x] `09:00–10:00` миграция `event_id` + UNIQUE, валидация ingest +- [x] `10:00–11:00` поведение duplicate (`201/409`) и логирование reject +- [x] `11:00–12:00` обновить `event-schema-v1.json` и `agent-integration.md` (в т.ч. `host.display_name` для агентов) +- [x] `13:00–14:30` модель Problems + миграция +- [x] `14:30–16:00` корреляция при ingest (`fingerprint`, `count`, `last_seen`) +- [x] `16:00–17:30` API `GET /problems`, `ack`, `resolve` + smoke +- [x] `18:00–19:00` правила `brute/privilege/silence` + unit tests + +#### День 2 + +- [x] `09:00–11:00` UI Problems: таблица + фильтры (`status/severity/host`) +- [x] `11:00–12:00` карточка проблемы + таймлайн событий +- [x] `13:00–14:30` Dashboard: виджеты top hosts/types +- [x] `14:30–16:00` Dashboard: `open/resolved 24h` + drill-down +- [x] `16:00–17:00` retention job (`events 30–90d`, `problems 180d+`) +- [x] `17:00–18:00` health checks (DB/worker/heartbeat stale) +- [x] `18:00–19:00` runbook + финальный push в kalinamall + freeze dev + +### Неделя после freeze (только тестирование) + +Чеклист: [testing-e2e-checklist.md](testing-e2e-checklist.md) + +- [ ] `дни 1–2` функциональные E2E (`RDP + SSH + mixed load`) +- [ ] `дни 3–4` нагрузка, дубликаты, флаппинг heartbeat +- [ ] `день 5` регресс + known issues +- [ ] `дни 6–7` soak-test, только bugfix + +--- + +## Gantt (упрощённо) + +```mermaid +gantt + title SAC — актуальный план + dateFormat YYYY-MM-DD + section Документы + Фаза 0 :done, 2026-05-26, 1d + section SAC + 1B ingest+deploy :done, 2026-05-26, 1d + 1C UI базовый :done, 2026-05-26, 1d + 1C notify+dash :active, 2026-05-27, 14d + section Агенты + 1A UseSAC dual :2026-05-26, 10d + section Prod + Пилот exclusive :2026-06-16, 7d +``` + +--- + +## Следующий рабочий день (29.05.2026) + +Оповещения SAC (**notif-01…32**) — ✅ см. [todo-2026-05-29.md](todo-2026-05-29.md). На prod: деплой, `notif-02` E2E, timer `sac-daily-report`. + +Отложенная аналитика: **[analytics-backlog.md](analytics-backlog.md)**. + +--- + +## См. также + +- [install-ubuntu-24.04.md](install-ubuntu-24.04.md) — подготовка сервера +- [operations-prod-status.md](operations-prod-status.md) — статус prod и деплой +- [roadmap.md](roadmap.md) +- [TZ.md](TZ.md) diff --git a/docs/workspace-three-repos.md b/docs/workspace-three-repos.md new file mode 100644 index 0000000..2559ee7 --- /dev/null +++ b/docs/workspace-three-repos.md @@ -0,0 +1,76 @@ +# Multi-root workspace: три репозитория + +Как работать в одном multi-root workspace над **ssh-monitor**, **RDP-login-monitor** и **security-alert-center** одновременно (VS Code или совместимый редактор). + +--- + +## 1. Расположение репозиториев (пример) + +| Имя в 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 | + +Пути подставьте свои. + +--- + +## 2. Файл workspace + +Создайте `security-monitors.code-workspace`: + +```json +{ + "folders": [ + { "path": "D:/Soft/Git/ssh-monitor", "name": "ssh-monitor" }, + { "path": "D:/Soft/Git/RDP-login-monitor", "name": "rdp-monitor" }, + { "path": "D:/Soft/Git/security-alert-center", "name": "security-alert-center" } + ], + "settings": { + "files.eol": "\n" + } +} +``` + +Открыть: **File → Open Workspace from File**. + +--- + +## 3. Порядок работ по фазам + +| Фаза | Где править | +|------|-------------| +| 0 — ТЗ | `security-alert-center/docs/` | +| 1 — MVP SAC | `security-alert-center/backend`, `frontend` | +| 2 — UseSAC | `ssh-monitor`, `RDP-login-monitor` + мелкие правки SAC при необходимости | + +--- + +## 4. Соглашения + +- Контракт событий: `security-alert-center/schemas/event-schema-v1.json` — **единственный источник истины**. +- Изменение схемы → обновить ТЗ + версию `schema_version` + оба агента. +- Коммиты в три репо **раздельные**; связь через теги/версии в README (см. [roadmap.md](roadmap.md)). + +```bash +git add … +git commit -m "…" +git push kalinamall main +``` + +--- + +## 5. URL репозиториев + +| Проект | 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` | + +--- + +## См. также + +- [work-plan.md](work-plan.md) фаза 0.6 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..420b5d0 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,9 @@ +# Frontend (Vue 3 + Vite) + +**Статус:** не реализован. См. [docs/work-plan.md](../docs/work-plan.md) фаза 1. + +Планируется: + +- Problems, Events, Hosts, Dashboards +- SSE live-лента +- Auth JWT diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b943dea --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Security Alert Center + + +

+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3c35bf5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1578 @@ +{ + "name": "sac-ui", + "version": "0.11.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sac-ui", + "version": "0.11.4", + "dependencies": { + "dompurify": "^3.2.4", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.2", + "vite": "^6.0.3", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..81f2c39 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "sac-ui", + "private": true, + "version": "0.11.4", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "dompurify": "^3.4.11", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.2", + "vite": "^6.4.3", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..9cbe5bf0961d92ca03e9d7bfb47778864af23409 GIT binary patch literal 1635 zcmV-p2AuhcP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1^7usK~z{rtygJm z6lEB0Y3=Up%--Eyx<|L$-R(}hTWm|YR6s5(N>IzC2uM?l0fQpZh(~HDhZ-!mhysZ; zM#V^COpKtS2}oiz;(3T9U)c{20O_kHjCeD66^Mb|vS z(0Hh>u?;DKS`$dCK05DTE;=f1U(0mh2w%X#xoO9{Be?9U8u)EDS;ZBc6H?w z89E%kCOTaU-QzMYB{2q;S5v1J=rK`Bpw1Z_hkzwK1C5l~9rf^aEkgF#_waTsl<^pe zQX(v`&KW4sW1^IR-4hWd#X?xHqrMq-n-~76OVIw>UuZl0HQoP&f7%k*?Ov!fpHJ4> z%LG_%u`a@u2;^CSf=KbiU|0MiG}gZdU8jJ?frGHsxe>ef0J=`oc-=mk=XDNRuLjej zGAbZoT0n$k;Jx-{QnVlKum6roZ~TE^&wAL^09hm{<_|7dNAEAoe5`*TJd=8iSTRW$ z1i}S+Ok9OPASI_y?>L9fcY)l<&xrO7LyNRS(L#~|4$2mlNbpz-=V<;pS;y3s?lA?P%F~4q9vyRDVKF#AsaqBJ=|n(e}c(XgzoljrScg(vp<(-PzP- z>|;4XKrfSkxWFXv^v7_eri;1xl*z&Oka+lnoV(XCbxqj09ZduI-3FFoO8~htWj$@Uo|%xEhce`Vj8a47ifhgg|93$PYq` zKw}4d-HYMBZUvcr6{2f)Q4MgxKcyG8Yn+I#-3|YY-WF)@Vf(Mw`}UdIipWye{wH>5>g88Kj5fRrol2+~_WK>hVAVXJY7 z!j3opMqq9qg7XK^eFkC?F2$X1nf;+gq4|EK22U9l;Zj_d1uPdZ0kvMDJhF0!q?fCC zD)il7AiC-q>4?70Tj8I+1hKWl$P8bQY~gh=3d;EUBeXv-N}&8g#02OBp@kbq(~pDD zlbeV7S*ywH+YsvMmskkQUIS0t9O;6*C%yeFmG1#K$bv$Eg0cKwKpU-8Y(4Y?+^siK zIi_mJ@Wv$7LOtu?N^}b`DbGDUo^M7g9FlOP3# za-H6XC?1v}cl=i=Tq?I>QwGnMN5FCcgA{NuHY|M-xtD(trCe;RZ$K8#W8Tv~@|zSS z{#eOBgh8Mj7vvK#&Bzg*96Swu&zIEoqT+8}Cl!)nLZ|*O9dR`BjeYVgE~q331%{cW z*VFgCzV|EW!=H)o?KY1TEPc=Cl%5xe3|WSYkGQ2`0)-YZd`!aF)O1=_oEiQU*_VDq zVAd+Cj6Wf>>k|n|{up6%8_Ep=TDb&_|AMv>0s;;`PD9G~#O7Bq`F$X<=@l7suv*H; z2$&>@0Lv}bMJOduV^d{=W^K+VARrQaTJpOmJ4fO{>IJ{I++`{^o(%#lw^$dUlmP$3 z8UKcs74nsjf#<{}CP^zT4bVFQt^JpO!z=zBJE)C1NB@pZ(Aa4{^Bh0Ry?C7oo_COS hCe+rI=%}iy!at#HHmtifIM)CG002ovPDHLkV1kH~{}KQI literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9e87c01 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/sac-icon.png b/frontend/public/sac-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..eb3bee0884d15753b876cc186e3fa89789ee8676 GIT binary patch literal 14259 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cd86d53 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..c5aa494 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,825 @@ +const TOKEN_KEY = "sac_token"; +const ROLE_KEY = "sac_role"; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function getRole(): string | null { + return localStorage.getItem(ROLE_KEY); +} + +export function isAdmin(): boolean { + return getRole() === "admin"; +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function setRole(role: string): void { + localStorage.setItem(ROLE_KEY, role); +} + +export function setAuth(token: string, role: string): void { + setToken(token); + setRole(role); +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ROLE_KEY); +} + +export async function logoutApi(): Promise { + try { + await fetch("/api/v1/auth/logout", { method: "POST", credentials: "same-origin" }); + } catch { + /* ignore network errors on logout */ + } +} + +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +export function redirectToErrorPage(status: 401 | 403 | 429): void { + window.location.href = `/${status}`; +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (!headers.has("Content-Type") && init.body) { + headers.set("Content-Type", "application/json"); + } + const token = getToken(); + const hadToken = !!token; + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + const res = await fetch(path, { ...init, headers, credentials: "same-origin" }); + const text = await res.text(); + if (res.status === 401) { + if (hadToken) { + clearToken(); + redirectToErrorPage(401); + } + throw new ApiError(parseApiError(text, 401) || "Unauthorized", 401); + } + if (res.status === 403) { + if (hadToken) { + redirectToErrorPage(403); + } + throw new ApiError(parseApiError(text, 403) || "Forbidden", 403); + } + if (res.status === 429) { + redirectToErrorPage(429); + throw new ApiError(parseApiError(text, 429) || "Too many requests", 429); + } + if (!res.ok) { + throw new ApiError(parseApiError(text, res.status) || res.statusText, res.status); + } + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + username: string; + role: string; +} + +export interface MeResponse { + username: string; + role: string; +} + +export function fetchMe(): Promise { + return apiFetch("/api/v1/auth/me"); +} + +export function parseApiError(text: string, status?: number): string { + if (status === 502 || status === 503 || status === 504) { + if (/\s*502 Bad Gateway/i.test(text) || /502 Bad Gateway/i.test(text)) { + return "Сервер SAC временно недоступен (перезапуск или обновление). Подождите 5–10 секунд и обновите страницу."; + } + if (/<title>\s*503 Service Unavailable/i.test(text) || /503 Service Unavailable/i.test(text)) { + return "SAC временно перегружен. Повторите запрос через несколько секунд."; + } + if (/<title>\s*504 Gateway Time-out/i.test(text) || /504 Gateway Time-out/i.test(text)) { + return "Превышено время ожидания ответа SAC. Долгая операция могла ещё выполняться на сервере."; + } + return "Временная ошибка шлюза SAC. Обновите страницу."; + } + if (!text) return ""; + if (text.trimStart().startsWith("<")) { + return status ? `Ошибка HTTP ${status}` : "Ошибка сервера"; + } + try { + const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> }; + if (typeof data.detail === "string") return data.detail; + if (Array.isArray(data.detail)) { + return data.detail.map((item) => item.msg ?? String(item)).join("; "); + } + } catch { + /* plain text */ + } + return text; +} + +export interface UserSummary { + id: number; + username: string; + role: string; + is_active: boolean; + created_at: string; +} + +export interface UserListResponse { + items: UserSummary[]; +} + +export interface UpdateUserPayload { + username?: string; + role?: string; + password?: string; + is_active?: boolean; +} + +export function fetchUsers(): Promise<UserListResponse> { + return apiFetch<UserListResponse>("/api/v1/users"); +} + +export function createUser(payload: { + username: string; + password: string; + role: string; +}): Promise<UserSummary> { + return apiFetch<UserSummary>("/api/v1/users", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateUser(userId: number, payload: UpdateUserPayload): Promise<UserSummary> { + return apiFetch<UserSummary>(`/api/v1/users/${userId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export interface EventSummary { + id: number; + event_id: string; + host_id: number; + hostname: string; + display_name?: string | null; + product_version?: string | null; + occurred_at: string; + received_at: string; + category: string; + type: string; + severity: string; + 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; + rdg_access_path?: string | null; + rdg_qwinsta_enabled?: boolean; + session_terminated?: boolean; +} + +export interface AgentCommandResponse { + command_uuid: string; + command_type: string; + status: string; + result_stdout?: string | null; + result_stderr?: string | null; + created_at?: string | null; + completed_at?: string | null; + target?: string | null; + client_hostname?: string | null; + internal_ip?: string | null; +} + +export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> { + return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/qwinsta`, { + method: "POST", + }); +} + +export function postEventLogoff(eventId: number, sessionId: number): Promise<AgentCommandResponse> { + return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/logoff`, { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); +} + +export function fetchEventAction(eventId: number, commandUuid: string): Promise<AgentCommandResponse> { + return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/${commandUuid}`); +} + +export interface EventListResponse { + items: EventSummary[]; + total: number; + page: number; + page_size: number; +} + +export interface EventDetail extends EventSummary { + details: Record<string, unknown> | null; + raw: Record<string, unknown> | null; + payload: Record<string, unknown>; +} + +export interface HostSummary { + id: number; + hostname: string; + display_name: string | null; + os_family: string; + os_version?: string | null; + product: string; + product_version: string | null; + ipv4: string | null; + last_seen_at: string; + event_count: number | null; + last_heartbeat_at: string | null; + last_daily_report_at: string | null; + last_inventory_at?: string | null; + agent_status: "online" | "stale" | "unknown"; + version_status?: "ok" | "outdated" | "unknown"; + pending_agent_update?: boolean; + ssh_admin_ok?: boolean | null; + ssh_admin_checked_at?: string | null; +} + +export interface HostDetail extends HostSummary { + agent_instance_id: string | null; + ipv6: string | null; + tags: string[] | null; + use_sac_mode: string | null; + inventory: Record<string, unknown> | null; + inventory_updated_at: string | null; + created_at: string | null; + pending_update_requested_at?: string | null; + pending_update_target_version?: string | null; + agent_config_revision?: number; + agent_config?: Record<string, unknown> | null; + agent_update_state?: string | null; + agent_update_last_at?: string | null; + agent_update_last_error?: string | null; +} + +export function fetchHost(id: number): Promise<HostDetail> { + return apiFetch<HostDetail>(`/api/v1/hosts/${id}`); +} + +export function fetchRecentEvents(limit = 1): Promise<EventSummary[]> { + return apiFetch<EventSummary[]>(`/api/v1/dashboards/recent-events?limit=${limit}`); +} + +export interface HostListResponse { + items: HostSummary[]; + total: number; + page: number; + page_size: number; + latest_agent_versions?: Record<string, string>; + /** Эталон для «устарела»: max(git latest, min, max в fleet) */ + reference_agent_versions?: Record<string, string>; + /** Последние версии из git (version.txt на main) */ + git_latest_rdp_version?: string | null; + git_latest_ssh_version?: string | null; +} + +export interface ProblemSummary { + id: number; + host_id: number | null; + hostname: string | null; + display_name?: string | null; + title: string; + summary: string; + severity: string; + status: string; + rule_id: string | null; + fingerprint?: string; + event_count?: number; + last_seen_at?: string; + created_at: string; + updated_at: string; +} + +export interface ProblemListResponse { + items: ProblemSummary[]; + total: number; + page: number; + page_size: number; +} + +export interface ProblemEventItem { + id: number; + event_id: string; + occurred_at: string; + type: string; + severity: string; + title: string; + summary: string; +} + +export interface ProblemDetail extends ProblemSummary { + events: ProblemEventItem[]; +} + +export function fetchProblem(id: number): Promise<ProblemDetail> { + return apiFetch<ProblemDetail>(`/api/v1/problems/${id}`); +} + +export async function ackProblem(problemId: number): Promise<{ id: number; status: string }> { + return apiFetch(`/api/v1/problems/${problemId}/ack`, { method: "POST" }); +} + +export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> { + return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" }); +} + +export interface TopHostItem { + host_id: number; + hostname: string; + display_name?: string | null; + count: number; +} + +export interface TopTypeItem { + type: string; + count: number; +} + +export interface MobileSettings { + devices_allowed: boolean; + max_devices_per_user: number; + min_app_version: string | null; + fcm_enabled: boolean; + fcm_configured: boolean; + source: string; +} + +export interface MobileEnrollmentCode { + id: number; + label: string; + code_prefix: string; + target_user_id: number | null; + target_username: string | null; + login_mode: string; + max_uses: number; + use_count: number; + expires_at: string; + revoked_at: string | null; + created_at: string; + is_active: boolean; +} + +export interface MobileEnrollmentCodeCreated extends MobileEnrollmentCode { + enrollment_code: string; +} + +export interface MobileDevice { + id: number; + user_id: number; + username: string; + device_uuid: string; + display_name: string; + platform: string; + app_version: string | null; + has_fcm_token: boolean; + enrolled_at: string; + last_seen_at: string | null; + revoked_at: string | null; + is_active: boolean; +} + +export function fetchMobileSettings(): Promise<MobileSettings> { + return apiFetch<MobileSettings>("/api/v1/mobile/settings"); +} + +export function updateMobileSettings(payload: { + devices_allowed: boolean; + max_devices_per_user: number; + min_app_version?: string | null; +}): Promise<MobileSettings> { + return apiFetch<MobileSettings>("/api/v1/mobile/settings", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export function fetchMobileEnrollmentCodes(): Promise<MobileEnrollmentCode[]> { + return apiFetch<MobileEnrollmentCode[]>("/api/v1/mobile/enrollment-codes"); +} + +export function createMobileEnrollmentCode(payload: { + label?: string; + target_user_id?: number | null; + login_mode?: string; + max_uses?: number; + expires_in_hours?: number; +}): Promise<MobileEnrollmentCodeCreated> { + return apiFetch<MobileEnrollmentCodeCreated>("/api/v1/mobile/enrollment-codes", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function revokeMobileEnrollmentCode(codeId: number): Promise<void> { + return apiFetch<void>(`/api/v1/mobile/enrollment-codes/${codeId}`, { method: "DELETE" }); +} + +export function fetchMobileDevices(): Promise<MobileDevice[]> { + return apiFetch<MobileDevice[]>("/api/v1/mobile/devices"); +} + +export function revokeMobileDevice(deviceId: number): Promise<MobileDevice> { + return apiFetch<MobileDevice>(`/api/v1/mobile/devices/${deviceId}/revoke`, { method: "POST" }); +} + +export function deleteMobileDevice(deviceId: number): Promise<void> { + return apiFetch<void>(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" }); +} + +export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> { + return apiFetch(`/api/v1/mobile/devices/${deviceId}/test-push`, { method: "POST" }); +} + +export interface WinAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + +export function fetchWinAdminSettings(): Promise<WinAdminSettings> { + return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin"); +} + +export function updateWinAdminSettings(payload: { + user?: string | null; + password?: string | null; +}): Promise<WinAdminSettings> { + return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export interface RdpFlapSettings { + auto_disconnect: boolean; + win_admin_configured: boolean; + source: string; +} + +export function fetchRdpFlapSettings(): Promise<RdpFlapSettings> { + return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap"); +} + +export function updateRdpFlapSettings(payload: { auto_disconnect: boolean }): Promise<RdpFlapSettings> { + return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export interface HostWinRmTestResult { + ok: boolean; + message: string; + target: string; + hostname: string | null; +} + +export function testHostWinRm(hostId: number): Promise<HostWinRmTestResult> { + return apiFetch<HostWinRmTestResult>(`/api/v1/hosts/${hostId}/actions/winrm-test`, { + method: "POST", + }); +} + +export interface LinuxAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + +export function fetchLinuxAdminSettings(): Promise<LinuxAdminSettings> { + return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin"); +} + +export function updateLinuxAdminSettings(payload: { + user?: string | null; + password?: string | null; +}): Promise<LinuxAdminSettings> { + return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin", { + method: "PUT", + body: JSON.stringify(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; + target: string; + stdout: string | null; + stderr: string | null; + exit_code: number | null; + product_version?: string | null; + ssh_admin_ok?: boolean | null; +} + +export function testHostSsh(hostId: number): Promise<HostSshActionResult> { + return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/ssh-test`, { + method: "POST", + }); +} + +export interface HostRemoteActionStartResult { + status: string; + host_id: number; + title: string; + message: string; +} + +export interface HostRemoteActionJobStatus { + active: boolean; + host_id: number; + hostname?: string | null; + status?: string | null; + title?: string; + message?: string; + stdout?: string | null; + stderr?: string | null; + output?: string | null; + ok?: boolean | null; + exit_code?: number | null; + product_version?: string | null; + agent_update_state?: string | null; + started_at?: string | null; + finished_at?: string | null; +} + +export function updateHostAgentViaSsh(hostId: number): Promise<HostRemoteActionStartResult> { + return apiFetch<HostRemoteActionStartResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, { + method: "POST", + }); +} + +export interface HostAgentUpdateRequestResult { + status: string; + pending_agent_update: boolean; + target_version: string | null; + agent_update_state: string | null; +} + +export function requestHostAgentUpdate(hostId: number): Promise<HostAgentUpdateRequestResult> { + return apiFetch<HostAgentUpdateRequestResult>( + `/api/v1/hosts/${hostId}/actions/request-agent-update`, + { method: "POST" }, + ); +} + +export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteActionStartResult> { + return apiFetch<HostRemoteActionStartResult>( + `/api/v1/hosts/${hostId}/actions/agent-update-fallback`, + { method: "POST" }, + ); +} + +export interface HostManualAddRequest { + platform: "windows" | "linux"; + target: string; +} + +export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteActionStartResult> { + return apiFetch<HostRemoteActionStartResult>("/api/v1/hosts/manual-add", { + method: "POST", + body: JSON.stringify(body), + }); +} + +export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> { + return apiFetch<HostRemoteActionJobStatus>( + `/api/v1/hosts/${hostId}/actions/remote-job?_ts=${Date.now()}`, + ); +} + +export interface HostSessionItem { + session_id: string; + user: string; + tty?: string | null; + state?: string | null; + source_ip?: string | null; + session_name?: string | null; +} + +export interface HostSessionsResponse { + ok: boolean; + message: string; + target?: string | null; + sessions: HostSessionItem[]; +} + +export interface HostSessionTerminateResponse { + ok: boolean; + message: string; + target?: string | null; + stdout?: string | null; + stderr?: string | null; +} + +export function fetchHostSessions(hostId: number): Promise<HostSessionsResponse> { + return apiFetch<HostSessionsResponse>(`/api/v1/hosts/${hostId}/actions/sessions/list`, { + method: "POST", + }); +} + +export function terminateHostSession( + hostId: number, + sessionId: string, +): Promise<HostSessionTerminateResponse> { + return apiFetch<HostSessionTerminateResponse>(`/api/v1/hosts/${hostId}/actions/sessions/terminate`, { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); +} + +export function postEventTerminateSession( + eventId: number, + sessionId?: string, +): Promise<HostSessionTerminateResponse> { + return apiFetch<HostSessionTerminateResponse>(`/api/v1/events/${eventId}/actions/terminate-session`, { + method: "POST", + body: JSON.stringify(sessionId ? { session_id: sessionId } : {}), + }); +} + +export interface HostAgentConfigResponse { + config_revision: number; + settings: Record<string, unknown>; +} + +export function patchHostAgentConfig( + hostId: number, + settings: Record<string, unknown>, +): Promise<HostAgentConfigResponse> { + return apiFetch<HostAgentConfigResponse>(`/api/v1/hosts/${hostId}/agent-config`, { + method: "PATCH", + body: JSON.stringify({ settings }), + }); +} + +export interface AgentUpdateSettings { + mode: "gpo" | "sac"; + fallback_enabled: boolean; + fallback_after_minutes: number; + recommended_rdp_version: string | null; + recommended_ssh_version: string | null; + min_rdp_version: string | null; + min_ssh_version: string | null; + win_agent_update_script: string | null; + rdp_git_repo_url: string | null; + ssh_git_repo_url: string | null; + git_branch: string; + git_latest_rdp_version: string | null; + git_latest_ssh_version: string | null; + git_fetched_at: string | null; + git_errors: Record<string, string>; + source: string; +} + +export interface AgentGitTestResult { + git_latest_rdp_version: string | null; + git_latest_ssh_version: string | null; + git_fetched_at: string | null; + git_errors: Record<string, string>; + from_cache: boolean; +} + +export function fetchAgentUpdateSettings(): Promise<AgentUpdateSettings> { + return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates"); +} + +export function saveAgentUpdateSettings( + payload: Partial<AgentUpdateSettings>, +): Promise<AgentUpdateSettings> { + return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export interface AgentGitTestPayload { + rdp_git_repo_url?: string | null; + ssh_git_repo_url?: string | null; + git_branch?: string | null; +} + +export function testAgentGitRelease(payload?: AgentGitTestPayload): Promise<AgentGitTestResult> { + return apiFetch<AgentGitTestResult>("/api/v1/settings/agent-updates/test-git", { + method: "POST", + body: JSON.stringify(payload ?? {}), + }); +} + +export interface LoginSecuritySettings { + ip_whitelist: string[]; + ip_whitelist_text: string; + sync_fail2ban: boolean; + max_failures: number; + window_minutes: number; + fail2ban_available: boolean; + source: string; +} + +export interface LoginBlockItem { + ip_address: string; + scope: string; + failure_count: number | null; + usernames: string[]; + blocked_until: string | null; + reason: string; +} + +export function fetchLoginSecuritySettings(): Promise<LoginSecuritySettings> { + return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security"); +} + +export function saveLoginSecuritySettings(payload: { + ip_whitelist_text: string; + sync_fail2ban: boolean; +}): Promise<LoginSecuritySettings> { + return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export function fetchLoginSecurityBlocks(): Promise<LoginBlockItem[]> { + return apiFetch<LoginBlockItem[]>("/api/v1/settings/login-security/blocks"); +} + +export function unblockLoginSecurityIp(payload: { + ip_address: string; + scope: "web" | "ssh" | "both"; +}): Promise<{ messages: string[] }> { + return apiFetch<{ messages: string[] }>("/api/v1/settings/login-security/unblock", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export interface DashboardSummary { + events_last_24h: number; + hosts_total: number; + hosts_stale: number; + heartbeats_24h: number; + daily_reports_24h: number; + problems_open: number; + problems_opened_24h: number; + problems_resolved_24h: number; + severity_24h: Record<string, number>; + top_hosts: TopHostItem[]; + top_event_types: TopTypeItem[]; + recent_events: EventSummary[]; +} diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue new file mode 100644 index 0000000..013ac3c --- /dev/null +++ b/frontend/src/components/AppSidebar.vue @@ -0,0 +1,237 @@ +<template> + <aside class="sac-sidebar"> + <div class="sac-sidebar-brand"> + <img src="/sac-icon.png" alt="" class="sac-brand-logo" width="40" height="40" /> + <div class="sac-brand-text"> + <span class="sac-brand-name">{{ appName }}</span> + <span class="sac-brand-version">v.{{ appVersion }}</span> + </div> + </div> + <nav class="sac-sidebar-nav" aria-label="Основная навигация"> + <button + v-if="remoteJobRunning && !remoteJobPanelOpen" + type="button" + class="sac-nav-item sac-remote-job-badge" + title="Обновления агентов выполняются на сервере SAC" + @click="showHostRemoteActionLog()" + > + <span class="sac-nav-icon sac-remote-job-spinner" aria-hidden="true" /> + <span class="sac-nav-label">Обновления…</span> + </button> + <RouterLink + v-for="item in navItems" + :key="item.to" + :to="item.to" + class="sac-nav-item" + :class="{ 'is-active': isActive(item) }" + > + <span class="sac-nav-icon" aria-hidden="true">{{ item.icon }}</span> + <span class="sac-nav-label">{{ item.label }}</span> + </RouterLink> + </nav> + <div class="sac-sidebar-footer"> + <SidebarSystemStats /> + <button type="button" class="sac-nav-item sac-nav-logout" @click="logout"> + <span class="sac-nav-icon" aria-hidden="true">⏻</span> + <span class="sac-nav-label">Выход</span> + </button> + </div> + </aside> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { clearToken, isAdmin, logoutApi } from "../api"; +import SidebarSystemStats from "./SidebarSystemStats.vue"; +import { useSacVersion } from "../composables/useSacVersion"; +import { + anyRemoteActionLogOpen, + hasActiveHostRemoteAction, + showHostRemoteActionLog, +} from "../composables/useHostRemoteAction"; + +const { appName, appVersion } = useSacVersion(); +const route = useRoute(); +const router = useRouter(); +const remoteJobRunning = computed(() => hasActiveHostRemoteAction()); +const remoteJobPanelOpen = computed(() => anyRemoteActionLogOpen()); + +const navItems = computed(() => { + const items = [ + { to: "/dashboard", label: "Обзор", icon: "▦", match: "exact" as const }, + { to: "/events", label: "События", icon: "☰", match: "prefix" as const }, + { to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const }, + { to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const }, + { to: "/hosts", label: "Хосты", icon: "⬢", match: "prefix" as const }, + ]; + if (isAdmin()) { + items.push( + { to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const }, + { to: "/users", label: "Пользователи", icon: "👤", match: "exact" as const }, + ); + } + return items; +}); + +function isActive(item: (typeof navItems.value)[number]) { + if (item.match === "exact") { + return route.path === item.to; + } + return route.path === item.to || route.path.startsWith(`${item.to}/`); +} + +function logout() { + void logoutApi().finally(() => { + clearToken(); + router.push("/login"); + }); +} +</script> + +<style scoped> +.sac-sidebar { + position: sticky; + top: 0; + align-self: flex-start; + flex-shrink: 0; + display: flex; + flex-direction: column; + width: max-content; + min-width: 12.5rem; + height: 100vh; + max-height: 100vh; + background: #15202b; + border-right: 1px solid #2a3441; + padding: 0.75rem 0.5rem; +} + +.sac-sidebar-brand { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.5rem 0.75rem 0.85rem; + border-bottom: 1px solid #2a3441; + margin-bottom: 0.5rem; +} + +.sac-brand-logo { + flex-shrink: 0; + border-radius: 8px; + display: block; +} + +.sac-brand-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +.sac-brand-name { + font-weight: 700; + color: #fff; + font-size: 0.78rem; + line-height: 1.25; + white-space: nowrap; +} + +.sac-brand-version { + color: #9aa4b2; + font-size: 0.72rem; + font-weight: 500; + white-space: nowrap; +} + +.sac-sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.sac-sidebar-footer { + flex-shrink: 0; + padding-top: 0.75rem; + margin-top: auto; + border-top: 1px solid #2a3441; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.sac-nav-item { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 4px; + background: transparent; + color: #c5d0dc; + text-decoration: none; + font: inherit; + text-align: left; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} + +.sac-remote-job-badge { + color: #58a6ff; + border-left-color: #58a6ff; + background: rgba(88, 166, 255, 0.08); +} + +.sac-remote-job-spinner { + display: inline-block; + width: 0.85rem; + height: 0.85rem; + border: 2px solid #3a4556; + border-top-color: #58a6ff; + border-radius: 50%; + animation: sac-remote-job-spin 0.8s linear infinite; +} + +@keyframes sac-remote-job-spin { + to { + transform: rotate(360deg); + } +} + +.sac-nav-item:hover { + background: #1e2d3d; + color: #fff; +} + +.sac-nav-item.is-active { + background: #1a3a52; + color: #fff; + border-left-color: #3db8ff; +} + +.sac-nav-icon { + width: 1.1rem; + flex-shrink: 0; + text-align: center; + font-size: 0.95rem; + opacity: 0.9; +} + +.sac-nav-label { + white-space: nowrap; +} + +.sac-nav-logout { + color: #9aa4b2; +} + +.sac-nav-logout:hover { + color: #ff8a8a; + background: #2a1f24; + border-left-color: transparent; +} +</style> diff --git a/frontend/src/components/HostActionLogModal.vue b/frontend/src/components/HostActionLogModal.vue new file mode 100644 index 0000000..4d2cd8b --- /dev/null +++ b/frontend/src/components/HostActionLogModal.vue @@ -0,0 +1,415 @@ +<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-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> + <button + type="button" + class="host-action-log-icon-btn" + :title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'" + :aria-label="isLoading ? 'Свернуть' : 'Закрыть'" + @click="emit('close')" + > + × + </button> + </div> + <p v-if="isLoading" 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> + <div class="host-action-log-actions"> + <button type="button" class="secondary" @click="emit('toggle-log')"> + {{ logVisible ? "Скрыть лог" : "Показать лог" }} + </button> + <button type="button" class="secondary" @click="emit('close')"> + {{ isLoading ? "Свернуть" : "Закрыть" }} + </button> + </div> + </div> + </div> +</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; + +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(); +}); +</script> + +<style scoped> +.host-action-log-dock { + width: 100%; + pointer-events: none; +} + +.host-action-log-panel { + pointer-events: auto; + background: var(--card-bg, #1e2430); + border: 1px solid var(--border, #3a4556); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + max-height: min(75vh, 36rem); + overflow: auto; + 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; + justify-content: space-between; + gap: 0.5rem; +} + +.host-action-log-header h3 { + 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 { + flex-shrink: 0; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #9aa4b2; + font-size: 1.25rem; + line-height: 1; + cursor: pointer; +} + +.host-action-log-icon-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: #e6edf3; +} + +.host-action-log-status { + display: flex; + align-items: flex-start; + gap: 0.65rem; + margin: 0.65rem 0 0; + color: #9aa4b2; + font-size: 0.9rem; +} + +.host-action-log-sub { + margin: 0.35rem 0 0; + font-size: 0.85rem; +} + +.host-action-log-message { + overflow-wrap: anywhere; + 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; + margin-top: 0.15rem; + border: 2px solid #3a4556; + border-top-color: #58a6ff; + border-radius: 50%; + animation: host-action-log-spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes host-action-log-spin { + to { + transform: rotate(360deg); + } +} + +.host-action-log-output { + margin-top: 0.65rem; + max-height: min(45vh, 22rem); + overflow: auto; + padding: 0.65rem; + background: #0d1117; + border-radius: 6px; + font-size: 0.8rem; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.host-action-log-actions { + margin-top: 0.75rem; + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} +</style> diff --git a/frontend/src/components/HostActionLogStack.vue b/frontend/src/components/HostActionLogStack.vue new file mode 100644 index 0000000..b58abba --- /dev/null +++ b/frontend/src/components/HostActionLogStack.vue @@ -0,0 +1,58 @@ +<template> + <div v-if="hasOpenLogs" class="host-action-log-stack" aria-live="polite"> + <template v-for="entry in hostRemoteActionLogs" :key="entry.id"> + <HostActionLogModal + v-if="entry.open" + :host-id="entry.hostId" + :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> + +<script setup lang="ts"> +import { computed } from "vue"; +import HostActionLogModal from "./HostActionLogModal.vue"; +import { + closeHostRemoteActionLog, + hostRemoteActionLogs, + syncHostRemoteActionJobFinished, + toggleHostRemoteActionLog, +} from "../composables/useHostRemoteAction"; + +const hasOpenLogs = computed(() => hostRemoteActionLogs.some((e) => e.open)); +</script> + +<style scoped> +.host-action-log-stack { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 1000; + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + gap: 0.65rem; + width: min(56rem, calc(100vw - 2rem)); + max-height: min(90vh, 48rem); + overflow-y: auto; + pointer-events: none; +} + +.host-action-log-stack :deep(.host-action-log-panel) { + pointer-events: auto; + width: 100%; + max-height: min(36rem, 45vh); +} +</style> diff --git a/frontend/src/components/HostAgentVersionUpgrade.vue b/frontend/src/components/HostAgentVersionUpgrade.vue new file mode 100644 index 0000000..d135235 --- /dev/null +++ b/frontend/src/components/HostAgentVersionUpgrade.vue @@ -0,0 +1,135 @@ +<template> + <span class="agent-version-cell"> + <span :class="{ 'agent-version-outdated': outdatedHighlight }" :title="currentTitle"> + {{ host.product_version || "—" }} + </span> + <button + v-if="showUpgrade" + type="button" + class="agent-version-upgrade" + :class="{ 'agent-version-upgrade--updating': updating }" + :disabled="updating" + :title="buttonTitle" + @click.stop="emit('upgrade')" + > + <span class="agent-version-upgrade__arrow" aria-hidden="true">↑</span> + <span class="agent-version-upgrade__target">{{ gitTargetVersion }}</span> + </button> + <span v-if="updating" class="agent-version-upgrade__busy muted">…</span> + </span> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; +import type { HostSummary } from "../api"; +import { + gitTargetVersionForHost, + isGitAgentUpgradeAvailable, + type AgentGitVersions, +} from "../utils/hostAgentUpgrade"; + +const props = defineProps<{ + host: HostSummary; + git: AgentGitVersions | null | undefined; + updating?: boolean; + outdatedHighlight?: boolean; +}>(); + +const emit = defineEmits<{ + upgrade: []; +}>(); + +const gitTargetVersion = computed(() => gitTargetVersionForHost(props.host, props.git)); + +const showUpgrade = computed(() => isGitAgentUpgradeAvailable(props.host, props.git)); + +const currentTitle = computed(() => { + if (!props.host.product_version) return ""; + if (showUpgrade.value && gitTargetVersion.value) { + return `Текущая версия ${props.host.product_version}. Доступна ${gitTargetVersion.value}`; + } + return props.host.product_version; +}); + +const upgradeTitle = computed(() => { + if (!gitTargetVersion.value) return ""; + return `Обновить агент до ${gitTargetVersion.value}`; +}); + +const buttonTitle = computed(() => { + if (props.updating) { + return "Обновление выполняется…"; + } + return upgradeTitle.value; +}); +</script> + +<style scoped> +.agent-version-cell { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.2rem 0.35rem; +} + +.agent-version-upgrade { + display: inline-flex; + align-items: center; + gap: 0.15rem; + margin: 0; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + font: inherit; + line-height: 1.2; +} + +.agent-version-upgrade:disabled { + cursor: not-allowed; +} + +.agent-version-upgrade__arrow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + background: rgba(63, 185, 80, 0.18); + color: #3fb950; + font-size: 0.72rem; + font-weight: 700; + line-height: 1; +} + +.agent-version-upgrade__target { + color: #3fb950; + font-size: 0.92em; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; +} + +.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__target { + color: #56d364; +} + +.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__arrow { + background: rgba(63, 185, 80, 0.28); +} + +.agent-version-upgrade--updating .agent-version-upgrade__arrow { + background: rgba(139, 148, 158, 0.2); + color: #8b949e; +} + +.agent-version-upgrade--updating .agent-version-upgrade__target { + color: #8b949e; + text-decoration: none; +} + +.agent-version-upgrade__busy { + font-size: 0.85em; +} +</style> diff --git a/frontend/src/components/HostManualAddModal.vue b/frontend/src/components/HostManualAddModal.vue new file mode 100644 index 0000000..aeff581 --- /dev/null +++ b/frontend/src/components/HostManualAddModal.vue @@ -0,0 +1,157 @@ +<template> + <div v-if="open" class="modal-backdrop" @click.self="emit('close')"> + <div class="modal-card host-manual-add-modal" role="dialog" aria-modal="true" aria-label="Добавить хост вручную"> + <h3>Добавить хост вручную</h3> + <p class="muted"> + SAC проверит подключение и установит агент (RDP-login-monitor или ssh-monitor) через WinRM / SSH. + </p> + + <fieldset class="platform-fieldset"> + <legend>Тип хоста</legend> + <label class="platform-option"> + <input v-model="platform" type="radio" value="windows" :disabled="submitting" /> + Windows — только по имени ПК (WinRM) + </label> + <label class="platform-option"> + <input v-model="platform" type="radio" value="linux" :disabled="submitting" /> + Linux — IP или hostname (SSH) + </label> + </fieldset> + + <label class="target-label"> + {{ platform === "windows" ? "Имя ПК (NetBIOS / DNS)" : "IP или hostname" }} + <input + v-model="target" + type="text" + :placeholder="platform === 'windows' ? 'WORKSTATION-01' : '10.10.36.9'" + :disabled="submitting" + @keyup.enter="submit" + /> + </label> + + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="submitting" class="muted">Проверка WinRM/SSH и запуск установки…</p> + + <div class="modal-actions"> + <button type="button" class="secondary" :disabled="submitting" @click="emit('close')">Отмена</button> + <button type="button" :disabled="submitting || !target.trim()" @click="submit"> + {{ submitting ? "Выполняется…" : "Добавить и установить" }} + </button> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref, watch } from "vue"; +import { addHostManually, ApiError } from "../api"; + +const props = defineProps<{ + open: boolean; +}>(); + +const emit = defineEmits<{ + close: []; + started: [payload: { hostId: number; title: string }]; +}>(); + +const platform = ref<"windows" | "linux">("windows"); +const target = ref(""); +const submitting = ref(false); +const error = ref(""); + +watch( + () => props.open, + (isOpen) => { + if (!isOpen) return; + platform.value = "windows"; + target.value = ""; + submitting.value = false; + error.value = ""; + }, +); + +async function submit() { + const trimmed = target.value.trim(); + if (!trimmed || submitting.value) return; + submitting.value = true; + error.value = ""; + try { + const result = await addHostManually({ + platform: platform.value, + target: trimmed, + }); + emit("started", { hostId: result.host_id, title: result.title }); + emit("close"); + } catch (e) { + error.value = + e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Не удалось добавить хост"; + } finally { + submitting.value = false; + } +} +</script> + +<style scoped> +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.55); +} + +.modal-card { + width: min(100%, 28rem); + padding: 1rem 1.1rem 1.1rem; + background: var(--card-bg, #1e2430); + border: 1px solid var(--border, #3a4556); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); +} + +.modal-card h3 { + margin: 0 0 0.5rem; +} + +.platform-fieldset { + margin: 0.75rem 0; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border, #3a4556); + border-radius: 6px; +} + +.platform-fieldset legend { + padding: 0 0.25rem; + font-size: 0.85rem; + color: #9aa4b2; +} + +.platform-option { + display: block; + margin: 0.35rem 0; + font-size: 0.92rem; +} + +.target-label { + display: block; + margin-top: 0.75rem; +} + +.target-label input { + display: block; + width: 100%; + margin-top: 0.35rem; + box-sizing: border-box; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} +</style> diff --git a/frontend/src/components/HostSessionsPanel.vue b/frontend/src/components/HostSessionsPanel.vue new file mode 100644 index 0000000..87235b8 --- /dev/null +++ b/frontend/src/components/HostSessionsPanel.vue @@ -0,0 +1,209 @@ +<template> + <section + ref="rootRef" + class="card host-sessions" + :class="{ 'host-sessions--expanded': loaded }" + :style="panelMinHeight ? { minHeight: panelMinHeight } : undefined" + > + <h2>Залогиненные пользователи</h2> + <p class="muted"> + Текущие сессии на хосте (Linux: loginctl, Windows: qwinsta). Требуются учётные данные admin в настройках SAC. + </p> + <p v-if="error" class="error">{{ error }}</p> + <template v-if="loaded"> + <p v-if="!sessions.length" class="muted">Активных сессий не найдено.</p> + <table v-else> + <thead> + <tr> + <th>Пользователь</th> + <th>Session</th> + <th>TTY / имя</th> + <th>Состояние</th> + <th>Действия</th> + </tr> + </thead> + <tbody> + <tr v-for="s in sessions" :key="s.session_id + s.user"> + <td>{{ s.user }}</td> + <td><code>{{ s.session_id }}</code></td> + <td>{{ s.tty || s.session_name || "—" }}</td> + <td>{{ s.state || "—" }}</td> + <td> + <button + type="button" + class="secondary" + :disabled="terminatingId === s.session_id" + @click="terminate(s.session_id)" + > + {{ terminatingId === s.session_id ? "…" : "Завершить" }} + </button> + </td> + </tr> + </tbody> + </table> + <p v-if="message" :class="messageOk ? 'success' : 'error'">{{ message }}</p> + </template> + <div class="host-sessions-footer"> + <button type="button" class="secondary" :disabled="loading" @click="load"> + {{ loading ? "Загрузка…" : loaded ? "Обновить" : "Показать пользователей" }} + </button> + </div> + </section> +</template> + +<script setup lang="ts"> +import { nextTick, onMounted, onUnmounted, ref, watch } from "vue"; +import { + fetchHostSessions, + terminateHostSession, + type HostSessionItem, +} from "../api"; + +const props = defineProps<{ + hostId: number; + heightSource?: HTMLElement | null; +}>(); + +const rootRef = ref<HTMLElement | null>(null); +const loading = ref(false); +const loaded = ref(false); +const sessions = ref<HostSessionItem[]>([]); +const error = ref(""); +const message = ref(""); +const messageOk = ref(true); +const terminatingId = ref<string | null>(null); +const panelMinHeight = ref<string | undefined>(undefined); + +let resizeObserver: ResizeObserver | null = null; +let observedSource: HTMLElement | null = null; + +function updateMatchedHeight() { + if (loaded.value) { + panelMinHeight.value = undefined; + return; + } + const source = props.heightSource; + if (!source) { + panelMinHeight.value = undefined; + return; + } + panelMinHeight.value = `${source.offsetHeight}px`; +} + +function bindHeightObserver(source: HTMLElement | null | undefined) { + if (observedSource && resizeObserver) { + resizeObserver.unobserve(observedSource); + observedSource = null; + } + if (!source || typeof ResizeObserver === "undefined") { + updateMatchedHeight(); + return; + } + if (!resizeObserver) { + resizeObserver = new ResizeObserver(() => updateMatchedHeight()); + } + resizeObserver.observe(source); + observedSource = source; + updateMatchedHeight(); +} + +function resetSessionsState() { + loaded.value = false; + sessions.value = []; + error.value = ""; + message.value = ""; + terminatingId.value = null; +} + +async function load() { + loading.value = true; + error.value = ""; + message.value = ""; + try { + const res = await fetchHostSessions(props.hostId); + if (!res.ok) { + error.value = res.message; + sessions.value = []; + } else { + sessions.value = res.sessions; + loaded.value = true; + } + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки сессий"; + } finally { + loading.value = false; + await nextTick(); + updateMatchedHeight(); + } +} + +async function terminate(sessionId: string) { + if (!window.confirm(`Завершить сессию ${sessionId}?`)) return; + terminatingId.value = sessionId; + message.value = ""; + try { + const res = await terminateHostSession(props.hostId, sessionId); + messageOk.value = res.ok; + message.value = res.message; + if (res.ok) { + sessions.value = sessions.value.filter((s) => s.session_id !== sessionId); + } + } catch (e) { + messageOk.value = false; + message.value = e instanceof Error ? e.message : "Ошибка завершения сессии"; + } finally { + terminatingId.value = null; + } +} + +watch( + () => props.heightSource, + (source) => { + bindHeightObserver(source ?? null); + }, +); + +watch( + () => props.hostId, + () => { + resetSessionsState(); + nextTick(() => updateMatchedHeight()); + }, +); + +watch(loaded, () => { + nextTick(() => updateMatchedHeight()); +}); + +onMounted(() => { + bindHeightObserver(props.heightSource ?? null); +}); + +onUnmounted(() => { + resizeObserver?.disconnect(); + resizeObserver = null; + observedSource = null; +}); +</script> + +<style scoped> +.host-sessions { + margin-top: 0; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.host-sessions:not(.host-sessions--expanded) .host-sessions-footer { + margin-top: auto; +} + +.host-sessions table { + width: 100%; + margin: 0.75rem 0; +} + +.host-sessions-footer { + padding-top: 0.5rem; +} +</style> diff --git a/frontend/src/components/RdgAccessBadge.vue b/frontend/src/components/RdgAccessBadge.vue new file mode 100644 index 0000000..75a1885 --- /dev/null +++ b/frontend/src/components/RdgAccessBadge.vue @@ -0,0 +1,40 @@ +<template> + <span class="rdg-access-badge" :title="tooltip">{{ event.rdg_access_path }}</span> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; +import type { EventSummary } from "../api"; + +const props = defineProps<{ + event: EventSummary; +}>(); + +const tooltip = computed(() => { + const path = props.event.rdg_access_path; + if (path === "Haproxy-RDG-Comp") { + return "Вход на рабочий ПК через HAProxy → RD Gateway"; + } + if (path === "RDG-Comp") { + return "Прямой вход на рабочий ПК через RD Gateway"; + } + return "Путь входа RDS через RD Gateway"; +}); +</script> + +<style scoped> +.rdg-access-badge { + display: inline-block; + margin-right: 0.45rem; + padding: 0.1rem 0.45rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + color: #7ec8e3; + background: rgba(126, 200, 227, 0.1); + border: 1px solid rgba(126, 200, 227, 0.4); + border-radius: 4px; + vertical-align: middle; + white-space: nowrap; +} +</style> diff --git a/frontend/src/components/RdgFlapBadge.vue b/frontend/src/components/RdgFlapBadge.vue new file mode 100644 index 0000000..c97c3e0 --- /dev/null +++ b/frontend/src/components/RdgFlapBadge.vue @@ -0,0 +1,54 @@ +<template> + <span class="rdg-flap-badge" :title="tooltip"> + RDG flap + <template v-if="event.rdg_flap_pair_event_id"> + {{ pairArrow }} + <RouterLink :to="pairLink">{{ event.rdg_flap_pair_event_id }}</RouterLink> + </template> + </span> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; +import type { EventSummary } from "../api"; +import { rdgFlapPairLabel } from "../utils/rdgFlap"; + +const props = defineProps<{ + event: EventSummary; + detailLinkQuery?: Record<string, string>; +}>(); + +const pairArrow = computed(() => rdgFlapPairLabel(props.event)); +const tooltip = computed( + () => + "302→303 за 1–10 с. Возможна зависшая сессия на ПК пользователя — qwinsta/logoff.", +); +const pairLink = computed(() => ({ + path: `/events/${props.event.rdg_flap_pair_event_id}`, + query: props.detailLinkQuery, +})); +</script> + +<style scoped> +.rdg-flap-badge { + display: inline-block; + margin-right: 0.45rem; + padding: 0.1rem 0.45rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #f0c674; + background: rgba(240, 198, 116, 0.12); + border: 1px solid rgba(240, 198, 116, 0.45); + border-radius: 4px; + vertical-align: middle; + white-space: nowrap; +} + +.rdg-flap-badge a { + color: inherit; + text-decoration: underline; + margin-left: 0.15rem; +} +</style> diff --git a/frontend/src/components/RdgQwinstaModal.vue b/frontend/src/components/RdgQwinstaModal.vue new file mode 100644 index 0000000..46e5f22 --- /dev/null +++ b/frontend/src/components/RdgQwinstaModal.vue @@ -0,0 +1,199 @@ +<template> + <div v-if="modal.open" class="modal-backdrop" @click.self="emit('close')"> + <div class="modal-card qwinsta-modal" role="dialog" aria-modal="true"> + <h3>qwinsta / logoff — {{ modal.clientHostname || modal.target || `#${modal.eventId}` }}</h3> + <p v-if="modal.internalIp" class="muted qwinsta-meta">Клиентский ПК: {{ modal.internalIp }}</p> + <p v-if="modal.error" class="error">{{ modal.error }}</p> + <p v-else-if="modal.loading">Ожидание ответа…</p> + <template v-else> + <template v-if="modal.sessions.length"> + <p class="qwinsta-hint"> + Сеансы на ПК пользователя — нажмите <strong>logoff</strong>, чтобы оборвать зависшую сессию: + </p> + <table class="dash-table qwinsta-sessions-table"> + <thead> + <tr> + <th>SESSION</th> + <th>USER</th> + <th>ID</th> + <th>STATE</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="s in modal.sessions" :key="s.id"> + <td>{{ s.sessionName }}</td> + <td>{{ s.userName }}</td> + <td>{{ s.id }}</td> + <td>{{ s.state }}</td> + <td> + <button + type="button" + class="danger" + :disabled="modal.logoffId === s.id" + @click="emit('logoff', s.id)" + > + logoff + </button> + </td> + </tr> + </tbody> + </table> + </template> + <div v-else-if="modal.stdout" class="qwinsta-manual"> + <p class="muted"> + Сеансы не распознаны автоматически. Введите ID из вывода qwinsta ниже: + </p> + <div class="qwinsta-manual-row"> + <input + v-model="manualSessionId" + type="text" + inputmode="numeric" + class="qwinsta-manual-input" + placeholder="ID сеанса" + /> + <button + type="button" + class="danger" + :disabled="!manualSessionId.trim() || modal.logoffId !== null" + @click="submitManualLogoff" + > + logoff + </button> + </div> + </div> + <p v-else class="muted">Нет вывода</p> + + <details v-if="modal.stdout" class="qwinsta-raw-details"> + <summary>Вывод qwinsta</summary> + <pre class="qwinsta-raw">{{ modal.stdout }}</pre> + </details> + </template> + <div class="modal-actions"> + <button type="button" class="secondary" @click="emit('close')">Закрыть</button> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref } from "vue"; +import type { QwinstaSessionRow } from "../composables/useRdgQwinsta"; + +defineProps<{ + modal: { + open: boolean; + eventId: number; + clientHostname: string; + target: string; + internalIp: string; + loading: boolean; + error: string; + stdout: string; + sessions: QwinstaSessionRow[]; + logoffId: number | null; + }; +}>(); + +const emit = defineEmits<{ + close: []; + logoff: [sessionId: number]; +}>(); + +const manualSessionId = ref(""); + +function submitManualLogoff() { + const id = Number.parseInt(manualSessionId.value.trim(), 10); + if (!Number.isFinite(id)) return; + emit("logoff", id); +} +</script> + +<style scoped> +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-card { + background: var(--card-bg, #1e2430); + border: 1px solid var(--border, #3a4556); + border-radius: 8px; + max-width: 720px; + width: 100%; + max-height: 85vh; + overflow: auto; + padding: 1rem 1.25rem; +} + +.qwinsta-meta { + margin: 0 0 0.75rem; + font-size: 0.9rem; +} + +.qwinsta-hint { + margin: 0 0 0.75rem; + font-size: 0.95rem; + color: #e6edf3; +} + +.qwinsta-sessions-table { + margin-bottom: 1rem; +} + +.qwinsta-manual { + margin-bottom: 1rem; +} + +.qwinsta-manual-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.5rem; +} + +.qwinsta-manual-input { + flex: 1; + min-width: 0; + padding: 0.45rem 0.6rem; + border-radius: 6px; + border: 1px solid #3d4f63; + background: #0f1419; + color: #e6edf3; +} + +.qwinsta-raw-details { + margin-top: 0.5rem; +} + +.qwinsta-raw-details summary { + cursor: pointer; + color: #9aa4b2; + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.qwinsta-raw { + font-size: 0.8rem; + max-height: 12rem; + overflow: auto; + white-space: pre-wrap; + background: #0d1117; + padding: 0.75rem; + border-radius: 4px; + margin: 0; +} + +.modal-actions { + margin-top: 1rem; + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} +</style> diff --git a/frontend/src/components/ReportBodyCard.vue b/frontend/src/components/ReportBodyCard.vue new file mode 100644 index 0000000..db18c93 --- /dev/null +++ b/frontend/src/components/ReportBodyCard.vue @@ -0,0 +1,58 @@ +<template> + <div class="report-card"> + <div v-if="statEntries.length" class="report-stats"> + <div v-for="item in statEntries" :key="item.key" class="report-stat"> + <div class="report-stat-value">{{ item.value }}</div> + <div class="report-stat-label">{{ item.label }}</div> + </div> + </div> + + <ul v-if="activeUserLines.length && !plainBody" class="report-active-users"> + <li v-for="(line, idx) in activeUserLines" :key="idx">{{ line }}</li> + </ul> + + <div v-if="htmlContent" class="report-body-html" v-html="htmlContent" /> + + <pre v-else-if="plainBody" class="report-body-plain">{{ plainBody }}</pre> + + <p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p> + + <p v-else class="report-empty"> + Полный текст отчёта не сохранён (старая версия агента). Обновите агент и дождитесь следующего + ежедневного отчёта. + </p> + </div> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; +import { + reportActiveUserLines, + normalizeReportPlainText, + reportBodyFromDetails, + reportHtmlFromDetails, + reportStatEntries, + reportStatsFromDetails, + sanitizeAgentHtml, +} from "../utils/reportDisplay"; + +const props = defineProps<{ + type: string; + summary: string; + details: Record<string, unknown> | null; +}>(); + +const stats = computed(() => reportStatsFromDetails(props.details, props.type)); +const statEntries = computed(() => reportStatEntries(stats.value, props.type)); +const activeUserLines = computed(() => reportActiveUserLines(stats.value, props.type)); + +const htmlContent = computed(() => { + const raw = reportHtmlFromDetails(props.details); + return raw ? sanitizeAgentHtml(raw) : null; +}); + +const plainBody = computed(() => { + const raw = reportBodyFromDetails(props.details); + return raw ? normalizeReportPlainText(raw) : null; +}); +</script> diff --git a/frontend/src/components/SidebarSystemStats.vue b/frontend/src/components/SidebarSystemStats.vue new file mode 100644 index 0000000..dcafaaf --- /dev/null +++ b/frontend/src/components/SidebarSystemStats.vue @@ -0,0 +1,191 @@ +<template> + <div v-if="visible" class="sidebar-stats" aria-label="Состояние сервера SAC"> + <p class="sidebar-stats-title">Сервер</p> + <div v-if="loading && !stats" class="sidebar-stats-muted">…</div> + <template v-else-if="stats"> + <div v-if="stats.cpu_percent != null" class="sidebar-stat-row"> + <span class="sidebar-stat-label">CPU</span> + <div class="sidebar-stat-bar-wrap"> + <div + class="sidebar-stat-bar" + :class="barClass(stats.cpu_percent)" + :style="{ width: `${Math.min(100, stats.cpu_percent)}%` }" + /> + </div> + <span class="sidebar-stat-value">{{ stats.cpu_percent }}%</span> + </div> + <div v-if="stats.memory" class="sidebar-stat-row"> + <span class="sidebar-stat-label">RAM</span> + <div class="sidebar-stat-bar-wrap"> + <div + class="sidebar-stat-bar" + :class="barClass(stats.memory.percent)" + :style="{ width: `${Math.min(100, stats.memory.percent)}%` }" + /> + </div> + <span class="sidebar-stat-value">{{ stats.memory.percent }}%</span> + </div> + <div v-if="stats.disk" class="sidebar-stat-row"> + <span class="sidebar-stat-label">Диск</span> + <div class="sidebar-stat-bar-wrap"> + <div + class="sidebar-stat-bar" + :class="barClass(stats.disk.percent)" + :style="{ width: `${Math.min(100, stats.disk.percent)}%` }" + /> + </div> + <span class="sidebar-stat-value">{{ stats.disk.percent }}%</span> + </div> + <ul class="sidebar-stats-meta"> + <li v-if="stats.database.latency_ms != null"> + БД: {{ stats.database.latency_ms }} ms + <span v-if="stats.database.active_connections != null"> + · {{ stats.database.active_connections }} conn + </span> + </li> + <li>События/ч: {{ stats.app.events_last_hour }}</li> + <li>Problems open: {{ stats.app.problems_open }}</li> + </ul> + </template> + <p v-else-if="error" class="sidebar-stats-error" :title="error">нет данных</p> + </div> +</template> + +<script setup lang="ts"> +import { onMounted, onUnmounted, ref } from "vue"; +import { apiFetch } from "../api"; + +export interface SystemStats { + visible: boolean; + collected_at: string; + cpu_percent: number | null; + memory: { used_mb: number; total_mb: number; percent: number } | null; + disk: { path: string; used_gb: number; total_gb: number; percent: number } | null; + database: { status: string; latency_ms: number | null; active_connections: number | null }; + app: { events_last_hour: number; problems_open: number }; +} + +const POLL_MS = 30_000; + +const visible = ref(false); +const stats = ref<SystemStats | null>(null); +const loading = ref(false); +const error = ref(""); + +let timer: ReturnType<typeof setInterval> | null = null; + +function barClass(percent: number): string { + if (percent >= 90) return "is-critical"; + if (percent >= 70) return "is-warn"; + return "is-ok"; +} + +async function refresh() { + loading.value = true; + try { + const data = await apiFetch<SystemStats>("/api/v1/system/stats"); + visible.value = data.visible; + if (data.visible) { + stats.value = data; + } else { + stats.value = null; + } + error.value = ""; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка"; + visible.value = false; + } finally { + loading.value = false; + } +} + +onMounted(() => { + void refresh(); + timer = setInterval(() => void refresh(), POLL_MS); +}); + +onUnmounted(() => { + if (timer) clearInterval(timer); +}); + +defineExpose({ refresh }); +</script> + +<style scoped> +.sidebar-stats { + flex-shrink: 0; + margin-bottom: 0.65rem; + padding: 0.55rem 0.65rem; + background: #0f1419; + border: 1px solid #2a3441; + border-radius: 6px; + font-size: 0.72rem; + color: #9aa4b2; +} + +.sidebar-stats-title { + margin: 0 0 0.45rem; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #7eb8ff; +} + +.sidebar-stat-row { + display: grid; + grid-template-columns: 2rem 1fr 2.1rem; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.35rem; +} + +.sidebar-stat-label { + color: #c5d0dc; +} + +.sidebar-stat-bar-wrap { + height: 0.35rem; + background: #1a2332; + border-radius: 2px; + overflow: hidden; +} + +.sidebar-stat-bar { + height: 100%; + border-radius: 2px; + transition: width 0.25s ease; +} + +.sidebar-stat-bar.is-ok { + background: #6bcb77; +} + +.sidebar-stat-bar.is-warn { + background: #ffc857; +} + +.sidebar-stat-bar.is-critical { + background: #ff6b6b; +} + +.sidebar-stat-value { + text-align: right; + color: #e8eaed; + font-variant-numeric: tabular-nums; +} + +.sidebar-stats-meta { + list-style: none; + margin: 0.45rem 0 0; + padding: 0; + line-height: 1.45; + font-size: 0.68rem; +} + +.sidebar-stats-muted, +.sidebar-stats-error { + margin: 0; + font-size: 0.72rem; +} +</style> diff --git a/frontend/src/composables/useHostRemoteAction.ts b/frontend/src/composables/useHostRemoteAction.ts new file mode 100644 index 0000000..f4fc1e4 --- /dev/null +++ b/frontend/src/composables/useHostRemoteAction.ts @@ -0,0 +1,329 @@ +import { reactive } from "vue"; +import { + ApiError, + fetchHost, + fetchHostRemoteJob, + runHostAgentUpdateFallback, + updateHostAgentViaSsh, + type HostDetail, + type HostRemoteActionJobStatus, +} from "../api"; +import { emitHostPatch } from "../utils/hostPatchBus"; + +export type HostRemoteActionKind = "fallback" | "ssh-update"; + +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[]>([]); + +const POLL_MS = 1500; +const SUCCESS_AUTO_CLOSE_MS = 30_000; + +let nextLogSeq = 1; +const autoCloseTimers = new Map<string, ReturnType<typeof setTimeout>>(); +const hostRunningPromises = new Map<number, Promise<HostRemoteActionJobStatus | null>>(); + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function newLogId(): string { + return `ra-${Date.now()}-${nextLogSeq++}`; +} + +function findLogById(entryId: string): HostRemoteActionLogEntry | undefined { + return hostRemoteActionLogs.find((e) => e.id === entryId); +} + +function findLoadingLogForHost(hostId: number): HostRemoteActionLogEntry | undefined { + return hostRemoteActionLogs.find((e) => e.hostId === hostId && e.loading); +} + +function clearAutoCloseTimer(entryId: string) { + const timer = autoCloseTimers.get(entryId); + if (timer != null) { + clearTimeout(timer); + autoCloseTimers.delete(entryId); + } +} + +function removeClosedFinishedLog(entryId: string) { + const idx = hostRemoteActionLogs.findIndex((e) => e.id === entryId); + if (idx < 0) return; + const entry = hostRemoteActionLogs[idx]; + if (!entry.loading && !entry.open) { + hostRemoteActionLogs.splice(idx, 1); + } +} + +function scheduleSuccessAutoClose(entryId: string) { + clearAutoCloseTimer(entryId); + autoCloseTimers.set( + entryId, + setTimeout(() => { + const entry = findLogById(entryId); + if (entry) { + entry.open = false; + removeClosedFinishedLog(entryId); + } + autoCloseTimers.delete(entryId); + }, SUCCESS_AUTO_CLOSE_MS), + ); +} + +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.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); + entry.open = true; + if (entry.ok) { + scheduleSuccessAutoClose(entry.id); + } +} + +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); + applyJobToEntry(entry, job); + if (!job.active && job.status !== "running") { + return job; + } + await sleep(POLL_MS); + } +} + +async function patchHostFromJob(hostId: number, job: HostRemoteActionJobStatus) { + try { + const detail: HostDetail = await fetchHost(hostId); + if (job.product_version) { + detail.product_version = job.product_version; + } + if (job.agent_update_state) { + detail.agent_update_state = job.agent_update_state; + } + if (job.ok === false && job.message) { + detail.agent_update_last_error = job.message; + } + emitHostPatch(detail); + } catch { + /* list refresh on next open is enough */ + } +} + +async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) { + if (kind === "fallback") { + await runHostAgentUpdateFallback(hostId); + return; + } + await updateHostAgentViaSsh(hostId); +} + +async function executeRemoteAction( + hostId: number, + title: string, + kind: HostRemoteActionKind, + entry: HostRemoteActionLogEntry, +): Promise<HostRemoteActionJobStatus | null> { + try { + try { + await startRemoteAction(hostId, kind); + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + /* already running — poll existing job */ + } else { + throw e; + } + } + entry.message = "Выполняется на удалённом хосте… Можно запустить обновление других хостов."; + const job = await pollRemoteJob(hostId, entry); + finishEntry(entry, job); + await patchHostFromJob(hostId, job); + return job; + } catch (e) { + entry.loading = false; + entry.ok = false; + entry.message = e instanceof Error ? e.message : "Ошибка выполнения"; + entry.open = true; + return null; + } +} + +export async function pollHostRemoteAction( + hostId: number, + title: string, + logPlaceholder?: string, +): Promise<HostRemoteActionJobStatus | null> { + const existingPromise = hostRunningPromises.get(hostId); + if (existingPromise) { + const existing = findLoadingLogForHost(hostId); + if (existing) { + existing.open = true; + } + return existingPromise; + } + + const entry: HostRemoteActionLogEntry = { + id: newLogId(), + hostId, + open: true, + logVisible: false, + loading: true, + ok: null, + title, + message: "Выполняется на удалённом хосте…", + output: "", + logPlaceholder: logPlaceholder || "Ожидание лога с хоста…", + sessionStartedAt: new Date().toISOString(), + }; + hostRemoteActionLogs.push(entry); + + const promise = (async () => { + try { + const job = await pollRemoteJob(hostId, entry); + finishEntry(entry, job); + await patchHostFromJob(hostId, job); + return job; + } catch (e) { + entry.loading = false; + entry.ok = false; + entry.message = e instanceof Error ? e.message : "Ошибка выполнения"; + entry.open = true; + return null; + } + })(); + + hostRunningPromises.set(hostId, promise); + try { + return await promise; + } finally { + hostRunningPromises.delete(hostId); + } +} + +export async function runHostRemoteAction( + hostId: number, + title: string, + kind: HostRemoteActionKind, + logPlaceholder?: string, +): Promise<HostRemoteActionJobStatus | null> { + const existingPromise = hostRunningPromises.get(hostId); + if (existingPromise) { + const existing = findLoadingLogForHost(hostId); + if (existing) { + existing.open = true; + } + return existingPromise; + } + + const entry: HostRemoteActionLogEntry = { + id: newLogId(), + hostId, + open: true, + logVisible: false, + loading: true, + ok: null, + title, + message: "Запуск на сервере SAC…", + output: "", + logPlaceholder: logPlaceholder || "Ожидание лога с хоста…", + sessionStartedAt: new Date().toISOString(), + }; + hostRemoteActionLogs.push(entry); + + const promise = executeRemoteAction(hostId, title, kind, entry); + hostRunningPromises.set(hostId, promise); + try { + return await promise; + } finally { + hostRunningPromises.delete(hostId); + } +} + +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); + if (!entry) return; + entry.open = false; + if (!entry.loading) { + removeClosedFinishedLog(entryId); + } +} + +export function showHostRemoteActionLog(hostId?: number) { + if (hostId != null) { + for (const entry of hostRemoteActionLogs) { + if (entry.hostId === hostId && (entry.loading || entry.ok != null)) { + entry.open = true; + } + } + return; + } + for (const entry of hostRemoteActionLogs) { + if (entry.loading || entry.ok === false) { + entry.open = true; + } + } +} + +export function isHostRemoteActionActive(hostId: number): boolean { + return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.loading); +} + +export function hasActiveHostRemoteAction(): boolean { + return hostRemoteActionLogs.some((e) => e.loading); +} + +export function hasOpenRemoteActionLogForHost(hostId: number): boolean { + return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.open); +} + +export function anyRemoteActionLogOpen(): boolean { + return hostRemoteActionLogs.some((e) => e.open); +} diff --git a/frontend/src/composables/useRdgQwinsta.ts b/frontend/src/composables/useRdgQwinsta.ts new file mode 100644 index 0000000..129e8ec --- /dev/null +++ b/frontend/src/composables/useRdgQwinsta.ts @@ -0,0 +1,188 @@ +import { onUnmounted, ref } from "vue"; +import { + fetchEventAction, + postEventLogoff, + postEventQwinsta, + type AgentCommandResponse, + type EventSummary, +} from "../api"; +import { rdgQwinstaEventId } from "../utils/rdgFlap"; + +export type QwinstaSessionRow = { + sessionName: string; + userName: string; + id: number; + state: string; +}; + +function parseQwinstaSessions(stdout: string, actorUser: string): QwinstaSessionRow[] { + const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const rows: QwinstaSessionRow[] = []; + for (const line of lines) { + if (/^SESSION/i.test(line) || /^---/.test(line)) continue; + const parts = line.split(/\s+/); + if (parts.length < 4) continue; + const sessionName = parts[0].replace(/^>/, ""); + const userName = parts[1]; + const id = Number.parseInt(parts[2], 10); + const state = parts.slice(3).join(" "); + if (!Number.isFinite(id)) continue; + const norm = (s: string) => s.replace(/^B26\\/i, "").toLowerCase(); + const matchUser = actorUser && norm(userName).includes(norm(actorUser)); + if (actorUser && !matchUser) continue; + rows.push({ sessionName, userName, id, state }); + } + if (rows.length === 0 && stdout.trim()) { + for (const line of lines) { + if (/^SESSION/i.test(line) || /^---/.test(line)) continue; + const parts = line.split(/\s+/); + if (parts.length < 4) continue; + const id = Number.parseInt(parts[2], 10); + if (!Number.isFinite(id)) continue; + rows.push({ + sessionName: parts[0].replace(/^>/, ""), + userName: parts[1], + id, + state: parts.slice(3).join(" "), + }); + } + } + return rows; +} + +export function useRdgQwinsta() { + let qwinstaPollTimer: ReturnType<typeof setInterval> | null = null; + const qwinstaLoadingId = ref<number | null>(null); + const qwinstaModal = ref({ + open: false, + eventId: 0, + actorUser: "", + commandUuid: "", + target: "", + clientHostname: "", + internalIp: "", + loading: false, + error: "", + stdout: "", + sessions: [] as QwinstaSessionRow[], + logoffId: null as number | null, + }); + + function stopQwinstaPoll() { + if (qwinstaPollTimer) { + clearInterval(qwinstaPollTimer); + qwinstaPollTimer = null; + } + } + + function closeQwinstaModal() { + stopQwinstaPoll(); + qwinstaModal.value.open = false; + } + + function applyCommandResult(eventId: number, actorUser: string, cmd: AgentCommandResponse) { + if (cmd.status === "pending") return false; + qwinstaModal.value.loading = false; + if (cmd.status === "failed") { + qwinstaModal.value.error = cmd.result_stderr || cmd.result_stdout || "Команда завершилась с ошибкой"; + return true; + } + qwinstaModal.value.stdout = cmd.result_stdout || ""; + qwinstaModal.value.sessions = parseQwinstaSessions(qwinstaModal.value.stdout, actorUser); + return true; + } + + function applyCommandMeta(modal: typeof qwinstaModal.value, cmd: AgentCommandResponse) { + modal.target = cmd.target || ""; + modal.clientHostname = cmd.client_hostname || ""; + modal.internalIp = cmd.internal_ip || ""; + } + + function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: string) { + stopQwinstaPoll(); + qwinstaPollTimer = setInterval(async () => { + try { + const cmd = await fetchEventAction(eventId, commandUuid); + if (applyCommandResult(eventId, actorUser, cmd)) { + stopQwinstaPoll(); + } + } catch (e) { + qwinstaModal.value.loading = false; + qwinstaModal.value.error = e instanceof Error ? e.message : "Ошибка опроса команды"; + stopQwinstaPoll(); + } + }, 2000); + } + + async function runQwinsta(event: EventSummary) { + const targetId = rdgQwinstaEventId(event) ?? event.id; + qwinstaLoadingId.value = event.id; + qwinstaModal.value = { + open: true, + eventId: targetId, + actorUser: event.actor_user || "", + commandUuid: "", + target: "", + clientHostname: "", + internalIp: "", + loading: true, + error: "", + stdout: "", + sessions: [], + logoffId: null, + }; + try { + const cmd = await postEventQwinsta(targetId); + applyCommandMeta(qwinstaModal.value, cmd); + qwinstaModal.value.commandUuid = cmd.command_uuid; + if (applyCommandResult(targetId, event.actor_user || "", cmd)) { + return; + } + pollQwinstaCommand(targetId, cmd.command_uuid, event.actor_user || ""); + } catch (e) { + qwinstaModal.value.loading = false; + qwinstaModal.value.error = e instanceof Error ? e.message : "Не удалось отправить qwinsta"; + } finally { + qwinstaLoadingId.value = null; + } + } + + async function runLogoff(sessionId: number) { + const modal = qwinstaModal.value; + if (!modal.eventId) return; + if (!window.confirm(`Завершить сеанс ID ${sessionId}?`)) return; + modal.logoffId = sessionId; + modal.error = ""; + try { + const cmd = await postEventLogoff(modal.eventId, sessionId); + applyCommandMeta(modal, cmd); + if (cmd.status === "failed") { + modal.error = cmd.result_stderr || cmd.result_stdout || "logoff failed"; + return; + } + modal.loading = true; + modal.stdout = ""; + modal.sessions = []; + const again = await postEventQwinsta(modal.eventId); + applyCommandMeta(modal, again); + if (applyCommandResult(modal.eventId, modal.actorUser, again)) { + return; + } + pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser); + } catch (e) { + modal.error = e instanceof Error ? e.message : "logoff error"; + } finally { + modal.logoffId = null; + } + } + + onUnmounted(() => stopQwinstaPoll()); + + return { + qwinstaLoadingId, + qwinstaModal, + closeQwinstaModal, + runQwinsta, + runLogoff, + }; +} diff --git a/frontend/src/composables/useSacLiveEventStream.ts b/frontend/src/composables/useSacLiveEventStream.ts new file mode 100644 index 0000000..e052ebe --- /dev/null +++ b/frontend/src/composables/useSacLiveEventStream.ts @@ -0,0 +1,73 @@ +import { onMounted, onUnmounted, ref } from "vue"; +import { getToken } from "../api"; + +export interface SacDashboardStreamMessage { + type?: string; + last_event_id?: number | null; +} + +/** SSE /api/v1/stream/events — вызывает onNewEvent при новом last_event_id (после первого тика). */ +export function useSacLiveEventStream(onNewEvent: () => void) { + const live = ref(false); + let eventSource: EventSource | null = null; + let lastKnownEventDbId: number | null = null; + + function handleStreamMessage(msg: SacDashboardStreamMessage) { + if (msg.type !== "dashboard") return; + const incoming = msg.last_event_id; + if (incoming === undefined) return; + + if (incoming === null) { + if (lastKnownEventDbId !== null) { + onNewEvent(); + } + lastKnownEventDbId = null; + return; + } + + if (lastKnownEventDbId === null) { + lastKnownEventDbId = incoming; + return; + } + + if (incoming !== lastKnownEventDbId) { + lastKnownEventDbId = incoming; + onNewEvent(); + } + } + + function connect() { + const token = getToken(); + if (!token) return; + + eventSource?.close(); + const url = `/api/v1/stream/events`; + eventSource = new EventSource(url); + + eventSource.onopen = () => { + live.value = true; + }; + + eventSource.onmessage = (ev) => { + try { + handleStreamMessage(JSON.parse(ev.data) as SacDashboardStreamMessage); + } catch { + /* ignore malformed SSE */ + } + }; + + eventSource.onerror = () => { + live.value = false; + }; + } + + onMounted(() => connect()); + + onUnmounted(() => { + eventSource?.close(); + eventSource = null; + live.value = false; + }); + + return { live }; +} diff --git a/frontend/src/composables/useSacVersion.ts b/frontend/src/composables/useSacVersion.ts new file mode 100644 index 0000000..e9280d4 --- /dev/null +++ b/frontend/src/composables/useSacVersion.ts @@ -0,0 +1,57 @@ +import { onMounted, ref } from "vue"; +import { APP_NAME, APP_VERSION } from "../version"; + +export interface SacVersionInfo { + name: string; + version: string; +} + +let cached: SacVersionInfo | null = null; +let inflight: Promise<SacVersionInfo> | null = null; + +async function fetchSacVersion(): Promise<SacVersionInfo> { + if (cached) { + return cached; + } + if (inflight) { + return inflight; + } + + inflight = (async () => { + const fallback: SacVersionInfo = { name: APP_NAME, version: APP_VERSION }; + try { + const response = await fetch("/health"); + if (!response.ok) { + return fallback; + } + const data = (await response.json()) as { name?: string; version?: string }; + const info: SacVersionInfo = { + name: (data.name || APP_NAME).trim() || APP_NAME, + version: (data.version || APP_VERSION).trim() || APP_VERSION, + }; + cached = info; + return info; + } catch { + return fallback; + } finally { + inflight = null; + } + })(); + + return inflight; +} + +/** Версия SAC с бэкенда (/health); fallback — frontend/src/version.ts */ +export function useSacVersion() { + const appName = ref(APP_NAME); + const appVersion = ref(APP_VERSION); + + onMounted(() => { + void fetchSacVersion().then((info) => { + appName.value = info.name; + appVersion.value = info.version; + }); + }); + + return { appName, appVersion }; +} diff --git a/frontend/src/errorPages.ts b/frontend/src/errorPages.ts new file mode 100644 index 0000000..44e108a --- /dev/null +++ b/frontend/src/errorPages.ts @@ -0,0 +1,53 @@ +export interface ErrorPageAction { + label: string; + to: string; +} + +export interface ErrorPageConfig { + code: number; + title: string; + lead: string; + hint: string; + primaryAction: ErrorPageAction; + secondaryAction?: ErrorPageAction; +} + +export const ERROR_PAGES: Record<401 | 403 | 404 | 429, ErrorPageConfig> = { + 401: { + code: 401, + title: "Требуется авторизация", + lead: "Сессия истекла, токен недействителен или вы ещё не вошли в SAC.", + hint: "Можно: войти снова под своим логином и паролем. Нельзя: просматривать события, хосты и настройки без входа.", + primaryAction: { label: "Войти", to: "/login" }, + }, + 403: { + code: 403, + title: "Доступ запрещён", + lead: "У вашей учётной записи нет прав на этот раздел или действие.", + hint: "Можно: работать с событиями, проблемами, хостами и отчётами (роль monitor). Нельзя: настройки уведомлений, пользователи и удаление хостов — только для admin.", + primaryAction: { label: "На обзор", to: "/dashboard" }, + secondaryAction: { label: "К событиям", to: "/events" }, + }, + 404: { + code: 404, + title: "Страница не найдена", + lead: "Такого адреса в интерфейсе SAC нет — ссылка устарела или опечатка в URL.", + hint: "Можно: перейти на обзор или воспользоваться меню слева. Нельзя: открыть несуществующий маршрут — сервер отдаёт только известные страницы SPA.", + primaryAction: { label: "На обзор", to: "/dashboard" }, + secondaryAction: { label: "К событиям", to: "/events" }, + }, + 429: { + code: 429, + title: "Слишком много попыток входа", + lead: "С вашего IP зафиксировано несколько неудачных попыток входа подряд.", + hint: "Можно: подождать около 15 минут и войти с правильным паролем. Нельзя: продолжать подбор пароля — IP временно блокируется, администратор может получить алерт в Telegram.", + primaryAction: { label: "Попробовать позже", to: "/login" }, + }, +}; + +export function getErrorPageConfig(code: number): ErrorPageConfig { + if (code === 401 || code === 403 || code === 404 || code === 429) { + return ERROR_PAGES[code]; + } + return ERROR_PAGES[404]; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..01b3dd4 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import router from "./router"; +import "./style.css"; + +createApp(App).use(router).mount("#app"); diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..d8ac9bc --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,72 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { getToken, isAdmin, setRole, fetchMe } from "./api"; +import EventsView from "./views/EventsView.vue"; +import EventDetailView from "./views/EventDetailView.vue"; +import HostsView from "./views/HostsView.vue"; +import HostDetailView from "./views/HostDetailView.vue"; +import LoginView from "./views/LoginView.vue"; +import ErrorPageView from "./views/ErrorPageView.vue"; +import ProblemsView from "./views/ProblemsView.vue"; +import ProblemDetailView from "./views/ProblemDetailView.vue"; +import DashboardView from "./views/DashboardView.vue"; +import ReportsView from "./views/ReportsView.vue"; +import SettingsView from "./views/SettingsView.vue"; +import UsersView from "./views/UsersView.vue"; + +const PUBLIC_PATHS = new Set(["/login", "/401", "/403", "/404", "/429"]); + +const errorRoute = (code: 401 | 403 | 404 | 429) => ({ + path: `/${code}`, + component: ErrorPageView, + props: { code }, + meta: { standalone: true }, +}); + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", redirect: "/dashboard" }, + { path: "/dashboard", component: DashboardView }, + { path: "/login", component: LoginView }, + errorRoute(401), + errorRoute(403), + errorRoute(404), + errorRoute(429), + { path: "/events", component: EventsView }, + { path: "/events/:id", component: EventDetailView, props: true }, + { path: "/reports", component: ReportsView }, + { path: "/hosts", component: HostsView }, + { path: "/hosts/:id", component: HostDetailView, props: true }, + { path: "/problems", component: ProblemsView }, + { path: "/problems/:id", component: ProblemDetailView, props: true }, + { path: "/settings", component: SettingsView, meta: { adminOnly: true } }, + { path: "/users", component: UsersView, meta: { adminOnly: true } }, + { + path: "/:pathMatch(.*)*", + component: ErrorPageView, + props: { code: 404 }, + meta: { standalone: true }, + }, + ], +}); + +router.beforeEach(async (to) => { + if (PUBLIC_PATHS.has(to.path)) return true; + if (!getToken()) return { path: "/login", query: { redirect: to.fullPath } }; + if (to.meta.adminOnly && !isAdmin()) { + return { path: "/403" }; + } + return true; +}); + +export default router; + +export async function refreshSessionRole(): Promise<void> { + if (!getToken()) return; + try { + const me = await fetchMe(); + setRole(me.role); + } catch { + /* 401 handled in apiFetch */ + } +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..49bb974 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,465 @@ +:root { + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + line-height: 1.5; + color: #e8eaed; + background: #0f1419; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +a { + color: #7eb8ff; +} + +/* Клик по строке хоста — как пункты левого меню (полоска слева на всю строку). */ +.sac-host-row { + cursor: pointer; + transition: background 0.12s ease, box-shadow 0.12s ease; +} + +.sac-host-row:hover, +.sac-host-row:focus-visible { + background: #1e2d3d; + box-shadow: inset 3px 0 0 #3db8ff; + outline: none; +} + +.sac-host-row td.actions { + cursor: default; + vertical-align: middle; +} + +.sac-host-row:hover td.actions, +.sac-host-row:focus-visible td.actions { + background: transparent; +} + +.layout { + width: 100%; + max-width: none; + margin: 0; + padding: 1rem 1rem 2rem; +} + +.app-shell { + display: flex; + width: 100%; + min-height: 100vh; + margin: 0; + padding: 0; +} + +.app-main { + flex: 1; + min-width: 0; + padding: 1rem 1.25rem 2rem; +} + +.layout-login { + min-height: 100vh; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +th, +td { + text-align: left; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid #2a3441; +} + +th { + color: #9aa4b2; + font-weight: 600; +} + +.th-sort { + background: transparent; + border: 0; + color: inherit; + padding: 0; + cursor: pointer; + font-weight: 600; +} + +.sev-critical, +.sev-high { + color: #ff6b6b; +} +.sev-warning { + color: #ffc857; +} +.sev-info { + color: #7eb8ff; +} + +.status-open { + color: #ff6b6b; +} +.status-acknowledged { + color: #ffc857; +} +.status-resolved { + color: #6bcb77; +} + +td.actions { + vertical-align: middle; + white-space: nowrap; +} + +td.actions .actions-inner { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.actions-muted { + color: #9aa4b2; + font-size: 0.85rem; +} + +p.actions { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + align-items: center; +} + +.card { + background: #1a2332; + border: 1px solid #2a3441; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.timeline-table td:nth-child(5) { + max-width: 28rem; + word-break: break-word; +} + +input, +select, +button { + font: inherit; + padding: 0.4rem 0.6rem; + border-radius: 6px; + border: 1px solid #3d4f63; + background: #0f1419; + color: inherit; +} + +button { + cursor: pointer; + background: #2563eb; + border-color: #2563eb; +} + +button.secondary { + background: transparent; + border-color: #3d4f63; +} + +button.danger { + background: transparent; + border-color: #b91c1c; + color: #f87171; +} + +button.danger:hover:not(:disabled) { + background: #3f1515; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.error { + color: #ff6b6b; +} + +.success { + color: #3fb950; +} + +pre { + overflow: auto; + font-size: 0.8rem; + background: #0f1419; + padding: 0.75rem; + border-radius: 6px; +} + +.dashboard-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.dashboard-panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.dash-panel { + background: #1a2332; + border: 1px solid #2a3441; + border-radius: 8px; + padding: 1rem; +} + +.dash-panel h2 { + margin-top: 0; + font-size: 1rem; +} + +.dash-table { + width: 100%; +} + +.dash-table td:last-child, +.dash-table th:last-child { + text-align: right; + width: 5rem; +} + +.muted { + color: #9aa4b2; + margin: 0; +} + +.dash-card { + background: #1a2332; + border: 1px solid #2a3441; + border-radius: 8px; + padding: 1rem 1.25rem; +} + +.dash-value { + font-size: 2rem; + font-weight: 700; + color: #fff; +} + +.dash-label { + color: #9aa4b2; + margin: 0.25rem 0 0.5rem; +} + +.dash-card.dash-warn { + border-color: #ff6b6b; +} + +.severity-list { + list-style: none; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.agent-online { + color: #6bcb77; +} +.agent-stale { + color: #ff6b6b; +} +.agent-unknown { + color: #9aa4b2; +} + +.agent-version-outdated { + display: inline-block; + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: #5c1a1a; + color: #ffd4d4; + font-weight: 600; +} + +.live-badge { + color: #6bcb77; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.report-intro { + color: #9aa4b2; + margin-top: -0.5rem; +} + +.reports-table .report-row { + cursor: pointer; +} + +.reports-table .report-row:hover, +.reports-table .report-row:focus { + background: #1a2332; + outline: none; +} + +.report-summary-cell { + max-width: 420px; +} + +.report-card { + background: #1a2332; + border: 1px solid #2a3441; + border-radius: 8px; + padding: 1.25rem; + margin-bottom: 1rem; +} + +.report-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.report-stat { + background: #0f1419; + border: 1px solid #2a3441; + border-radius: 8px; + padding: 0.75rem; + text-align: center; +} + +.report-stat-value { + font-size: 1.5rem; + font-weight: 700; + color: #fff; +} + +.report-stat-label { + font-size: 0.8rem; + color: #9aa4b2; + margin-top: 0.25rem; +} + +.report-active-users { + list-style: none; + padding: 0; + margin: 0 0 1rem; + font-size: 0.9rem; + line-height: 1.5; +} + +.report-active-users li { + padding: 0.2rem 0; + font-family: ui-monospace, monospace; +} + +.report-body-html { + line-height: 1.55; + font-size: 0.95rem; +} + +.report-body-html .agent-report { + white-space: normal; + word-break: break-word; +} + +.report-body-html .agent-report br { + display: block; + margin-bottom: 0.25em; + content: ""; +} + +.report-body-plain { + white-space: pre-wrap; + word-break: break-word; + margin: 0; + font-family: inherit; + font-size: 0.9rem; + background: #0f1419; + padding: 1rem; + border-radius: 6px; + border: 1px solid #2a3441; +} + +.report-empty, +.report-fallback { + color: #9aa4b2; +} + +.report-summary-line { + color: #9aa4b2; + margin-bottom: 0.75rem; +} + +.report-meta { + margin-bottom: 1rem; +} + +.detail-fields { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.85rem 1.25rem; + margin: 0; +} + +.detail-fields-compact { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.detail-field { + min-width: 0; +} + +.detail-field-wide { + grid-column: 1 / -1; +} + +.detail-field dt { + margin: 0 0 0.2rem; + font-size: 0.75rem; + font-weight: 600; + color: #9aa4b2; +} + +.detail-field dd { + margin: 0; + color: #e8edf2; + word-break: break-word; + line-height: 1.45; +} + +.detail-field dd code { + word-break: break-all; +} + +.raw-details { + margin-top: 1rem; +} + +.raw-details summary { + cursor: pointer; + color: #9aa4b2; + margin-bottom: 0.5rem; +} diff --git a/frontend/src/utils/agentVersion.ts b/frontend/src/utils/agentVersion.ts new file mode 100644 index 0000000..2e6eb7b --- /dev/null +++ b/frontend/src/utils/agentVersion.ts @@ -0,0 +1,66 @@ +const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-SAC)?$/i; +const DEFAULT_LAG_THRESHOLD = 3; + +export interface ParsedAgentVersion { + major: number; + minor: number; + patch: number; +} + +export function parseAgentVersion(raw: string | null | undefined): ParsedAgentVersion | null { + if (!raw) return null; + const match = raw.trim().match(VERSION_RE); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function compareVersions(a: ParsedAgentVersion, b: ParsedAgentVersion): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +function agentVersionLag(host: ParsedAgentVersion, latest: ParsedAgentVersion): number | null { + if (compareVersions(host, latest) > 0) return 0; + if (host.major !== latest.major || host.minor !== latest.minor) return null; + return latest.patch - host.patch; +} + +export function isAgentVersionOutdated( + hostVersion: string | null | undefined, + latestVersion: string | null | undefined, + lagThreshold = DEFAULT_LAG_THRESHOLD, +): boolean { + const host = parseAgentVersion(hostVersion); + const latest = parseAgentVersion(latestVersion); + if (!host || !latest || compareVersions(host, latest) >= 0) return false; + const lag = agentVersionLag(host, latest); + if (lag === null) return true; + return lag >= lagThreshold; +} + +export function isAgentVersionNewer( + candidate: string | null | undefined, + current: string | null | undefined, +): boolean { + const next = parseAgentVersion(candidate); + const prev = parseAgentVersion(current); + if (!next) return false; + if (!prev) return true; + return compareVersions(next, prev) > 0; +} + +/** Хост строго младше целевой версии (например из git), без порога lag. */ +export function isAgentVersionBehind( + hostVersion: string | null | undefined, + targetVersion: string | null | undefined, +): boolean { + const host = parseAgentVersion(hostVersion); + const target = parseAgentVersion(targetVersion); + if (!host || !target) return false; + return compareVersions(host, target) < 0; +} diff --git a/frontend/src/utils/eventsListQuery.ts b/frontend/src/utils/eventsListQuery.ts new file mode 100644 index 0000000..4ce30d1 --- /dev/null +++ b/frontend/src/utils/eventsListQuery.ts @@ -0,0 +1,80 @@ +/** Query params for /events list — preserved when opening event detail and going back. */ + +import type { LocationQuery } from "vue-router"; + +export interface EventsListState { + page: number; + q: string; + severity: string; + type: string; + hostname: string; + dateFrom: string; + dateTo: string; +} + +const LIST_KEYS = ["page", "q", "severity", "type", "hostname", "from", "to"] as const; + +function queryString(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value) && typeof value[0] === "string") return value[0]; + return ""; +} + +export function parseEventsListQuery(query: LocationQuery): EventsListState { + const pageRaw = parseInt(queryString(query.page), 10); + return { + page: Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1, + q: queryString(query.q), + severity: queryString(query.severity), + type: queryString(query.type), + hostname: queryString(query.hostname), + dateFrom: queryString(query.from), + dateTo: queryString(query.to), + }; +} + +export function buildEventsListQuery(state: EventsListState): Record<string, string> { + const q: Record<string, string> = {}; + if (state.page > 1) q.page = String(state.page); + if (state.q.trim()) q.q = state.q.trim(); + if (state.severity) q.severity = state.severity; + if (state.type.trim()) q.type = state.type.trim(); + if (state.hostname.trim()) q.hostname = state.hostname.trim(); + if (state.dateFrom) q.from = state.dateFrom; + if (state.dateTo) q.to = state.dateTo; + return q; +} + +/** Subset of detail route query to restore the events list (excludes `from=reports`). */ +export function eventsBackQueryFromDetail(query: LocationQuery): Record<string, string> { + const out: Record<string, string> = {}; + for (const key of LIST_KEYS) { + const v = queryString(query[key]); + if (v) out[key] = v; + } + return out; +} + +export function apiParamsFromState(state: EventsListState, pageSize: number): URLSearchParams { + const params = new URLSearchParams({ + page: String(state.page), + page_size: String(pageSize), + }); + if (state.severity) params.set("severity", state.severity); + if (state.type.trim()) params.set("type", state.type.trim()); + if (state.hostname.trim()) params.set("hostname", state.hostname.trim()); + if (state.q.trim()) params.set("q", state.q.trim()); + if (state.dateFrom) params.set("from", state.dateFrom); + if (state.dateTo) params.set("to", state.dateTo); + return params; +} + +export function eventsListQueryMatches(state: EventsListState, query: LocationQuery): boolean { + const built = buildEventsListQuery(state); + const fromRoute = buildEventsListQuery(parseEventsListQuery(query)); + const keys = new Set([...Object.keys(built), ...Object.keys(fromRoute)]); + for (const key of keys) { + if ((built[key] ?? "") !== (fromRoute[key] ?? "")) return false; + } + return true; +} diff --git a/frontend/src/utils/hostAgentUpgrade.ts b/frontend/src/utils/hostAgentUpgrade.ts new file mode 100644 index 0000000..4a73f22 --- /dev/null +++ b/frontend/src/utils/hostAgentUpgrade.ts @@ -0,0 +1,94 @@ +import type { HostSummary } from "../api"; +import type { HostRemoteActionKind } from "../composables/useHostRemoteAction"; +import { isAgentVersionBehind } from "./agentVersion"; + +export const PRODUCT_RDP = "rdp-login-monitor"; +export const PRODUCT_SSH = "ssh-monitor"; + +export interface AgentGitVersions { + git_latest_rdp_version?: string | null; + git_latest_ssh_version?: string | null; +} + +export function isWindowsAgentHost(host: Pick<HostSummary, "os_family" | "product">): boolean { + if ((host.os_family || "").toLowerCase() === "windows") return true; + return host.product === PRODUCT_RDP; +} + +export function isLinuxAgentHost(host: Pick<HostSummary, "os_family" | "product">): boolean { + if ((host.os_family || "").toLowerCase() === "linux") return true; + return host.product === PRODUCT_SSH; +} + +export function gitTargetVersionForHost( + host: Pick<HostSummary, "os_family" | "product">, + git: AgentGitVersions | null | undefined, +): string | null { + if (!git) return null; + if (isWindowsAgentHost(host)) { + return git.git_latest_rdp_version?.trim() || null; + } + if (isLinuxAgentHost(host)) { + return git.git_latest_ssh_version?.trim() || null; + } + return null; +} + +export function isGitAgentUpgradeAvailable( + host: Pick<HostSummary, "os_family" | "product" | "product_version">, + git: AgentGitVersions | null | undefined, +): boolean { + const target = gitTargetVersionForHost(host, git); + if (!target || !host.product_version?.trim()) return false; + return isAgentVersionBehind(host.product_version, target); +} + +export function remoteActionKindForHost( + host: Pick<HostSummary, "os_family" | "product">, +): HostRemoteActionKind | null { + if (isWindowsAgentHost(host)) return "fallback"; + if (isLinuxAgentHost(host)) return "ssh-update"; + return null; +} + +export function remoteActionTitleForHost(host: HostSummary): string { + const label = host.display_name || host.hostname; + if (isWindowsAgentHost(host)) { + return `Обновление ${label} через WinRM`; + } + 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; +} diff --git a/frontend/src/utils/hostDetailLive.ts b/frontend/src/utils/hostDetailLive.ts new file mode 100644 index 0000000..038c0b5 --- /dev/null +++ b/frontend/src/utils/hostDetailLive.ts @@ -0,0 +1,37 @@ +import type { EventListResponse, EventSummary, HostDetail } from "../api"; +import { isAgentVersionBehind } from "./agentVersion"; +import type { AgentGitVersions } from "./hostAgentUpgrade"; +import { gitTargetVersionForHost } from "./hostAgentUpgrade"; + +/** Точечное обновление карточки хоста по событию ingest (без полного GET /hosts/{id}). */ +export function patchHostDetailFromEvent(host: HostDetail, ev: EventSummary): void { + host.last_seen_at = ev.received_at || ev.occurred_at; + host.event_count = (host.event_count ?? 0) + 1; + if (ev.product_version?.trim()) { + host.product_version = ev.product_version.trim(); + } +} + +export function recalcVersionStatusFromGit( + host: HostDetail, + git: AgentGitVersions | null | undefined, +): void { + const target = gitTargetVersionForHost(host, git); + if (!host.product_version?.trim() || !target) { + return; + } + host.version_status = isAgentVersionBehind(host.product_version, target) ? "outdated" : "ok"; +} + +/** Добавляет событие в начало списка на стр. 1, если его ещё нет. */ +export function prependHostEventIfMissing( + events: EventListResponse | null, + ev: EventSummary, + page: number, +): boolean { + if (!events || page !== 1) return false; + if (events.items.some((row) => row.id === ev.id)) return false; + events.items = [ev, ...events.items]; + events.total = (events.total ?? 0) + 1; + return true; +} diff --git a/frontend/src/utils/hostDisplay.ts b/frontend/src/utils/hostDisplay.ts new file mode 100644 index 0000000..91f97e4 --- /dev/null +++ b/frontend/src/utils/hostDisplay.ts @@ -0,0 +1,5 @@ +/** Display name from host card (SAC «Имя сервера»). */ +export function formatServerName(displayName?: string | null): string { + const name = displayName?.trim(); + return name || "—"; +} diff --git a/frontend/src/utils/hostPatchBus.ts b/frontend/src/utils/hostPatchBus.ts new file mode 100644 index 0000000..c76717a --- /dev/null +++ b/frontend/src/utils/hostPatchBus.ts @@ -0,0 +1,16 @@ +import type { HostDetail } from "../api"; + +type HostPatchListener = (detail: HostDetail) => void; + +const listeners = new Set<HostPatchListener>(); + +export function subscribeHostPatch(listener: HostPatchListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function emitHostPatch(detail: HostDetail): void { + for (const listener of listeners) { + listener(detail); + } +} diff --git a/frontend/src/utils/hostsLiveUpdate.ts b/frontend/src/utils/hostsLiveUpdate.ts new file mode 100644 index 0000000..4acf8a5 --- /dev/null +++ b/frontend/src/utils/hostsLiveUpdate.ts @@ -0,0 +1,30 @@ +import type { HostDetail, HostSummary } from "../api"; +import { isAgentVersionNewer } from "./agentVersion"; + +/** Обновляет поля строки списка хостов из GET /hosts/{id} (без перезагрузки таблицы). */ +export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDetail): void { + target.display_name = detail.display_name; + target.os_version = detail.os_version; + target.product_version = detail.product_version; + target.ipv4 = detail.ipv4; + target.last_seen_at = detail.last_seen_at; + target.event_count = detail.event_count; + target.last_heartbeat_at = detail.last_heartbeat_at; + target.last_daily_report_at = detail.last_daily_report_at; + target.last_inventory_at = detail.last_inventory_at; + target.agent_status = detail.agent_status; + if (detail.version_status) { + target.version_status = detail.version_status; + } +} + +export function bumpLatestAgentVersion( + latest: Record<string, string> | undefined, + product: string, + version: string | null | undefined, +): void { + if (!latest || !version) return; + if (isAgentVersionNewer(version, latest[product])) { + latest[product] = version; + } +} diff --git a/frontend/src/utils/rdgFlap.ts b/frontend/src/utils/rdgFlap.ts new file mode 100644 index 0000000..29b2afe --- /dev/null +++ b/frontend/src/utils/rdgFlap.ts @@ -0,0 +1,32 @@ +import type { EventSummary } from "../api"; + +export function hasRdgFlapUi(event: EventSummary): boolean { + return Boolean(event.rdg_flap || event.rdg_flap_qwinsta_event_id); +} + +export function hasRdgAccessPath(event: EventSummary): boolean { + return Boolean(event.rdg_access_path); +} + +export function rdgAccessPathLabel(event: EventSummary): string { + return event.rdg_access_path || ""; +} + +export function rdgQwinstaEventId(event: EventSummary): number | null { + if (!event.rdg_qwinsta_enabled) return null; + return event.rdg_flap_qwinsta_event_id ?? event.id; +} + +/** @deprecated use rdgQwinstaEventId */ +export function rdgFlapQwinstaEventId(event: EventSummary): number | null { + return rdgQwinstaEventId(event); +} + +export function rdgFlapPairLabel(event: EventSummary): string { + if (!event.rdg_flap_pair_event_id) return ""; + return event.type === "rdg.connection.success" ? "→" : "←"; +} + +export function isRdgConnectionEvent(event: EventSummary): boolean { + return event.type.startsWith("rdg.connection."); +} diff --git a/frontend/src/utils/reportDisplay.ts b/frontend/src/utils/reportDisplay.ts new file mode 100644 index 0000000..fdd483d --- /dev/null +++ b/frontend/src/utils/reportDisplay.ts @@ -0,0 +1,191 @@ +/** Безопасный вывод HTML из агента (DOMPurify, белый список тегов). */ +import DOMPurify from "dompurify"; + +const ALLOWED_REPORT_TAGS = [ + "b", + "strong", + "i", + "em", + "u", + "code", + "pre", + "br", + "p", + "div", + "span", + "ul", + "ol", + "li", + "hr", +]; + +export function sanitizeAgentHtml(html: string): string { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ALLOWED_REPORT_TAGS, + ALLOWED_ATTR: [], + }); +} +export interface DailyReportStats { + platform?: "ssh" | "windows"; + successful_logins?: number; + failed_logins?: number; + sudo_commands?: number; + active_bans?: number; + top_failed_ips?: string[]; + active_users?: string[]; + /** legacy */ + successful_ssh?: number; + failed_ssh?: number; + rdp_success?: number; + rdp_failed?: number; + unique_users?: string[]; + active_sessions?: number; + active_sessions_rdp?: number; +} + +export interface NormalizedReportStat { + key: string; + label: string; + value: string | number; +} + +export const DAILY_REPORT_TYPE_LABELS: Record<string, string> = { + "report.daily.ssh": "Отчёты ssh клиентов", + "report.daily.rdp": "Отчёты RDP клиентов", +}; + +export function dailyReportTypeLabel(type: string): string { + return DAILY_REPORT_TYPE_LABELS[type] ?? type; +} + +export function isDailyReportType(type: string): boolean { + return type === "report.daily.ssh" || type === "report.daily.rdp"; +} + +/** Текст отчёта: literal \\n и <br> из legacy-агентов → нормальные переносы. */ +export function normalizeReportPlainText(body: string): string { + let text = body.replace(/\r\n/g, "\n"); + text = text.replace(/\\n/g, "\n"); + text = text.replace(/<br\s*\/?>/gi, "\n"); + return text; +} + +export function reportPlatform(type: string): "ssh" | "windows" { + return type === "report.daily.rdp" ? "windows" : "ssh"; +} + +/** Приводит stats агента/SAC к единым полям для карточки. */ +export function normalizeReportStats( + stats: DailyReportStats | null, + type: string, +): DailyReportStats | null { + if (!stats) return null; + const platform = stats.platform ?? reportPlatform(type); + const out: DailyReportStats = { ...stats, platform }; + + const asInt = (v: unknown, fallback = 0): number => { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const n = parseInt(v.replace(/[^\d-]/g, ""), 10); + if (Number.isFinite(n)) return n; + } + return fallback; + }; + + if (platform === "ssh") { + out.successful_logins = asInt(stats.successful_logins, asInt(stats.successful_ssh)); + out.failed_logins = asInt(stats.failed_logins, asInt(stats.failed_ssh)); + out.sudo_commands = asInt(stats.sudo_commands); + } else { + out.successful_logins = asInt(stats.successful_logins, asInt(stats.rdp_success)); + out.failed_logins = asInt(stats.failed_logins, asInt(stats.rdp_failed)); + if (!out.active_users?.length && stats.unique_users?.length) { + out.active_users = stats.unique_users.map((u) => + u.startsWith("👤") ? u : `👤 ${u}`, + ); + } + } + out.active_bans = asInt(stats.active_bans); + return out; +} + +export function reportStatsFromDetails( + details: Record<string, unknown> | null, + type = "report.daily.ssh", +): DailyReportStats | null { + if (!details || typeof details.stats !== "object" || details.stats === null) { + return null; + } + return normalizeReportStats(details.stats as DailyReportStats, type); +} + +export function reportStatEntries( + stats: DailyReportStats | null, + type: string, +): NormalizedReportStat[] { + const s = normalizeReportStats(stats, type); + if (!s) return []; + const platform = s.platform ?? reportPlatform(type); + const entries: NormalizedReportStat[] = [ + { + key: "successful", + label: platform === "ssh" ? "Успешных SSH" : "Успешных RDP", + value: s.successful_logins ?? 0, + }, + { + key: "failed", + label: platform === "ssh" ? "Неудачных SSH" : "Неудачных RDP", + value: s.failed_logins ?? 0, + }, + { key: "bans", label: "Активных банов", value: s.active_bans ?? 0 }, + ]; + if (platform === "ssh") { + entries.splice(2, 0, { + key: "sudo", + label: "Команд sudo", + value: s.sudo_commands ?? 0, + }); + } + const users = s.active_users ?? []; + if (users.length) { + entries.push({ + key: "active_users", + label: "Активные пользователи", + value: users.length, + }); + } else if (typeof s.active_sessions === "number" && s.active_sessions > 0) { + entries.push({ + key: "active_sessions", + label: "Сессий (SSH)", + value: s.active_sessions, + }); + } else if (typeof s.active_sessions_rdp === "number" && s.active_sessions_rdp > 0) { + entries.push({ + key: "active_sessions_rdp", + label: "Сессий (RDP)", + value: s.active_sessions_rdp, + }); + } + return entries; +} + +export function reportHtmlFromDetails(details: Record<string, unknown> | null): string | null { + if (!details) return null; + const html = details.report_html; + return typeof html === "string" && html.trim() ? html : null; +} + +export function reportBodyFromDetails(details: Record<string, unknown> | null): string | null { + if (!details) return null; + const body = details.report_body; + return typeof body === "string" && body.trim() ? body : null; +} + +export function reportActiveUserLines( + stats: DailyReportStats | null, + type: string, +): string[] { + const s = normalizeReportStats(stats, type); + if (!s?.active_users?.length) return []; + return s.active_users; +} diff --git a/frontend/src/utils/sessionActions.ts b/frontend/src/utils/sessionActions.ts new file mode 100644 index 0000000..d7e908c --- /dev/null +++ b/frontend/src/utils/sessionActions.ts @@ -0,0 +1,32 @@ +import type { EventSummary } from "../api"; + +const LINUX_SESSION_EVENT_TYPES = new Set([ + "session.logind.new", + "ssh.login.success", + "privilege.sudo.command", +]); + +const WINDOWS_SESSION_EVENT_TYPES = new Set(["rdp.login.success"]); + +export function eventSupportsSessionTerminate(event: Pick<EventSummary, "type">): boolean { + return ( + LINUX_SESSION_EVENT_TYPES.has(event.type) || + WINDOWS_SESSION_EVENT_TYPES.has(event.type) + ); +} + +export function showEventSessionTerminateButton( + event: Pick<EventSummary, "type" | "session_terminated">, +): boolean { + return eventSupportsSessionTerminate(event) && !event.session_terminated; +} + +export function markEventSessionTerminatedInList( + items: EventSummary[] | undefined, + eventId: number, +): void { + const item = items?.find((row) => row.id === eventId); + if (item) { + item.session_terminated = true; + } +} diff --git a/frontend/src/utils/sessionDuration.ts b/frontend/src/utils/sessionDuration.ts new file mode 100644 index 0000000..7ba303c --- /dev/null +++ b/frontend/src/utils/sessionDuration.ts @@ -0,0 +1,17 @@ +/** 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; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts new file mode 100644 index 0000000..4168b16 --- /dev/null +++ b/frontend/src/version.ts @@ -0,0 +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_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..91402cc --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,664 @@ +<template> + <div class="overview-page"> + <h1>Обзор</h1> + + <p v-if="error" class="error">{{ error }}</p> + + <p v-else-if="loading">Загрузка…</p> + + <template v-else-if="data"> + + <div class="dashboard-cards"> + + <div class="dash-card"> + + <div class="dash-value">{{ data.events_last_24h }}</div> + + <div class="dash-label">Событий за 24 ч</div> + + <RouterLink to="/events">Все события →</RouterLink> + + </div> + + <div class="dash-card"> + + <div class="dash-value">{{ data.problems_open }}</div> + + <div class="dash-label">Открытых проблем</div> + + <RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы →</RouterLink> + + </div> + + <div class="dash-card"> + + <div class="dash-value">{{ data.problems_opened_24h }}</div> + + <div class="dash-label">Новых проблем за 24 ч</div> + + <RouterLink :to="{ path: '/problems', query: { created_within_hours: '24' } }">Новые за 24 ч →</RouterLink> + + </div> + + <div class="dash-card"> + + <div class="dash-value">{{ data.problems_resolved_24h }}</div> + + <div class="dash-label">Закрыто за 24 ч</div> + + <RouterLink :to="{ path: '/problems', query: { resolved_within_hours: '24' } }">Закрытые за 24 ч →</RouterLink> + + </div> + + <div class="dash-card"> + + <div class="dash-value">{{ data.hosts_total }}</div> + + <div class="dash-label">Хостов</div> + + <RouterLink to="/hosts">Хосты →</RouterLink> + + </div> + + <div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }"> + + <div class="dash-value">{{ data.hosts_stale }}</div> + + <div class="dash-label">Устаревший heartbeat</div> + + <RouterLink :to="{ path: '/hosts', query: { agent_status: 'stale' } }">Stale-хосты →</RouterLink> + + </div> + + <div class="dash-card"> + + <div class="dash-value">{{ data.heartbeats_24h }}</div> + + <div class="dash-label">Heartbeat за 24 ч</div> + + <RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События →</RouterLink> + + </div> + + <div class="dash-card"> + + <div class="dash-value">{{ data.daily_reports_24h }}</div> + + <div class="dash-label">Отчёты за 24 ч</div> + + <RouterLink to="/reports">Отчёты →</RouterLink> + + </div> + + </div> + + + + <div class="dashboard-panels"> + + <section class="dash-panel"> + + <h2>Топ хостов (24 ч)</h2> + + <div v-if="data.top_hosts.length" class="dash-table-scroll"> + <table class="dash-table"> + + <thead> + + <tr> + + <th>Хост</th> + + <th>Событий</th> + + </tr> + + </thead> + + <tbody> + + <tr v-for="h in data.top_hosts" :key="h.host_id"> + + <td> + + <RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }"> + + {{ h.hostname }} + + </RouterLink> + + </td> + + <td>{{ h.count }}</td> + + </tr> + + </tbody> + + </table> + </div> + + <p v-else class="muted">Нет событий за 24 ч</p> + + </section> + + + + <section class="dash-panel"> + + <h2>Топ типов (24 ч)</h2> + + <div v-if="data.top_event_types.length" class="dash-table-scroll"> + <table class="dash-table"> + + <thead> + + <tr> + + <th>Type</th> + + <th>Событий</th> + + </tr> + + </thead> + + <tbody> + + <tr v-for="t in data.top_event_types" :key="t.type"> + + <td> + + <RouterLink :to="{ path: '/events', query: { type: t.type } }"> + + <code>{{ t.type }}</code> + + </RouterLink> + + </td> + + <td>{{ t.count }}</td> + + </tr> + + </tbody> + + </table> + </div> + + <p v-else class="muted">Нет событий за 24 ч</p> + + </section> + + </div> + + + + <h2>Severity за 24 ч</h2> + + <ul class="severity-list"> + + <li v-for="(count, sev) in data.severity_24h" :key="sev"> + + <RouterLink :to="{ path: '/events', query: { severity: sev } }"> + + <span :class="'sev-' + sev">{{ sev }}</span>: {{ count }} + + </RouterLink> + + </li> + + <li v-if="!Object.keys(data.severity_24h).length">Нет событий</li> + + </ul> + + + + <h2>Последние события</h2> + + <p class="muted dash-recent-hint"> + + Обновляется при поступлении событий (live, ~5 с). Время — момент приёма SAC. + + </p> + + <table> + + <thead> + + <tr> + + <th>ID</th> + + <th>Получено</th> + + <th>Хост</th> + + <th>Имя сервера</th> + + <th>Пользователь</th> + + <th title="Длительность сессии (из session_duration_sec)">Длительность</th> + + <th>Версия агента</th> + + <th>Severity</th> + + <th>Title</th> + + <th>Действия</th> + + </tr> + + </thead> + + <tbody> + + <tr v-for="e in data.recent_events" :key="e.id"> + + <td> + + <RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink> + + </td> + + <td>{{ formatDt(e.received_at) }}</td> + + <td> + + <RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }"> + + {{ e.hostname }} + + </RouterLink> + + </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> + <RdgAccessBadge v-if="e.rdg_access_path" :event="e" /> + <RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" /> + {{ e.title }} + </td> + + <td class="dash-actions"> + + <button + v-if="rdgQwinstaEventId(e)" + type="button" + class="secondary dash-qwinsta-btn" + :disabled="qwinstaLoadingId === e.id" + title="qwinsta и завершение сеанса на рабочем ПК" + @click="runQwinsta(e)" + > + Обрыв RDS + </button> + <button + v-if="showEventSessionTerminateButton(e)" + type="button" + class="secondary dash-qwinsta-btn" + :disabled="sessionTerminateLoadingId === e.id" + @click="runSessionTerminate(e)" + > + {{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }} + </button> + + </td> + + </tr> + + </tbody> + + </table> + + <RdgQwinstaModal + :modal="qwinstaModal" + @close="closeQwinstaModal" + @logoff="runLogoff" + /> + + <div class="filters" style="margin-top: 1rem"> + + <button type="button" class="secondary" @click="load">Обновить всё</button> + + <span v-if="live" class="live-badge">live</span> + + </div> + + </template> + </div> +</template> + + + +<script setup lang="ts"> + +import { onMounted, onUnmounted, ref } from "vue"; + +import { + apiFetch, + ApiError, + getToken, + postEventTerminateSession, + type DashboardSummary, + type EventSummary, +} from "../api"; +import RdgAccessBadge from "../components/RdgAccessBadge.vue"; +import RdgFlapBadge from "../components/RdgFlapBadge.vue"; +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(); +const sessionTerminateLoadingId = ref<number | null>(null); + +const data = ref<DashboardSummary | null>(null); + +const loading = ref(false); + +const error = ref(""); + +const live = ref(false); + +const lastKnownEventDbId = ref<number | null>(null); + +let eventSource: EventSource | null = null; + +let refreshingRecent = false; + +function formatDt(iso: string) { + + return new Date(iso).toLocaleString("ru-RU"); + +} + +async function runSessionTerminate(event: EventSummary) { + const label = event.actor_user || event.title; + if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return; + sessionTerminateLoadingId.value = event.id; + try { + const res = await postEventTerminateSession(event.id); + if (!res.ok) { + window.alert(res.message); + } else { + markEventSessionTerminatedInList(data.value?.recent_events, event.id); + } + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + markEventSessionTerminatedInList(data.value?.recent_events, event.id); + } + window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии"); + } finally { + sessionTerminateLoadingId.value = null; + } +} + + + +function syncLastKnownFromRecent() { + + const top = data.value?.recent_events?.[0]; + + lastKnownEventDbId.value = top?.id ?? null; + +} + + + +async function refreshRecentEvents() { + + if (!data.value || refreshingRecent) return; + + refreshingRecent = true; + + try { + + const events = await apiFetch<EventSummary[]>( + + "/api/v1/dashboards/recent-events?limit=8", + + ); + + data.value.recent_events = events; + + syncLastKnownFromRecent(); + + } catch { + + /* keep table on transient errors */ + + } finally { + + refreshingRecent = false; + + } + +} + + + +async function load() { + + loading.value = true; + + error.value = ""; + + try { + + data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary"); + + syncLastKnownFromRecent(); + + } catch (e) { + + error.value = e instanceof Error ? e.message : String(e); + + } finally { + + loading.value = false; + + } + +} + + + +function applyDashboardCounters(msg: { + + events_last_24h?: number; + + problems_open?: number; + + hosts_stale?: number; + + heartbeats_24h?: number; + + daily_reports_24h?: number; + + last_event_id?: number | null; + +}) { + + if (!data.value) return; + + if (typeof msg.events_last_24h === "number") { + + data.value.events_last_24h = msg.events_last_24h; + + } + + if (typeof msg.problems_open === "number") { + + data.value.problems_open = msg.problems_open; + + } + + if (typeof msg.hosts_stale === "number") { + + data.value.hosts_stale = msg.hosts_stale; + + } + + if (typeof msg.heartbeats_24h === "number") { + + data.value.heartbeats_24h = msg.heartbeats_24h; + + } + + if (typeof msg.daily_reports_24h === "number") { + + data.value.daily_reports_24h = msg.daily_reports_24h; + + } + + + + const incoming = msg.last_event_id; + + if (incoming === undefined) return; + + const prev = lastKnownEventDbId.value; + + if (incoming === null) { + + if (prev !== null) void refreshRecentEvents(); + + lastKnownEventDbId.value = null; + + return; + + } + + if (prev === null || incoming !== prev) { + + void refreshRecentEvents(); + + } + + lastKnownEventDbId.value = incoming; + +} + + + +function connectLive() { + + const token = getToken(); + + if (!token) return; + + eventSource?.close(); + + const url = `/api/v1/stream/events`; + + eventSource = new EventSource(url); + + eventSource.onopen = () => { + + live.value = true; + + }; + + eventSource.onmessage = (ev) => { + + try { + + const msg = JSON.parse(ev.data) as { + + type?: string; + + events_last_24h?: number; + + problems_open?: number; + + hosts_stale?: number; + + heartbeats_24h?: number; + + daily_reports_24h?: number; + + last_event_id?: number | null; + + }; + + if (msg.type !== "dashboard" || !data.value) return; + + applyDashboardCounters(msg); + + } catch { + + /* ignore malformed SSE */ + + } + + }; + + eventSource.onerror = () => { + + live.value = false; + + }; + +} + + + +onMounted(() => { + + load().then(() => connectLive()); + +}); + + + +onUnmounted(() => { + eventSource?.close(); + eventSource = null; +}); + +</script> + + + +<style scoped> + +.dash-table-scroll { + + max-height: 21rem; + + overflow-y: auto; + +} + +.dash-recent-hint { + + margin: -0.25rem 0 0.75rem; + + font-size: 0.9rem; + +} + +.dash-actions { + white-space: nowrap; +} + +.dash-qwinsta-btn { + font-size: 0.85rem; + padding: 0.2rem 0.55rem; +} + +</style> + + diff --git a/frontend/src/views/ErrorPageView.vue b/frontend/src/views/ErrorPageView.vue new file mode 100644 index 0000000..cfbbc44 --- /dev/null +++ b/frontend/src/views/ErrorPageView.vue @@ -0,0 +1,124 @@ +<template> + <div class="error-page"> + <div class="error-card card"> + <img src="/sac-icon.png" alt="" class="error-logo" width="96" height="96" /> + <p class="error-code">{{ config.code }}</p> + <h1 class="error-title">{{ config.title }}</h1> + <p class="error-lead">{{ config.lead }}</p> + <p class="error-hint">{{ config.hint }}</p> + <div class="error-actions"> + <RouterLink :to="config.primaryAction.to" class="error-btn error-btn-primary"> + {{ config.primaryAction.label }} + </RouterLink> + <RouterLink + v-if="showSecondary" + :to="config.secondaryAction!.to" + class="error-btn error-btn-secondary" + > + {{ config.secondaryAction!.label }} + </RouterLink> + </div> + <p class="error-brand muted">Security Alert Center</p> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; +import { getErrorPageConfig } from "../errorPages"; + +const props = defineProps<{ + code: number; +}>(); + +const config = computed(() => getErrorPageConfig(props.code)); + +const showSecondary = computed(() => Boolean(config.value.secondaryAction)); +</script> + +<style scoped> +.error-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.error-card { + max-width: 520px; + width: 100%; + margin: 0; + text-align: center; + padding: 2rem 1.75rem; +} + +.error-logo { + border-radius: 16px; + margin-bottom: 1rem; +} + +.error-code { + margin: 0; + font-size: 3rem; + font-weight: 800; + line-height: 1; + color: #7eb8ff; + letter-spacing: 0.04em; +} + +.error-title { + margin: 0.75rem 0 0; + font-size: 1.35rem; +} + +.error-lead { + margin: 1rem 0 0; + color: #e8eaed; +} + +.error-hint { + margin: 1rem 0 0; + padding: 0.85rem 1rem; + background: #0f1419; + border: 1px solid #2a3441; + border-radius: 8px; + color: #9aa4b2; + font-size: 0.92rem; + text-align: left; + line-height: 1.55; +} + +.error-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + justify-content: center; + margin-top: 1.5rem; +} + +.error-btn { + display: inline-block; + padding: 0.55rem 1.1rem; + border-radius: 6px; + text-decoration: none; + font: inherit; +} + +.error-btn-primary { + background: #2563eb; + border: 1px solid #2563eb; + color: #fff; +} + +.error-btn-secondary { + background: transparent; + border: 1px solid #3d4f63; + color: #e8eaed; +} + +.error-brand { + margin: 1.5rem 0 0; + font-size: 0.85rem; +} +</style> diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue new file mode 100644 index 0000000..21cab1f --- /dev/null +++ b/frontend/src/views/EventDetailView.vue @@ -0,0 +1,162 @@ +<template> + <p> + <RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }">← Отчёты</RouterLink> + <RouterLink v-else :to="{ path: '/events', query: backToEventsQuery }">← События</RouterLink> + </p> + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="loading">Загрузка…</p> + <template v-else-if="event"> + <h1>{{ event.title }}</h1> + <p v-if="event.rdg_access_path" class="event-rdg-access-banner"> + <RdgAccessBadge :event="event" /> + <span class="muted">Вход на рабочий ПК через RD Gateway.</span> + </p> + <p v-if="hasRdgFlapUi(event)" class="event-rdg-flap-banner"> + <RdgFlapBadge :event="event" :detail-link-query="backToEventsQuery" /> + <span class="muted">302→303 за несколько секунд — возможна зависшая сессия на ПК пользователя.</span> + </p> + <p v-if="rdgQwinstaEventId(event)" class="event-rdg-actions"> + <button + type="button" + class="secondary" + :disabled="qwinstaLoadingId === event.id" + title="qwinsta и завершение сеанса на рабочем ПК" + @click="runQwinsta(event)" + > + Обрыв RDS + </button> + </p> + <RdgQwinstaModal + :modal="qwinstaModal" + @close="closeQwinstaModal" + @logoff="runLogoff" + /> + <div class="card report-meta"> + <dl class="detail-fields"> + <div class="detail-field"> + <dt>ID</dt> + <dd>{{ event.id }}</dd> + </div> + <div class="detail-field detail-field-wide"> + <dt>event_id</dt> + <dd><code>{{ event.event_id }}</code></dd> + </div> + <div class="detail-field"> + <dt>Хост</dt> + <dd> + <RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }"> + {{ event.hostname }} + </RouterLink> + </dd> + </div> + <div class="detail-field"> + <dt>Severity</dt> + <dd><span :class="'sev-' + event.severity">{{ event.severity }}</span></dd> + </div> + <div class="detail-field"> + <dt>Тип</dt> + <dd> + <template v-if="isReport">{{ reportTypeLabel(event.type) }}</template> + <code v-else>{{ event.type }}</code> + </dd> + </div> + <div class="detail-field"> + <dt>Occurred</dt> + <dd>{{ formatDt(event.occurred_at) }}</dd> + </div> + <div v-if="!isReport" class="detail-field detail-field-wide"> + <dt>Summary</dt> + <dd>{{ event.summary }}</dd> + </div> + </dl> + </div> + + <template v-if="isReport"> + <h2>Отчёт</h2> + <p class="report-summary-line">{{ event.summary }}</p> + <ReportBodyCard :type="event.type" :summary="event.summary" :details="event.details" /> + </template> + + <details v-if="event.details && !isReport" class="raw-details"> + <summary>details (JSON)</summary> + <pre>{{ JSON.stringify(event.details, null, 2) }}</pre> + </details> + + <details v-if="isReport && event.details" class="raw-details"> + <summary>details (JSON)</summary> + <pre>{{ JSON.stringify(event.details, null, 2) }}</pre> + </details> + + <details class="raw-details"> + <summary>payload (JSON)</summary> + <pre>{{ JSON.stringify(event.payload, null, 2) }}</pre> + </details> + </template> +</template> + +<script setup lang="ts"> +import { computed, onMounted, ref, watch } from "vue"; +import { useRoute } from "vue-router"; +import { apiFetch, type EventDetail } from "../api"; +import RdgAccessBadge from "../components/RdgAccessBadge.vue"; +import RdgFlapBadge from "../components/RdgFlapBadge.vue"; +import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; +import ReportBodyCard from "../components/ReportBodyCard.vue"; +import { useRdgQwinsta } from "../composables/useRdgQwinsta"; +import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap"; +import { eventsBackQueryFromDetail } from "../utils/eventsListQuery"; +import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay"; + +const props = defineProps<{ id: string }>(); +const route = useRoute(); + +const event = ref<EventDetail | null>(null); +const loading = ref(false); +const error = ref(""); +const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); + +const fromReports = computed(() => route.query.from === "reports"); +const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false)); + +const backToReportsQuery = computed(() => { + const q: Record<string, string> = {}; + if (typeof route.query.type === "string" && route.query.type) q.type = route.query.type; + if (typeof route.query.hostname === "string" && route.query.hostname) q.hostname = route.query.hostname; + return q; +}); + +const backToEventsQuery = computed(() => eventsBackQueryFromDetail(route.query)); + +function reportTypeLabel(type: string) { + return dailyReportTypeLabel(type); +} + +function formatDt(iso: string) { + return new Date(iso).toLocaleString("ru-RU"); +} + +async function load() { + loading.value = true; + error.value = ""; + try { + event.value = await apiFetch<EventDetail>(`/api/v1/events/${props.id}`); + } catch (e) { + error.value = e instanceof Error ? e.message : "Не найдено"; + } finally { + loading.value = false; + } +} + +onMounted(load); +watch(() => props.id, load); +</script> + +<style scoped> +.event-rdg-flap-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin: 0.5rem 0 1rem; +} +</style> diff --git a/frontend/src/views/EventsView.vue b/frontend/src/views/EventsView.vue new file mode 100644 index 0000000..513cc27 --- /dev/null +++ b/frontend/src/views/EventsView.vue @@ -0,0 +1,303 @@ +<template> + <h1>События</h1> + <div class="filters"> + <input v-model="q" placeholder="Поиск в summary/title" @keyup.enter="applyFilters" /> + <select v-model="severity" @change="applyFilters"> + <option value="">Все severity</option> + <option>info</option> + <option>warning</option> + <option>high</option> + <option>critical</option> + </select> + <input v-model="typeFilter" placeholder="type (префикс)" @keyup.enter="applyFilters" /> + <input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" /> + <label class="events-date-field"> + <span class="events-date-label">С</span> + <input v-model="dateFrom" type="date" @change="applyFilters" /> + </label> + <label class="events-date-field"> + <span class="events-date-label">По</span> + <input v-model="dateTo" type="date" @change="applyFilters" /> + </label> + <button type="button" @click="applyFilters">Применить</button> + <button v-if="hasActiveFilters" type="button" class="secondary" @click="clearFilters">Сбросить</button> + </div> + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="loading">Загрузка…</p> + <template v-else> + <p>Всего: {{ data?.total ?? 0 }}</p> + <table> + <thead> + <tr> + <th>ID</th> + <th>Время</th> + <th>Хост</th> + <th>Имя сервера</th> + <th>Пользователь</th> + <th title="Длительность сессии (из session_duration_sec)">Длительность</th> + <th>Версия агента</th> + <th>Severity</th> + <th>Type</th> + <th>Title</th> + <th>Действия</th> + </tr> + </thead> + <tbody> + <tr v-for="e in data?.items ?? []" :key="e.id"> + <td> + <RouterLink :to="{ path: `/events/${e.id}`, query: detailLinkQuery }">{{ e.id }}</RouterLink> + </td> + <td>{{ formatDt(e.occurred_at) }}</td> + <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> + <td> + <RdgAccessBadge v-if="e.rdg_access_path" :event="e" /> + <RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" :detail-link-query="detailLinkQuery" /> + {{ e.title }} + </td> + <td class="events-actions"> + <button + v-if="rdgQwinstaEventId(e)" + type="button" + class="secondary events-qwinsta-btn" + :disabled="qwinstaLoadingId === e.id" + title="qwinsta и завершение сеанса на рабочем ПК" + @click="runQwinsta(e)" + > + Обрыв RDS + </button> + <button + v-if="showEventSessionTerminateButton(e)" + type="button" + class="secondary events-qwinsta-btn" + :disabled="sessionTerminateLoadingId === e.id" + @click="runSessionTerminate(e)" + > + {{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }} + </button> + </td> + </tr> + </tbody> + </table> + <RdgQwinstaModal + :modal="qwinstaModal" + @close="closeQwinstaModal" + @logoff="runLogoff" + /> + <div class="filters" style="margin-top: 1rem"> + <button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button> + <span>Стр. {{ page }}</span> + <button + type="button" + class="secondary" + :disabled="!data || page * pageSize >= data.total" + @click="load(page + 1)" + > + Вперёд + </button> + </div> + </template> +</template> + +<script setup lang="ts"> +import { computed, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; +import { apiFetch, ApiError, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api"; +import RdgAccessBadge from "../components/RdgAccessBadge.vue"; +import RdgFlapBadge from "../components/RdgFlapBadge.vue"; +import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; +import { useRdgQwinsta } from "../composables/useRdgQwinsta"; +import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap"; +import { + apiParamsFromState, + buildEventsListQuery, + eventsListQueryMatches, + parseEventsListQuery, + type EventsListState, +} from "../utils/eventsListQuery"; +import { formatServerName } from "../utils/hostDisplay"; +import { formatSessionDuration } from "../utils/sessionDuration"; +import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions"; + +const route = useRoute(); +const router = useRouter(); +const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); +const sessionTerminateLoadingId = ref<number | null>(null); + +const data = ref<EventListResponse | null>(null); +const loading = ref(false); +const error = ref(""); +const page = ref(1); +const pageSize = 50; +const q = ref(""); +const severity = ref(""); +const typeFilter = ref(""); +const hostnameFilter = ref(""); +const dateFrom = ref(""); +const dateTo = ref(""); + +const syncingFromRoute = ref(false); + +const listState = computed( + (): EventsListState => ({ + page: page.value, + q: q.value, + severity: severity.value, + type: typeFilter.value, + hostname: hostnameFilter.value, + dateFrom: dateFrom.value, + dateTo: dateTo.value, + }), +); + +const detailLinkQuery = computed(() => buildEventsListQuery(listState.value)); + +const hasActiveFilters = computed( + () => + Boolean( + q.value.trim() || + severity.value || + typeFilter.value.trim() || + hostnameFilter.value.trim() || + dateFrom.value || + dateTo.value, + ), +); + +function formatDt(iso: string) { + return new Date(iso).toLocaleString("ru-RU"); +} + +function applyStateFromQuery() { + const parsed = parseEventsListQuery(route.query); + page.value = parsed.page; + q.value = parsed.q; + severity.value = parsed.severity; + typeFilter.value = parsed.type; + hostnameFilter.value = parsed.hostname; + dateFrom.value = parsed.dateFrom; + dateTo.value = parsed.dateTo; +} + +async function syncRouteQuery() { + if (eventsListQueryMatches(listState.value, route.query)) return; + syncingFromRoute.value = true; + try { + await router.replace({ path: "/events", query: buildEventsListQuery(listState.value) }); + } finally { + syncingFromRoute.value = false; + } +} + +async function load(p?: number, options?: { syncUrl?: boolean; silent?: boolean }) { + if (p !== undefined) page.value = p; + if (!options?.silent) { + loading.value = true; + error.value = ""; + } + try { + const params = apiParamsFromState(listState.value, pageSize); + data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`); + if (options?.syncUrl !== false && !syncingFromRoute.value) { + await syncRouteQuery(); + } + } catch (e) { + if (!options?.silent) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки"; + } + } finally { + if (!options?.silent) { + loading.value = false; + } + } +} + +useSacLiveEventStream(() => { + void load(undefined, { syncUrl: false, silent: true }); +}); + +async function runSessionTerminate(event: EventSummary) { + const label = event.actor_user || event.title; + if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return; + sessionTerminateLoadingId.value = event.id; + try { + const res = await postEventTerminateSession(event.id); + if (!res.ok) { + window.alert(res.message); + } else { + markEventSessionTerminatedInList(data.value?.items, event.id); + } + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + markEventSessionTerminatedInList(data.value?.items, event.id); + } + window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии"); + } finally { + sessionTerminateLoadingId.value = null; + } +} + +function applyFilters() { + load(1); +} + +function clearFilters() { + q.value = ""; + severity.value = ""; + typeFilter.value = ""; + hostnameFilter.value = ""; + dateFrom.value = ""; + dateTo.value = ""; + load(1); +} + +watch( + () => route.fullPath, + async () => { + if (route.path !== "/events") return; + const matched = eventsListQueryMatches(listState.value, route.query); + if (!matched) { + syncingFromRoute.value = true; + applyStateFromQuery(); + syncingFromRoute.value = false; + } + if (!matched || !data.value) { + await load(undefined, { syncUrl: false }); + } + }, + { immediate: true }, +); +</script> + +<style scoped> +.events-date-field { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.events-date-label { + color: #9aa4b2; + font-size: 0.9rem; + white-space: nowrap; +} + +.events-date-field input[type="date"] { + min-width: 9.5rem; +} + +.events-actions { + white-space: nowrap; +} + +.events-qwinsta-btn { + font-size: 0.85rem; + padding: 0.2rem 0.55rem; +} +</style> diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue new file mode 100644 index 0000000..0a043bb --- /dev/null +++ b/frontend/src/views/HostDetailView.vue @@ -0,0 +1,1131 @@ +<template> + <p><RouterLink to="/hosts">← Хосты</RouterLink></p> + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="loading">Загрузка…</p> + <template v-else-if="host"> + <h1>{{ host.display_name || host.hostname }}</h1> + <div class="card report-meta"> + <dl class="detail-fields"> + <div class="detail-field"> + <dt>Hostname</dt> + <dd>{{ host.hostname }}</dd> + </div> + <div class="detail-field"> + <dt>ID</dt> + <dd>{{ host.id }}</dd> + </div> + <div class="detail-field"> + <dt>Агент</dt> + <dd><span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span></dd> + </div> + <div class="detail-field"> + <dt>Версия агента</dt> + <dd> + <HostAgentVersionUpgrade + v-if="host" + :host="host" + :git="agentGitVersions" + :updating="isHostRemoteActionActive(hostId)" + :outdated-highlight="host.version_status === 'outdated'" + @upgrade="runAgentUpgradeFromCell" + /> + <span v-if="host.pending_agent_update" class="pending-update-badge">обновление запрошено</span> + </dd> + </div> + <div v-if="host.agent_update_state" class="detail-field"> + <dt>Обновление</dt> + <dd> + {{ host.agent_update_state }} + <span v-if="host.pending_update_target_version"> → {{ host.pending_update_target_version }}</span> + <span v-if="host.agent_update_last_at" class="muted"> ({{ formatDt(host.agent_update_last_at) }})</span> + </dd> + </div> + <div v-if="host.agent_update_last_error" class="detail-field"> + <dt>Ошибка обновления</dt> + <dd class="error-inline">{{ host.agent_update_last_error }}</dd> + </div> + <div class="detail-field"> + <dt>OS</dt> + <dd>{{ host.os_family }} {{ host.os_version || "" }}</dd> + </div> + <div class="detail-field"> + <dt>IPv4</dt> + <dd>{{ host.ipv4 || "—" }}</dd> + </div> + <div class="detail-field"> + <dt>Heartbeat</dt> + <dd>{{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}</dd> + </div> + <div class="detail-field"> + <dt>Last seen</dt> + <dd>{{ formatDt(host.last_seen_at) }}</dd> + </div> + <div class="detail-field"> + <dt>Инвентаризация</dt> + <dd>{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}</dd> + </div> + <div class="detail-field"> + <dt>Событий</dt> + <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" + class="secondary" + :disabled="testingWinRm" + @click="runWinRmTest" + > + {{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }} + </button> + <p v-if="showWinRmTestFeedback && winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p> + <div class="host-actions-row"> + <button + type="button" + class="secondary" + :disabled="requestingAgentUpdate" + @click="runRequestAgentUpdate" + > + {{ requestingAgentUpdate ? "Запрос…" : "Запросить обновление (SAC)" }} + </button> + <button + type="button" + class="secondary" + :disabled="isHostRemoteActionActive(hostId)" + @click="runAgentFallback" + > + {{ isHostRemoteActionActive(hostId) ? "Обновление…" : "Обновить через WinRM" }} + </button> + </div> + <p v-if="agentControlMessage && !hasOpenRemoteActionLogForHost(hostId)" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p> + </div> + <div v-if="isLinuxHost" class="host-actions"> + <div class="host-actions-row"> + <div class="host-action-item"> + <button + type="button" + class="secondary" + :disabled="testingSsh" + @click="runSshTestManual" + > + {{ testingSsh ? "Проверка…" : "Проверить SSH" }} + </button> + <span + v-if="sshVerified" + class="host-action-ok" + :title="sshVerifiedTitle" + >✓</span> + </div> + <button + type="button" + class="secondary" + :disabled="isHostRemoteActionActive(hostId)" + @click="runAgentUpdate" + > + {{ isHostRemoteActionActive(hostId) ? "Обновление…" : "Обновить ssh-monitor (SSH)" }} + </button> + <button + type="button" + class="secondary" + :disabled="requestingAgentUpdate" + @click="runRequestAgentUpdate" + > + {{ requestingAgentUpdate ? "Запрос…" : "Запросить обновление (SAC)" }} + </button> + <button + type="button" + class="secondary" + :disabled="isHostRemoteActionActive(hostId)" + @click="runAgentFallback" + > + {{ isHostRemoteActionActive(hostId) ? "Fallback…" : "Fallback SSH" }} + </button> + </div> + <p v-if="agentControlMessage && !hasOpenRemoteActionLogForHost(hostId)" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p> + <div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback"> + <p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p> + <pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre> + </div> + </div> + </div> + + <div v-if="showAgentConfig" class="host-config-sessions-row"> + <section ref="agentConfigSectionRef" class="card host-agent-config"> + <h2>Desired config (poll агента)</h2> + <p class="muted">Агент заберёт настройки при следующем poll. Revision: {{ host.agent_config_revision ?? 0 }}</p> + <form class="agent-config-form" @submit.prevent="saveAgentConfig"> + <label v-if="isWindowsHost" class="config-field"> + ServerDisplayName + <input v-model="configForm.serverDisplayName" type="text" /> + </label> + <label v-if="isLinuxHost" class="config-field"> + SERVER_DISPLAY_NAME + <input v-model="configForm.serverDisplayName" type="text" /> + </label> + <label v-if="isWindowsHost" class="config-inline"> + <input v-model="configForm.getInventory" type="checkbox" /> + GetInventory + </label> + <button type="submit" :disabled="savingAgentConfig"> + {{ savingAgentConfig ? "Сохранение…" : "Сохранить config" }} + </button> + <p v-if="agentConfigMessage" :class="agentConfigOk ? 'success' : 'error'">{{ agentConfigMessage }}</p> + </form> + </section> + + <HostSessionsPanel :host-id="host.id" :height-source="agentConfigSectionRef" /> + </div> + + <section v-if="inventory" class="host-inventory card"> + <h2>Железо и ПО</h2> + <div v-if="windowsInfo" class="inv-block"> + <h3>Windows</h3> + <dl class="detail-fields detail-fields-compact"> + <div v-for="item in windowsFields" :key="item.label" class="detail-field"> + <dt>{{ item.label }}</dt> + <dd>{{ item.value }}</dd> + </div> + </dl> + </div> + <div v-if="computerName" class="inv-block"> + <p><strong>Имя компьютера:</strong> {{ computerName }}</p> + </div> + <div v-if="processors.length" class="inv-block"> + <h3>Процессор</h3> + <ul> + <li v-for="(p, i) in processors" :key="'cpu-' + i"> + {{ p.name }} + <span v-if="p.cores != null"> — {{ p.cores }} ядер / {{ p.logical }} потоков</span> + </li> + </ul> + </div> + <div v-if="motherboard" class="inv-block"> + <h3>Материнская плата</h3> + <p>{{ motherboard }}</p> + </div> + <div v-if="memoryGb != null" class="inv-block"> + <h3>Память</h3> + <p>{{ memoryGb }} GB</p> + </div> + <div v-if="disks.length" class="inv-block"> + <h3>Диски</h3> + <table class="dash-table"> + <thead> + <tr> + <th>Имя</th> + <th>Тип</th> + <th>Размер, GB</th> + </tr> + </thead> + <tbody> + <tr v-for="(d, i) in disks" :key="'disk-' + i"> + <td>{{ d.friendly_name || "—" }}</td> + <td>{{ d.media_type || "—" }}</td> + <td>{{ d.size_gb ?? "—" }}</td> + </tr> + </tbody> + </table> + </div> + <div v-if="video.length" class="inv-block"> + <h3>Видео</h3> + <ul> + <li v-for="(v, i) in video" :key="'gpu-' + i">{{ v.name }}</li> + </ul> + </div> + <div v-if="ipv4List.length" class="inv-block"> + <h3>IPv4</h3> + <p>{{ ipv4List.join(", ") }}</p> + </div> + </section> + <p v-else class="muted">Инвентаризация ещё не поступала с агента (agent.inventory).</p> + + <h2>События хоста</h2> + <p v-if="eventsLoading">Загрузка событий…</p> + <p v-else-if="eventsError" class="error">{{ eventsError }}</p> + <template v-else> + <p>Всего: {{ events?.total ?? 0 }}</p> + <table> + <thead> + <tr> + <th>ID</th> + <th>Время</th> + <th>Severity</th> + <th>Type</th> + <th>Title</th> + </tr> + </thead> + <tbody> + <tr v-for="e in events?.items ?? []" :key="e.id"> + <td> + <RouterLink :to="{ path: `/events/${e.id}`, query: eventsBackQuery }">{{ e.id }}</RouterLink> + </td> + <td>{{ formatDt(e.occurred_at) }}</td> + <td :class="'sev-' + e.severity">{{ e.severity }}</td> + <td><code>{{ e.type }}</code></td> + <td>{{ e.title }}</td> + </tr> + </tbody> + </table> + <div class="filters" style="margin-top: 1rem"> + <button type="button" class="secondary" :disabled="eventsPage <= 1" @click="loadEvents(eventsPage - 1)"> + Назад + </button> + <span>Стр. {{ eventsPage }}</span> + <button + type="button" + class="secondary" + :disabled="!events || eventsPage * eventsPageSize >= events.total" + @click="loadEvents(eventsPage + 1)" + > + Вперёд + </button> + </div> + </template> + </template> +</template> + +<script setup lang="ts"> +import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue"; +import { useRoute } from "vue-router"; +import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; +import { + hasOpenRemoteActionLogForHost, + isHostRemoteActionActive, + runHostRemoteAction, +} from "../composables/useHostRemoteAction"; +import { + apiFetch, + fetchHost, + fetchHostAccess, + updateHostAccess, + fetchRecentEvents, + testHostWinRm, + testHostSsh, + requestHostAgentUpdate, + patchHostAgentConfig, + type EventListResponse, + type HostDetail, + type HostMgmtAccess, +} from "../api"; +import { emitHostPatch } from "../utils/hostPatchBus"; +import HostSessionsPanel from "../components/HostSessionsPanel.vue"; +import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue"; +import { + patchHostDetailFromEvent, + prependHostEventIfMissing, + recalcVersionStatusFromGit, +} from "../utils/hostDetailLive"; +import { + isLinuxAgentHost, + remoteActionKindForHost, + remoteActionLogPlaceholder, + remoteActionTitleForHost, + type AgentGitVersions, +} from "../utils/hostAgentUpgrade"; +import { fetchAgentUpdateSettings } from "../api"; + +const HOST_ACTION_FEEDBACK_MS = 30_000; + +const props = defineProps<{ id: string }>(); +const route = useRoute(); + +const agentConfigSectionRef = ref<HTMLElement | null>(null); +const host = ref<HostDetail | null>(null); +const events = ref<EventListResponse | null>(null); +const loading = ref(false); +const eventsLoading = ref(false); +const error = ref(""); +const eventsError = ref(""); +const eventsPage = ref(1); +const eventsPageSize = 50; +const testingWinRm = ref(false); +const winRmTestMessage = ref(""); +const winRmTestOk = ref(false); +const showWinRmTestFeedback = ref(false); +const testingSsh = ref(false); +const showSshTestFeedback = ref(false); +const sshTestMessage = ref(""); +const sshTestOk = ref(false); +const sshTestOutput = ref(""); +const requestingAgentUpdate = ref(false); +const agentControlMessage = ref(""); +const agentControlOk = ref(false); +const savingAgentConfig = ref(false); +const agentConfigMessage = ref(""); +const agentConfigOk = ref(false); +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; +let refreshingLive = false; +let liveRefreshTimer: ReturnType<typeof setTimeout> | null = null; +const LIVE_REFRESH_DEBOUNCE_MS = 400; + +const agentGitVersions = ref<AgentGitVersions | null>(null); + +useSacLiveEventStream(() => { + scheduleLiveRefreshFromEvent(); +}); + +const hostId = computed(() => parseInt(props.id, 10)); + +const isWindowsHost = computed(() => { + const h = host.value; + if (!h) return false; + if ((h.os_family || "").toLowerCase() === "windows") return true; + return h.product === "rdp-login-monitor"; +}); + +const isLinuxHost = computed(() => { + const h = host.value; + if (!h) return false; + if ((h.os_family || "").toLowerCase() === "linux") return true; + return h.product === "ssh-monitor"; +}); + +const sshVerified = computed(() => host.value?.ssh_admin_ok === true); + +const sshVerifiedTitle = computed(() => { + const checkedAt = host.value?.ssh_admin_checked_at; + if (checkedAt) { + return `SSH доступен (проверено ${formatDt(checkedAt)})`; + } + return "SSH доступен"; +}); + +const showAgentConfig = computed(() => isWindowsHost.value || isLinuxHost.value); + +function isLinuxHostDetail(detail: HostDetail): boolean { + return isLinuxAgentHost(detail); +} + +function syncConfigFormFromHost(detail: HostDetail) { + const cfg = detail.agent_config ?? {}; + configForm.serverDisplayName = String( + cfg.ServerDisplayName ?? cfg.SERVER_DISPLAY_NAME ?? "", + ); + configForm.getInventory = cfg.GetInventory !== false; +} + +const inventory = computed(() => host.value?.inventory ?? null); + +const windowsFields = computed(() => { + const w = inventory.value?.windows as Record<string, string> | undefined; + if (!w) return []; + const labels: Record<string, string> = { + product_name: "Продукт", + version: "Версия", + os_hardware_abstraction_layer: "HAL", + }; + return Object.entries(labels) + .map(([key, label]) => ({ label, value: w[key]?.trim() || "" })) + .filter((item) => item.value); +}); + +const windowsInfo = computed(() => windowsFields.value.length > 0); + +const computerName = computed(() => { + const v = inventory.value?.computer_name; + return typeof v === "string" && v.trim() ? v : ""; +}); + +const processors = computed(() => { + const raw = inventory.value?.processor; + if (!Array.isArray(raw)) return []; + return raw + .filter((p): p is Record<string, unknown> => p != null && typeof p === "object") + .map((p) => ({ + name: String(p.name ?? "—"), + cores: typeof p.cores === "number" ? p.cores : null, + logical: typeof p.logical_processors === "number" ? p.logical_processors : null, + })); +}); + +const motherboard = computed(() => { + const m = inventory.value?.motherboard as Record<string, string> | undefined; + if (!m) return ""; + return [m.manufacturer, m.product].filter(Boolean).join(" "); +}); + +const memoryGb = computed(() => { + const v = inventory.value?.memory_gb; + return typeof v === "number" ? v : null; +}); + +const disks = computed(() => { + const raw = inventory.value?.disks; + if (!Array.isArray(raw)) return []; + return raw.filter((d): d is Record<string, unknown> => d != null && typeof d === "object"); +}); + +const video = computed(() => { + const raw = inventory.value?.video; + if (!Array.isArray(raw)) return []; + return raw.filter((v): v is Record<string, string> => v != null && typeof v === "object"); +}); + +const ipv4List = computed(() => { + const raw = inventory.value?.ipv4; + if (!Array.isArray(raw)) return []; + return raw.map(String).filter(Boolean); +}); + +const eventsBackQuery = computed(() => ({ host_id: String(hostId.value) })); + +function formatDt(iso: string) { + return new Date(iso).toLocaleString("ru-RU"); +} + +function agentLabel(status: string) { + if (status === "online") return "online"; + if (status === "stale") return "stale"; + return "unknown"; +} + +function clearSshTestFeedbackTimer() { + if (sshTestHideTimer != null) { + clearTimeout(sshTestHideTimer); + sshTestHideTimer = null; + } +} + +function applySshAdminStatus(result: { ok: boolean; ssh_admin_ok?: boolean | null }) { + if (!host.value) return; + host.value = { + ...host.value, + ssh_admin_ok: result.ssh_admin_ok ?? result.ok, + ssh_admin_checked_at: new Date().toISOString(), + }; +} + +function clearWinRmTestFeedbackTimer() { + if (winRmTestHideTimer != null) { + clearTimeout(winRmTestHideTimer); + winRmTestHideTimer = null; + } +} + +function scheduleSshTestFeedbackHide() { + clearSshTestFeedbackTimer(); + sshTestHideTimer = setTimeout(() => { + sshTestMessage.value = ""; + sshTestOutput.value = ""; + showSshTestFeedback.value = false; + sshTestHideTimer = null; + }, HOST_ACTION_FEEDBACK_MS); +} + +function scheduleWinRmTestFeedbackHide() { + clearWinRmTestFeedbackTimer(); + winRmTestHideTimer = setTimeout(() => { + winRmTestMessage.value = ""; + showWinRmTestFeedback.value = false; + winRmTestHideTimer = null; + }, HOST_ACTION_FEEDBACK_MS); +} + +async function runSshTest(options: { silent?: boolean } = {}) { + const silent = options.silent === true; + const probeId = ++sshProbeSeq; + + if (!silent) { + testingSsh.value = true; + clearSshTestFeedbackTimer(); + sshTestMessage.value = ""; + sshTestOutput.value = ""; + showSshTestFeedback.value = false; + } + + try { + const result = await testHostSsh(hostId.value); + if (probeId !== sshProbeSeq) return; + + applySshAdminStatus(result); + + if (!silent) { + showSshTestFeedback.value = true; + sshTestOk.value = result.ok; + sshTestMessage.value = result.message; + sshTestOutput.value = formatSshOutput(result); + if (result.ok) { + scheduleSshTestFeedbackHide(); + } + } + } catch (e) { + if (probeId !== sshProbeSeq) return; + if (host.value) { + host.value = { ...host.value, ssh_admin_ok: false, ssh_admin_checked_at: new Date().toISOString() }; + } + if (!silent) { + showSshTestFeedback.value = true; + sshTestOk.value = false; + sshTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH"; + } + } finally { + if (!silent && probeId === sshProbeSeq) { + testingSsh.value = false; + } + } +} + +function runSshTestManual() { + void runSshTest({ silent: false }); +} + +async function probeSshOnOpen() { + await runSshTest({ silent: true }); +} + +async function runWinRmTest() { + testingWinRm.value = true; + clearWinRmTestFeedbackTimer(); + winRmTestMessage.value = ""; + showWinRmTestFeedback.value = false; + try { + const result = await testHostWinRm(hostId.value); + winRmTestOk.value = result.ok; + winRmTestMessage.value = result.message; + showWinRmTestFeedback.value = true; + if (result.ok) { + scheduleWinRmTestFeedbackHide(); + } + } catch (e) { + winRmTestOk.value = false; + winRmTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки WinRM"; + showWinRmTestFeedback.value = true; + } finally { + testingWinRm.value = false; + } +} + +function formatSshOutput(result: { stdout: string | null; stderr: string | null; exit_code: number | null }) { + const parts: string[] = []; + if (result.stdout?.trim()) { + parts.push(result.stdout.trim()); + } + if (result.stderr?.trim()) { + parts.push(result.stderr.trim()); + } + if (result.exit_code != null) { + parts.push(`exit code: ${result.exit_code}`); + } + return parts.join("\n\n"); +} + +function applyHostDetail(detail: HostDetail) { + host.value = detail; + syncConfigFormFromHost(detail); + emitHostPatch(detail); +} + +async function runRequestAgentUpdate() { + requestingAgentUpdate.value = true; + agentControlMessage.value = ""; + try { + const result = await requestHostAgentUpdate(hostId.value); + agentControlOk.value = true; + agentControlMessage.value = result.target_version + ? `Запрошено обновление до ${result.target_version}` + : "Запрошено обновление агента"; + await loadHost(); + } catch (e) { + agentControlOk.value = false; + agentControlMessage.value = e instanceof Error ? e.message : "Не удалось запросить обновление"; + } finally { + requestingAgentUpdate.value = false; + } +} + +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")); + if (job) { + agentControlOk.value = job.ok ?? false; + if (job.ok) { + agentControlMessage.value = ""; + await loadHost(); + } else { + agentControlMessage.value = job.message || ""; + } + } +} + +async function saveAgentConfig() { + savingAgentConfig.value = true; + agentConfigMessage.value = ""; + const settings: Record<string, unknown> = {}; + if (isWindowsHost.value) { + if (configForm.serverDisplayName.trim()) { + settings.ServerDisplayName = configForm.serverDisplayName.trim(); + } + settings.GetInventory = configForm.getInventory; + } else if (isLinuxHost.value) { + if (configForm.serverDisplayName.trim()) { + settings.SERVER_DISPLAY_NAME = configForm.serverDisplayName.trim(); + } + } + try { + const result = await patchHostAgentConfig(hostId.value, settings); + agentConfigOk.value = true; + agentConfigMessage.value = `Config сохранён (revision ${result.config_revision})`; + await loadHost(); + } catch (e) { + agentConfigOk.value = false; + agentConfigMessage.value = e instanceof Error ? e.message : "Ошибка сохранения config"; + } finally { + savingAgentConfig.value = false; + } +} + +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 = ""; + winRmTestMessage.value = ""; + clearWinRmTestFeedbackTimer(); + showWinRmTestFeedback.value = false; + sshProbeSeq += 1; + clearSshTestFeedbackTimer(); + showSshTestFeedback.value = false; + sshTestMessage.value = ""; + sshTestOutput.value = ""; + try { + const detail = await fetchHost(hostId.value); + applyHostDetail(detail); + void loadHostAccess(); + if (isLinuxHostDetail(detail)) { + void probeSshOnOpen(); + } + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста"; + } finally { + loading.value = false; + } +} + +async function runAgentUpdate() { + const job = await runHostRemoteAction( + hostId.value, + "Обновление ssh-monitor (SSH)", + "ssh-update", + remoteActionLogPlaceholder(host.value, "ssh-update"), + ); + if (job?.ok) { + const detail = await fetchHost(hostId.value); + if (job.product_version) { + detail.product_version = job.product_version; + } + applyHostDetail(detail); + recalcVersionStatusFromGit(detail, agentGitVersions.value); + } +} + +async function runAgentUpgradeFromCell() { + if (!host.value) return; + const kind = remoteActionKindForHost(host.value); + if (!kind) return; + if (isHostRemoteActionActive(hostId.value)) { + return; + } + const job = await runHostRemoteAction( + hostId.value, + remoteActionTitleForHost(host.value), + kind, + remoteActionLogPlaceholder(host.value, kind), + ); + if (job?.ok) { + const detail = await fetchHost(hostId.value); + if (job.product_version) { + detail.product_version = job.product_version; + } + applyHostDetail(detail); + recalcVersionStatusFromGit(detail, agentGitVersions.value); + } +} + +function clearLiveRefreshTimer() { + if (liveRefreshTimer != null) { + clearTimeout(liveRefreshTimer); + liveRefreshTimer = null; + } +} + +function scheduleLiveRefreshFromEvent() { + clearLiveRefreshTimer(); + liveRefreshTimer = setTimeout(() => { + liveRefreshTimer = null; + void refreshFromLatestEvent(); + }, LIVE_REFRESH_DEBOUNCE_MS); +} + +async function refreshFromLatestEvent() { + if (refreshingLive || !host.value) return; + refreshingLive = true; + try { + const recent = await fetchRecentEvents(1); + const ev = recent[0]; + if (!ev || ev.host_id !== hostId.value) return; + patchHostDetailFromEvent(host.value, ev); + recalcVersionStatusFromGit(host.value, agentGitVersions.value); + emitHostPatch(host.value); + prependHostEventIfMissing(events.value, ev, eventsPage.value); + } catch { + /* ignore transient errors */ + } finally { + refreshingLive = false; + } +} + +async function loadAgentGitVersions() { + try { + const settings = await fetchAgentUpdateSettings(); + agentGitVersions.value = { + git_latest_rdp_version: settings.git_latest_rdp_version, + git_latest_ssh_version: settings.git_latest_ssh_version, + }; + if (host.value) { + recalcVersionStatusFromGit(host.value, agentGitVersions.value); + } + } catch { + agentGitVersions.value = null; + } +} + +async function loadEvents(page = 1) { + eventsLoading.value = true; + eventsError.value = ""; + eventsPage.value = page; + try { + const params = new URLSearchParams({ + page: String(page), + page_size: String(eventsPageSize), + host_id: String(hostId.value), + include_hidden: "true", + }); + events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`); + } catch (e) { + eventsError.value = e instanceof Error ? e.message : "Ошибка загрузки событий"; + } finally { + eventsLoading.value = false; + } +} + +onMounted(async () => { + void loadAgentGitVersions(); + await loadHost(); + await loadEvents(1); +}); + +onUnmounted(() => { + clearSshTestFeedbackTimer(); + clearWinRmTestFeedbackTimer(); + clearLiveRefreshTimer(); +}); + +watch( + () => route.params.id, + async () => { + await loadHost(); + await loadEvents(1); + }, +); +</script> + +<style scoped> +.host-inventory { + margin: 1rem 0 1.5rem; +} + +.inv-block { + margin-bottom: 1rem; +} + +.inv-block h3 { + margin: 0 0 0.35rem; + font-size: 1rem; +} + +.muted { + 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; + flex-direction: column; + gap: 0.75rem; +} + +.host-actions-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.host-action-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.host-action-ok { + color: #3fb950; + font-size: 1.15rem; + font-weight: 700; + line-height: 1; +} + +.host-action-feedback { + width: 100%; +} + +.host-ssh-output { + margin-top: 0.75rem; + max-height: 16rem; + overflow: auto; + padding: 0.75rem; + background: #0d1117; + border-radius: 6px; + font-size: 0.85rem; + white-space: pre-wrap; +} + +.agent-version-outdated { + color: #f0883e; +} + +.agent-version-ok { + color: #3fb950; +} + +.pending-update-badge { + margin-left: 0.5rem; + font-size: 0.85rem; + color: #58a6ff; +} + +.version-hint { + font-size: 0.9rem; +} + +.error-inline { + color: #f85149; +} + +.host-agent-config { + margin-top: 0; +} + +.host-config-sessions-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 1rem; + align-items: flex-start; +} + +.host-config-sessions-row > .host-agent-config, +.host-config-sessions-row > .host-sessions { + flex: 1 1 22rem; + min-width: min(100%, 22rem); +} + +.agent-config-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 28rem; +} + +.config-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.config-inline { + display: flex; + align-items: center; + gap: 0.5rem; +} +</style> diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue new file mode 100644 index 0000000..64fe2bc --- /dev/null +++ b/frontend/src/views/HostsView.vue @@ -0,0 +1,557 @@ +<template> + <h1>Хосты</h1> + <div class="filters"> + <label> + Поиск (hostname / display name) + <input v-model="search" type="search" placeholder="UNMS Kalina" @keyup.enter="applyFilters" /> + </label> + <label> + Статус агента + <select v-model="agentStatusFilter" @change="applyFilters"> + <option value="">Все</option> + <option value="online">online</option> + <option value="stale">stale</option> + <option value="unknown">unknown</option> + </select> + </label> + <button type="button" class="secondary" @click="applyFilters">Найти</button> + <button type="button" @click="manualAddOpen = true">Добавить вручную</button> + </div> + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="loading">Загрузка…</p> + <template v-else> + <div class="hosts-list-meta"> + <p class="hosts-list-total"> + Всего: {{ data?.total ?? 0 }} + <span v-if="live" class="live-badge">live</span> + </p> + <aside + class="agent-releases-plate" + title="Версии из git-репозиториев агентов (version.txt, ветка main)" + > + <span class="agent-releases-plate__label">Последние версии агентов:</span> + <span class="agent-releases-plate__item"> + Win: <code>{{ gitWinVersion }}</code> + </span> + <span class="agent-releases-plate__item"> + SSH: <code>{{ gitSshVersion }}</code> + </span> + </aside> + </div> + <table class="hosts-table"> + <thead> + <tr> + <th><button type="button" class="th-sort" @click="setSort('id')">ID {{ sortMark('id') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('display_name')">Имя {{ sortMark('display_name') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('hostname')">Hostname {{ sortMark('hostname') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('product_version')">Версия агента {{ sortMark('product_version') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('os_family')">OS {{ sortMark('os_family') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('ipv4')">IPv4 {{ sortMark('ipv4') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('agent_status')">Статус {{ sortMark('agent_status') }}</button></th> + <th>Heartbeat</th> + <th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th> + <th><button type="button" class="th-sort" @click="setSort('event_count')">Events {{ sortMark('event_count') }}</button></th> + <th>Действия</th> + </tr> + </thead> + <tbody> + <tr + v-for="h in sortedItems" + :key="h.id" + class="sac-host-row" + tabindex="0" + :title="`Карточка хоста ${h.hostname}`" + @click="openHost(h.id)" + @keydown.enter="openHost(h.id)" + > + <td>{{ h.id }}</td> + <td>{{ h.display_name || h.hostname }}</td> + <td>{{ h.hostname }}</td> + <td @click.stop> + <HostAgentVersionUpgrade + :host="h" + :git="data" + :updating="isHostRemoteActionActive(h.id)" + :outdated-highlight="isVersionOutdated(h)" + @upgrade="startAgentUpgrade(h)" + /> + </td> + <td>{{ h.os_family }}</td> + <td>{{ h.ipv4 || "—" }}</td> + <td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td> + <td>{{ h.last_heartbeat_at ? formatDt(h.last_heartbeat_at) : "—" }}</td> + <td @click.stop> + <RouterLink + v-if="h.last_daily_report_at" + :to="{ path: '/reports', query: { hostname: h.hostname } }" + > + {{ formatDt(h.last_daily_report_at) }} + </RouterLink> + <span v-else>—</span> + </td> + <td>{{ formatDt(h.last_seen_at) }}</td> + <td>{{ h.event_count ?? 0 }}</td> + <td class="actions" @click.stop> + <div class="actions-inner"> + <button + type="button" + class="danger" + :disabled="deletingId === h.id" + title="Удалить хост и все его события" + @click="confirmDelete(h)" + > + Удалить + </button> + </div> + </td> + </tr> + </tbody> + </table> + </template> + <HostManualAddModal + :open="manualAddOpen" + @close="manualAddOpen = false" + @started="onManualAddStarted" + /> +</template> + +<script setup lang="ts"> +import { computed, onMounted, onUnmounted, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; +import { + apiFetch, + ApiError, + fetchHost, + fetchRecentEvents, + type HostDetail, + type HostListResponse, + type HostSummary, +} from "../api"; +import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate"; +import { subscribeHostPatch } from "../utils/hostPatchBus"; +import { isAgentVersionOutdated } from "../utils/agentVersion"; +import { + isGitAgentUpgradeAvailable, + remoteActionKindForHost, + remoteActionLogPlaceholder, + remoteActionLogPlaceholderFromTitle, + remoteActionTitleForHost, +} from "../utils/hostAgentUpgrade"; +import { + hostRemoteActionLogs, + isHostRemoteActionActive, + pollHostRemoteAction, + runHostRemoteAction, + showHostRemoteActionLog, +} from "../composables/useHostRemoteAction"; +import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue"; +import HostManualAddModal from "../components/HostManualAddModal.vue"; + +const router = useRouter(); +const route = useRoute(); + +const data = ref<HostListResponse | null>(null); +const loading = ref(false); +const error = ref(""); +const search = ref(""); +const agentStatusFilter = ref(""); +type SortKey = + | "id" + | "display_name" + | "hostname" + | "product_version" + | "os_family" + | "ipv4" + | "agent_status" + | "last_seen_at" + | "event_count" + | "last_daily_report_at"; + +const HOSTS_SORT_KEYS: SortKey[] = [ + "id", + "display_name", + "hostname", + "product_version", + "os_family", + "ipv4", + "agent_status", + "last_seen_at", + "event_count", + "last_daily_report_at", +]; + +const HOSTS_SORT_STORAGE_KEY = "sac_hosts_sort"; + +function loadHostsSortPrefs(): { sortBy: SortKey; sortDir: "asc" | "desc" } { + const fallback = { sortBy: "last_seen_at" as SortKey, sortDir: "desc" as const }; + try { + const raw = localStorage.getItem(HOSTS_SORT_STORAGE_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as { sortBy?: string; sortDir?: string }; + if ( + parsed.sortBy && + HOSTS_SORT_KEYS.includes(parsed.sortBy as SortKey) && + (parsed.sortDir === "asc" || parsed.sortDir === "desc") + ) { + return { sortBy: parsed.sortBy as SortKey, sortDir: parsed.sortDir }; + } + } catch { + /* ignore corrupt storage */ + } + return fallback; +} + +function saveHostsSortPrefs(sortBy: SortKey, sortDir: "asc" | "desc") { + localStorage.setItem(HOSTS_SORT_STORAGE_KEY, JSON.stringify({ sortBy, sortDir })); +} + +const initialSort = loadHostsSortPrefs(); +const sortBy = ref<SortKey>(initialSort.sortBy); +const sortDir = ref<"asc" | "desc">(initialSort.sortDir); +const deletingId = ref<number | null>(null); +const manualAddOpen = ref(false); +let patchingHostRow = false; +let unsubscribeHostPatch: (() => void) | null = null; + +function applyHostPatchFromDetail(detail: HostDetail) { + if (!data.value) return; + const row = data.value.items.find((h) => h.id === detail.id); + if (row) { + patchHostSummaryFromDetail(row, detail); + bumpLatestAgentVersion(data.value.latest_agent_versions, row.product, detail.product_version); + } +} + +const { live } = useSacLiveEventStream(() => { + void patchHostRowFromLatestEvent(); +}); + +function formatDt(iso: string) { + return new Date(iso).toLocaleString("ru-RU"); +} + +function agentLabel(status: string) { + if (status === "online") return "online"; + if (status === "stale") return "stale"; + return "unknown"; +} + +function isVersionOutdated(h: HostSummary): boolean { + if (h.version_status === "outdated") return true; + if (h.version_status === "ok") return false; + const reference = + data.value?.reference_agent_versions?.[h.product] ?? + data.value?.latest_agent_versions?.[h.product]; + 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; + } + if (isHostRemoteActionActive(h.id)) { + 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), + ); +} + +function openHost(id: number) { + router.push(`/hosts/${id}`); +} + +function agentStatusOrder(status: string) { + if (status === "online") return 0; + if (status === "stale") return 1; + return 2; +} + +function setSort(key: SortKey) { + if (sortBy.value === key) { + sortDir.value = sortDir.value === "asc" ? "desc" : "asc"; + } else { + sortBy.value = key; + sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc"; + } + saveHostsSortPrefs(sortBy.value, sortDir.value); +} + +const gitWinVersion = computed( + () => data.value?.git_latest_rdp_version?.trim() || "—", +); +const gitSshVersion = computed( + () => data.value?.git_latest_ssh_version?.trim() || "—", +); + +function sortMark(key: SortKey) { + if (sortBy.value !== key) return ""; + return sortDir.value === "asc" ? "↑" : "↓"; +} + +function compareNullableStrings(a: string | null, b: string | null) { + const av = (a ?? "").trim().toLowerCase(); + const bv = (b ?? "").trim().toLowerCase(); + return av.localeCompare(bv, "ru"); +} + +function compareNullableDates(a: string | null, b: string | null) { + const av = a ? new Date(a).getTime() : 0; + const bv = b ? new Date(b).getTime() : 0; + return av - bv; +} + +const sortedItems = computed(() => { + const items = [...(data.value?.items ?? [])]; + const key = sortBy.value; + const dir = sortDir.value === "asc" ? 1 : -1; + items.sort((a: HostSummary, b: HostSummary) => { + let cmp = 0; + switch (key) { + case "id": + cmp = a.id - b.id; + break; + case "display_name": + cmp = compareNullableStrings(a.display_name, b.display_name); + break; + case "hostname": + cmp = compareNullableStrings(a.hostname, b.hostname); + break; + case "product_version": + cmp = compareNullableStrings(a.product_version, b.product_version); + break; + case "os_family": + cmp = compareNullableStrings(a.os_family, b.os_family); + break; + case "ipv4": + cmp = compareNullableStrings(a.ipv4, b.ipv4); + break; + case "agent_status": + cmp = agentStatusOrder(a.agent_status) - agentStatusOrder(b.agent_status); + break; + case "event_count": + cmp = (a.event_count ?? 0) - (b.event_count ?? 0); + break; + case "last_daily_report_at": + cmp = compareNullableDates(a.last_daily_report_at, b.last_daily_report_at); + break; + case "last_seen_at": + cmp = compareNullableDates(a.last_seen_at, b.last_seen_at); + break; + } + if (cmp === 0) { + cmp = a.hostname.localeCompare(b.hostname, "ru"); + } + return cmp * dir; + }); + return items; +}); + +async function loadHosts(retry = 0) { + loading.value = true; + error.value = ""; + try { + const q = search.value.trim(); + const params = new URLSearchParams({ page: "1", page_size: "100" }); + if (q) params.set("hostname", q); + if (agentStatusFilter.value) params.set("agent_status", agentStatusFilter.value); + data.value = await apiFetch<HostListResponse>(`/api/v1/hosts?${params}`); + } catch (e) { + const status = e instanceof ApiError ? e.status : 0; + if (retry < 2 && (status === 502 || status === 503)) { + await new Promise((resolve) => window.setTimeout(resolve, 2500)); + return loadHosts(retry + 1); + } + error.value = e instanceof Error ? e.message : "Ошибка"; + } finally { + loading.value = false; + } +} + +function buildHostsQuery(): Record<string, string> { + const q: Record<string, string> = {}; + if (search.value.trim()) q.hostname = search.value.trim(); + if (agentStatusFilter.value) q.agent_status = agentStatusFilter.value; + return q; +} + +function applyFilters() { + void router.replace({ path: "/hosts", query: buildHostsQuery() }); + void loadHosts(); +} + +function syncFromRoute() { + const hostname = route.query.hostname; + if (typeof hostname === "string") search.value = hostname; + const status = route.query.agent_status; + agentStatusFilter.value = typeof status === "string" ? status : ""; +} + +async function patchHostRowFromLatestEvent() { + if (!data.value?.items.length || patchingHostRow) return; + patchingHostRow = true; + try { + const recent = await fetchRecentEvents(1); + const ev = recent[0]; + if (!ev) return; + const row = data.value.items.find((h) => h.id === ev.host_id); + if (!row) return; + const detail = await fetchHost(ev.host_id); + patchHostSummaryFromDetail(row, detail); + } catch { + /* keep table on transient errors */ + } finally { + patchingHostRow = false; + } +} + +interface HostDeleteResponse { + status: string; + host_id: number; + hostname: string; + deleted_events: number; + deleted_problems: number; +} + +async function onManualAddStarted(payload: { hostId: number; title: string }) { + await loadHosts(); + void pollHostRemoteAction( + payload.hostId, + payload.title, + remoteActionLogPlaceholderFromTitle(payload.title), + ); +} + +async function confirmDelete(h: HostSummary) { + const label = h.display_name || h.hostname; + const n = h.event_count ?? 0; + const msg = + `Удалить хост «${label}» (${h.hostname})?\n\n` + + `Будут удалены все события (${n}) и проблемы этого хоста. ` + + `При новом ingest агент снова появится в списке.`; + if (!window.confirm(msg)) { + return; + } + deletingId.value = h.id; + error.value = ""; + try { + await apiFetch<HostDeleteResponse>(`/api/v1/hosts/${h.id}`, { method: "DELETE" }); + await loadHosts(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Не удалось удалить хост"; + } finally { + deletingId.value = null; + } +} + +onMounted(() => { + syncFromRoute(); + loadHosts(); + unsubscribeHostPatch = subscribeHostPatch(applyHostPatchFromDetail); +}); + +onUnmounted(() => { + unsubscribeHostPatch?.(); + unsubscribeHostPatch = null; +}); + +watch( + () => [route.query.hostname, route.query.agent_status] as const, + () => { + syncFromRoute(); + loadHosts(); + }, +); +</script> + +<style scoped> +.hosts-list-meta { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 0.5rem 0.75rem; + margin: 0.35rem 0 1rem; +} + +.hosts-list-total { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + line-height: 1.5; +} + +.agent-releases-plate { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 0.85rem; + margin: -0.15rem 0 0; + padding: 0.45rem 1rem 0.5rem 1.1rem; + border-radius: 0 0.75rem 0.75rem 0.35rem; + border: 1px solid #2d3a4d; + border-left: 3px solid #58a6ff; + background: linear-gradient(135deg, #1a2332 0%, #151d28 100%); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.35), + 0 1px 0 rgba(88, 166, 255, 0.12) inset; + font-size: 0.9rem; + color: #9aa4b2; + transform: translateY(-1px); +} + +.agent-releases-plate__label { + color: #c9d1d9; + font-weight: 500; +} + +.agent-releases-plate__item code { + font-size: 0.88em; + color: #79c0ff; + background: rgba(88, 166, 255, 0.08); + padding: 0.1rem 0.35rem; + border-radius: 0.25rem; +} +</style> diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..1579e58 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,83 @@ +<template> + <div class="card login-card"> + <div class="login-brand"> + <img src="/sac-icon.png" alt="" class="login-brand-icon" width="64" height="64" /> + <h1 class="login-title">Вход в SAC</h1> + <p class="login-subtitle">Security Alert Center</p> + </div> + <form @submit.prevent="submit"> + <p> + <label>Логин<br /> + <input v-model="username" autocomplete="username" required /> + </label> + </p> + <p> + <label>Пароль<br /> + <input v-model="password" type="password" autocomplete="current-password" required /> + </label> + </p> + <p v-if="error" class="error">{{ error }}</p> + <button type="submit" :disabled="loading">{{ loading ? "…" : "Войти" }}</button> + </form> + </div> +</template> + +<script setup lang="ts"> +import { ref } from "vue"; +import { useRouter } from "vue-router"; +import { apiFetch, setAuth, type TokenResponse } from "../api"; + +const router = useRouter(); +const username = ref("admin"); +const password = ref(""); +const error = ref(""); +const loading = ref(false); + +async function submit() { + error.value = ""; + loading.value = true; + try { + const data = await apiFetch<TokenResponse>("/api/v1/auth/login", { + method: "POST", + body: JSON.stringify({ username: username.value, password: password.value }), + }); + setAuth(data.access_token, data.role); + await router.push("/dashboard"); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка входа"; + } finally { + loading.value = false; + } +} +</script> + +<style scoped> +.login-card { + max-width: 360px; + margin: 4rem auto; +} + +.login-brand { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: 1.25rem; +} + +.login-brand-icon { + border-radius: 12px; + margin-bottom: 0.75rem; +} + +.login-title { + margin: 0; + font-size: 1.35rem; +} + +.login-subtitle { + margin: 0.35rem 0 0; + color: #9aa4b2; + font-size: 0.9rem; +} +</style> diff --git a/frontend/src/views/ProblemDetailView.vue b/frontend/src/views/ProblemDetailView.vue new file mode 100644 index 0000000..6b47e36 --- /dev/null +++ b/frontend/src/views/ProblemDetailView.vue @@ -0,0 +1,136 @@ +<template> + <p><RouterLink to="/problems">← Проблемы</RouterLink></p> + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="loading">Загрузка…</p> + <template v-else-if="problem"> + <h1>{{ problem.title }}</h1> + <div class="card report-meta"> + <p> + <strong>ID:</strong> {{ problem.id }} + · <strong>Status:</strong> + <span :class="'status-' + problem.status">{{ problem.status }}</span> + · <strong>Severity:</strong> + <span :class="'sev-' + problem.severity">{{ problem.severity }}</span> + </p> + <p> + <strong>Хост:</strong> + <RouterLink + v-if="problem.hostname" + :to="{ path: '/reports', query: { hostname: problem.hostname } }" + > + {{ problem.hostname }} + </RouterLink> + <span v-else>—</span> + · <strong>Rule:</strong> <code>{{ problem.rule_id ?? "—" }}</code> + </p> + <p><strong>Summary:</strong> {{ problem.summary }}</p> + <p> + <strong>Событий:</strong> {{ problem.event_count ?? "—" }} + · <strong>Last seen:</strong> {{ formatDt(problem.last_seen_at ?? problem.updated_at) }} + · <strong>Обновлено:</strong> {{ formatDt(problem.updated_at) }} + </p> + <p v-if="problem.fingerprint"> + <strong>Fingerprint:</strong> <code>{{ problem.fingerprint }}</code> + </p> + <p class="actions"> + <button + v-if="problem.status === 'open'" + type="button" + class="secondary" + :disabled="acting" + @click="doAck" + > + Ack + </button> + <button v-if="problem.status !== 'resolved'" type="button" :disabled="acting" @click="doResolve"> + Resolve + </button> + <span v-else class="actions-muted">Закрыта</span> + </p> + </div> + + <h2>Таймлайн событий</h2> + <p v-if="!problem.events.length">Нет связанных событий.</p> + <table v-else class="timeline-table"> + <thead> + <tr> + <th>Время</th> + <th>Severity</th> + <th>Type</th> + <th>Title</th> + <th>Summary</th> + </tr> + </thead> + <tbody> + <tr v-for="e in problem.events" :key="e.id"> + <td>{{ formatDt(e.occurred_at) }}</td> + <td :class="'sev-' + e.severity">{{ e.severity }}</td> + <td> + <RouterLink :to="`/events/${e.id}`"><code>{{ e.type }}</code></RouterLink> + </td> + <td>{{ e.title }}</td> + <td>{{ e.summary }}</td> + </tr> + </tbody> + </table> + </template> +</template> + +<script setup lang="ts"> +import { onMounted, ref, watch } from "vue"; +import { ackProblem, fetchProblem, resolveProblem, type ProblemDetail } from "../api"; + +const props = defineProps<{ id: string }>(); + +const problem = ref<ProblemDetail | null>(null); +const loading = ref(false); +const error = ref(""); +const acting = ref(false); + +function formatDt(iso: string) { + return new Date(iso).toLocaleString("ru-RU"); +} + +async function load() { + loading.value = true; + error.value = ""; + try { + problem.value = await fetchProblem(Number(props.id)); + } catch (e) { + error.value = e instanceof Error ? e.message : "Не найдено"; + } finally { + loading.value = false; + } +} + +async function doAck() { + if (!problem.value) return; + acting.value = true; + error.value = ""; + try { + await ackProblem(problem.value.id); + await load(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка ack"; + } finally { + acting.value = false; + } +} + +async function doResolve() { + if (!problem.value) return; + acting.value = true; + error.value = ""; + try { + await resolveProblem(problem.value.id); + await load(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка resolve"; + } finally { + acting.value = false; + } +} + +onMounted(load); +watch(() => props.id, load); +</script> diff --git a/frontend/src/views/ProblemsView.vue b/frontend/src/views/ProblemsView.vue new file mode 100644 index 0000000..b264378 --- /dev/null +++ b/frontend/src/views/ProblemsView.vue @@ -0,0 +1,228 @@ +<template> + <h1>Проблемы</h1> + <div class="filters"> + <select v-model="statusFilter" @change="applyFilters"> + <option value="">Все статусы</option> + <option value="open">open</option> + <option value="acknowledged">acknowledged</option> + <option value="resolved">resolved</option> + </select> + <select v-model="severityFilter" @change="applyFilters"> + <option value="">Все severity</option> + <option>info</option> + <option>warning</option> + <option>high</option> + <option>critical</option> + </select> + <input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" /> + <button type="button" @click="applyFilters">Применить</button> + </div> + <p v-if="timeFilterHint" class="muted">{{ timeFilterHint }}</p> + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="loading">Загрузка…</p> + <template v-else> + <p>Всего: {{ data?.total ?? 0 }}</p> + <table> + <thead> + <tr> + <th>ID</th> + <th>Обновлено</th> + <th>Хост</th> + <th>Имя сервера</th> + <th>Severity</th> + <th>Status</th> + <th>Rule</th> + <th>Title</th> + <th>Действия</th> + </tr> + </thead> + <tbody> + <tr v-for="p in data?.items ?? []" :key="p.id"> + <td> + <RouterLink :to="`/problems/${p.id}`">{{ p.id }}</RouterLink> + </td> + <td>{{ formatDt(p.updated_at) }}</td> + <td>{{ p.hostname ?? "—" }}</td> + <td>{{ formatServerName(p.display_name) }}</td> + <td :class="'sev-' + p.severity">{{ p.severity }}</td> + <td :class="'status-' + p.status">{{ p.status }}</td> + <td><code>{{ p.rule_id ?? "—" }}</code></td> + <td> + <RouterLink :to="`/problems/${p.id}`">{{ p.title }}</RouterLink> + </td> + <td class="actions"> + <div class="actions-inner"> + <button + v-if="p.status === 'open'" + type="button" + class="secondary" + :disabled="actingId === p.id" + @click="doAck(p.id)" + > + Ack + </button> + <button + v-if="p.status !== 'resolved'" + type="button" + :disabled="actingId === p.id" + @click="doResolve(p.id)" + > + Resolve + </button> + <span v-if="p.status === 'resolved'" class="actions-muted">Закрыта</span> + </div> + </td> + </tr> + </tbody> + </table> + <div class="filters" style="margin-top: 1rem"> + <button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button> + <span>Стр. {{ page }}</span> + <button + type="button" + class="secondary" + :disabled="!data || page * pageSize >= data.total" + @click="load(page + 1)" + > + Вперёд + </button> + </div> + </template> +</template> + +<script setup lang="ts"> +import { computed, onMounted, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api"; +import { formatServerName } from "../utils/hostDisplay"; + +const route = useRoute(); +const router = useRouter(); + +const data = ref<ProblemListResponse | null>(null); +const loading = ref(false); +const error = ref(""); +const page = ref(1); +const pageSize = 50; +const statusFilter = ref(""); +const severityFilter = ref(""); +const hostnameFilter = ref(""); +const createdWithinHours = ref(""); +const resolvedWithinHours = ref(""); +const actingId = ref<number | null>(null); + +const timeFilterHint = computed(() => { + if (createdWithinHours.value) { + return `Показаны активные проблемы, созданные за последние ${createdWithinHours.value} ч (без закрытых).`; + } + if (resolvedWithinHours.value) { + return `Показаны проблемы, закрытые за последние ${resolvedWithinHours.value} ч.`; + } + return ""; +}); + +function formatDt(iso: string) { + return new Date(iso).toLocaleString("ru-RU"); +} + +async function load(p: number) { + page.value = p; + loading.value = true; + error.value = ""; + try { + const params = new URLSearchParams({ + page: String(page.value), + page_size: String(pageSize), + }); + if (statusFilter.value && !resolvedWithinHours.value) { + params.set("status", statusFilter.value); + } + if (severityFilter.value) params.set("severity", severityFilter.value); + if (hostnameFilter.value.trim()) params.set("hostname", hostnameFilter.value.trim()); + if (createdWithinHours.value) params.set("created_within_hours", createdWithinHours.value); + if (resolvedWithinHours.value) params.set("resolved_within_hours", resolvedWithinHours.value); + data.value = await apiFetch<ProblemListResponse>(`/api/v1/problems?${params}`); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки"; + } finally { + loading.value = false; + } +} + +function buildProblemsQuery(): Record<string, string> { + const q: Record<string, string> = {}; + if (statusFilter.value && !resolvedWithinHours.value) q.status = statusFilter.value; + if (severityFilter.value) q.severity = severityFilter.value; + if (hostnameFilter.value.trim()) q.hostname = hostnameFilter.value.trim(); + if (createdWithinHours.value) q.created_within_hours = createdWithinHours.value; + if (resolvedWithinHours.value) q.resolved_within_hours = resolvedWithinHours.value; + return q; +} + +function applyFilters() { + if (resolvedWithinHours.value) createdWithinHours.value = ""; + else if (createdWithinHours.value) resolvedWithinHours.value = ""; + void router.replace({ path: "/problems", query: buildProblemsQuery() }); + void load(1); +} + +function syncFromRoute() { + const status = route.query.status; + statusFilter.value = typeof status === "string" ? status : ""; + const severity = route.query.severity; + severityFilter.value = typeof severity === "string" ? severity : ""; + const hostname = route.query.hostname; + hostnameFilter.value = typeof hostname === "string" ? hostname : ""; + const created = route.query.created_within_hours; + createdWithinHours.value = typeof created === "string" ? created : ""; + const resolved = route.query.resolved_within_hours; + resolvedWithinHours.value = typeof resolved === "string" ? resolved : ""; + if (resolvedWithinHours.value) statusFilter.value = "resolved"; +} + +async function doAck(id: number) { + actingId.value = id; + error.value = ""; + try { + await ackProblem(id); + await load(page.value); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка ack"; + } finally { + actingId.value = null; + } +} + +async function doResolve(id: number) { + actingId.value = id; + error.value = ""; + try { + await resolveProblem(id); + await load(page.value); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка resolve"; + } finally { + actingId.value = null; + } +} + +onMounted(() => { + syncFromRoute(); + load(1); +}); + +watch( + () => + [ + route.query.status, + route.query.severity, + route.query.hostname, + route.query.created_within_hours, + route.query.resolved_within_hours, + ] as const, + () => { + syncFromRoute(); + load(1); + }, +); +</script> diff --git a/frontend/src/views/ReportsView.vue b/frontend/src/views/ReportsView.vue new file mode 100644 index 0000000..850539f --- /dev/null +++ b/frontend/src/views/ReportsView.vue @@ -0,0 +1,147 @@ +<template> + <h1>Отчёты агентов</h1> + <p class="report-intro"> + Ежедневные сводки с агентов. Нажмите на строку, чтобы открыть полный отчёт с форматированием. + </p> + + <div class="filters"> + <input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" /> + <select v-model="reportType" @change="load(1)"> + <option value="report.daily.ssh">Отчёты ssh клиентов</option> + <option value="report.daily.rdp">Отчёты RDP клиентов</option> + </select> + <button type="button" @click="load(1)">Применить</button> + </div> + + <p v-if="error" class="error">{{ error }}</p> + <p v-else-if="loading">Загрузка…</p> + <template v-else> + <p>Всего: {{ data?.total ?? 0 }}</p> + <table class="reports-table"> + <thead> + <tr> + <th>Время</th> + <th>Хост</th> + <th>Имя сервера</th> + <th>Тип</th> + <th>Сводка</th> + </tr> + </thead> + <tbody> + <tr + v-for="e in data?.items ?? []" + :key="e.id" + class="report-row" + tabindex="0" + @click="openReport(e.id)" + @keyup.enter="openReport(e.id)" + > + <td>{{ formatDt(e.occurred_at) }}</td> + <td> + <strong>{{ e.hostname }}</strong> + </td> + <td>{{ formatServerName(e.display_name) }}</td> + <td>{{ reportTypeLabel(e.type) }}</td> + <td class="report-summary-cell">{{ e.summary }}</td> + </tr> + </tbody> + </table> + <div class="filters" style="margin-top: 1rem"> + <button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button> + <span>Стр. {{ page }}</span> + <button + type="button" + class="secondary" + :disabled="!data || page * pageSize >= data.total" + @click="load(page + 1)" + > + Вперёд + </button> + </div> + </template> +</template> + +<script setup lang="ts"> +import { onMounted, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { apiFetch, type EventListResponse } from "../api"; +import { formatServerName } from "../utils/hostDisplay"; +import { dailyReportTypeLabel } from "../utils/reportDisplay"; + +function reportTypeLabel(type: string) { + return dailyReportTypeLabel(type); +} + +const route = useRoute(); +const router = useRouter(); + +const data = ref<EventListResponse | null>(null); +const loading = ref(false); +const error = ref(""); +const page = ref(1); +const pageSize = 30; +const hostname = ref(""); +const reportType = ref("report.daily.ssh"); +const REPORT_TYPE_KEY = "sac_reports_type"; + +function formatDt(iso: string) { + return new Date(iso).toLocaleString("ru-RU"); +} + +function openReport(id: number) { + const query: Record<string, string> = { from: "reports", type: reportType.value }; + if (hostname.value.trim()) query.hostname = hostname.value.trim(); + router.push({ path: `/events/${id}`, query }); +} + +async function load(p: number) { + page.value = p; + loading.value = true; + error.value = ""; + try { + const params = new URLSearchParams({ + page: String(page.value), + page_size: String(pageSize), + severity: "info", + }); + params.set("type", reportType.value); + if (hostname.value.trim()) { + params.set("hostname", hostname.value.trim()); + } + data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки"; + } finally { + loading.value = false; + } +} + +onMounted(() => { + const h = route.query.hostname; + if (typeof h === "string" && h) hostname.value = h; + const t = route.query.type; + if (typeof t === "string" && t) { + reportType.value = t; + } else { + const savedType = localStorage.getItem(REPORT_TYPE_KEY); + if (savedType === "report.daily.ssh" || savedType === "report.daily.rdp") { + reportType.value = savedType; + } + } + load(1); +}); + +watch( + () => route.query.hostname, + (h) => { + if (typeof h === "string") { + hostname.value = h; + load(1); + } + } +); + +watch(reportType, (value) => { + localStorage.setItem(REPORT_TYPE_KEY, value); +}); +</script> diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..42d7e53 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,1899 @@ +<template> + <div class="settings-page"> + <h1>Настройки</h1> + <p class="settings-intro"> + Каналы оповещений SAC. Значения в БД (<code>source=db</code>) или из <code>sac-api.env</code> (<code>env</code>), + пока запись не создана через UI. + </p> + + <p v-if="error" class="error">{{ error }}</p> + <p v-if="loading">Загрузка…</p> + + <template v-if="!loading"> + <p v-if="success" class="success">{{ success }}</p> + + <div v-if="lastCreatedCode" class="settings-enrollment-banner" role="status"> + <p class="settings-enrollment-banner-title">Код регистрации Seaca</p> + <p class="settings-enrollment-banner-hint"> + Скопируйте и передайте оператору вместе с URL сервера. Полный код показывается только сейчас. + </p> + <div class="settings-enrollment-code-row"> + <code class="settings-enrollment-code-value">{{ lastCreatedCode }}</code> + <button type="button" @click="copyEnrollmentCode">Копировать</button> + <button type="button" class="secondary" @click="dismissEnrollmentCode">Скрыть</button> + </div> + </div> + <section class="card settings-card settings-policy"> + <h2>Интерфейс</h2> + <p class="settings-hint settings-hint-top"> + Блок «Сервер» в левой панели: CPU, RAM, диск, задержка БД и счётчики SAC. Обновляется каждые 30 секунд. + </p> + <form class="settings-form" @submit.prevent="saveUiSettings"> + <label class="settings-field settings-inline"> + <input v-model="uiForm.show_sidebar_system_stats" type="checkbox" /> + Показывать блок системной информации в боковой панели + </label> + <p v-if="uiLoaded" class="settings-meta"> + Источник: <code>{{ uiLoaded.source }}</code> + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingUi"> + {{ savingUi ? "Сохранение…" : "Сохранить интерфейс" }} + </button> + </div> + </form> + </section> + + <section class="card settings-card settings-policy"> + <h2>Блокировки входа (UI и SSH)</h2> + <p class="settings-hint settings-hint-top"> + <strong>Web UI:</strong> SAC блокирует IP после {{ loginSecurityLoaded?.max_failures ?? 3 }} неудачных + попыток входа за {{ loginSecurityLoaded?.window_minutes ?? 15 }} мин (таблица + <code>login_attempts</code>, ответ HTTP 429). + <strong> SSH</strong> блокирует <code>fail2ban</code> на ОС — это отдельно от SAC; типичная причина — + неверный пароль при <code>ssh</code>, не путать с паролем UI. + IP из белого списка не учитываются в лимите UI и могут синхронизироваться в fail2ban + (<code>ignoreip</code>), если включена опция ниже. + </p> + <form class="settings-form" @submit.prevent="saveLoginSecuritySettingsHandler"> + <label class="settings-field"> + Белый список IP (по одному на строку) + <textarea + v-model="loginSecurityForm.ip_whitelist_text" + rows="5" + placeholder="192.168.160.3 192.168.160.42" + spellcheck="false" + /> + </label> + <label class="settings-field settings-inline"> + <input v-model="loginSecurityForm.sync_fail2ban" type="checkbox" /> + Синхронизировать белый список в fail2ban ignoreip (SSH) + </label> + <p v-if="loginSecurityLoaded" class="settings-meta"> + fail2ban: {{ loginSecurityLoaded.fail2ban_available ? "доступен" : "не найден на сервере" }} + · источник whitelist: <code>{{ loginSecurityLoaded.source }}</code> + · лимит UI: {{ loginSecurityLoaded.max_failures }} / + {{ loginSecurityLoaded.window_minutes }} мин + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingLoginSecurity"> + {{ savingLoginSecurity ? "Сохранение…" : "Сохранить белый список" }} + </button> + <button type="button" class="secondary" :disabled="loadingLoginBlocks" @click="refreshLoginBlocks"> + {{ loadingLoginBlocks ? "Обновление…" : "Обновить список блокировок" }} + </button> + </div> + </form> + + <h3>Заблокированные IP</h3> + <p v-if="loadingLoginBlocks" class="settings-meta">Загрузка…</p> + <p v-else-if="!loginBlocks.length" class="settings-meta">Сейчас блокировок нет (или все IP в белом списке).</p> + <table v-else class="settings-blocks-table"> + <thead> + <tr> + <th>IP</th> + <th>Где</th> + <th>Попытки / причина</th> + <th>Логины</th> + <th>До</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="row in loginBlocks" :key="row.ip_address + row.scope"> + <td><code>{{ row.ip_address }}</code></td> + <td>{{ row.scope }}</td> + <td> + <span v-if="row.failure_count != null">{{ row.failure_count }} · </span>{{ row.reason }} + </td> + <td>{{ row.usernames.length ? row.usernames.join(", ") : "—" }}</td> + <td>{{ row.blocked_until ? formatDateTime(row.blocked_until) : "—" }}</td> + <td> + <button + type="button" + class="secondary" + :disabled="unblockingIp === row.ip_address" + @click="unblockIp(row.ip_address, row.scope)" + > + {{ unblockingIp === row.ip_address ? "…" : "Разблокировать" }} + </button> + </td> + </tr> + </tbody> + </table> + </section> + + <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). + Формат логина: <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"> + <label class="settings-field"> + Доменный admin + <input + v-model="winAdminForm.user" + type="text" + autocomplete="off" + placeholder="B26\papatramp" + /> + </label> + <label class="settings-field"> + Пароль + <input + v-model="winAdminForm.password" + type="password" + autocomplete="new-password" + :placeholder="winAdminPasswordPlaceholder" + /> + </label> + <p v-if="winAdminLoaded" class="settings-meta"> + Настроен: {{ winAdminLoaded.configured ? "да" : "нет" }} + · источник: <code>{{ winAdminLoaded.source }}</code> + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingWinAdmin"> + {{ savingWinAdmin ? "Сохранение…" : "Сохранить Windows admin" }} + </button> + </div> + </form> + + <form class="settings-form" @submit.prevent="saveRdpFlapSettings"> + <h3>RDP flapping</h3> + <p class="settings-hint"> + При включении SAC автоматически завершает зависшую сессию пользователя на целевом ПК + (WinRM qwinsta/logoff): при RDG flap 302→303 и при прямом RDP, если вход не удался из‑за + открытой сессии. Нужен настроенный доменный admin выше. + </p> + <label class="settings-field settings-inline"> + <input v-model="rdpFlapForm.auto_disconnect" type="checkbox" /> + Автоматически определять RDP flapping и отключать сессии + </label> + <p v-if="rdpFlapLoaded" class="settings-meta"> + Windows admin: {{ rdpFlapLoaded.win_admin_configured ? "настроен" : "не настроен" }} + · источник: <code>{{ rdpFlapLoaded.source }}</code> + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingRdpFlap"> + {{ savingRdpFlap ? "Сохранение…" : "Сохранить RDP flapping" }} + </button> + </div> + </form> + </section> + + <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-хоста. + Рекомендуется <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"> + <label class="settings-field"> + SSH login + <input + v-model="linuxAdminForm.user" + type="text" + autocomplete="off" + placeholder="root" + /> + </label> + <label class="settings-field"> + SSH password + <input + v-model="linuxAdminForm.password" + type="password" + autocomplete="new-password" + :placeholder="linuxAdminPasswordPlaceholder" + /> + </label> + <p v-if="linuxAdminLoaded" class="settings-meta"> + Настроен: {{ linuxAdminLoaded.configured ? "да" : "нет" }} + · источник: <code>{{ linuxAdminLoaded.source }}</code> + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingLinuxAdmin"> + {{ savingLinuxAdmin ? "Сохранение…" : "Сохранить Linux admin" }} + </button> + </div> + </form> + </section> + + <section class="card settings-card settings-policy"> + <h2>Обновления агентов</h2> + <p class="settings-hint settings-hint-top"> + Режим <code>gpo</code> — обновления вручную/GPO; SAC только показывает устаревшие версии. + Режим <code>sac</code> — «Запросить обновление» ставит флаг в poll; агент self-update; + через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа — fallback SSH/WinRM с SAC. + <strong>Эталон «устарела»</strong> берётся из <code>version.txt</code> / скриптов в git-репозиториях + (ветка <code>main</code> по умолчанию); при недоступности git — fallback на max версию в fleet. + </p> + <form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm"> + <label class="settings-field"> + Режим + <select v-model="agentUpdateForm.mode"> + <option value="gpo">GPO / ручной</option> + <option value="sac">SAC (poll + fallback)</option> + </select> + </label> + <label class="settings-field settings-inline"> + <input v-model="agentUpdateForm.fallback_enabled" type="checkbox" /> + Авто-fallback SSH/WinRM после таймаута + </label> + <label class="settings-field"> + Fallback через (мин) + <input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" /> + </label> + <label class="settings-field"> + Git: RDP-login-monitor + <input + v-model="agentUpdateForm.rdp_git_repo_url" + type="text" + placeholder="https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git" + /> + </label> + <label class="settings-field"> + Git: ssh-monitor + <input + v-model="agentUpdateForm.ssh_git_repo_url" + type="text" + placeholder="https://git.papatramp.ru/PapaTramp/ssh-monitor.git" + /> + </label> + <label class="settings-field"> + Git-ветка + <input v-model="agentUpdateForm.git_branch" type="text" placeholder="main" /> + </label> + <div ref="agentGitResultRef" class="settings-agent-git-panel"> + <p v-if="agentGitFeedback" :class="['settings-agent-git-feedback', agentGitFeedback.kind]"> + {{ agentGitFeedback.text }} + </p> + <p v-if="agentGitShowVersions" class="settings-meta settings-agent-git-versions"> + Latest из git: + RDP <code>{{ agentUpdateLoaded?.git_latest_rdp_version ?? "—" }}</code>, + SSH <code>{{ agentUpdateLoaded?.git_latest_ssh_version ?? "—" }}</code> + <span v-if="agentUpdateLoaded?.git_fetched_at"> + · обновлено {{ formatDateTime(agentUpdateLoaded.git_fetched_at) }} + </span> + </p> + <p v-if="agentGitTestErrors" class="settings-agent-git-feedback error">{{ agentGitTestErrors }}</p> + </div> + <label class="settings-field"> + WinRM: путь к Deploy-LoginMonitor.ps1 (fallback) + <input + v-model="agentUpdateForm.win_agent_update_script" + type="text" + placeholder="C:\ProgramData\LoginMonitor\Deploy-LoginMonitor.ps1" + /> + </label> + <p v-if="agentUpdateLoaded" class="settings-meta"> + Источник: <code>{{ agentUpdateLoaded.source }}</code> + </p> + <div class="settings-actions"> + <button type="button" :disabled="testingAgentGit" @click="runAgentGitTest"> + {{ testingAgentGit ? "Проверка git…" : "Проверить git" }} + </button> + <button type="submit" :disabled="savingAgentUpdate"> + {{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }} + </button> + </div> + </form> + </section> + + <section class="card settings-card settings-policy"> + <h2>Правило оповещений</h2> + <p class="settings-hint settings-hint-top"> + События и problems с severity <strong>≥</strong> порога уходят в выбранные каналы (если канал включён и + настроен). + </p> + <form class="settings-form" @submit.prevent="savePolicy"> + <label class="settings-field"> + Мин. severity + <select v-model="policyForm.min_severity"> + <option value="info">info</option> + <option value="warning">warning</option> + <option value="high">high</option> + <option value="critical">critical</option> + </select> + </label> + <fieldset class="settings-channels"> + <legend>Каналы</legend> + <label class="settings-inline"> + <input v-model="policyForm.use_telegram" type="checkbox" /> + Telegram + </label> + <label class="settings-inline"> + <input v-model="policyForm.use_webhook" type="checkbox" /> + Webhook + </label> + <label class="settings-inline"> + <input v-model="policyForm.use_email" type="checkbox" /> + Email + </label> + <label class="settings-inline"> + <input v-model="policyForm.use_mobile" type="checkbox" /> + Seaca (push) + </label> + </fieldset> + <p v-if="policyLoaded" class="settings-meta"> + Источник: <code>{{ policyLoaded.source }}</code> + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingPolicy"> + {{ savingPolicy ? "Сохранение…" : "Сохранить правило" }} + </button> + </div> + </form> + </section> + + <section class="card settings-card settings-policy"> + <h2>Мобильные устройства (Seaca)</h2> + <p class="settings-hint settings-hint-top"> + Подключение Android-клиента по коду регистрации. Управление пользователями SAC — в разделе «Пользователи». + </p> + <form class="settings-form" @submit.prevent="saveMobileSettings"> + <label class="settings-field settings-inline"> + <input v-model="mobileForm.devices_allowed" type="checkbox" /> + Разрешать мобильным устройствам подключаться + </label> + <label class="settings-field"> + Макс. устройств на пользователя + <input v-model.number="mobileForm.max_devices_per_user" type="number" min="1" max="50" /> + </label> + <label class="settings-field"> + Мин. версия приложения (необязательно) + <input v-model="mobileForm.min_app_version" type="text" placeholder="0.1.0" /> + </label> + <p v-if="mobileLoaded" class="settings-meta"> + FCM: {{ mobileLoaded.fcm_configured ? "настроен" : "не настроен" }} + · источник: <code>{{ mobileLoaded.source }}</code> + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingMobile"> + {{ savingMobile ? "Сохранение…" : "Сохранить мобильные" }} + </button> + </div> + </form> + + <h3>Коды регистрации</h3> + <form class="settings-form settings-mobile-code-form" @submit.prevent="createEnrollmentCode"> + <label class="settings-field"> + Метка + <input v-model="codeForm.label" type="text" placeholder="Телефон Иванова" /> + </label> + <label class="settings-field"> + Пользователь (необязательно) + <select v-model="codeForm.target_user_id"> + <option :value="null">— любой с паролем —</option> + <option v-for="u in sacUsers" :key="u.id" :value="u.id">{{ u.username }} ({{ u.role }})</option> + </select> + </label> + <label class="settings-field"> + Способ входа + <select v-model="codeForm.login_mode"> + <option value="password">Логин и пароль SAC + код</option> + <option value="code_only">Только код (пользователь привязан)</option> + </select> + </label> + <label class="settings-field"> + Срок (часов) + <input v-model.number="codeForm.expires_in_hours" type="number" min="1" max="720" /> + </label> + <div class="settings-actions"> + <button type="submit" :disabled="creatingCode"> + {{ creatingCode ? "Создание…" : "Выдать код" }} + </button> + </div> + </form> + <div class="settings-mobile-table-wrap"> + <table class="settings-severity-table"> + <thead> + <tr> + <th>Метка</th> + <th>Префикс</th> + <th>Пользователь</th> + <th>Вход</th> + <th>Использований</th> + <th>Действия</th> + </tr> + </thead> + <tbody> + <tr v-for="code in enrollmentCodes" :key="code.id"> + <td>{{ code.label || "—" }}</td> + <td><code>{{ code.code_prefix }}…</code></td> + <td>{{ code.target_username || "—" }}</td> + <td>{{ code.login_mode }}</td> + <td>{{ code.use_count }}/{{ code.max_uses }}</td> + <td> + <button + v-if="code.is_active" + type="button" + class="secondary" + @click="revokeCode(code.id)" + > + Отозвать + </button> + </td> + </tr> + </tbody> + </table> + </div> + + <h3>Подключённые устройства</h3> + <div class="settings-mobile-table-wrap"> + <table class="settings-severity-table"> + <thead> + <tr> + <th>Имя</th> + <th>Пользователь</th> + <th>Статус</th> + <th>FCM</th> + <th>Последняя активность</th> + <th>Действия</th> + </tr> + </thead> + <tbody> + <tr v-for="device in mobileDevices" :key="device.id"> + <td>{{ device.display_name }}</td> + <td>{{ device.username }}</td> + <td>{{ device.is_active ? "активно" : "отключено" }}</td> + <td>{{ device.has_fcm_token ? "да" : "нет" }}</td> + <td>{{ device.last_seen_at || device.enrolled_at }}</td> + <td class="settings-mobile-actions"> + <button + v-if="device.is_active && device.has_fcm_token" + type="button" + class="secondary" + :disabled="testingPushDeviceId === device.id" + @click="testDevicePush(device.id)" + > + {{ testingPushDeviceId === device.id ? "Отправка…" : "Тест push" }} + </button> + <button + v-if="device.is_active" + type="button" + class="secondary" + @click="revokeDevice(device.id)" + > + Отключить + </button> + <button + type="button" + class="danger" + @click="deleteDevice(device.id)" + > + Удалить + </button> + </td> + </tr> + </tbody> + </table> + </div> + </section> + + <section class="card settings-card"> + <h2>Severity по типу события</h2> + <p class="settings-hint settings-hint-top"> + Переопределение значимости для новых событий после ingest. Исходное severity агента сохраняется в + <code>details.severity_agent</code>. Пустое значение — как прислал агент (см. «По умолчанию»). + Снимите «Показывать в событиях», чтобы скрыть тип из списков SAC (например <code>agent.heartbeat</code>). + </p> + <div class="settings-severity-toolbar"> + <input v-model="severityFilter" type="search" placeholder="Фильтр по type…" /> + <button type="button" :disabled="savingSeverity" @click="saveSeverityOverrides"> + {{ savingSeverity ? "Сохранение…" : "Сохранить severity" }} + </button> + </div> + <div class="settings-severity-table-wrap"> + <table class="settings-severity-table"> + <thead> + <tr> + <th>Тип события</th> + <th>По умолчанию</th> + <th>Effective</th> + <th>Показывать в событиях</th> + </tr> + </thead> + <tbody> + <tr v-for="row in filteredSeverityRows" :key="row.event_type"> + <td><code>{{ row.event_type }}</code></td> + <td>{{ row.default_severity }}</td> + <td> + <select v-model="row.selected"> + <option value="">по умолчанию агента</option> + <option value="info">info</option> + <option value="warning">warning</option> + <option value="high">high</option> + <option value="critical">critical</option> + </select> + </td> + <td class="settings-severity-show-cell"> + <label> + <input v-model="row.show_in_events" type="checkbox" /> + показывать + </label> + </td> + </tr> + </tbody> + </table> + </div> + <p class="settings-meta">Записей: {{ filteredSeverityRows.length }} / {{ severityRows.length }}</p> + </section> + + <section class="card settings-card"> + <h2>Telegram</h2> + <form class="settings-form" @submit.prevent="saveTelegram"> + <label class="settings-field"> + <input v-model="telegramForm.enabled" type="checkbox" /> + Включить Telegram + </label> + <label class="settings-field"> + Bot token + <input + v-model="telegramForm.bot_token" + type="password" + autocomplete="off" + :placeholder="tokenPlaceholder" + /> + </label> + <label class="settings-field"> + Chat ID + <input + v-model="telegramForm.chat_id" + type="text" + autocomplete="off" + :placeholder="chatPlaceholder" + /> + </label> + <p v-if="telegramLoaded" class="settings-meta"> + Источник: <code>{{ telegramLoaded.source }}</code> + · настроен: {{ telegramLoaded.configured ? "да" : "нет" }} + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingTelegram"> + {{ savingTelegram ? "Сохранение…" : "Сохранить" }} + </button> + <button type="button" class="secondary" :disabled="testingTelegram" @click="testTelegram"> + {{ testingTelegram ? "Отправка…" : "Проверить Telegram" }} + </button> + </div> + </form> + <p class="settings-hint"> + Для <strong>UseSAC=exclusive</strong> рекомендуется <code>warning</code> — failed login и problems, без + успешных RDP (<code>info</code>). + </p> + </section> + + <section class="card settings-card"> + <h2>Webhook</h2> + <form class="settings-form" @submit.prevent="saveWebhook"> + <label class="settings-field"> + <input v-model="webhookForm.enabled" type="checkbox" /> + Включить webhook + </label> + <label class="settings-field"> + URL + <input + v-model="webhookForm.url" + type="url" + autocomplete="off" + :placeholder="urlPlaceholder" + /> + </label> + <label class="settings-field"> + Secret header (необязательно) + <input v-model="webhookForm.secret_header" type="text" autocomplete="off" placeholder="X-SAC-Token" /> + </label> + <label class="settings-field"> + Secret value + <input + v-model="webhookForm.secret" + type="password" + autocomplete="off" + :placeholder="webhookSecretPlaceholder" + /> + </label> + <p v-if="webhookLoaded" class="settings-meta"> + Источник: <code>{{ webhookLoaded.source }}</code> + · настроен: {{ webhookLoaded.configured ? "да" : "нет" }} + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingWebhook"> + {{ savingWebhook ? "Сохранение…" : "Сохранить" }} + </button> + <button type="button" class="secondary" :disabled="testingWebhook" @click="testWebhook"> + {{ testingWebhook ? "Отправка…" : "Проверить webhook" }} + </button> + </div> + </form> + <p class="settings-hint">JSON POST: <code>kind</code> = event | problem | test; те же пороги severity, что у Telegram.</p> + </section> + + <section class="card settings-card"> + <h2>Email (SMTP)</h2> + <form class="settings-form" @submit.prevent="saveEmail"> + <label class="settings-field"> + <input v-model="emailForm.enabled" type="checkbox" /> + Включить email + </label> + <label class="settings-field"> + SMTP host + <input v-model="emailForm.smtp_host" type="text" autocomplete="off" :placeholder="smtpHostPlaceholder" /> + </label> + <label class="settings-field"> + SMTP port + <input v-model.number="emailForm.smtp_port" type="number" min="1" max="65535" /> + </label> + <label class="settings-field"> + SMTP user + <input v-model="emailForm.smtp_user" type="text" autocomplete="off" /> + </label> + <label class="settings-field"> + SMTP password + <input + v-model="emailForm.smtp_password" + type="password" + autocomplete="off" + :placeholder="smtpPasswordPlaceholder" + /> + </label> + <label class="settings-field"> + From + <input v-model="emailForm.mail_from" type="email" autocomplete="off" /> + </label> + <label class="settings-field"> + To (через запятую или ;) + <input v-model="emailForm.mail_to" type="text" autocomplete="off" :placeholder="mailToPlaceholder" /> + </label> + <label class="settings-field settings-inline"> + <input v-model="emailForm.smtp_starttls" type="checkbox" /> + STARTTLS + </label> + <label class="settings-field settings-inline"> + <input v-model="emailForm.smtp_ssl" type="checkbox" /> + SSL (порт 465) + </label> + <p v-if="emailLoaded" class="settings-meta"> + Источник: <code>{{ emailLoaded.source }}</code> + · настроен: {{ emailLoaded.configured ? "да" : "нет" }} + </p> + <div class="settings-actions"> + <button type="submit" :disabled="savingEmail"> + {{ savingEmail ? "Сохранение…" : "Сохранить" }} + </button> + <button type="button" class="secondary" :disabled="testingEmail" @click="testEmail"> + {{ testingEmail ? "Отправка…" : "Проверить email" }} + </button> + </div> + </form> + </section> + </template> + </div> +</template> + +<script setup lang="ts"> +import { computed, nextTick, onMounted, reactive, ref } from "vue"; +import { + apiFetch, + createMobileEnrollmentCode, + fetchMobileDevices, + fetchMobileEnrollmentCodes, + fetchMobileSettings, + fetchUsers, + fetchWinAdminSettings, + fetchRdpFlapSettings, + updateRdpFlapSettings, + fetchLinuxAdminSettings, + fetchAgentUpdateSettings, + saveAgentUpdateSettings, + testAgentGitRelease, + fetchLoginSecuritySettings, + saveLoginSecuritySettings, + fetchLoginSecurityBlocks, + unblockLoginSecurityIp, + type LoginBlockItem, + type LoginSecuritySettings, + revokeMobileDevice, + deleteMobileDevice, + revokeMobileEnrollmentCode, + testMobileDevicePush, + updateMobileSettings, + updateWinAdminSettings, + updateLinuxAdminSettings, + type MobileDevice, + type MobileEnrollmentCode, + type MobileSettings, + type AgentUpdateSettings, + type RdpFlapSettings, + type UserSummary, +} from "../api"; + +interface TelegramSettings { + enabled: boolean; + configured: boolean; + chat_id_hint: string | null; + bot_token_hint: string | null; + min_severity: string; + source: string; +} + +interface WebhookSettings { + enabled: boolean; + configured: boolean; + url_hint: string | null; + secret_header: string | null; + secret_hint: string | null; + min_severity: string; + source: string; +} + +interface EmailSettings { + enabled: boolean; + configured: boolean; + smtp_host_hint: string | null; + smtp_port: number; + smtp_user: string | null; + smtp_password_hint: string | null; + mail_from: string | null; + mail_to_hint: string | null; + smtp_starttls: boolean; + smtp_ssl: boolean; + min_severity: string; + source: string; +} + +interface NotificationPolicy { + min_severity: string; + use_telegram: boolean; + use_webhook: boolean; + use_email: boolean; + use_mobile: boolean; + source: string; +} + +interface UiSettings { + show_sidebar_system_stats: boolean; + source: string; +} + +interface WinAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + +interface LinuxAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + +interface SeverityOverrideRowApi { + event_type: string; + default_severity: string; + override_severity: string | null; + show_in_events: boolean; +} + +interface SeverityOverrideRowUi extends SeverityOverrideRowApi { + selected: string; + override_show_in_events: boolean; +} + +const loading = ref(true); +const savingPolicy = ref(false); +const savingUi = ref(false); +const savingWinAdmin = ref(false); +const savingLinuxAdmin = ref(false); +const savingAgentUpdate = ref(false); +const savingLoginSecurity = ref(false); +const loadingLoginBlocks = ref(false); +const unblockingIp = ref(""); +const testingAgentGit = ref(false); +const agentGitTestErrors = ref(""); +const agentGitShowVersions = ref(false); +const agentGitResultRef = ref<HTMLElement | null>(null); +const agentGitFeedback = ref<{ kind: "success" | "error" | "info"; text: string } | null>(null); +const savingTelegram = ref(false); +const savingWebhook = ref(false); +const savingEmail = ref(false); +const savingSeverity = ref(false); +const savingMobile = ref(false); +const creatingCode = ref(false); +const testingTelegram = ref(false); +const testingWebhook = ref(false); +const testingEmail = ref(false); +const testingPushDeviceId = ref<number | null>(null); +const error = ref(""); +const success = ref(""); +const telegramLoaded = ref<TelegramSettings | null>(null); +const webhookLoaded = ref<WebhookSettings | null>(null); +const emailLoaded = ref<EmailSettings | null>(null); +const policyLoaded = ref<NotificationPolicy | null>(null); +const uiLoaded = ref<UiSettings | null>(null); +const winAdminLoaded = ref<WinAdminSettings | null>(null); +const rdpFlapLoaded = ref<RdpFlapSettings | null>(null); +const savingRdpFlap = ref(false); +const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null); +const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null); +const loginSecurityLoaded = ref<LoginSecuritySettings | null>(null); +const loginBlocks = ref<LoginBlockItem[]>([]); +const severityRows = ref<SeverityOverrideRowUi[]>([]); +const severityFilter = ref(""); +const mobileLoaded = ref<MobileSettings | null>(null); +const enrollmentCodes = ref<MobileEnrollmentCode[]>([]); +const mobileDevices = ref<MobileDevice[]>([]); +const sacUsers = ref<UserSummary[]>([]); +const lastCreatedCode = ref(""); +const ENROLLMENT_CODE_STORAGE_KEY = "sac_mobile_last_enrollment_code"; + +function restoreLastCreatedCode() { + const stored = sessionStorage.getItem(ENROLLMENT_CODE_STORAGE_KEY); + if (stored) { + lastCreatedCode.value = stored; + } +} + +function persistLastCreatedCode(code: string) { + sessionStorage.setItem(ENROLLMENT_CODE_STORAGE_KEY, code); +} + +function dismissEnrollmentCode() { + lastCreatedCode.value = ""; + sessionStorage.removeItem(ENROLLMENT_CODE_STORAGE_KEY); +} + +async function copyEnrollmentCode() { + if (!lastCreatedCode.value) return; + error.value = ""; + try { + await navigator.clipboard.writeText(lastCreatedCode.value); + success.value = "Код скопирован в буфер обмена."; + } catch { + error.value = "Не удалось скопировать автоматически — выделите код вручную."; + } +} + +const policyForm = reactive({ + min_severity: "warning", + use_telegram: true, + use_webhook: false, + use_email: false, + use_mobile: false, +}); + +const mobileForm = reactive({ + devices_allowed: false, + max_devices_per_user: 3, + min_app_version: "", +}); + +const codeForm = reactive({ + label: "", + target_user_id: null as number | null, + login_mode: "password", + expires_in_hours: 24, + max_uses: 1, +}); + +const uiForm = reactive({ + show_sidebar_system_stats: true, +}); + +const loginSecurityForm = reactive({ + ip_whitelist_text: "", + sync_fail2ban: false, +}); + +const winAdminForm = reactive({ + user: "", + password: "", +}); + +const rdpFlapForm = reactive({ + auto_disconnect: false, +}); + +const linuxAdminForm = reactive({ + user: "", + password: "", +}); + +const agentUpdateForm = reactive({ + mode: "gpo" as "gpo" | "sac", + fallback_enabled: true, + fallback_after_minutes: 15, + rdp_git_repo_url: "", + ssh_git_repo_url: "", + git_branch: "main", + win_agent_update_script: "", +}); + +const telegramForm = reactive({ + enabled: false, + bot_token: "", + chat_id: "", +}); + +const webhookForm = reactive({ + enabled: false, + url: "", + secret_header: "", + secret: "", +}); + +const emailForm = reactive({ + enabled: false, + smtp_host: "", + smtp_port: 587, + smtp_user: "", + smtp_password: "", + mail_from: "", + mail_to: "", + smtp_starttls: true, + smtp_ssl: false, +}); + +const tokenPlaceholder = computed(() => + telegramLoaded.value?.bot_token_hint + ? `оставить ${telegramLoaded.value.bot_token_hint}` + : "обязателен при первой настройке", +); +const chatPlaceholder = computed(() => + telegramLoaded.value?.chat_id_hint + ? `оставить ${telegramLoaded.value.chat_id_hint}` + : "обязателен при первой настройке", +); +const urlPlaceholder = computed(() => + webhookLoaded.value?.url_hint ? `оставить ${webhookLoaded.value.url_hint}` : "https://…", +); +const webhookSecretPlaceholder = computed(() => + webhookLoaded.value?.secret_hint ? `оставить ${webhookLoaded.value.secret_hint}` : "необязательно", +); +const smtpHostPlaceholder = computed(() => + emailLoaded.value?.smtp_host_hint ? `оставить ${emailLoaded.value.smtp_host_hint}` : "smtp.example.com", +); +const smtpPasswordPlaceholder = computed(() => + emailLoaded.value?.smtp_password_hint ? `оставить ${emailLoaded.value.smtp_password_hint}` : "необязательно", +); +const mailToPlaceholder = computed(() => + emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com", +); +const winAdminPasswordPlaceholder = computed(() => + winAdminLoaded.value?.password_hint + ? `оставить ${winAdminLoaded.value.password_hint}` + : "обязателен при первой настройке", +); +const linuxAdminPasswordPlaceholder = computed(() => + linuxAdminLoaded.value?.password_hint + ? `оставить ${linuxAdminLoaded.value.password_hint}` + : "обязателен при первой настройке", +); + +const filteredSeverityRows = computed(() => { + const q = severityFilter.value.trim().toLowerCase(); + if (!q) return severityRows.value; + return severityRows.value.filter((row) => row.event_type.toLowerCase().includes(q)); +}); + +function mapSeverityRows(items: SeverityOverrideRowApi[]): SeverityOverrideRowUi[] { + return items.map((row) => ({ + ...row, + selected: row.override_severity ?? "", + override_show_in_events: row.show_in_events, + })); +} + +async function loadSeverityOverrides() { + const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>( + "/api/v1/settings/notifications/severity-overrides", + ); + severityRows.value = mapSeverityRows(data.items); +} + +async function saveSeverityOverrides() { + savingSeverity.value = true; + error.value = ""; + success.value = ""; + try { + const overrides: Record<string, string | null> = {}; + const visibility: Record<string, boolean | null> = {}; + for (const row of severityRows.value) { + const prevSeverity = row.override_severity ?? ""; + const nextSeverity = row.selected.trim(); + if (nextSeverity !== prevSeverity) { + overrides[row.event_type] = nextSeverity || null; + } + if (row.show_in_events !== (row.override_show_in_events ?? true)) { + visibility[row.event_type] = row.show_in_events ? null : false; + } + } + if (Object.keys(overrides).length === 0 && Object.keys(visibility).length === 0) { + error.value = "Нет изменений для сохранения"; + return; + } + const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>( + "/api/v1/settings/notifications/severity-overrides", + { + method: "PUT", + body: JSON.stringify({ overrides, visibility }), + }, + ); + severityRows.value = mapSeverityRows(data.items); + success.value = "Severity и видимость событий сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения severity"; + } finally { + savingSeverity.value = false; + } +} + +async function loadLoginSecuritySettings() { + loginSecurityLoaded.value = await fetchLoginSecuritySettings(); + loginSecurityForm.ip_whitelist_text = loginSecurityLoaded.value.ip_whitelist_text ?? ""; + loginSecurityForm.sync_fail2ban = loginSecurityLoaded.value.sync_fail2ban; + await refreshLoginBlocks(); +} + +async function saveLoginSecuritySettingsHandler() { + savingLoginSecurity.value = true; + error.value = ""; + success.value = ""; + try { + loginSecurityLoaded.value = await saveLoginSecuritySettings({ + ip_whitelist_text: loginSecurityForm.ip_whitelist_text, + sync_fail2ban: loginSecurityForm.sync_fail2ban, + }); + success.value = "Белый список IP сохранён."; + await refreshLoginBlocks(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения login-security"; + } finally { + savingLoginSecurity.value = false; + } +} + +async function refreshLoginBlocks() { + loadingLoginBlocks.value = true; + try { + loginBlocks.value = await fetchLoginSecurityBlocks(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки блокировок"; + } finally { + loadingLoginBlocks.value = false; + } +} + +function unblockScopeFromRow(scope: string): "web" | "ssh" | "both" { + const s = scope.toLowerCase(); + if (s.includes("web") && s.includes("ssh")) return "both"; + if (s.includes("ssh")) return "ssh"; + return "web"; +} + +async function unblockIp(ip: string, scope: string) { + unblockingIp.value = ip; + error.value = ""; + success.value = ""; + try { + const result = await unblockLoginSecurityIp({ + ip_address: ip, + scope: unblockScopeFromRow(scope), + }); + success.value = result.messages.join(" · "); + await refreshLoginBlocks(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка разблокировки"; + } finally { + unblockingIp.value = ""; + } +} + +async function loadUiSettings() { + const data = await apiFetch<UiSettings>("/api/v1/settings/ui"); + uiLoaded.value = data; + uiForm.show_sidebar_system_stats = data.show_sidebar_system_stats; +} + +async function saveUiSettings() { + savingUi.value = true; + error.value = ""; + success.value = ""; + try { + uiLoaded.value = await apiFetch<UiSettings>("/api/v1/settings/ui", { + method: "PUT", + body: JSON.stringify({ + show_sidebar_system_stats: uiForm.show_sidebar_system_stats, + }), + }); + success.value = "Настройки интерфейса сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения"; + } finally { + savingUi.value = false; + } +} + +async function loadWinAdminSettings() { + const data = await fetchWinAdminSettings(); + winAdminLoaded.value = data; + winAdminForm.user = data.user ?? ""; + winAdminForm.password = ""; +} + +async function loadRdpFlapSettings() { + const data = await fetchRdpFlapSettings(); + rdpFlapLoaded.value = data; + rdpFlapForm.auto_disconnect = data.auto_disconnect; +} + +async function saveRdpFlapSettings() { + savingRdpFlap.value = true; + error.value = ""; + success.value = ""; + try { + rdpFlapLoaded.value = await updateRdpFlapSettings({ + auto_disconnect: rdpFlapForm.auto_disconnect, + }); + success.value = "Настройки RDP flapping сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения RDP flapping"; + } finally { + savingRdpFlap.value = false; + } +} + +async function saveWinAdminSettings() { + savingWinAdmin.value = true; + error.value = ""; + success.value = ""; + try { + const payload: { user?: string; password?: string } = {}; + if (winAdminForm.user.trim()) { + payload.user = winAdminForm.user.trim(); + } + if (winAdminForm.password.trim()) { + payload.password = winAdminForm.password; + } + if (!payload.user && !payload.password) { + error.value = "Укажите логин и/или новый пароль Windows admin"; + return; + } + if (!winAdminLoaded.value?.configured && (!payload.user || !payload.password)) { + error.value = "Для первой настройки укажите логин и пароль"; + return; + } + winAdminLoaded.value = await updateWinAdminSettings(payload); + winAdminForm.user = winAdminLoaded.value.user ?? winAdminForm.user; + winAdminForm.password = ""; + success.value = "Учётные данные Windows admin сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения Windows admin"; + } finally { + savingWinAdmin.value = false; + } +} + +async function loadLinuxAdminSettings() { + const data = await fetchLinuxAdminSettings(); + linuxAdminLoaded.value = data; + linuxAdminForm.user = data.user ?? ""; + linuxAdminForm.password = ""; +} + +async function saveLinuxAdminSettings() { + savingLinuxAdmin.value = true; + error.value = ""; + success.value = ""; + try { + const payload: { user?: string; password?: string } = {}; + if (linuxAdminForm.user.trim()) { + payload.user = linuxAdminForm.user.trim(); + } + if (linuxAdminForm.password.trim()) { + payload.password = linuxAdminForm.password; + } + if (!payload.user && !payload.password) { + error.value = "Укажите логин и/или новый пароль Linux admin"; + return; + } + if (!linuxAdminLoaded.value?.configured && (!payload.user || !payload.password)) { + error.value = "Для первой настройки укажите логин и пароль"; + return; + } + linuxAdminLoaded.value = await updateLinuxAdminSettings(payload); + linuxAdminForm.user = linuxAdminLoaded.value.user ?? linuxAdminForm.user; + linuxAdminForm.password = ""; + success.value = "Учётные данные Linux admin сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения Linux admin"; + } finally { + savingLinuxAdmin.value = false; + } +} + +function formatDateTime(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString("ru-RU"); +} + +async function loadAgentUpdateSettings() { + const data = await fetchAgentUpdateSettings(); + agentUpdateLoaded.value = data; + agentUpdateForm.mode = data.mode; + agentUpdateForm.fallback_enabled = data.fallback_enabled; + agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes; + agentUpdateForm.rdp_git_repo_url = data.rdp_git_repo_url ?? ""; + agentUpdateForm.ssh_git_repo_url = data.ssh_git_repo_url ?? ""; + agentUpdateForm.git_branch = data.git_branch ?? "main"; + agentUpdateForm.win_agent_update_script = data.win_agent_update_script ?? ""; + agentGitTestErrors.value = Object.values(data.git_errors ?? {}).join("; "); + agentGitShowVersions.value = Boolean( + data.git_latest_rdp_version || data.git_latest_ssh_version || agentGitTestErrors.value, + ); +} + +async function runAgentGitTest() { + testingAgentGit.value = true; + agentGitTestErrors.value = ""; + agentGitFeedback.value = { kind: "info", text: "Проверка git…" }; + agentGitShowVersions.value = true; + try { + const result = await testAgentGitRelease({ + rdp_git_repo_url: agentUpdateForm.rdp_git_repo_url.trim() || null, + ssh_git_repo_url: agentUpdateForm.ssh_git_repo_url.trim() || null, + git_branch: agentUpdateForm.git_branch.trim() || "main", + }); + if (agentUpdateLoaded.value) { + agentUpdateLoaded.value = { + ...agentUpdateLoaded.value, + git_latest_rdp_version: result.git_latest_rdp_version, + git_latest_ssh_version: result.git_latest_ssh_version, + git_fetched_at: result.git_fetched_at, + git_errors: result.git_errors, + }; + } + const errs = Object.values(result.git_errors ?? {}); + agentGitTestErrors.value = errs.length ? errs.join("; ") : ""; + if (errs.length) { + agentGitFeedback.value = { + kind: "error", + text: "Git недоступен или версии не найдены — см. детали ниже.", + }; + } else { + const rdp = result.git_latest_rdp_version ?? "—"; + const ssh = result.git_latest_ssh_version ?? "—"; + agentGitFeedback.value = { + kind: "success", + text: result.from_cache + ? `Версии из кэша: RDP ${rdp}, SSH ${ssh}.` + : `Версии прочитаны из git: RDP ${rdp}, SSH ${ssh}.`, + }; + } + await nextTick(); + agentGitResultRef.value?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } catch (e) { + agentGitFeedback.value = { + kind: "error", + text: e instanceof Error ? e.message : "Ошибка проверки git", + }; + agentGitTestErrors.value = agentGitFeedback.value.text; + await nextTick(); + agentGitResultRef.value?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } finally { + testingAgentGit.value = false; + } +} + +async function saveAgentUpdateSettingsForm() { + savingAgentUpdate.value = true; + error.value = ""; + success.value = ""; + try { + agentUpdateLoaded.value = await saveAgentUpdateSettings({ + mode: agentUpdateForm.mode, + fallback_enabled: agentUpdateForm.fallback_enabled, + fallback_after_minutes: agentUpdateForm.fallback_after_minutes, + rdp_git_repo_url: agentUpdateForm.rdp_git_repo_url.trim() || null, + ssh_git_repo_url: agentUpdateForm.ssh_git_repo_url.trim() || null, + git_branch: agentUpdateForm.git_branch.trim() || "main", + win_agent_update_script: agentUpdateForm.win_agent_update_script.trim() || null, + }); + agentGitTestErrors.value = Object.values(agentUpdateLoaded.value.git_errors ?? {}).join("; "); + success.value = "Настройки обновления агентов сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения agent-updates"; + } finally { + savingAgentUpdate.value = false; + } +} + +async function loadMobileSection() { + mobileLoaded.value = await fetchMobileSettings(); + mobileForm.devices_allowed = mobileLoaded.value.devices_allowed; + mobileForm.max_devices_per_user = mobileLoaded.value.max_devices_per_user; + mobileForm.min_app_version = mobileLoaded.value.min_app_version ?? ""; + enrollmentCodes.value = await fetchMobileEnrollmentCodes(); + mobileDevices.value = await fetchMobileDevices(); + const users = await fetchUsers(); + sacUsers.value = users.items; +} + +async function saveMobileSettings() { + savingMobile.value = true; + error.value = ""; + success.value = ""; + try { + mobileLoaded.value = await updateMobileSettings({ + devices_allowed: mobileForm.devices_allowed, + max_devices_per_user: mobileForm.max_devices_per_user, + min_app_version: mobileForm.min_app_version.trim() || null, + }); + success.value = "Настройки мобильных устройств сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения"; + } finally { + savingMobile.value = false; + } +} + +async function createEnrollmentCode() { + creatingCode.value = true; + error.value = ""; + success.value = ""; + try { + const created = await createMobileEnrollmentCode({ + label: codeForm.label, + target_user_id: codeForm.target_user_id, + login_mode: codeForm.login_mode, + max_uses: codeForm.max_uses, + expires_in_hours: codeForm.expires_in_hours, + }); + lastCreatedCode.value = created.enrollment_code; + persistLastCreatedCode(created.enrollment_code); + enrollmentCodes.value = await fetchMobileEnrollmentCodes(); + codeForm.label = ""; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка создания кода"; + } finally { + creatingCode.value = false; + } +} + +async function revokeCode(codeId: number) { + error.value = ""; + success.value = ""; + try { + await revokeMobileEnrollmentCode(codeId); + enrollmentCodes.value = await fetchMobileEnrollmentCodes(); + success.value = "Код отозван."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка"; + } +} + +async function revokeDevice(deviceId: number) { + error.value = ""; + success.value = ""; + try { + await revokeMobileDevice(deviceId); + mobileDevices.value = await fetchMobileDevices(); + success.value = "Устройство отключено."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка"; + } +} + +async function deleteDevice(deviceId: number) { + const device = mobileDevices.value.find((item) => item.id === deviceId); + const label = device?.display_name || `#${deviceId}`; + if (!window.confirm(`Удалить запись устройства «${label}» из базы? Повторная привязка потребует новый код enroll.`)) { + return; + } + error.value = ""; + success.value = ""; + try { + await deleteMobileDevice(deviceId); + mobileDevices.value = await fetchMobileDevices(); + success.value = "Запись устройства удалена."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка"; + } +} + +async function testDevicePush(deviceId: number) { + error.value = ""; + success.value = ""; + testingPushDeviceId.value = deviceId; + try { + const res = await testMobileDevicePush(deviceId); + success.value = res.message; + window.scrollTo({ top: 0, behavior: "smooth" }); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка push"; + window.scrollTo({ top: 0, behavior: "smooth" }); + } finally { + testingPushDeviceId.value = null; + } +} + +async function load() { + loading.value = true; + error.value = ""; + try { + const data = await apiFetch<{ + policy: NotificationPolicy; + telegram: TelegramSettings; + webhook: WebhookSettings; + email: EmailSettings; + }>("/api/v1/settings/notifications"); + policyLoaded.value = data.policy; + policyForm.min_severity = data.policy.min_severity; + policyForm.use_telegram = data.policy.use_telegram; + policyForm.use_webhook = data.policy.use_webhook; + policyForm.use_email = data.policy.use_email; + policyForm.use_mobile = data.policy.use_mobile ?? false; + telegramLoaded.value = data.telegram; + webhookLoaded.value = data.webhook; + emailLoaded.value = data.email; + telegramForm.enabled = data.telegram.enabled; + telegramForm.bot_token = ""; + telegramForm.chat_id = ""; + webhookForm.enabled = data.webhook.enabled; + webhookForm.url = ""; + webhookForm.secret_header = data.webhook.secret_header ?? ""; + webhookForm.secret = ""; + emailForm.enabled = data.email.enabled; + emailForm.smtp_host = ""; + emailForm.smtp_port = data.email.smtp_port || 587; + emailForm.smtp_user = data.email.smtp_user ?? ""; + emailForm.smtp_password = ""; + emailForm.mail_from = data.email.mail_from ?? ""; + emailForm.mail_to = ""; + emailForm.smtp_starttls = data.email.smtp_starttls; + emailForm.smtp_ssl = data.email.smtp_ssl; + await loadUiSettings(); + await loadLoginSecuritySettings(); + await loadWinAdminSettings(); + await loadRdpFlapSettings(); + await loadLinuxAdminSettings(); + await loadAgentUpdateSettings(); + await loadSeverityOverrides(); + await loadMobileSection(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки"; + } finally { + loading.value = false; + } +} + +async function savePolicy() { + savingPolicy.value = true; + error.value = ""; + success.value = ""; + try { + policyLoaded.value = await apiFetch<NotificationPolicy>("/api/v1/settings/notifications/policy", { + method: "PUT", + body: JSON.stringify({ + min_severity: policyForm.min_severity, + use_telegram: policyForm.use_telegram, + use_webhook: policyForm.use_webhook, + use_email: policyForm.use_email, + use_mobile: policyForm.use_mobile, + }), + }); + success.value = "Правило оповещений сохранено."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения"; + } finally { + savingPolicy.value = false; + } +} + +async function saveTelegram() { + savingTelegram.value = true; + error.value = ""; + success.value = ""; + try { + const body: Record<string, unknown> = { + enabled: telegramForm.enabled, + }; + if (telegramForm.bot_token.trim()) body.bot_token = telegramForm.bot_token.trim(); + if (telegramForm.chat_id.trim()) body.chat_id = telegramForm.chat_id.trim(); + + telegramLoaded.value = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", { + method: "PUT", + body: JSON.stringify(body), + }); + telegramForm.bot_token = ""; + telegramForm.chat_id = ""; + success.value = "Настройки Telegram сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения"; + } finally { + savingTelegram.value = false; + } +} + +async function saveWebhook() { + savingWebhook.value = true; + error.value = ""; + success.value = ""; + try { + const body: Record<string, unknown> = { + enabled: webhookForm.enabled, + secret_header: webhookForm.secret_header.trim() || null, + }; + if (webhookForm.url.trim()) body.url = webhookForm.url.trim(); + if (webhookForm.secret.trim()) body.secret = webhookForm.secret.trim(); + + webhookLoaded.value = await apiFetch<WebhookSettings>("/api/v1/settings/notifications/webhook", { + method: "PUT", + body: JSON.stringify(body), + }); + webhookForm.url = ""; + webhookForm.secret = ""; + success.value = "Настройки webhook сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения"; + } finally { + savingWebhook.value = false; + } +} + +async function testTelegram() { + testingTelegram.value = true; + error.value = ""; + success.value = ""; + try { + const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/telegram/test", { + method: "POST", + }); + success.value = res.message; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка отправки"; + } finally { + testingTelegram.value = false; + } +} + +async function testWebhook() { + testingWebhook.value = true; + error.value = ""; + success.value = ""; + try { + const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/webhook/test", { + method: "POST", + }); + success.value = res.message; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка отправки"; + } finally { + testingWebhook.value = false; + } +} + +async function saveEmail() { + savingEmail.value = true; + error.value = ""; + success.value = ""; + try { + const body: Record<string, unknown> = { + enabled: emailForm.enabled, + smtp_port: emailForm.smtp_port, + smtp_starttls: emailForm.smtp_starttls, + smtp_ssl: emailForm.smtp_ssl, + }; + if (emailForm.smtp_host.trim()) body.smtp_host = emailForm.smtp_host.trim(); + if (emailForm.smtp_user.trim()) body.smtp_user = emailForm.smtp_user.trim(); + if (emailForm.smtp_password.trim()) body.smtp_password = emailForm.smtp_password.trim(); + if (emailForm.mail_from.trim()) body.mail_from = emailForm.mail_from.trim(); + if (emailForm.mail_to.trim()) body.mail_to = emailForm.mail_to.trim(); + + emailLoaded.value = await apiFetch<EmailSettings>("/api/v1/settings/notifications/email", { + method: "PUT", + body: JSON.stringify(body), + }); + emailForm.smtp_host = ""; + emailForm.smtp_password = ""; + emailForm.mail_to = ""; + success.value = "Настройки email сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения"; + } finally { + savingEmail.value = false; + } +} + +async function testEmail() { + testingEmail.value = true; + error.value = ""; + success.value = ""; + try { + const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/email/test", { + method: "POST", + }); + success.value = res.message; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка отправки"; + } finally { + testingEmail.value = false; + } +} + +onMounted(() => { + restoreLastCreatedCode(); + load(); +}); +</script> + +<style scoped> +.settings-page { + max-width: 52rem; +} + +.settings-intro { + color: #9aa4b2; + margin-bottom: 1.25rem; +} + +.settings-card { + margin-bottom: 1.25rem; +} + +.settings-card h2 { + margin-top: 0; +} + +.settings-card h3 { + margin-top: 1.25rem; +} + +.settings-form { + display: flex; + flex-direction: column; + gap: 0.85rem; + margin-bottom: 1rem; +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.settings-field input[type="text"], +.settings-field input[type="password"], +.settings-field input[type="url"], +.settings-field select, +.settings-field textarea { + max-width: 28rem; +} + +.settings-blocks-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + margin-top: 0.5rem; +} + +.settings-blocks-table th, +.settings-blocks-table td { + border: 1px solid #3d4f66; + padding: 0.35rem 0.5rem; + text-align: left; + vertical-align: top; +} + +.settings-blocks-table th { + color: #9aa4b2; + font-weight: 600; +} + +.settings-policy { + border-color: #3d4f66; +} + +.settings-hint-top { + margin-top: 0; + margin-bottom: 1rem; +} + +.settings-channels { + border: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.settings-channels legend { + font-size: 0.9rem; + color: #9aa4b2; + margin-bottom: 0.35rem; +} + +.settings-inline { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.settings-meta { + font-size: 0.85rem; + color: #9aa4b2; + margin: 0; +} + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.settings-actions button.secondary { + background: #2a3441; + color: #c5d0dc; +} + +.settings-hint { + font-size: 0.9rem; + color: #9aa4b2; + margin: 0; +} + +.settings-agent-git-panel { + margin: 0.75rem 0; + padding: 0.75rem 0.85rem; + border: 1px solid #2a3441; + border-radius: 6px; + background: #141a22; +} + +.settings-agent-git-feedback { + margin: 0 0 0.5rem; + font-size: 0.92rem; +} + +.settings-agent-git-feedback.success { + color: #6dd196; +} + +.settings-agent-git-feedback.error { + color: #e87272; +} + +.settings-agent-git-feedback.info { + color: #9aa4b2; +} + +.settings-agent-git-versions { + margin: 0; +} + +.success { + color: #6dd196; +} + +.settings-severity-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; + align-items: center; +} + +.settings-severity-toolbar input[type="search"] { + flex: 1; + min-width: 12rem; +} + +.settings-severity-table-wrap { + max-height: 24rem; + overflow: auto; + border: 1px solid #2a3441; + border-radius: 6px; +} + +.settings-severity-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.settings-severity-table th, +.settings-severity-table td { + text-align: left; + padding: 0.4rem 0.5rem; + border-bottom: 1px solid #2a3441; +} + +.settings-severity-table select { + min-width: 11rem; +} + +.settings-mobile-table-wrap { + max-height: 16rem; + overflow: auto; + border: 1px solid #2a3441; + border-radius: 6px; + margin-bottom: 1rem; +} + +.settings-mobile-code-form { + margin-top: 0.5rem; +} + +.settings-enrollment-banner { + margin: 0 0 1.25rem; + padding: 1rem 1.1rem; + border: 1px solid #3d8b5f; + border-radius: 8px; + background: #15251c; +} + +.settings-enrollment-banner-title { + margin: 0 0 0.35rem; + font-size: 1.05rem; + font-weight: 600; + color: #8ce8b0; +} + +.settings-enrollment-banner-hint { + margin: 0 0 0.75rem; + color: #a8b8c8; + font-size: 0.92rem; +} + +.settings-enrollment-code-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.settings-enrollment-code-value { + flex: 1 1 16rem; + padding: 0.55rem 0.65rem; + border-radius: 6px; + background: #0f1419; + color: #e8f0f8; + word-break: break-all; + user-select: all; +} + +.settings-mobile-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +</style> diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue new file mode 100644 index 0000000..2b59429 --- /dev/null +++ b/frontend/src/views/UsersView.vue @@ -0,0 +1,320 @@ +<template> + <div class="users-page"> + <h1>Пользователи</h1> + <p class="users-intro"> + Учётные записи входа в веб-панель SAC. Роль <strong>monitor</strong> — просмотр без раздела «Настройки». + </p> + + <p v-if="error" class="error">{{ error }}</p> + <p v-if="success" class="success">{{ success }}</p> + + <section class="card users-card" :class="{ 'users-card-editing': editingUser }"> + <h2>{{ editingUser ? `Редактирование: ${editingUser.username}` : "Новый пользователь" }}</h2> + <form class="users-form" @submit.prevent="submitForm"> + <label> + Логин + <input v-model="form.username" required minlength="2" autocomplete="off" /> + </label> + <label> + {{ editingUser ? "Новый пароль" : "Пароль" }} + <input + v-model="form.password" + type="password" + :required="!editingUser" + minlength="8" + autocomplete="new-password" + :placeholder="editingUser ? 'оставьте пустым, чтобы не менять' : ''" + /> + </label> + <label> + Роль + <select v-model="form.role"> + <option value="monitor">monitor — просмотр</option> + <option value="admin">admin — полный доступ</option> + </select> + </label> + <label v-if="editingUser" class="users-inline"> + <input v-model="form.is_active" type="checkbox" /> + Активен (может входить) + </label> + <div class="users-actions"> + <button type="submit" :disabled="submitting"> + {{ submitting ? "Сохранение…" : editingUser ? "Сохранить изменения" : "Создать" }} + </button> + <button + v-if="editingUser" + type="button" + class="secondary" + :disabled="submitting" + @click="cancelEdit" + > + Отмена + </button> + </div> + </form> + </section> + + <section class="card users-card"> + <h2>Список</h2> + <p v-if="loading">Загрузка…</p> + <table v-else class="users-table"> + <thead> + <tr> + <th>Логин</th> + <th>Роль</th> + <th>Статус</th> + <th>Создан</th> + <th>Действия</th> + </tr> + </thead> + <tbody> + <tr v-for="u in users" :key="u.id" :class="{ 'users-row-editing': editingUser?.id === u.id }"> + <td>{{ u.username }}</td> + <td><code>{{ u.role }}</code></td> + <td>{{ u.is_active ? "активен" : "отключён" }}</td> + <td>{{ formatDate(u.created_at) }}</td> + <td> + <button type="button" class="secondary users-edit-btn" @click="startEdit(u)">Изменить</button> + </td> + </tr> + </tbody> + </table> + </section> + </div> +</template> + +<script setup lang="ts"> +import { nextTick, onMounted, reactive, ref } from "vue"; +import { + createUser as apiCreateUser, + fetchUsers, + updateUser, + type UserSummary, +} from "../api"; + +const users = ref<UserSummary[]>([]); +const loading = ref(true); +const submitting = ref(false); +const error = ref(""); +const success = ref(""); +const editingUser = ref<UserSummary | null>(null); + +const form = reactive({ + username: "", + password: "", + role: "monitor", + is_active: true, +}); + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function clearMessages() { + error.value = ""; + success.value = ""; +} + +function resetCreateForm() { + form.username = ""; + form.password = ""; + form.role = "monitor"; + form.is_active = true; +} + +async function loadUsers() { + loading.value = true; + try { + const data = await fetchUsers(); + users.value = data.items; + if (editingUser.value) { + const fresh = data.items.find((u) => u.id === editingUser.value?.id); + if (fresh) { + editingUser.value = fresh; + fillEditForm(fresh); + } else { + cancelEdit(); + } + } + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка загрузки"; + } finally { + loading.value = false; + } +} + +function fillEditForm(u: UserSummary) { + form.username = u.username; + form.password = ""; + form.role = u.role; + form.is_active = u.is_active; +} + +async function startEdit(u: UserSummary) { + clearMessages(); + editingUser.value = u; + fillEditForm(u); + await nextTick(); + document.querySelector(".users-card-editing")?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function cancelEdit() { + editingUser.value = null; + resetCreateForm(); + clearMessages(); +} + +async function submitForm() { + if (editingUser.value) { + await saveEdit(); + } else { + await createUser(); + } +} + +async function createUser() { + submitting.value = true; + clearMessages(); + try { + await apiCreateUser({ + username: form.username.trim(), + password: form.password, + role: form.role, + }); + success.value = `Пользователь ${form.username} создан`; + resetCreateForm(); + await loadUsers(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка создания"; + } finally { + submitting.value = false; + } +} + +async function saveEdit() { + if (!editingUser.value) return; + + const payload: { + username?: string; + role?: string; + password?: string; + is_active?: boolean; + } = {}; + + const trimmedUsername = form.username.trim(); + if (trimmedUsername !== editingUser.value.username) { + payload.username = trimmedUsername; + } + if (form.role !== editingUser.value.role) { + payload.role = form.role; + } + if (form.is_active !== editingUser.value.is_active) { + payload.is_active = form.is_active; + } + if (form.password.trim()) { + if (form.password.length < 8) { + error.value = "Пароль должен быть не короче 8 символов"; + return; + } + payload.password = form.password; + } + + if (Object.keys(payload).length === 0) { + error.value = "Нет изменений для сохранения"; + return; + } + + submitting.value = true; + clearMessages(); + try { + const updated = await updateUser(editingUser.value.id, payload); + success.value = `Пользователь ${updated.username} обновлён`; + editingUser.value = updated; + fillEditForm(updated); + await loadUsers(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения"; + } finally { + submitting.value = false; + } +} + +onMounted(loadUsers); +</script> + +<style scoped> +.users-page { + max-width: 52rem; +} + +.users-intro { + color: #9aa4b2; + margin-bottom: 1rem; +} + +.users-card { + margin-bottom: 1.25rem; +} + +.users-card-editing { + border-color: #3d5a80; + box-shadow: 0 0 0 1px rgba(61, 90, 128, 0.35); +} + +.users-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 24rem; +} + +.users-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.users-inline { + flex-direction: row !important; + align-items: center; + gap: 0.5rem !important; +} + +.users-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.users-edit-btn { + white-space: nowrap; +} + +.users-row-editing td { + background: rgba(61, 90, 128, 0.15); +} + +.users-table { + width: 100%; + border-collapse: collapse; +} + +.users-table th, +.users-table td { + text-align: left; + padding: 0.45rem 0.5rem; + border-bottom: 1px solid #2a3441; +} + +.error { + color: #ff8a8a; +} + +.success { + color: #7dcea0; +} +</style> diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..85a8a97 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..14f78c6 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + "/api": "http://127.0.0.1:8000", + "/health": "http://127.0.0.1:8000", + "/healthz": "http://127.0.0.1:8000", + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/schemas/event-schema-v1.json b/schemas/event-schema-v1.json new file mode 100644 index 0000000..da7790f --- /dev/null +++ b/schemas/event-schema-v1.json @@ -0,0 +1,179 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json", + "title": "Security Alert Center Event v1", + "description": "Каноническое событие от ssh-monitor или RDP-login-monitor", + "type": "object", + "required": [ + "schema_version", + "event_id", + "occurred_at", + "source", + "host", + "category", + "type", + "severity", + "title", + "summary" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "const": "1.0" + }, + "event_id": { + "type": "string", + "format": "uuid", + "description": "UUID v4, уникален глобально, для идемпотентности" + }, + "occurred_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 с offset, время события на хосте" + }, + "source": { + "type": "object", + "required": ["product", "product_version"], + "additionalProperties": false, + "properties": { + "product": { + "type": "string", + "enum": ["ssh-monitor", "rdp-login-monitor"] + }, + "product_version": { + "type": "string" + }, + "agent_instance_id": { + "type": "string", + "description": "Стабильный ID установки агента" + } + } + }, + "host": { + "type": "object", + "required": ["hostname", "os_family"], + "additionalProperties": false, + "properties": { + "hostname": { "type": "string" }, + "display_name": { "type": "string" }, + "fqdn": { "type": "string" }, + "os_family": { + "type": "string", + "enum": ["linux", "windows"] + }, + "os_version": { "type": "string" }, + "ipv4": { "type": "string" }, + "ipv6": { "type": "string" }, + "timezone": { + "type": "string", + "description": "IANA, например Europe/Moscow" + } + } + }, + "category": { + "type": "string", + "enum": [ + "auth", + "privilege", + "network", + "session", + "report", + "agent" + ] + }, + "type": { + "type": "string", + "description": "Машиночитаемый тип, см. docs/agent-integration.md", + "examples": [ + "ssh.login.success", + "ssh.login.failed", + "privilege.sudo.command", + "ssh.ip.banned", + "ssh.bruteforce.mass", + "session.logind.new", + "rdp.login.success", + "rdp.login.failed", + "rdp.session.logoff", + "rdp.shadow.control.started", + "rdp.shadow.control.stopped", + "rdp.shadow.control.permission", + "winrm.session.started", + "smb.admin_share.access", + "rdg.connection.success", + "rdg.connection.disconnected", + "rdg.connection.failed", + "report.daily.ssh", + "agent.heartbeat", + "agent.inventory", + "agent.test", + "agent.update.started", + "agent.update.success", + "agent.update.failed" + ] + }, + "severity": { + "type": "string", + "enum": ["info", "warning", "high", "critical"] + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "summary": { + "type": "string", + "description": "Человекочитаемый текст (как в Telegram сейчас)", + "maxLength": 8192 + }, + "details": { + "type": "object", + "description": "Структурированные поля по типу события", + "additionalProperties": true + }, + "raw": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": ["journal", "windows_event_xml", "text"] + }, + "payload": { + "type": "string", + "maxLength": 16384 + } + }, + "additionalProperties": false + }, + "tags": { + "type": "array", + "items": { "type": "string", "maxLength": 64 }, + "maxItems": 32 + }, + "dedup_key": { + "type": "string", + "maxLength": 512 + }, + "correlation_id": { + "type": "string", + "format": "uuid" + }, + "fingerprint": { + "type": "string", + "maxLength": 128 + }, + "enrichment": { + "type": "object", + "additionalProperties": true, + "properties": { + "attempt_number": { "type": "integer", "minimum": 0 }, + "threshold": { "type": "integer", "minimum": 0 }, + "risk_level": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + }, + "filtered_out": { "type": "boolean" }, + "filter_reason": { "type": "string" } + } + } + } +} diff --git a/tools/Invoke-SacRemoteSudo.ps1 b/tools/Invoke-SacRemoteSudo.ps1 new file mode 100644 index 0000000..1cc1fa1 --- /dev/null +++ b/tools/Invoke-SacRemoteSudo.ps1 @@ -0,0 +1,38 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Удалённый sudo на SAC-сервере без интерактивного ввода. + Пароль: Password из ssh-setup/local/kalinamall-sac.conf +#> +[CmdletBinding()] +param( + [string]$HostAlias = 'sac.kalinamall.ru', + [Parameter(Mandatory = $true)] + [string]$RemoteCommand, + [string]$PasswordFile = (Join-Path $HOME 'Projects\Answer.and.other.shit\scripts\ssh-setup\local\kalinamall-sac.conf') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Read-ConfigFile { + param([string]$Path) + $data = @{} + if (-not (Test-Path -LiteralPath $Path)) { throw "Config not found: $Path" } + Get-Content -LiteralPath $Path | ForEach-Object { + if ($_ -match '^([^#=]+)=(.*)$') { + $data[$Matches[1].Trim()] = $Matches[2].Trim() + } + } + return $data +} + +$cfg = Read-ConfigFile -Path $PasswordFile +$password = $cfg['Password'] +if (-not $password) { + throw "Password not found in $PasswordFile" +} + +$inner = $RemoteCommand.Replace("'", "'\\''") +$remote = "sudo -S -p '' bash -lc '$inner'" +$password | & ssh -o BatchMode=yes -o ConnectTimeout=15 $HostAlias $remote diff --git a/tools/patch-nginx-sac-long-timeout.sh b/tools/patch-nginx-sac-long-timeout.sh new file mode 100644 index 0000000..e60d989 --- /dev/null +++ b/tools/patch-nginx-sac-long-timeout.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Одноразовый hotfix: увеличить proxy_read_timeout для долгих SSH/WinRM API. +# Запуск на SAC-сервере: sudo bash patch-nginx-sac-long-timeout.sh +set -euo pipefail + +CFG="${SAC_NGINX_SITE:-/etc/nginx/sites-available/sac}" + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Run as root: sudo bash $0" >&2 + exit 1 +fi + +if [[ ! -f "$CFG" ]]; then + echo "Config not found: $CFG" >&2 + exit 1 +fi + +if grep -q 'agent-update-fallback' "$CFG"; then + echo "Already patched: $CFG" +else + cp "$CFG" "${CFG}.bak-$(date +%Y%m%d%H%M%S)" + python3 - "$CFG" <<'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +text = path.read_text(encoding="utf-8") +block = """ + # SSH/WinRM и git-probe: backend ждёт до 900s + location ~ ^/api/v1/(hosts/[0-9]+/actions/(agent-update|agent-update-fallback|ssh-test|winrm-test)|settings/agent-updates/test-git) { + 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 960s; + proxy_read_timeout 960s; + } + +""" +needle = " location / {" +if needle not in text: + raise SystemExit(f"needle not found in {path}") +path.write_text(text.replace(needle, block + needle, 1), encoding="utf-8") +print(f"Patched {path}") +PY +fi + +nginx -t +systemctl reload nginx +echo "nginx reloaded OK" diff --git a/tools/probe_git_release.py b/tools/probe_git_release.py new file mode 100644 index 0000000..d35f521 --- /dev/null +++ b/tools/probe_git_release.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import sys +sys.path.insert(0, "/opt/security-alert-center/backend") + +from app.database import SessionLocal +from app.services.agent_update_settings import get_effective_agent_update_config +from app.services.agent_git_release import get_git_release_versions + +db = SessionLocal() +try: + cfg = get_effective_agent_update_config(db) + print("cfg rdp:", cfg.rdp_git_repo_url) + print("cfg ssh:", cfg.ssh_git_repo_url) + print("cfg branch:", cfg.git_branch) + r = get_git_release_versions(cfg, db=db, force_refresh=True) + print("versions:", r.versions) + print("errors:", r.errors) + print("from_cache:", r.from_cache) + print("fetched_at:", r.fetched_at) +finally: + db.close() diff --git a/tools/push-github-snapshot.sh b/tools/push-github-snapshot.sh new file mode 100644 index 0000000..a09b194 --- /dev/null +++ b/tools/push-github-snapshot.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Публичный snapshot на github: без prod URL, доменов, логинов, имён и внутренних IP. +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/sac-github-snapshot.XXXX")" +trap 'rm -rf "$WORK"' EXIT + +REF="${1:-HEAD}" +MSG="${2:-chore: public release snapshot $(date +%F) (SAC $(git -C "$ROOT" show -s --format=%s "$REF" | sed -n 's/.*(\([0-9.]*\)).*/\1/p' | head -1))}" + +echo "[snapshot] export $REF -> $WORK" +git -C "$ROOT" archive --format=tar "$REF" \ + | tar -x -C "$WORK" \ + --exclude='.cursor' \ + --exclude='.venv' \ + --exclude='frontend/node_modules' \ + --exclude='frontend/dist' \ + --exclude='.env' \ + --exclude='deploy/.env' + +apply_sed() { + local expr=$1 + if [[ "$(uname -s)" == Darwin ]]; then + find "$WORK" -type f \( -name '*.py' -o -name '*.md' -o -name '*.ts' -o -name '*.vue' -o -name '*.sh' -o -name '*.ps1' -o -name '*.example' -o -name '*.yml' -o -name '*.json' -o -name '*.conf' \) \ + ! -path '*/tools/push-github-snapshot.sh' \ + -print0 | xargs -0 sed -i '' -E "$expr" + else + find "$WORK" -type f \( -name '*.py' -o -name '*.md' -o -name '*.ts' -o -name '*.vue' -o -name '*.sh' -o -name '*.ps1' -o -name '*.example' -o -name '*.yml' -o -name '*.json' -o -name '*.conf' \) \ + ! -path '*/tools/push-github-snapshot.sh' \ + -print0 | xargs -0 sed -i -E "$expr" + fi +} + +echo "[snapshot] rewrite hosts, domains, logins, IPs" +apply_sed 's#https://git\.kalinamall\.ru/PapaTramp#https://github.com/PTah#g' +apply_sed 's#git\.kalinamall\.ru/PapaTramp#github.com/PTah#g' +apply_sed 's#git\.kalinamall\.ru/papatramp#github.com/PTah#g' +apply_sed 's#git\.papatramp\.ru/PapaTramp#github.com/PTah#g' +apply_sed 's#git\.papatramp\.lan:[0-9]+/PapaTramp#github.com/PTah#g' +apply_sed 's#sac\.kalinamall\.ru#sac.example.com#g' +apply_sed 's#git\.kalinamall\.lan#github.com#g' +apply_sed 's#kalinamall-sac\.conf#origin-sac.conf#g' +apply_sed 's#remote add kalinamall#remote add origin#g' +apply_sed 's#remote set-url kalinamall#remote set-url origin#g' +apply_sed 's#B26\\\\#CONTOSO\\\\#g' +apply_sed 's#B26\\#CONTOSO\\#g' +apply_sed 's#papatramp#example.user#g' +apply_sed 's#PapaTramp#PTah#g' +apply_sed 's#192\.168\.160\.#192.0.2.#g' +apply_sed 's#192\.168\.128\.#192.0.2.#g' +apply_sed 's#192\.168\.163\.#192.0.2.#g' +apply_sed 's#password="[^"]*"#password=_TEST_PASSWORD#g' +apply_sed "s#password='[^']*'#password=_TEST_PASSWORD#g" +apply_sed 's#postgresql\+psycopg2://sac:sac@#postgresql+psycopg2://sac:CHANGE_ME@#g' + +if ! grep -q '_TEST_PASSWORD' "$WORK/backend/tests/conftest.py" 2>/dev/null; then + sed -i.bak '1a\ +_TEST_PASSWORD = "test-only" +' "$WORK/backend/tests/conftest.py" && rm -f "$WORK/backend/tests/conftest.py.bak" +fi + +echo "[snapshot] git commit in temp repo" +cd "$WORK" +git init -q +git checkout -q -b main +git add -A +git -c user.name="PTah" -c user.email="papatramp@gmail.com" commit -q -m "$MSG" + +echo "[snapshot] secret scan (private keys only)" +while IFS= read -r -d '' f; do + if grep -q 'BEGIN OPENSSH PRIVATE KEY' "$f" 2>/dev/null || grep -q 'BEGIN RSA PRIVATE KEY' "$f" 2>/dev/null; then + echo " BLOCKED: $f (private key)" + exit 1 + fi +done < <(find . -type f -print0) + +echo "[snapshot] push github main (force snapshot)" +git remote add github "$(git -C "$ROOT" remote get-url github)" +git push github main --force + +echo "[snapshot] OK -> github/main @ $(git rev-parse --short HEAD)" diff --git a/tools/test_sac_agent_git.py b/tools/test_sac_agent_git.py new file mode 100644 index 0000000..4a0f523 --- /dev/null +++ b/tools/test_sac_agent_git.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Run agent git fetch test on SAC server.""" +import subprocess + +SUDO_PW = "[eqyfhskj\n" +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" + "echo RDP_version_txt:\n" + "cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n" + "echo RDP_script:\n" + "grep -m1 ScriptVersion \"$TMP/rdp/Login_Monitor.ps1\" 2>/dev/null || echo missing\n" + "echo SSH_version:\n" + "grep -m1 '^SSH_MONITOR_VERSION=' \"$TMP/ssh/ssh-monitor\" 2>/dev/null || cat \"$TMP/ssh/version.txt\" 2>/dev/null || echo missing\n" +) + +r = subprocess.run( + ["ssh", "sac.kalinamall.ru", "sudo", "-S", "-u", "sac", "bash", "-s"], + input=SUDO_PW + REMOTE_CMD, + text=True, + capture_output=True, + timeout=120, +) +print(r.stdout) +if r.stderr: + print("STDERR:", r.stderr[-400:]) +print("exit", r.returncode) diff --git a/tools/test_sac_agent_git.sh b/tools/test_sac_agent_git.sh new file mode 100644 index 0000000..4900d6e --- /dev/null +++ b/tools/test_sac_agent_git.sh @@ -0,0 +1,12 @@ +#!/bin/bash +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" +echo RDP_version_txt: +cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing +echo RDP_script: +grep -m1 ScriptVersion "$TMP/rdp/Login_Monitor.ps1" 2>/dev/null || echo missing +echo SSH_version: +grep -m1 '^SSH_MONITOR_VERSION=' "$TMP/ssh/ssh-monitor" 2>/dev/null || cat "$TMP/ssh/version.txt" 2>/dev/null || echo missing