From b840047e1936d4a514c356fd2f38c42c763572df Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sun, 8 Mar 2026 17:46:37 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20The=20Other=20Dude=20v9.0.1=20=E2=80=94?= =?UTF-8?q?=20full-featured=20email=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 --- .env.example | 52 + .env.staging.example | 43 + .github/workflows/ci.yml | 267 + .github/workflows/pages.yml | 39 + .github/workflows/security-scan.yml | 56 + .gitignore | 40 + LICENSE | 53 + README.md | 132 + backend/.gitignore | 26 + backend/alembic.ini | 114 + backend/alembic/env.py | 78 + backend/alembic/script.py.mako | 26 + .../alembic/versions/001_initial_schema.py | 376 + ..._routeros_major_version_and_poller_role.py | 92 + .../versions/003_metrics_hypertables.py | 174 + .../alembic/versions/004_config_management.py | 128 + .../versions/005_alerting_and_firmware.py | 286 + .../alembic/versions/006_advanced_features.py | 212 + backend/alembic/versions/007_audit_logs.py | 82 + .../versions/008_maintenance_windows.py | 86 + backend/alembic/versions/009_api_keys.py | 93 + backend/alembic/versions/010_wireguard_vpn.py | 90 + .../versions/012_seed_starter_templates.py | 169 + backend/alembic/versions/013_certificates.py | 203 + .../versions/014_timescaledb_retention.py | 50 + .../versions/015_password_reset_tokens.py | 62 + .../versions/016_zero_knowledge_schema.py | 207 + .../017_openbao_envelope_encryption.py | 90 + .../alembic/versions/018_data_encryption.py | 62 + .../alembic/versions/019_deprecate_bcrypt.py | 52 + .../alembic/versions/020_tls_mode_opt_in.py | 51 + .../versions/021_system_tenant_for_audit.py | 44 + .../versions/022_rls_super_admin_devices.py | 49 + .../023_slack_notification_channel.py | 21 + .../024_contact_email_and_offline_rule.py | 41 + .../025_fix_key_access_log_device_fk.py | 37 + .../alembic/versions/026_system_settings.py | 41 + backend/app/__init__.py | 1 + backend/app/config.py | 177 + backend/app/database.py | 114 + backend/app/logging_config.py | 81 + backend/app/main.py | 330 + backend/app/middleware/__init__.py | 1 + backend/app/middleware/rate_limit.py | 48 + backend/app/middleware/rbac.py | 186 + backend/app/middleware/request_id.py | 67 + backend/app/middleware/security_headers.py | 79 + backend/app/middleware/tenant_context.py | 177 + backend/app/models/__init__.py | 35 + backend/app/models/alert.py | 177 + backend/app/models/api_key.py | 60 + backend/app/models/audit_log.py | 59 + backend/app/models/certificate.py | 140 + backend/app/models/config_backup.py | 178 + backend/app/models/config_template.py | 153 + backend/app/models/device.py | 214 + backend/app/models/firmware.py | 102 + backend/app/models/key_set.py | 134 + backend/app/models/maintenance_window.py | 74 + backend/app/models/tenant.py | 49 + backend/app/models/user.py | 74 + backend/app/models/vpn.py | 85 + backend/app/observability.py | 140 + backend/app/routers/__init__.py | 1 + backend/app/routers/alerts.py | 1088 ++ backend/app/routers/api_keys.py | 172 + backend/app/routers/audit_logs.py | 294 + backend/app/routers/auth.py | 1052 ++ backend/app/routers/certificates.py | 763 ++ backend/app/routers/clients.py | 297 + backend/app/routers/config_backups.py | 745 ++ backend/app/routers/config_editor.py | 371 + backend/app/routers/device_groups.py | 94 + backend/app/routers/device_logs.py | 150 + backend/app/routers/device_tags.py | 94 + backend/app/routers/devices.py | 452 + backend/app/routers/events.py | 164 + backend/app/routers/firmware.py | 712 ++ backend/app/routers/maintenance_windows.py | 309 + backend/app/routers/metrics.py | 414 + backend/app/routers/reports.py | 146 + backend/app/routers/settings.py | 155 + backend/app/routers/sse.py | 141 + backend/app/routers/templates.py | 613 ++ backend/app/routers/tenants.py | 367 + backend/app/routers/topology.py | 374 + backend/app/routers/transparency.py | 391 + backend/app/routers/users.py | 231 + backend/app/routers/vpn.py | 236 + backend/app/schemas/__init__.py | 18 + backend/app/schemas/auth.py | 123 + backend/app/schemas/certificate.py | 78 + backend/app/schemas/device.py | 271 + backend/app/schemas/tenant.py | 31 + backend/app/schemas/user.py | 53 + backend/app/schemas/vpn.py | 91 + backend/app/security/__init__.py | 0 backend/app/security/command_blocklist.py | 95 + backend/app/services/__init__.py | 1 + backend/app/services/account_service.py | 240 + backend/app/services/alert_evaluator.py | 723 ++ backend/app/services/api_key_service.py | 190 + backend/app/services/audit_service.py | 92 + backend/app/services/auth.py | 154 + backend/app/services/backup_scheduler.py | 197 + backend/app/services/backup_service.py | 378 + backend/app/services/ca_service.py | 462 + .../app/services/config_change_subscriber.py | 118 + backend/app/services/crypto.py | 183 + backend/app/services/device.py | 670 ++ backend/app/services/email_service.py | 124 + backend/app/services/emergency_kit_service.py | 54 + backend/app/services/event_publisher.py | 52 + backend/app/services/firmware_service.py | 303 + backend/app/services/firmware_subscriber.py | 206 + backend/app/services/git_store.py | 296 + backend/app/services/key_service.py | 324 + backend/app/services/metrics_subscriber.py | 346 + backend/app/services/nats_subscriber.py | 231 + backend/app/services/notification_service.py | 256 + backend/app/services/openbao_service.py | 174 + .../app/services/push_rollback_subscriber.py | 141 + backend/app/services/push_tracker.py | 70 + backend/app/services/report_service.py | 572 + backend/app/services/restore_service.py | 599 ++ backend/app/services/routeros_proxy.py | 165 + backend/app/services/rsc_parser.py | 220 + backend/app/services/scanner.py | 124 + backend/app/services/srp_service.py | 113 + backend/app/services/sse_manager.py | 311 + backend/app/services/template_service.py | 480 + backend/app/services/upgrade_service.py | 564 + backend/app/services/vpn_service.py | 392 + .../app/templates/reports/alert_history.html | 66 + backend/app/templates/reports/base.html | 208 + backend/app/templates/reports/change_log.html | 46 + .../templates/reports/device_inventory.html | 59 + .../templates/reports/metrics_summary.html | 45 + backend/gunicorn.conf.py | 30 + backend/pyproject.toml | 59 + backend/templates/emergency_kit.html | 297 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 16 + backend/tests/integration/__init__.py | 2 + backend/tests/integration/conftest.py | 439 + backend/tests/integration/test_alerts_api.py | 275 + backend/tests/integration/test_auth_api.py | 302 + backend/tests/integration/test_config_api.py | 149 + backend/tests/integration/test_devices_api.py | 227 + .../tests/integration/test_firmware_api.py | 183 + .../tests/integration/test_monitoring_api.py | 323 + .../tests/integration/test_rls_isolation.py | 437 + .../tests/integration/test_templates_api.py | 322 + backend/tests/test_backup_scheduler.py | 42 + .../tests/test_config_change_subscriber.py | 55 + backend/tests/test_config_checkpoint.py | 82 + backend/tests/test_push_recovery.py | 120 + .../tests/test_push_rollback_subscriber.py | 156 + backend/tests/test_restore_preview.py | 211 + backend/tests/test_rsc_parser.py | 106 + backend/tests/test_srp_interop.py | 128 + backend/tests/unit/__init__.py | 0 backend/tests/unit/test_api_key_service.py | 76 + backend/tests/unit/test_audit_service.py | 75 + backend/tests/unit/test_auth.py | 169 + backend/tests/unit/test_crypto.py | 126 + .../tests/unit/test_maintenance_windows.py | 121 + backend/tests/unit/test_security.py | 231 + docker-compose.observability.yml | 49 + docker-compose.override.yml | 111 + docker-compose.prod.yml | 82 + docker-compose.staging.yml | 88 + docker-compose.yml | 164 + docs/API.md | 117 + docs/ARCHITECTURE.md | 329 + docs/CONFIGURATION.md | 127 + docs/DEPLOYMENT.md | 257 + docs/README.md | 203 + docs/SECURITY.md | 149 + docs/USER-GUIDE.md | 246 + docs/website/CNAME | 1 + docs/website/assets/alerts.png | Bin 0 -> 86776 bytes docs/website/assets/config-editor.png | Bin 0 -> 75703 bytes .../assets/dashboard-lebowski-lanes.png | Bin 0 -> 146317 bytes .../assets/dashboard-strangers-ranch.png | Bin 0 -> 110220 bytes docs/website/assets/device-detail.png | Bin 0 -> 118537 bytes docs/website/assets/device-list.png | Bin 0 -> 117383 bytes docs/website/assets/login.png | Bin 0 -> 41969 bytes docs/website/assets/topology.png | Bin 0 -> 70403 bytes docs/website/docs.html | 1412 +++ docs/website/index.html | 520 + docs/website/robots.txt | 4 + docs/website/script.js | 241 + docs/website/sitemap.xml | 15 + docs/website/style.css | 1868 ++++ frontend/.gitignore | 32 + frontend/README.md | 73 + frontend/eslint.config.js | 23 + frontend/index.html | 14 + frontend/package-lock.json | 9521 +++++++++++++++++ frontend/package.json | 86 + frontend/playwright.config.ts | 25 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 21 + frontend/public/vite.svg | 1 + frontend/src/App.tsx | 45 + .../src/assets/fonts/Geist-Variable.woff2 | Bin 0 -> 69436 bytes .../src/assets/fonts/GeistMono-Variable.woff2 | Bin 0 -> 71004 bytes frontend/src/assets/react.svg | 1 + .../components/__tests__/DeviceList.test.tsx | 214 + .../components/__tests__/LoginPage.test.tsx | 229 + .../__tests__/TemplatePushWizard.test.tsx | 502 + frontend/src/components/alerts/AlertBadge.tsx | 31 + .../src/components/alerts/AlertRulesPage.tsx | 907 ++ frontend/src/components/alerts/AlertsPage.tsx | 396 + .../src/components/audit/AuditLogTable.tsx | 421 + .../components/auth/EmergencyKitDialog.tsx | 196 + .../components/auth/PasswordStrengthMeter.tsx | 131 + .../src/components/auth/SecretKeyInput.tsx | 159 + .../src/components/auth/SrpUpgradeDialog.tsx | 189 + frontend/src/components/brand/RugLogo.tsx | 60 + .../certificates/BulkDeployDialog.tsx | 326 + .../components/certificates/CAStatusCard.tsx | 229 + .../certificates/CertConfirmDialog.tsx | 146 + .../certificates/CertificatesPage.tsx | 115 + .../certificates/DeployCertDialog.tsx | 256 + .../certificates/DeviceCertTable.tsx | 434 + .../command-palette/CommandPalette.tsx | 320 + .../command-palette/useCommandPalette.ts | 13 + .../config-editor/CommandExecutor.tsx | 168 + .../config-editor/ConfigEditorPage.tsx | 431 + .../components/config-editor/EntryForm.tsx | 173 + .../components/config-editor/EntryTable.tsx | 170 + .../src/components/config-editor/MenuTree.tsx | 236 + .../components/config/AddressListPanel.tsx | 282 + .../src/components/config/AddressPanel.tsx | 505 + frontend/src/components/config/ArpPanel.tsx | 454 + .../src/components/config/BackupTimeline.tsx | 210 + .../components/config/BandwidthTestTool.tsx | 211 + .../components/config/BatchConfigPanel.tsx | 886 ++ .../src/components/config/BridgePortPanel.tsx | 321 + .../src/components/config/BridgeVlanPanel.tsx | 304 + .../components/config/ChangePreviewModal.tsx | 133 + .../components/config/ConfigDiffViewer.tsx | 149 + frontend/src/components/config/ConfigTab.tsx | 232 + .../src/components/config/ConnTrackPanel.tsx | 214 + .../src/components/config/DhcpClientPanel.tsx | 476 + frontend/src/components/config/DhcpPanel.tsx | 1256 +++ frontend/src/components/config/DnsPanel.tsx | 613 ++ .../src/components/config/FirewallPanel.tsx | 1362 +++ .../src/components/config/InterfacesPanel.tsx | 1392 +++ frontend/src/components/config/IpsecPanel.tsx | 314 + .../src/components/config/ManglePanel.tsx | 320 + .../components/config/NetworkToolsPanel.tsx | 54 + frontend/src/components/config/PingTool.tsx | 216 + frontend/src/components/config/PoolPanel.tsx | 448 + frontend/src/components/config/PppPanel.tsx | 310 + .../src/components/config/QueuesPanel.tsx | 1176 ++ .../src/components/config/RestoreButton.tsx | 140 + .../src/components/config/RestorePreview.tsx | 180 + .../src/components/config/RollbackAlert.tsx | 67 + .../src/components/config/RoutesPanel.tsx | 500 + .../src/components/config/SafetyToggle.tsx | 57 + .../src/components/config/ScriptsPanel.tsx | 604 ++ .../src/components/config/ServicesPanel.tsx | 355 + frontend/src/components/config/SnmpPanel.tsx | 360 + .../components/config/SwitchPortManager.tsx | 339 + .../src/components/config/SystemPanel.tsx | 477 + frontend/src/components/config/TorchTool.tsx | 247 + .../src/components/config/TracerouteTool.tsx | 189 + frontend/src/components/config/UsersPanel.tsx | 416 + frontend/src/components/config/WifiPanel.tsx | 987 ++ .../src/components/dashboard/AlertSummary.tsx | 126 + .../components/dashboard/BandwidthChart.tsx | 110 + .../components/dashboard/EventsTimeline.tsx | 192 + .../src/components/dashboard/HealthScore.tsx | 187 + .../src/components/dashboard/KpiCards.tsx | 131 + .../src/components/dashboard/QuickActions.tsx | 105 + .../src/components/firmware/FirmwarePage.tsx | 449 + .../firmware/UpgradeProgressModal.tsx | 340 + .../src/components/fleet/AddDeviceForm.tsx | 230 + .../src/components/fleet/AdoptionWizard.tsx | 1119 ++ .../src/components/fleet/DeviceFilters.tsx | 110 + .../src/components/fleet/FleetDashboard.tsx | 297 + frontend/src/components/fleet/FleetTable.tsx | 455 + .../src/components/fleet/ScanResultsList.tsx | 224 + .../src/components/fleet/SubnetScanForm.tsx | 94 + frontend/src/components/layout/AppLayout.tsx | 31 + frontend/src/components/layout/Header.tsx | 225 + .../src/components/layout/PageTransition.tsx | 35 + .../src/components/layout/ShortcutsDialog.tsx | 70 + frontend/src/components/layout/Sidebar.tsx | 344 + .../maintenance/MaintenanceForm.tsx | 322 + .../maintenance/MaintenanceList.tsx | 342 + frontend/src/components/map/DeviceMarker.tsx | 93 + frontend/src/components/map/FleetMap.tsx | 132 + frontend/src/components/map/MapPage.tsx | 161 + .../src/components/monitoring/HealthChart.tsx | 95 + .../src/components/monitoring/HealthTab.tsx | 98 + .../components/monitoring/InterfacesTab.tsx | 115 + .../src/components/monitoring/SignalBar.tsx | 58 + .../src/components/monitoring/Sparkline.tsx | 24 + .../monitoring/TimeRangeSelector.tsx | 153 + .../components/monitoring/TrafficChart.tsx | 127 + .../src/components/monitoring/WirelessTab.tsx | 180 + .../src/components/network/ClientsTab.tsx | 459 + .../components/network/InterfaceGauges.tsx | 178 + frontend/src/components/network/LogsTab.tsx | 267 + .../src/components/network/TopologyMap.tsx | 394 + frontend/src/components/network/VpnTab.tsx | 157 + .../operations/BulkCommandWizard.tsx | 824 ++ .../src/components/reports/ReportsPage.tsx | 254 + .../src/components/settings/ApiKeysPage.tsx | 421 + .../settings/ChangePasswordForm.tsx | 222 + .../src/components/settings/SettingsPage.tsx | 454 + frontend/src/components/setup/SetupWizard.tsx | 519 + .../simple-config/SimpleApplyBar.tsx | 35 + .../simple-config/SimpleConfigSidebar.tsx | 63 + .../simple-config/SimpleConfigView.tsx | 240 + .../simple-config/SimpleFormField.tsx | 132 + .../simple-config/SimpleFormSection.tsx | 35 + .../simple-config/SimpleModeToggle.tsx | 44 + .../simple-config/SimpleStatusBanner.tsx | 30 + .../simple-config/StandardConfigSidebar.tsx | 182 + .../categories/DnsSimplePanel.tsx | 299 + .../categories/FirewallBasicsPanel.tsx | 659 ++ .../categories/InternetSetupPanel.tsx | 325 + .../simple-config/categories/LanDhcpPanel.tsx | 217 + .../categories/PortForwardingPanel.tsx | 347 + .../categories/SystemSimplePanel.tsx | 299 + .../categories/WifiSimplePanel.tsx | 264 + .../templates/PushProgressPanel.tsx | 143 + .../components/templates/TemplateEditor.tsx | 320 + .../templates/TemplatePushWizard.tsx | 435 + .../components/templates/TemplatesPage.tsx | 348 + .../components/tenants/CreateTenantForm.tsx | 95 + .../src/components/tenants/TenantList.tsx | 151 + .../transparency/TransparencyLogTable.tsx | 575 + frontend/src/components/ui/badge.tsx | 25 + frontend/src/components/ui/button.tsx | 49 + frontend/src/components/ui/card.tsx | 54 + frontend/src/components/ui/checkbox.tsx | 25 + frontend/src/components/ui/dialog.tsx | 95 + frontend/src/components/ui/dropdown-menu.tsx | 185 + frontend/src/components/ui/empty-state.tsx | 27 + frontend/src/components/ui/error-boundary.tsx | 139 + frontend/src/components/ui/input.tsx | 23 + frontend/src/components/ui/label.tsx | 20 + frontend/src/components/ui/page-skeleton.tsx | 84 + frontend/src/components/ui/popover.tsx | 28 + frontend/src/components/ui/select.tsx | 147 + frontend/src/components/ui/shortcut-hint.tsx | 31 + frontend/src/components/ui/skeleton.tsx | 15 + frontend/src/components/ui/tabs.tsx | 49 + frontend/src/components/ui/toast.tsx | 48 + .../src/components/users/CreateUserForm.tsx | 136 + frontend/src/components/users/UserList.tsx | 153 + .../components/vpn/VpnOnboardingWizard.tsx | 263 + frontend/src/components/vpn/VpnPage.tsx | 485 + frontend/src/contexts/EventStreamContext.tsx | 34 + frontend/src/hooks/useAnimatedCounter.ts | 78 + frontend/src/hooks/useConfigPanel.ts | 161 + frontend/src/hooks/useEventStream.ts | 208 + frontend/src/hooks/usePageTitle.ts | 51 + frontend/src/hooks/useShortcut.ts | 84 + frontend/src/hooks/useSimpleConfig.ts | 39 + frontend/src/index.css | 159 + frontend/src/lib/alertsApi.ts | 226 + frontend/src/lib/api.ts | 1009 ++ frontend/src/lib/auth.ts | 258 + frontend/src/lib/certificatesApi.ts | 176 + frontend/src/lib/configEditorApi.ts | 80 + frontend/src/lib/configPanelTypes.ts | 166 + frontend/src/lib/crypto/dataEncryption.ts | 137 + frontend/src/lib/crypto/keyStore.ts | 123 + frontend/src/lib/crypto/keys.ts | 236 + frontend/src/lib/crypto/registration.ts | 170 + frontend/src/lib/crypto/secretKey.ts | 83 + frontend/src/lib/crypto/srp.ts | 331 + frontend/src/lib/crypto/types.ts | 70 + frontend/src/lib/crypto/worker.ts | 52 + frontend/src/lib/diffUtils.ts | 117 + frontend/src/lib/errors.ts | 58 + frontend/src/lib/eventsApi.ts | 24 + frontend/src/lib/firmwareApi.ts | 207 + frontend/src/lib/networkApi.ts | 199 + frontend/src/lib/settingsApi.ts | 42 + frontend/src/lib/shortcuts.ts | 29 + frontend/src/lib/simpleConfigSchema.ts | 335 + frontend/src/lib/smtpPresets.ts | 71 + frontend/src/lib/store.ts | 44 + frontend/src/lib/templatesApi.ts | 127 + frontend/src/lib/theme.ts | 47 + frontend/src/lib/transparencyApi.ts | 83 + frontend/src/lib/utils.ts | 36 + frontend/src/main.tsx | 14 + frontend/src/routeTree.gen.ts | 794 ++ frontend/src/routes/__root.tsx | 25 + frontend/src/routes/_authenticated.tsx | 231 + frontend/src/routes/_authenticated/about.tsx | 625 ++ .../src/routes/_authenticated/alert-rules.tsx | 6 + frontend/src/routes/_authenticated/alerts.tsx | 6 + frontend/src/routes/_authenticated/audit.tsx | 64 + .../routes/_authenticated/batch-config.tsx | 70 + .../routes/_authenticated/bulk-commands.tsx | 74 + .../routes/_authenticated/certificates.tsx | 6 + .../routes/_authenticated/config-editor.tsx | 6 + .../src/routes/_authenticated/firmware.tsx | 6 + frontend/src/routes/_authenticated/index.tsx | 43 + .../src/routes/_authenticated/maintenance.tsx | 75 + frontend/src/routes/_authenticated/map.tsx | 6 + .../src/routes/_authenticated/reports.tsx | 73 + .../_authenticated/settings.api-keys.tsx | 46 + .../src/routes/_authenticated/settings.tsx | 6 + frontend/src/routes/_authenticated/setup.tsx | 10 + .../src/routes/_authenticated/templates.tsx | 6 + .../tenants/$tenantId/devices/$deviceId.tsx | 883 ++ .../tenants/$tenantId/devices/add.tsx | 48 + .../tenants/$tenantId/devices/adopt.tsx | 59 + .../tenants/$tenantId/devices/index.tsx | 89 + .../tenants/$tenantId/devices/scan.tsx | 61 + .../tenants/$tenantId/index.tsx | 88 + .../tenants/$tenantId/users.tsx | 41 + .../routes/_authenticated/tenants/index.tsx | 10 + .../src/routes/_authenticated/topology.tsx | 59 + .../routes/_authenticated/transparency.tsx | 73 + frontend/src/routes/_authenticated/vpn.tsx | 6 + frontend/src/routes/forgot-password.tsx | 110 + frontend/src/routes/login.tsx | 269 + frontend/src/routes/privacy.tsx | 255 + frontend/src/routes/reset-password.tsx | 165 + frontend/src/routes/terms.tsx | 102 + frontend/src/test/setup.ts | 1 + frontend/src/test/test-utils.tsx | 39 + frontend/tailwind.config.ts | 93 + frontend/tests/e2e/alerts.spec.ts | 29 + frontend/tests/e2e/auth.setup.ts | 33 + frontend/tests/e2e/dashboard.spec.ts | 41 + frontend/tests/e2e/device.spec.ts | 39 + frontend/tests/e2e/fixtures.ts | 12 + frontend/tests/e2e/login.spec.ts | 58 + frontend/tests/e2e/pages/dashboard.page.ts | 29 + frontend/tests/e2e/pages/login.page.ts | 35 + frontend/tsconfig.app.json | 32 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 68 + frontend/vitest.config.ts | 19 + infrastructure/docker/Dockerfile.api | 55 + infrastructure/docker/Dockerfile.frontend | 39 + infrastructure/docker/nginx-spa.conf | 56 + infrastructure/helm/Chart.yaml | 13 + infrastructure/helm/templates/_helpers.tpl | 171 + .../helm/templates/api-deployment.yaml | 76 + .../helm/templates/api-service.yaml | 15 + infrastructure/helm/templates/configmap.yaml | 21 + .../helm/templates/frontend-deployment.yaml | 56 + infrastructure/helm/templates/ingress.yaml | 57 + .../helm/templates/nats-statefulset.yaml | 115 + .../helm/templates/poller-deployment.yaml | 62 + .../helm/templates/postgres-statefulset.yaml | 137 + .../helm/templates/redis-deployment.yaml | 60 + infrastructure/helm/templates/secrets.yaml | 15 + infrastructure/helm/values.yaml | 219 + .../grafana/dashboards/api-overview.json | 258 + .../grafana/dashboards/infrastructure.json | 222 + .../grafana/dashboards/poller-status.json | 324 + .../provisioning/dashboards/provider.yml | 12 + .../provisioning/datasources/prometheus.yml | 8 + infrastructure/observability/prometheus.yml | 18 + infrastructure/openbao/init.sh | 38 + poller/.dockerignore | 9 + poller/.gitignore | 7 + poller/Dockerfile | 17 + poller/cmd/poller/main.go | 231 + poller/docker-entrypoint.sh | 15 + poller/go.mod | 92 + poller/go.sum | 227 + poller/internal/bus/cmd_cert_deploy.go | 182 + poller/internal/bus/cmd_responder.go | 166 + poller/internal/bus/credential_subscriber.go | 75 + poller/internal/bus/publisher.go | 322 + .../bus/publisher_integration_test.go | 232 + poller/internal/config/config.go | 160 + poller/internal/config/config_prod_test.go | 79 + poller/internal/config/config_test.go | 104 + poller/internal/device/cert_deploy.go | 122 + poller/internal/device/client.go | 115 + poller/internal/device/command.go | 50 + poller/internal/device/crypto.go | 61 + poller/internal/device/crypto_test.go | 91 + poller/internal/device/firmware.go | 99 + poller/internal/device/health.go | 110 + poller/internal/device/interfaces.go | 61 + poller/internal/device/sftp.go | 53 + poller/internal/device/version.go | 86 + poller/internal/device/wireless.go | 145 + poller/internal/observability/metrics.go | 60 + poller/internal/observability/server.go | 59 + poller/internal/poller/integration_test.go | 195 + poller/internal/poller/interfaces.go | 14 + poller/internal/poller/scheduler.go | 264 + poller/internal/poller/scheduler_test.go | 184 + poller/internal/poller/worker.go | 409 + poller/internal/store/devices.go | 161 + .../store/devices_integration_test.go | 150 + poller/internal/testutil/containers.go | 241 + poller/internal/vault/cache.go | 173 + poller/internal/vault/transit.go | 127 + scripts/init-postgres.sql | 29 + scripts/seed-demo-data.sql | 269 + 511 files changed, 106948 insertions(+) create mode 100644 .env.example create mode 100644 .env.staging.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/security-scan.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/.gitignore 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_schema.py create mode 100644 backend/alembic/versions/002_add_routeros_major_version_and_poller_role.py create mode 100644 backend/alembic/versions/003_metrics_hypertables.py create mode 100644 backend/alembic/versions/004_config_management.py create mode 100644 backend/alembic/versions/005_alerting_and_firmware.py create mode 100644 backend/alembic/versions/006_advanced_features.py create mode 100644 backend/alembic/versions/007_audit_logs.py create mode 100644 backend/alembic/versions/008_maintenance_windows.py create mode 100644 backend/alembic/versions/009_api_keys.py create mode 100644 backend/alembic/versions/010_wireguard_vpn.py create mode 100644 backend/alembic/versions/012_seed_starter_templates.py create mode 100644 backend/alembic/versions/013_certificates.py create mode 100644 backend/alembic/versions/014_timescaledb_retention.py create mode 100644 backend/alembic/versions/015_password_reset_tokens.py create mode 100644 backend/alembic/versions/016_zero_knowledge_schema.py create mode 100644 backend/alembic/versions/017_openbao_envelope_encryption.py create mode 100644 backend/alembic/versions/018_data_encryption.py create mode 100644 backend/alembic/versions/019_deprecate_bcrypt.py create mode 100644 backend/alembic/versions/020_tls_mode_opt_in.py create mode 100644 backend/alembic/versions/021_system_tenant_for_audit.py create mode 100644 backend/alembic/versions/022_rls_super_admin_devices.py create mode 100644 backend/alembic/versions/023_slack_notification_channel.py create mode 100644 backend/alembic/versions/024_contact_email_and_offline_rule.py create mode 100644 backend/alembic/versions/025_fix_key_access_log_device_fk.py create mode 100644 backend/alembic/versions/026_system_settings.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/logging_config.py create mode 100644 backend/app/main.py create mode 100644 backend/app/middleware/__init__.py create mode 100644 backend/app/middleware/rate_limit.py create mode 100644 backend/app/middleware/rbac.py create mode 100644 backend/app/middleware/request_id.py create mode 100644 backend/app/middleware/security_headers.py create mode 100644 backend/app/middleware/tenant_context.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/alert.py create mode 100644 backend/app/models/api_key.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/certificate.py create mode 100644 backend/app/models/config_backup.py create mode 100644 backend/app/models/config_template.py create mode 100644 backend/app/models/device.py create mode 100644 backend/app/models/firmware.py create mode 100644 backend/app/models/key_set.py create mode 100644 backend/app/models/maintenance_window.py create mode 100644 backend/app/models/tenant.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/models/vpn.py create mode 100644 backend/app/observability.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/alerts.py create mode 100644 backend/app/routers/api_keys.py create mode 100644 backend/app/routers/audit_logs.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/certificates.py create mode 100644 backend/app/routers/clients.py create mode 100644 backend/app/routers/config_backups.py create mode 100644 backend/app/routers/config_editor.py create mode 100644 backend/app/routers/device_groups.py create mode 100644 backend/app/routers/device_logs.py create mode 100644 backend/app/routers/device_tags.py create mode 100644 backend/app/routers/devices.py create mode 100644 backend/app/routers/events.py create mode 100644 backend/app/routers/firmware.py create mode 100644 backend/app/routers/maintenance_windows.py create mode 100644 backend/app/routers/metrics.py create mode 100644 backend/app/routers/reports.py create mode 100644 backend/app/routers/settings.py create mode 100644 backend/app/routers/sse.py create mode 100644 backend/app/routers/templates.py create mode 100644 backend/app/routers/tenants.py create mode 100644 backend/app/routers/topology.py create mode 100644 backend/app/routers/transparency.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/routers/vpn.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/certificate.py create mode 100644 backend/app/schemas/device.py create mode 100644 backend/app/schemas/tenant.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/schemas/vpn.py create mode 100644 backend/app/security/__init__.py create mode 100644 backend/app/security/command_blocklist.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/account_service.py create mode 100644 backend/app/services/alert_evaluator.py create mode 100644 backend/app/services/api_key_service.py create mode 100644 backend/app/services/audit_service.py create mode 100644 backend/app/services/auth.py create mode 100644 backend/app/services/backup_scheduler.py create mode 100644 backend/app/services/backup_service.py create mode 100644 backend/app/services/ca_service.py create mode 100644 backend/app/services/config_change_subscriber.py create mode 100644 backend/app/services/crypto.py create mode 100644 backend/app/services/device.py create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/emergency_kit_service.py create mode 100644 backend/app/services/event_publisher.py create mode 100644 backend/app/services/firmware_service.py create mode 100644 backend/app/services/firmware_subscriber.py create mode 100644 backend/app/services/git_store.py create mode 100644 backend/app/services/key_service.py create mode 100644 backend/app/services/metrics_subscriber.py create mode 100644 backend/app/services/nats_subscriber.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/openbao_service.py create mode 100644 backend/app/services/push_rollback_subscriber.py create mode 100644 backend/app/services/push_tracker.py create mode 100644 backend/app/services/report_service.py create mode 100644 backend/app/services/restore_service.py create mode 100644 backend/app/services/routeros_proxy.py create mode 100644 backend/app/services/rsc_parser.py create mode 100644 backend/app/services/scanner.py create mode 100644 backend/app/services/srp_service.py create mode 100644 backend/app/services/sse_manager.py create mode 100644 backend/app/services/template_service.py create mode 100644 backend/app/services/upgrade_service.py create mode 100644 backend/app/services/vpn_service.py create mode 100644 backend/app/templates/reports/alert_history.html create mode 100644 backend/app/templates/reports/base.html create mode 100644 backend/app/templates/reports/change_log.html create mode 100644 backend/app/templates/reports/device_inventory.html create mode 100644 backend/app/templates/reports/metrics_summary.html create mode 100644 backend/gunicorn.conf.py create mode 100644 backend/pyproject.toml create mode 100644 backend/templates/emergency_kit.html create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/conftest.py create mode 100644 backend/tests/integration/test_alerts_api.py create mode 100644 backend/tests/integration/test_auth_api.py create mode 100644 backend/tests/integration/test_config_api.py create mode 100644 backend/tests/integration/test_devices_api.py create mode 100644 backend/tests/integration/test_firmware_api.py create mode 100644 backend/tests/integration/test_monitoring_api.py create mode 100644 backend/tests/integration/test_rls_isolation.py create mode 100644 backend/tests/integration/test_templates_api.py create mode 100644 backend/tests/test_backup_scheduler.py create mode 100644 backend/tests/test_config_change_subscriber.py create mode 100644 backend/tests/test_config_checkpoint.py create mode 100644 backend/tests/test_push_recovery.py create mode 100644 backend/tests/test_push_rollback_subscriber.py create mode 100644 backend/tests/test_restore_preview.py create mode 100644 backend/tests/test_rsc_parser.py create mode 100644 backend/tests/test_srp_interop.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_api_key_service.py create mode 100644 backend/tests/unit/test_audit_service.py create mode 100644 backend/tests/unit/test_auth.py create mode 100644 backend/tests/unit/test_crypto.py create mode 100644 backend/tests/unit/test_maintenance_windows.py create mode 100644 backend/tests/unit/test_security.py create mode 100644 docker-compose.observability.yml create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.staging.yml create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/README.md create mode 100644 docs/SECURITY.md create mode 100644 docs/USER-GUIDE.md create mode 100644 docs/website/CNAME create mode 100644 docs/website/assets/alerts.png create mode 100644 docs/website/assets/config-editor.png create mode 100644 docs/website/assets/dashboard-lebowski-lanes.png create mode 100644 docs/website/assets/dashboard-strangers-ranch.png create mode 100644 docs/website/assets/device-detail.png create mode 100644 docs/website/assets/device-list.png create mode 100644 docs/website/assets/login.png create mode 100644 docs/website/assets/topology.png create mode 100644 docs/website/docs.html create mode 100644 docs/website/index.html create mode 100644 docs/website/robots.txt create mode 100644 docs/website/script.js create mode 100644 docs/website/sitemap.xml create mode 100644 docs/website/style.css create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/fonts/Geist-Variable.woff2 create mode 100644 frontend/src/assets/fonts/GeistMono-Variable.woff2 create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/__tests__/DeviceList.test.tsx create mode 100644 frontend/src/components/__tests__/LoginPage.test.tsx create mode 100644 frontend/src/components/__tests__/TemplatePushWizard.test.tsx create mode 100644 frontend/src/components/alerts/AlertBadge.tsx create mode 100644 frontend/src/components/alerts/AlertRulesPage.tsx create mode 100644 frontend/src/components/alerts/AlertsPage.tsx create mode 100644 frontend/src/components/audit/AuditLogTable.tsx create mode 100644 frontend/src/components/auth/EmergencyKitDialog.tsx create mode 100644 frontend/src/components/auth/PasswordStrengthMeter.tsx create mode 100644 frontend/src/components/auth/SecretKeyInput.tsx create mode 100644 frontend/src/components/auth/SrpUpgradeDialog.tsx create mode 100644 frontend/src/components/brand/RugLogo.tsx create mode 100644 frontend/src/components/certificates/BulkDeployDialog.tsx create mode 100644 frontend/src/components/certificates/CAStatusCard.tsx create mode 100644 frontend/src/components/certificates/CertConfirmDialog.tsx create mode 100644 frontend/src/components/certificates/CertificatesPage.tsx create mode 100644 frontend/src/components/certificates/DeployCertDialog.tsx create mode 100644 frontend/src/components/certificates/DeviceCertTable.tsx create mode 100644 frontend/src/components/command-palette/CommandPalette.tsx create mode 100644 frontend/src/components/command-palette/useCommandPalette.ts create mode 100644 frontend/src/components/config-editor/CommandExecutor.tsx create mode 100644 frontend/src/components/config-editor/ConfigEditorPage.tsx create mode 100644 frontend/src/components/config-editor/EntryForm.tsx create mode 100644 frontend/src/components/config-editor/EntryTable.tsx create mode 100644 frontend/src/components/config-editor/MenuTree.tsx create mode 100644 frontend/src/components/config/AddressListPanel.tsx create mode 100644 frontend/src/components/config/AddressPanel.tsx create mode 100644 frontend/src/components/config/ArpPanel.tsx create mode 100644 frontend/src/components/config/BackupTimeline.tsx create mode 100644 frontend/src/components/config/BandwidthTestTool.tsx create mode 100644 frontend/src/components/config/BatchConfigPanel.tsx create mode 100644 frontend/src/components/config/BridgePortPanel.tsx create mode 100644 frontend/src/components/config/BridgeVlanPanel.tsx create mode 100644 frontend/src/components/config/ChangePreviewModal.tsx create mode 100644 frontend/src/components/config/ConfigDiffViewer.tsx create mode 100644 frontend/src/components/config/ConfigTab.tsx create mode 100644 frontend/src/components/config/ConnTrackPanel.tsx create mode 100644 frontend/src/components/config/DhcpClientPanel.tsx create mode 100644 frontend/src/components/config/DhcpPanel.tsx create mode 100644 frontend/src/components/config/DnsPanel.tsx create mode 100644 frontend/src/components/config/FirewallPanel.tsx create mode 100644 frontend/src/components/config/InterfacesPanel.tsx create mode 100644 frontend/src/components/config/IpsecPanel.tsx create mode 100644 frontend/src/components/config/ManglePanel.tsx create mode 100644 frontend/src/components/config/NetworkToolsPanel.tsx create mode 100644 frontend/src/components/config/PingTool.tsx create mode 100644 frontend/src/components/config/PoolPanel.tsx create mode 100644 frontend/src/components/config/PppPanel.tsx create mode 100644 frontend/src/components/config/QueuesPanel.tsx create mode 100644 frontend/src/components/config/RestoreButton.tsx create mode 100644 frontend/src/components/config/RestorePreview.tsx create mode 100644 frontend/src/components/config/RollbackAlert.tsx create mode 100644 frontend/src/components/config/RoutesPanel.tsx create mode 100644 frontend/src/components/config/SafetyToggle.tsx create mode 100644 frontend/src/components/config/ScriptsPanel.tsx create mode 100644 frontend/src/components/config/ServicesPanel.tsx create mode 100644 frontend/src/components/config/SnmpPanel.tsx create mode 100644 frontend/src/components/config/SwitchPortManager.tsx create mode 100644 frontend/src/components/config/SystemPanel.tsx create mode 100644 frontend/src/components/config/TorchTool.tsx create mode 100644 frontend/src/components/config/TracerouteTool.tsx create mode 100644 frontend/src/components/config/UsersPanel.tsx create mode 100644 frontend/src/components/config/WifiPanel.tsx create mode 100644 frontend/src/components/dashboard/AlertSummary.tsx create mode 100644 frontend/src/components/dashboard/BandwidthChart.tsx create mode 100644 frontend/src/components/dashboard/EventsTimeline.tsx create mode 100644 frontend/src/components/dashboard/HealthScore.tsx create mode 100644 frontend/src/components/dashboard/KpiCards.tsx create mode 100644 frontend/src/components/dashboard/QuickActions.tsx create mode 100644 frontend/src/components/firmware/FirmwarePage.tsx create mode 100644 frontend/src/components/firmware/UpgradeProgressModal.tsx create mode 100644 frontend/src/components/fleet/AddDeviceForm.tsx create mode 100644 frontend/src/components/fleet/AdoptionWizard.tsx create mode 100644 frontend/src/components/fleet/DeviceFilters.tsx create mode 100644 frontend/src/components/fleet/FleetDashboard.tsx create mode 100644 frontend/src/components/fleet/FleetTable.tsx create mode 100644 frontend/src/components/fleet/ScanResultsList.tsx create mode 100644 frontend/src/components/fleet/SubnetScanForm.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/layout/Header.tsx create mode 100644 frontend/src/components/layout/PageTransition.tsx create mode 100644 frontend/src/components/layout/ShortcutsDialog.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/maintenance/MaintenanceForm.tsx create mode 100644 frontend/src/components/maintenance/MaintenanceList.tsx create mode 100644 frontend/src/components/map/DeviceMarker.tsx create mode 100644 frontend/src/components/map/FleetMap.tsx create mode 100644 frontend/src/components/map/MapPage.tsx create mode 100644 frontend/src/components/monitoring/HealthChart.tsx create mode 100644 frontend/src/components/monitoring/HealthTab.tsx create mode 100644 frontend/src/components/monitoring/InterfacesTab.tsx create mode 100644 frontend/src/components/monitoring/SignalBar.tsx create mode 100644 frontend/src/components/monitoring/Sparkline.tsx create mode 100644 frontend/src/components/monitoring/TimeRangeSelector.tsx create mode 100644 frontend/src/components/monitoring/TrafficChart.tsx create mode 100644 frontend/src/components/monitoring/WirelessTab.tsx create mode 100644 frontend/src/components/network/ClientsTab.tsx create mode 100644 frontend/src/components/network/InterfaceGauges.tsx create mode 100644 frontend/src/components/network/LogsTab.tsx create mode 100644 frontend/src/components/network/TopologyMap.tsx create mode 100644 frontend/src/components/network/VpnTab.tsx create mode 100644 frontend/src/components/operations/BulkCommandWizard.tsx create mode 100644 frontend/src/components/reports/ReportsPage.tsx create mode 100644 frontend/src/components/settings/ApiKeysPage.tsx create mode 100644 frontend/src/components/settings/ChangePasswordForm.tsx create mode 100644 frontend/src/components/settings/SettingsPage.tsx create mode 100644 frontend/src/components/setup/SetupWizard.tsx create mode 100644 frontend/src/components/simple-config/SimpleApplyBar.tsx create mode 100644 frontend/src/components/simple-config/SimpleConfigSidebar.tsx create mode 100644 frontend/src/components/simple-config/SimpleConfigView.tsx create mode 100644 frontend/src/components/simple-config/SimpleFormField.tsx create mode 100644 frontend/src/components/simple-config/SimpleFormSection.tsx create mode 100644 frontend/src/components/simple-config/SimpleModeToggle.tsx create mode 100644 frontend/src/components/simple-config/SimpleStatusBanner.tsx create mode 100644 frontend/src/components/simple-config/StandardConfigSidebar.tsx create mode 100644 frontend/src/components/simple-config/categories/DnsSimplePanel.tsx create mode 100644 frontend/src/components/simple-config/categories/FirewallBasicsPanel.tsx create mode 100644 frontend/src/components/simple-config/categories/InternetSetupPanel.tsx create mode 100644 frontend/src/components/simple-config/categories/LanDhcpPanel.tsx create mode 100644 frontend/src/components/simple-config/categories/PortForwardingPanel.tsx create mode 100644 frontend/src/components/simple-config/categories/SystemSimplePanel.tsx create mode 100644 frontend/src/components/simple-config/categories/WifiSimplePanel.tsx create mode 100644 frontend/src/components/templates/PushProgressPanel.tsx create mode 100644 frontend/src/components/templates/TemplateEditor.tsx create mode 100644 frontend/src/components/templates/TemplatePushWizard.tsx create mode 100644 frontend/src/components/templates/TemplatesPage.tsx create mode 100644 frontend/src/components/tenants/CreateTenantForm.tsx create mode 100644 frontend/src/components/tenants/TenantList.tsx create mode 100644 frontend/src/components/transparency/TransparencyLogTable.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/empty-state.tsx create mode 100644 frontend/src/components/ui/error-boundary.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/page-skeleton.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/shortcut-hint.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/users/CreateUserForm.tsx create mode 100644 frontend/src/components/users/UserList.tsx create mode 100644 frontend/src/components/vpn/VpnOnboardingWizard.tsx create mode 100644 frontend/src/components/vpn/VpnPage.tsx create mode 100644 frontend/src/contexts/EventStreamContext.tsx create mode 100644 frontend/src/hooks/useAnimatedCounter.ts create mode 100644 frontend/src/hooks/useConfigPanel.ts create mode 100644 frontend/src/hooks/useEventStream.ts create mode 100644 frontend/src/hooks/usePageTitle.ts create mode 100644 frontend/src/hooks/useShortcut.ts create mode 100644 frontend/src/hooks/useSimpleConfig.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/alertsApi.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/certificatesApi.ts create mode 100644 frontend/src/lib/configEditorApi.ts create mode 100644 frontend/src/lib/configPanelTypes.ts create mode 100644 frontend/src/lib/crypto/dataEncryption.ts create mode 100644 frontend/src/lib/crypto/keyStore.ts create mode 100644 frontend/src/lib/crypto/keys.ts create mode 100644 frontend/src/lib/crypto/registration.ts create mode 100644 frontend/src/lib/crypto/secretKey.ts create mode 100644 frontend/src/lib/crypto/srp.ts create mode 100644 frontend/src/lib/crypto/types.ts create mode 100644 frontend/src/lib/crypto/worker.ts create mode 100644 frontend/src/lib/diffUtils.ts create mode 100644 frontend/src/lib/errors.ts create mode 100644 frontend/src/lib/eventsApi.ts create mode 100644 frontend/src/lib/firmwareApi.ts create mode 100644 frontend/src/lib/networkApi.ts create mode 100644 frontend/src/lib/settingsApi.ts create mode 100644 frontend/src/lib/shortcuts.ts create mode 100644 frontend/src/lib/simpleConfigSchema.ts create mode 100644 frontend/src/lib/smtpPresets.ts create mode 100644 frontend/src/lib/store.ts create mode 100644 frontend/src/lib/templatesApi.ts create mode 100644 frontend/src/lib/theme.ts create mode 100644 frontend/src/lib/transparencyApi.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/routeTree.gen.ts create mode 100644 frontend/src/routes/__root.tsx create mode 100644 frontend/src/routes/_authenticated.tsx create mode 100644 frontend/src/routes/_authenticated/about.tsx create mode 100644 frontend/src/routes/_authenticated/alert-rules.tsx create mode 100644 frontend/src/routes/_authenticated/alerts.tsx create mode 100644 frontend/src/routes/_authenticated/audit.tsx create mode 100644 frontend/src/routes/_authenticated/batch-config.tsx create mode 100644 frontend/src/routes/_authenticated/bulk-commands.tsx create mode 100644 frontend/src/routes/_authenticated/certificates.tsx create mode 100644 frontend/src/routes/_authenticated/config-editor.tsx create mode 100644 frontend/src/routes/_authenticated/firmware.tsx create mode 100644 frontend/src/routes/_authenticated/index.tsx create mode 100644 frontend/src/routes/_authenticated/maintenance.tsx create mode 100644 frontend/src/routes/_authenticated/map.tsx create mode 100644 frontend/src/routes/_authenticated/reports.tsx create mode 100644 frontend/src/routes/_authenticated/settings.api-keys.tsx create mode 100644 frontend/src/routes/_authenticated/settings.tsx create mode 100644 frontend/src/routes/_authenticated/setup.tsx create mode 100644 frontend/src/routes/_authenticated/templates.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/$tenantId/devices/$deviceId.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/$tenantId/devices/add.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/$tenantId/devices/adopt.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/$tenantId/devices/index.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/$tenantId/devices/scan.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/$tenantId/index.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/$tenantId/users.tsx create mode 100644 frontend/src/routes/_authenticated/tenants/index.tsx create mode 100644 frontend/src/routes/_authenticated/topology.tsx create mode 100644 frontend/src/routes/_authenticated/transparency.tsx create mode 100644 frontend/src/routes/_authenticated/vpn.tsx create mode 100644 frontend/src/routes/forgot-password.tsx create mode 100644 frontend/src/routes/login.tsx create mode 100644 frontend/src/routes/privacy.tsx create mode 100644 frontend/src/routes/reset-password.tsx create mode 100644 frontend/src/routes/terms.tsx create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/test/test-utils.tsx create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tests/e2e/alerts.spec.ts create mode 100644 frontend/tests/e2e/auth.setup.ts create mode 100644 frontend/tests/e2e/dashboard.spec.ts create mode 100644 frontend/tests/e2e/device.spec.ts create mode 100644 frontend/tests/e2e/fixtures.ts create mode 100644 frontend/tests/e2e/login.spec.ts create mode 100644 frontend/tests/e2e/pages/dashboard.page.ts create mode 100644 frontend/tests/e2e/pages/login.page.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts create mode 100644 infrastructure/docker/Dockerfile.api create mode 100644 infrastructure/docker/Dockerfile.frontend create mode 100644 infrastructure/docker/nginx-spa.conf create mode 100644 infrastructure/helm/Chart.yaml create mode 100644 infrastructure/helm/templates/_helpers.tpl create mode 100644 infrastructure/helm/templates/api-deployment.yaml create mode 100644 infrastructure/helm/templates/api-service.yaml create mode 100644 infrastructure/helm/templates/configmap.yaml create mode 100644 infrastructure/helm/templates/frontend-deployment.yaml create mode 100644 infrastructure/helm/templates/ingress.yaml create mode 100644 infrastructure/helm/templates/nats-statefulset.yaml create mode 100644 infrastructure/helm/templates/poller-deployment.yaml create mode 100644 infrastructure/helm/templates/postgres-statefulset.yaml create mode 100644 infrastructure/helm/templates/redis-deployment.yaml create mode 100644 infrastructure/helm/templates/secrets.yaml create mode 100644 infrastructure/helm/values.yaml create mode 100644 infrastructure/observability/grafana/dashboards/api-overview.json create mode 100644 infrastructure/observability/grafana/dashboards/infrastructure.json create mode 100644 infrastructure/observability/grafana/dashboards/poller-status.json create mode 100644 infrastructure/observability/grafana/provisioning/dashboards/provider.yml create mode 100644 infrastructure/observability/grafana/provisioning/datasources/prometheus.yml create mode 100644 infrastructure/observability/prometheus.yml create mode 100755 infrastructure/openbao/init.sh create mode 100644 poller/.dockerignore create mode 100644 poller/.gitignore create mode 100644 poller/Dockerfile create mode 100644 poller/cmd/poller/main.go create mode 100755 poller/docker-entrypoint.sh create mode 100644 poller/go.mod create mode 100644 poller/go.sum create mode 100644 poller/internal/bus/cmd_cert_deploy.go create mode 100644 poller/internal/bus/cmd_responder.go create mode 100644 poller/internal/bus/credential_subscriber.go create mode 100644 poller/internal/bus/publisher.go create mode 100644 poller/internal/bus/publisher_integration_test.go create mode 100644 poller/internal/config/config.go create mode 100644 poller/internal/config/config_prod_test.go create mode 100644 poller/internal/config/config_test.go create mode 100644 poller/internal/device/cert_deploy.go create mode 100644 poller/internal/device/client.go create mode 100644 poller/internal/device/command.go create mode 100644 poller/internal/device/crypto.go create mode 100644 poller/internal/device/crypto_test.go create mode 100644 poller/internal/device/firmware.go create mode 100644 poller/internal/device/health.go create mode 100644 poller/internal/device/interfaces.go create mode 100644 poller/internal/device/sftp.go create mode 100644 poller/internal/device/version.go create mode 100644 poller/internal/device/wireless.go create mode 100644 poller/internal/observability/metrics.go create mode 100644 poller/internal/observability/server.go create mode 100644 poller/internal/poller/integration_test.go create mode 100644 poller/internal/poller/interfaces.go create mode 100644 poller/internal/poller/scheduler.go create mode 100644 poller/internal/poller/scheduler_test.go create mode 100644 poller/internal/poller/worker.go create mode 100644 poller/internal/store/devices.go create mode 100644 poller/internal/store/devices_integration_test.go create mode 100644 poller/internal/testutil/containers.go create mode 100644 poller/internal/vault/cache.go create mode 100644 poller/internal/vault/transit.go create mode 100644 scripts/init-postgres.sql create mode 100644 scripts/seed-demo-data.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..915179a --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# .env.example -- Copy to .env for development, .env.prod for production +# DO NOT commit .env or .env.prod to git + +# Environment (dev | staging | production) +ENVIRONMENT=dev +LOG_LEVEL=debug +DEBUG=true + +# Database +POSTGRES_DB=mikrotik +POSTGRES_USER=postgres +POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION +DATABASE_URL=postgresql+asyncpg://postgres:CHANGE_ME_IN_PRODUCTION@postgres:5432/mikrotik +SYNC_DATABASE_URL=postgresql+psycopg2://postgres:CHANGE_ME_IN_PRODUCTION@postgres:5432/mikrotik +APP_USER_DATABASE_URL=postgresql+asyncpg://app_user:CHANGE_ME_IN_PRODUCTION@postgres:5432/mikrotik + +# Poller database (different role, no RLS) +POLLER_DATABASE_URL=postgres://poller_user:poller_password@postgres:5432/mikrotik + +# Redis +REDIS_URL=redis://redis:6379/0 + +# NATS +NATS_URL=nats://nats:4222 + +# Security +JWT_SECRET_KEY=CHANGE_ME_IN_PRODUCTION +CREDENTIAL_ENCRYPTION_KEY=CHANGE_ME_IN_PRODUCTION + +# First admin bootstrap (dev only) +FIRST_ADMIN_EMAIL=admin@mikrotik-portal.dev +FIRST_ADMIN_PASSWORD=changeme-in-production + +# CORS (comma-separated origins) +# Dev: localhost ports for Vite/React dev server +# Prod: set to your actual domain, e.g., https://mikrotik.yourdomain.com +CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8080 + +# Git store path +GIT_STORE_PATH=/data/git-store + +# Firmware +FIRMWARE_CACHE_DIR=/data/firmware-cache + +# SMTP (system emails like password reset) +# For dev: run `docker compose --profile mail-testing up -d` for Mailpit UI at http://localhost:8025 +SMTP_HOST=mailpit +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASSWORD= +SMTP_USE_TLS=false +SMTP_FROM_ADDRESS=noreply@example.com diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 0000000..c79573c --- /dev/null +++ b/.env.staging.example @@ -0,0 +1,43 @@ +# .env.staging -- Copy to .env.staging and fill in values +# DO NOT commit this file to git + +ENVIRONMENT=staging +LOG_LEVEL=info +DEBUG=false + +# Database +POSTGRES_DB=mikrotik +POSTGRES_USER=postgres +POSTGRES_PASSWORD=CHANGE_ME_STAGING + +DATABASE_URL=postgresql+asyncpg://postgres:CHANGE_ME_STAGING@postgres:5432/mikrotik +SYNC_DATABASE_URL=postgresql+psycopg2://postgres:CHANGE_ME_STAGING@postgres:5432/mikrotik +APP_USER_DATABASE_URL=postgresql+asyncpg://app_user:CHANGE_ME_STAGING@postgres:5432/mikrotik + +# Poller database (different role, no RLS) +POLLER_DATABASE_URL=postgres://poller_user:poller_password@postgres:5432/mikrotik + +# Redis +REDIS_URL=redis://redis:6379/0 + +# NATS +NATS_URL=nats://nats:4222 + +# Security -- generate unique values for staging +# JWT: python3 -c "import secrets; print(secrets.token_urlsafe(64))" +# Fernet: python3 -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())" +JWT_SECRET_KEY=CHANGE_ME_STAGING +CREDENTIAL_ENCRYPTION_KEY=CHANGE_ME_STAGING + +# First admin bootstrap +FIRST_ADMIN_EMAIL=admin@mikrotik-portal.staging +FIRST_ADMIN_PASSWORD=CHANGE_ME_STAGING + +# CORS (staging URL) +CORS_ORIGINS=http://localhost:3080 + +# Git store path +GIT_STORE_PATH=/data/git-store + +# Firmware +FIRMWARE_CACHE_DIR=/data/firmware-cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3e2a7b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,267 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +# Cancel in-progress runs for the same branch/PR to save runner minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # --------------------------------------------------------------------------- + # LINT — parallel linting for all three services + # --------------------------------------------------------------------------- + python-lint: + name: Lint Python (Ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ruff + run: pip install ruff + + - name: Ruff check + run: ruff check backend/ + + - name: Ruff format check + run: ruff format --check backend/ + + go-lint: + name: Lint Go (golangci-lint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + working-directory: poller + + frontend-lint: + name: Lint Frontend (ESLint + tsc) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: ESLint + working-directory: frontend + run: npx eslint . + + - name: TypeScript type check + working-directory: frontend + run: npx tsc --noEmit + + # --------------------------------------------------------------------------- + # TEST — parallel test suites for all three services + # --------------------------------------------------------------------------- + backend-test: + name: Test Backend (pytest) + runs-on: ubuntu-latest + + services: + postgres: + image: timescale/timescaledb:latest-pg17 + env: + POSTGRES_DB: mikrotik_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + nats: + image: nats:2-alpine + ports: + - 4222:4222 + options: >- + --health-cmd "true" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + ENVIRONMENT: dev + DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/mikrotik_test" + SYNC_DATABASE_URL: "postgresql+psycopg2://postgres:postgres@localhost:5432/mikrotik_test" + APP_USER_DATABASE_URL: "postgresql+asyncpg://app_user:app_password@localhost:5432/mikrotik_test" + TEST_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/mikrotik_test" + TEST_APP_USER_DATABASE_URL: "postgresql+asyncpg://app_user:app_password@localhost:5432/mikrotik_test" + CREDENTIAL_ENCRYPTION_KEY: "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=" + JWT_SECRET_KEY: "change-this-in-production-use-a-long-random-string" + REDIS_URL: "redis://localhost:6379/0" + NATS_URL: "nats://localhost:4222" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-${{ hashFiles('backend/pyproject.toml') }} + restore-keys: pip- + + - name: Install backend dependencies + working-directory: backend + run: pip install -e ".[dev]" + + - name: Set up test database roles + env: + PGPASSWORD: postgres + run: | + # Create app_user role for RLS-enforced connections + psql -h localhost -U postgres -d mikrotik_test -c " + CREATE ROLE app_user WITH LOGIN PASSWORD 'app_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; + GRANT CONNECT ON DATABASE mikrotik_test TO app_user; + GRANT USAGE ON SCHEMA public TO app_user; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO app_user; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO app_user; + " || true + + # Create poller_user role + psql -h localhost -U postgres -d mikrotik_test -c " + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'poller_user') THEN + CREATE ROLE poller_user WITH LOGIN PASSWORD 'poller_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; + END IF; + END + \$\$; + GRANT CONNECT ON DATABASE mikrotik_test TO poller_user; + GRANT USAGE ON SCHEMA public TO poller_user; + " || true + + - name: Run backend tests + working-directory: backend + run: python -m pytest tests/ -x -v --tb=short + + poller-test: + name: Test Go Poller + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-${{ hashFiles('poller/go.sum') }} + restore-keys: go- + + - name: Run poller tests + working-directory: poller + run: go test ./... -v -count=1 + + frontend-test: + name: Test Frontend (Vitest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run frontend tests + working-directory: frontend + run: npx vitest run + + # --------------------------------------------------------------------------- + # BUILD — sequential Docker builds + Trivy scans (depends on lint + test) + # --------------------------------------------------------------------------- + build: + name: Build & Scan Docker Images + runs-on: ubuntu-latest + needs: [python-lint, go-lint, frontend-lint, backend-test, poller-test, frontend-test] + + steps: + - uses: actions/checkout@v4 + + # Build and scan each image SEQUENTIALLY to avoid OOM. + # Each multi-stage build (Go, Python/pip, Node/tsc) can peak at 1-2 GB. + # Running them in parallel would exceed typical runner memory. + + - name: Build API image + run: docker build -f infrastructure/docker/Dockerfile.api -t mikrotik-api:ci . + + - name: Scan API image + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: "mikrotik-api:ci" + format: "table" + exit-code: "1" + severity: "HIGH,CRITICAL" + trivyignores: ".trivyignore" + + - name: Build Poller image + run: docker build -f poller/Dockerfile -t mikrotik-poller:ci ./poller + + - name: Scan Poller image + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: "mikrotik-poller:ci" + format: "table" + exit-code: "1" + severity: "HIGH,CRITICAL" + trivyignores: ".trivyignore" + + - name: Build Frontend image + run: docker build -f infrastructure/docker/Dockerfile.frontend -t mikrotik-frontend:ci . + + - name: Scan Frontend image + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: "mikrotik-frontend:ci" + format: "table" + exit-code: "1" + severity: "HIGH,CRITICAL" + trivyignores: ".trivyignore" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..277326a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,39 @@ +name: Deploy Docs to GitHub Pages + +on: + push: + branches: [main] + paths: + - "docs/website/**" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/website + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..54282de --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,56 @@ +name: Container Security Scan + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + trivy-scan: + name: Trivy Container Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Build and scan each container image sequentially to avoid OOM. + # Scans are BLOCKING (exit-code: 1) — HIGH/CRITICAL CVEs fail the pipeline. + # Add base-image CVEs to .trivyignore with justification if needed. + + - name: Build API image + run: docker build -f infrastructure/docker/Dockerfile.api -t mikrotik-api:scan . + + - name: Scan API image + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: "mikrotik-api:scan" + format: "table" + exit-code: "1" + severity: "HIGH,CRITICAL" + trivyignores: ".trivyignore" + + - name: Build Poller image + run: docker build -f poller/Dockerfile -t mikrotik-poller:scan ./poller + + - name: Scan Poller image + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: "mikrotik-poller:scan" + format: "table" + exit-code: "1" + severity: "HIGH,CRITICAL" + trivyignores: ".trivyignore" + + - name: Build Frontend image + run: docker build -f infrastructure/docker/Dockerfile.frontend -t mikrotik-frontend:scan . + + - name: Scan Frontend image + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: "mikrotik-frontend:scan" + format: "table" + exit-code: "1" + severity: "HIGH,CRITICAL" + trivyignores: ".trivyignore" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f4dc20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Environment files with secrets +.env +.env.prod +.env.local +.env.*.local + +# Docker data +docker-data/ + +# Python +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.coverage +htmlcov/ + +# Node +node_modules/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Build caches +.go-cache/ +.npm-cache/ +.tmp/ + +# Git worktrees +.worktrees/ + +# OS +.DS_Store +Thumbs.db + +# Playwright MCP logs +.playwright-mcp/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb28c8c --- /dev/null +++ b/LICENSE @@ -0,0 +1,53 @@ +Business Source License 1.1 + +Parameters + +Licensor: The Other Dude +Licensed Work: The Other Dude v9.0.0 + The Licensed Work is (c) 2026 The Other Dude +Additional Use Grant: You may use the Licensed Work for non-production, + personal, educational, and evaluation purposes. +Change Date: March 8, 2030 +Change License: Apache License, Version 2.0 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3aa7030 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# The Other Dude + +**Self-hosted MikroTik fleet management for MSPs.** + +TOD is a multi-tenant platform for managing RouterOS devices at scale. It replaces +the chaos of juggling WinBox sessions and SSH terminals across hundreds of routers +with a single, centralized web interface -- fleet visibility, configuration management, +real-time monitoring, and zero-knowledge security, all self-hosted on your infrastructure. + +--- + +## Key Features + +- **Fleet Management** -- Dashboard with device health, uptime sparklines, virtual-scrolled fleet table, geographic map, and subnet discovery. +- **Configuration Push with Panic-Revert** -- Two-phase config deployment ensures you never brick a remote device. Batch config, templates, and git-backed version history with one-click restore. +- **Real-Time Monitoring** -- Live CPU, memory, disk, and interface traffic via Server-Sent Events backed by NATS JetStream. Configurable alert rules with email, webhook, and Slack notifications. +- **Zero-Knowledge Security** -- 1Password-style architecture. SRP-6a authentication (server never sees your password), per-tenant envelope encryption via Transit KMS, Emergency Kit export. +- **Multi-Tenant with PostgreSQL RLS** -- Full organization isolation enforced at the database layer. Four roles: super_admin, admin, operator, viewer. +- **Internal Certificate Authority** -- Issue and deploy TLS certificates to RouterOS devices via SFTP. Three-tier TLS fallback for maximum compatibility. +- **WireGuard VPN Onboarding** -- Create device + VPN peer in one transaction. Generates ready-to-paste RouterOS commands for devices behind NAT. +- **PDF Reports** -- Fleet summary, device detail, security audit, and performance reports generated server-side. +- **Command Palette UX** -- Cmd+K quick navigation, keyboard shortcuts, dark/light mode, smooth page transitions, and skeleton loaders throughout. + +--- + +## Architecture + +``` + +----------------+ + | Frontend | + | React / Vite | + +-------+--------+ + | + /api/ proxy + | + +-------v--------+ + | Backend | + | FastAPI | + +--+----+-----+--+ + | | | + +-------------+ | +--------------+ + | | | + +------v-------+ +------v------+ +----------v----------+ + | PostgreSQL | | Redis | | NATS | + | TimescaleDB | | (locks, | | JetStream | + | (RLS) | | caching) | | (pub/sub) | + +------^-------+ +------^------+ +----------^----------+ + | | | + +------+------------------+--------------------+------+ + | Go Poller | + | RouterOS binary API (port 8729 TLS) | + +---------------------------+-------------------------+ + | + +----------v-----------+ + | RouterOS Fleet | + | (your devices) | + +----------------------+ +``` + +The **Go poller** communicates with RouterOS devices using the binary API over TLS, +publishing metrics to NATS and persisting to PostgreSQL with TimescaleDB hypertables. +The **FastAPI backend** enforces tenant isolation via Row-Level Security and streams +real-time events to the **React frontend** over SSE. **OpenBao** provides Transit +secret engine for per-tenant envelope encryption. + +--- + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Frontend | React 19, TanStack Router + Query, Tailwind CSS, Vite | +| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 async, asyncpg | +| Poller | Go 1.24, go-routeros/v3, pgx/v5, nats.go | +| Database | PostgreSQL 17 + TimescaleDB, Row-Level Security | +| Cache / Locks | Redis 7 | +| Message Bus | NATS with JetStream | +| KMS | OpenBao (Transit secret engine) | +| VPN | WireGuard | +| Auth | SRP-6a (zero-knowledge), JWT | +| Reports | Jinja2 + WeasyPrint | + +--- + +## Quick Start + +```bash +# Clone and configure +git clone https://github.com/your-org/tod.git && cd tod +cp .env.example .env +# Edit .env -- set CREDENTIAL_ENCRYPTION_KEY and JWT_SECRET_KEY at minimum + +# Build images sequentially (avoids OOM on low-RAM machines) +docker compose --profile full build api +docker compose --profile full build poller +docker compose --profile full build frontend + +# Start the full stack +docker compose --profile full up -d + +# Open the UI +open http://localhost:3000 +``` + +On first launch, the setup wizard walks you through creating a super admin account, +enrolling your Secret Key, adding your first organization, and onboarding your first device. + +--- + +## Documentation + +Full documentation is available at [theotherdude.net](https://theotherdude.net). + +See the documentation site for screenshots and feature walkthroughs. + +--- + +## License + +[Business Source License 1.1](LICENSE) + +Free for personal and educational use. Commercial use (managing devices for paying +customers or as part of a paid service) requires a commercial license. See the +LICENSE file for full terms. + +--- + +## The Name + +"The Other Dude" -- because every MSP needs one. When the network is down at 2 AM +and someone has to fix it, TOD is the other dude on the job. The Big Lebowski inspired, +the rug really ties the room together. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b0edf8c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +.env +*.egg-info/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Logs +*.log diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..f794886 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,114 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide os agnostic paths +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the character specified by +# "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses +# os.pathsep. Note that this may cause alembic to miss version files if the separator +# character is actually part of the version file path. +# version_path_separator = os # Use os.pathsep. Default configuration used for new projects. +# version_path_separator = ; # Windows +# version_path_separator = : # Unix +version_path_separator = space # No separator in paths. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# New in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/mikrotik + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, +# if available. +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[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..3915537 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,78 @@ +"""Alembic environment configuration for async SQLAlchemy with PostgreSQL.""" + +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +# Import all models to register them with Base.metadata +from app.database import Base +import app.models.tenant # noqa: F401 +import app.models.user # noqa: F401 +import app.models.device # noqa: F401 +import app.models.config_backup # noqa: F401 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Override sqlalchemy.url from DATABASE_URL env var if set (for Docker) +if os.environ.get("DATABASE_URL"): + config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"]) + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Add your model's MetaData object here for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + 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 do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async engine.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_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..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${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 identifiers, used by Alembic. +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_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..ff1cfe3 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,376 @@ +"""Initial schema with RLS policies for multi-tenant isolation. + +Revision ID: 001 +Revises: None +Create Date: 2026-02-24 + +This migration creates: +1. All database tables (tenants, users, devices, device_groups, device_tags, + device_group_memberships, device_tag_assignments) +2. Composite unique indexes for tenant-scoped uniqueness +3. Row Level Security (RLS) on all tenant-scoped tables +4. RLS policies using app.current_tenant PostgreSQL setting +5. The app_user role with appropriate grants (cannot bypass RLS) +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +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: + # ========================================================================= + # TENANTS TABLE + # ========================================================================= + op.create_table( + "tenants", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text, 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.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index("ix_tenants_name", "tenants", ["name"], unique=True) + + # ========================================================================= + # USERS TABLE + # ========================================================================= + op.create_table( + "users", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("email", sa.String(255), nullable=False), + sa.Column("hashed_password", sa.String(255), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("role", sa.String(50), nullable=False, server_default="viewer"), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), + sa.Column("last_login", sa.DateTime(timezone=True), 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.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + op.create_index("ix_users_tenant_id", "users", ["tenant_id"]) + + # ========================================================================= + # DEVICES TABLE + # ========================================================================= + op.create_table( + "devices", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("hostname", sa.String(255), nullable=False), + sa.Column("ip_address", sa.String(45), nullable=False), + sa.Column("api_port", sa.Integer, nullable=False, server_default="8728"), + sa.Column("api_ssl_port", sa.Integer, nullable=False, server_default="8729"), + sa.Column("model", sa.String(255), nullable=True), + sa.Column("serial_number", sa.String(255), nullable=True), + sa.Column("firmware_version", sa.String(100), nullable=True), + sa.Column("routeros_version", sa.String(100), nullable=True), + sa.Column("uptime_seconds", sa.Integer, nullable=True), + sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True), + sa.Column("encrypted_credentials", sa.LargeBinary, nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="unknown"), + 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.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.UniqueConstraint("tenant_id", "hostname", name="uq_devices_tenant_hostname"), + ) + op.create_index("ix_devices_tenant_id", "devices", ["tenant_id"]) + + # ========================================================================= + # DEVICE GROUPS TABLE + # ========================================================================= + op.create_table( + "device_groups", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.UniqueConstraint("tenant_id", "name", name="uq_device_groups_tenant_name"), + ) + op.create_index("ix_device_groups_tenant_id", "device_groups", ["tenant_id"]) + + # ========================================================================= + # DEVICE TAGS TABLE + # ========================================================================= + op.create_table( + "device_tags", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("color", sa.String(7), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.UniqueConstraint("tenant_id", "name", name="uq_device_tags_tenant_name"), + ) + op.create_index("ix_device_tags_tenant_id", "device_tags", ["tenant_id"]) + + # ========================================================================= + # DEVICE GROUP MEMBERSHIPS TABLE + # ========================================================================= + op.create_table( + "device_group_memberships", + sa.Column("device_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("group_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.PrimaryKeyConstraint("device_id", "group_id"), + sa.ForeignKeyConstraint(["device_id"], ["devices.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["group_id"], ["device_groups.id"], ondelete="CASCADE"), + ) + + # ========================================================================= + # DEVICE TAG ASSIGNMENTS TABLE + # ========================================================================= + op.create_table( + "device_tag_assignments", + sa.Column("device_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("tag_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.PrimaryKeyConstraint("device_id", "tag_id"), + sa.ForeignKeyConstraint(["device_id"], ["devices.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tag_id"], ["device_tags.id"], ondelete="CASCADE"), + ) + + # ========================================================================= + # ROW LEVEL SECURITY (RLS) + # ========================================================================= + # RLS is the core tenant isolation mechanism. The app_user role CANNOT + # bypass RLS (only superusers can). All queries through app_user will + # be filtered by the current_setting('app.current_tenant') value which + # is set per-request by the tenant_context middleware. + + conn = op.get_bind() + + # --- TENANTS RLS --- + # Super admin sees all; tenant users see only their tenant + conn.execute(sa.text("ALTER TABLE tenants ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE tenants FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON tenants + USING ( + id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """)) + + # --- USERS RLS --- + # Users see only other users in their tenant; super_admin sees all + conn.execute(sa.text("ALTER TABLE users ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE users FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON users + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """)) + + # --- DEVICES RLS --- + conn.execute(sa.text("ALTER TABLE devices ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE devices FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON devices + USING (tenant_id::text = current_setting('app.current_tenant', true)) + WITH CHECK (tenant_id::text = current_setting('app.current_tenant', true)) + """)) + + # --- DEVICE GROUPS RLS --- + conn.execute(sa.text("ALTER TABLE device_groups ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE device_groups FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON device_groups + USING (tenant_id::text = current_setting('app.current_tenant', true)) + WITH CHECK (tenant_id::text = current_setting('app.current_tenant', true)) + """)) + + # --- DEVICE TAGS RLS --- + conn.execute(sa.text("ALTER TABLE device_tags ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE device_tags FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON device_tags + USING (tenant_id::text = current_setting('app.current_tenant', true)) + WITH CHECK (tenant_id::text = current_setting('app.current_tenant', true)) + """)) + + # --- DEVICE GROUP MEMBERSHIPS RLS --- + # These are filtered by joining through devices/groups (which already have RLS) + # But we also add direct RLS via a join to the devices table + conn.execute(sa.text("ALTER TABLE device_group_memberships ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE device_group_memberships FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON device_group_memberships + USING ( + EXISTS ( + SELECT 1 FROM devices d + WHERE d.id = device_id + AND d.tenant_id::text = current_setting('app.current_tenant', true) + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM devices d + WHERE d.id = device_id + AND d.tenant_id::text = current_setting('app.current_tenant', true) + ) + ) + """)) + + # --- DEVICE TAG ASSIGNMENTS RLS --- + conn.execute(sa.text("ALTER TABLE device_tag_assignments ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE device_tag_assignments FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON device_tag_assignments + USING ( + EXISTS ( + SELECT 1 FROM devices d + WHERE d.id = device_id + AND d.tenant_id::text = current_setting('app.current_tenant', true) + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM devices d + WHERE d.id = device_id + AND d.tenant_id::text = current_setting('app.current_tenant', true) + ) + ) + """)) + + # ========================================================================= + # GRANT PERMISSIONS TO app_user (RLS-enforcing application role) + # ========================================================================= + # app_user is a non-superuser role — it CANNOT bypass RLS policies. + # All API queries use this role to ensure tenant isolation. + + tables = [ + "tenants", + "users", + "devices", + "device_groups", + "device_tags", + "device_group_memberships", + "device_tag_assignments", + ] + + for table in tables: + conn.execute(sa.text( + f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO app_user" + )) + + # Grant sequence usage for UUID generation (gen_random_uuid is built-in, but just in case) + conn.execute(sa.text("GRANT USAGE ON SCHEMA public TO app_user")) + + # Allow app_user to set the tenant context variable + conn.execute(sa.text("GRANT SET ON PARAMETER app.current_tenant TO app_user")) + + +def downgrade() -> None: + conn = op.get_bind() + + # Revoke grants + tables = [ + "tenants", + "users", + "devices", + "device_groups", + "device_tags", + "device_group_memberships", + "device_tag_assignments", + ] + for table in tables: + try: + conn.execute(sa.text(f"REVOKE ALL ON {table} FROM app_user")) + except Exception: + pass + + # Drop tables (in reverse dependency order) + op.drop_table("device_tag_assignments") + op.drop_table("device_group_memberships") + op.drop_table("device_tags") + op.drop_table("device_groups") + op.drop_table("devices") + op.drop_table("users") + op.drop_table("tenants") diff --git a/backend/alembic/versions/002_add_routeros_major_version_and_poller_role.py b/backend/alembic/versions/002_add_routeros_major_version_and_poller_role.py new file mode 100644 index 0000000..d50c021 --- /dev/null +++ b/backend/alembic/versions/002_add_routeros_major_version_and_poller_role.py @@ -0,0 +1,92 @@ +"""Add routeros_major_version column and poller_user PostgreSQL role. + +Revision ID: 002 +Revises: 001 +Create Date: 2026-02-24 + +This migration: +1. Adds routeros_major_version INTEGER column to devices table (nullable). + Stores the detected major version (6 or 7) as populated by the Go poller. +2. Creates the poller_user PostgreSQL role with SELECT-only access to the + devices table. The poller_user bypasses RLS intentionally — it must read + all devices across all tenants to poll them. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + # ========================================================================= + # ADD routeros_major_version COLUMN + # ========================================================================= + # Stores the detected RouterOS major version (6 or 7) as an INTEGER. + # Populated by the Go poller after a successful connection and + # /system/resource/print query. NULL until the poller has connected at + # least once. + op.add_column( + "devices", + sa.Column("routeros_major_version", sa.Integer(), nullable=True), + ) + + # ========================================================================= + # CREATE poller_user ROLE AND GRANT PERMISSIONS + # ========================================================================= + # The poller_user role is used exclusively by the Go poller service. + # It has SELECT-only access to the devices table and does NOT enforce + # RLS policies (RLS is applied to app_user only). This allows the poller + # to read all devices across all tenants, which is required for polling. + conn = op.get_bind() + + conn.execute(sa.text(""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'poller_user') THEN + CREATE ROLE poller_user WITH LOGIN PASSWORD 'poller_password' BYPASSRLS; + END IF; + END + $$ + """)) + + conn.execute(sa.text("GRANT CONNECT ON DATABASE mikrotik TO poller_user")) + conn.execute(sa.text("GRANT USAGE ON SCHEMA public TO poller_user")) + + # SELECT on devices only — poller needs to read encrypted_credentials + # and other device fields. No INSERT/UPDATE/DELETE needed. + conn.execute(sa.text("GRANT SELECT ON devices TO poller_user")) + + +def downgrade() -> None: + conn = op.get_bind() + + # Revoke grants from poller_user + try: + conn.execute(sa.text("REVOKE SELECT ON devices FROM poller_user")) + except Exception: + pass + + try: + conn.execute(sa.text("REVOKE USAGE ON SCHEMA public FROM poller_user")) + except Exception: + pass + + try: + conn.execute(sa.text("REVOKE CONNECT ON DATABASE mikrotik FROM poller_user")) + except Exception: + pass + + try: + conn.execute(sa.text("DROP ROLE IF EXISTS poller_user")) + except Exception: + pass + + # Drop the column + op.drop_column("devices", "routeros_major_version") diff --git a/backend/alembic/versions/003_metrics_hypertables.py b/backend/alembic/versions/003_metrics_hypertables.py new file mode 100644 index 0000000..9ac6c8d --- /dev/null +++ b/backend/alembic/versions/003_metrics_hypertables.py @@ -0,0 +1,174 @@ +"""Add TimescaleDB hypertables for metrics and denormalized columns on devices. + +Revision ID: 003 +Revises: 002 +Create Date: 2026-02-25 + +This migration: +1. Creates interface_metrics hypertable for per-interface traffic counters. +2. Creates health_metrics hypertable for per-device CPU/memory/disk/temperature. +3. Creates wireless_metrics hypertable for per-interface wireless client stats. +4. Adds last_cpu_load and last_memory_used_pct denormalized columns to devices + for efficient fleet table display without joining hypertables. +5. Applies RLS tenant_isolation policies and appropriate GRANTs on all hypertables. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + conn = op.get_bind() + + # ========================================================================= + # CREATE interface_metrics HYPERTABLE + # ========================================================================= + # Stores per-interface byte counters from /interface/print on every poll cycle. + # rx_bps/tx_bps are stored as NULL — computed at query time via LAG() window + # function to avoid delta state in the poller. + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS interface_metrics ( + time TIMESTAMPTZ NOT NULL, + device_id UUID NOT NULL, + tenant_id UUID NOT NULL, + interface TEXT NOT NULL, + rx_bytes BIGINT, + tx_bytes BIGINT, + rx_bps BIGINT, + tx_bps BIGINT + ) + """)) + + conn.execute(sa.text( + "SELECT create_hypertable('interface_metrics', 'time', if_not_exists => TRUE)" + )) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_interface_metrics_device_time " + "ON interface_metrics (device_id, time DESC)" + )) + + conn.execute(sa.text("ALTER TABLE interface_metrics ENABLE ROW LEVEL SECURITY")) + + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON interface_metrics + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + + conn.execute(sa.text("GRANT SELECT, INSERT ON interface_metrics TO app_user")) + conn.execute(sa.text("GRANT SELECT, INSERT ON interface_metrics TO poller_user")) + + # ========================================================================= + # CREATE health_metrics HYPERTABLE + # ========================================================================= + # Stores per-device system health metrics from /system/resource/print and + # /system/health/print on every poll cycle. + # temperature is nullable — not all RouterOS devices have temperature sensors. + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS health_metrics ( + time TIMESTAMPTZ NOT NULL, + device_id UUID NOT NULL, + tenant_id UUID NOT NULL, + cpu_load SMALLINT, + free_memory BIGINT, + total_memory BIGINT, + free_disk BIGINT, + total_disk BIGINT, + temperature SMALLINT + ) + """)) + + conn.execute(sa.text( + "SELECT create_hypertable('health_metrics', 'time', if_not_exists => TRUE)" + )) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_health_metrics_device_time " + "ON health_metrics (device_id, time DESC)" + )) + + conn.execute(sa.text("ALTER TABLE health_metrics ENABLE ROW LEVEL SECURITY")) + + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON health_metrics + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + + conn.execute(sa.text("GRANT SELECT, INSERT ON health_metrics TO app_user")) + conn.execute(sa.text("GRANT SELECT, INSERT ON health_metrics TO poller_user")) + + # ========================================================================= + # CREATE wireless_metrics HYPERTABLE + # ========================================================================= + # Stores per-wireless-interface aggregated client stats from + # /interface/wireless/registration-table/print (v6) or + # /interface/wifi/registration-table/print (v7). + # ccq may be 0 on RouterOS v7 (not available in the WiFi API path). + # avg_signal is dBm (negative integer, e.g. -67). + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS wireless_metrics ( + time TIMESTAMPTZ NOT NULL, + device_id UUID NOT NULL, + tenant_id UUID NOT NULL, + interface TEXT NOT NULL, + client_count SMALLINT, + avg_signal SMALLINT, + ccq SMALLINT, + frequency INTEGER + ) + """)) + + conn.execute(sa.text( + "SELECT create_hypertable('wireless_metrics', 'time', if_not_exists => TRUE)" + )) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_wireless_metrics_device_time " + "ON wireless_metrics (device_id, time DESC)" + )) + + conn.execute(sa.text("ALTER TABLE wireless_metrics ENABLE ROW LEVEL SECURITY")) + + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON wireless_metrics + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + + conn.execute(sa.text("GRANT SELECT, INSERT ON wireless_metrics TO app_user")) + conn.execute(sa.text("GRANT SELECT, INSERT ON wireless_metrics TO poller_user")) + + # ========================================================================= + # ADD DENORMALIZED COLUMNS TO devices TABLE + # ========================================================================= + # These columns are updated by the metrics subscriber alongside each + # health_metrics insert, enabling the fleet table to display CPU and memory + # usage without a JOIN to the hypertable. + op.add_column( + "devices", + sa.Column("last_cpu_load", sa.SmallInteger(), nullable=True), + ) + op.add_column( + "devices", + sa.Column("last_memory_used_pct", sa.SmallInteger(), nullable=True), + ) + + +def downgrade() -> None: + # Remove denormalized columns from devices first + op.drop_column("devices", "last_memory_used_pct") + op.drop_column("devices", "last_cpu_load") + + conn = op.get_bind() + + # Drop hypertables (CASCADE handles indexes, policies, and chunks) + conn.execute(sa.text("DROP TABLE IF EXISTS wireless_metrics CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS health_metrics CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS interface_metrics CASCADE")) diff --git a/backend/alembic/versions/004_config_management.py b/backend/alembic/versions/004_config_management.py new file mode 100644 index 0000000..20032e4 --- /dev/null +++ b/backend/alembic/versions/004_config_management.py @@ -0,0 +1,128 @@ +"""Add config management tables: config_backup_runs, config_backup_schedules, config_push_operations. + +Revision ID: 004 +Revises: 003 +Create Date: 2026-02-25 + +This migration: +1. Creates config_backup_runs table for backup metadata (content lives in git). +2. Creates config_backup_schedules table for per-tenant/per-device schedule config. +3. Creates config_push_operations table for panic-revert recovery (API-restart safety). +4. Applies RLS tenant_isolation policies and appropriate GRANTs on all tables. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + conn = op.get_bind() + + # ========================================================================= + # CREATE config_backup_runs TABLE + # ========================================================================= + # Stores metadata for each backup run. The actual config content lives in + # the tenant's bare git repository (GIT_STORE_PATH). This table provides + # the timeline view and change tracking without duplicating file content. + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS config_backup_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + commit_sha TEXT NOT NULL, + trigger_type TEXT NOT NULL, + lines_added INT, + lines_removed INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """)) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_config_backup_runs_device_created " + "ON config_backup_runs (device_id, created_at DESC)" + )) + + conn.execute(sa.text("ALTER TABLE config_backup_runs ENABLE ROW LEVEL SECURITY")) + + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON config_backup_runs + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + + conn.execute(sa.text("GRANT SELECT, INSERT ON config_backup_runs TO app_user")) + conn.execute(sa.text("GRANT SELECT ON config_backup_runs TO poller_user")) + + # ========================================================================= + # CREATE config_backup_schedules TABLE + # ========================================================================= + # Stores per-tenant default and per-device override schedules. + # device_id = NULL means tenant default (applies to all devices in tenant). + # A per-device row with a specific device_id overrides the tenant default. + # UNIQUE(tenant_id, device_id) allows one entry per (tenant, device) pair + # where device_id NULL is the tenant-level default. + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS config_backup_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + device_id UUID REFERENCES devices(id) ON DELETE CASCADE, + cron_expression TEXT NOT NULL DEFAULT '0 2 * * *', + enabled BOOL NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, device_id) + ) + """)) + + conn.execute(sa.text("ALTER TABLE config_backup_schedules ENABLE ROW LEVEL SECURITY")) + + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON config_backup_schedules + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + + conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE ON config_backup_schedules TO app_user")) + + # ========================================================================= + # CREATE config_push_operations TABLE + # ========================================================================= + # Tracks pending two-phase config push operations for panic-revert recovery. + # If the API pod restarts during the 60-second verification window, the + # startup handler checks for 'pending_verification' rows and either verifies + # connectivity (clean up the RouterOS scheduler job) or marks as failed. + # See Pitfall 6 in 04-RESEARCH.md. + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS config_push_operations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + pre_push_commit_sha TEXT NOT NULL, + scheduler_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending_verification', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ + ) + """)) + + conn.execute(sa.text("ALTER TABLE config_push_operations ENABLE ROW LEVEL SECURITY")) + + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON config_push_operations + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + + conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE ON config_push_operations TO app_user")) + + +def downgrade() -> None: + conn = op.get_bind() + + conn.execute(sa.text("DROP TABLE IF EXISTS config_push_operations CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS config_backup_schedules CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS config_backup_runs CASCADE")) diff --git a/backend/alembic/versions/005_alerting_and_firmware.py b/backend/alembic/versions/005_alerting_and_firmware.py new file mode 100644 index 0000000..1af426a --- /dev/null +++ b/backend/alembic/versions/005_alerting_and_firmware.py @@ -0,0 +1,286 @@ +"""Add alerting and firmware management tables. + +Revision ID: 005 +Revises: 004 +Create Date: 2026-02-25 + +This migration: +1. ALTERs devices table: adds architecture and preferred_channel columns. +2. ALTERs device_groups table: adds preferred_channel column. +3. Creates alert_rules, notification_channels, alert_rule_channels, alert_events tables. +4. Creates firmware_versions, firmware_upgrade_jobs tables. +5. Applies RLS policies on tenant-scoped tables. +6. Seeds default alert rules for all existing tenants. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + conn = op.get_bind() + + # ========================================================================= + # ALTER devices TABLE — add architecture and preferred_channel columns + # ========================================================================= + conn.execute(sa.text( + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS architecture TEXT" + )) + conn.execute(sa.text( + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS preferred_channel TEXT DEFAULT 'stable' NOT NULL" + )) + + # ========================================================================= + # ALTER device_groups TABLE — add preferred_channel column + # ========================================================================= + conn.execute(sa.text( + "ALTER TABLE device_groups ADD COLUMN IF NOT EXISTS preferred_channel TEXT DEFAULT 'stable' NOT NULL" + )) + + # ========================================================================= + # CREATE alert_rules TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS alert_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + device_id UUID REFERENCES devices(id) ON DELETE CASCADE, + group_id UUID REFERENCES device_groups(id) ON DELETE SET NULL, + name TEXT NOT NULL, + metric TEXT NOT NULL, + operator TEXT NOT NULL, + threshold NUMERIC NOT NULL, + duration_polls INTEGER NOT NULL DEFAULT 1, + severity TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """)) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_alert_rules_tenant_enabled " + "ON alert_rules (tenant_id, enabled)" + )) + + conn.execute(sa.text("ALTER TABLE alert_rules ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON alert_rules + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON alert_rules TO app_user" + )) + conn.execute(sa.text("GRANT ALL ON alert_rules TO poller_user")) + + # ========================================================================= + # CREATE notification_channels TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS notification_channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + channel_type TEXT NOT NULL, + smtp_host TEXT, + smtp_port INTEGER, + smtp_user TEXT, + smtp_password BYTEA, + smtp_use_tls BOOLEAN DEFAULT FALSE, + from_address TEXT, + to_address TEXT, + webhook_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """)) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_notification_channels_tenant " + "ON notification_channels (tenant_id)" + )) + + conn.execute(sa.text("ALTER TABLE notification_channels ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON notification_channels + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON notification_channels TO app_user" + )) + conn.execute(sa.text("GRANT ALL ON notification_channels TO poller_user")) + + # ========================================================================= + # CREATE alert_rule_channels TABLE (M2M association) + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS alert_rule_channels ( + rule_id UUID NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, + PRIMARY KEY (rule_id, channel_id) + ) + """)) + + conn.execute(sa.text("ALTER TABLE alert_rule_channels ENABLE ROW LEVEL SECURITY")) + # RLS for M2M: join through parent table's tenant_id via rule_id + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON alert_rule_channels + USING (rule_id IN ( + SELECT id FROM alert_rules + WHERE tenant_id::text = current_setting('app.current_tenant') + )) + """)) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON alert_rule_channels TO app_user" + )) + conn.execute(sa.text("GRANT ALL ON alert_rule_channels TO poller_user")) + + # ========================================================================= + # CREATE alert_events TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS alert_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + rule_id UUID REFERENCES alert_rules(id) ON DELETE SET NULL, + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + status TEXT NOT NULL, + severity TEXT NOT NULL, + metric TEXT, + value NUMERIC, + threshold NUMERIC, + message TEXT, + is_flapping BOOLEAN NOT NULL DEFAULT FALSE, + acknowledged_at TIMESTAMPTZ, + acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL, + silenced_until TIMESTAMPTZ, + fired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ + ) + """)) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_alert_events_device_rule_status " + "ON alert_events (device_id, rule_id, status)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_alert_events_tenant_fired " + "ON alert_events (tenant_id, fired_at)" + )) + + conn.execute(sa.text("ALTER TABLE alert_events ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON alert_events + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON alert_events TO app_user" + )) + conn.execute(sa.text("GRANT ALL ON alert_events TO poller_user")) + + # ========================================================================= + # CREATE firmware_versions TABLE (global — NOT tenant-scoped) + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS firmware_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + architecture TEXT NOT NULL, + channel TEXT NOT NULL, + version TEXT NOT NULL, + npk_url TEXT NOT NULL, + npk_local_path TEXT, + npk_size_bytes BIGINT, + checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(architecture, channel, version) + ) + """)) + + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_firmware_versions_arch_channel " + "ON firmware_versions (architecture, channel)" + )) + + # No RLS on firmware_versions — global cache table + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE ON firmware_versions TO app_user" + )) + conn.execute(sa.text("GRANT ALL ON firmware_versions TO poller_user")) + + # ========================================================================= + # CREATE firmware_upgrade_jobs TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS firmware_upgrade_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + rollout_group_id UUID, + target_version TEXT NOT NULL, + architecture TEXT NOT NULL, + channel TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + pre_upgrade_backup_sha TEXT, + scheduled_at TIMESTAMPTZ, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + error_message TEXT, + confirmed_major_upgrade BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """)) + + conn.execute(sa.text("ALTER TABLE firmware_upgrade_jobs ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON firmware_upgrade_jobs + USING (tenant_id::text = current_setting('app.current_tenant')) + """)) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON firmware_upgrade_jobs TO app_user" + )) + conn.execute(sa.text("GRANT ALL ON firmware_upgrade_jobs TO poller_user")) + + # ========================================================================= + # SEED DEFAULT ALERT RULES for all existing tenants + # ========================================================================= + # Note: New tenant creation (in the tenants API router) should also seed + # these three default rules. A _seed_default_alert_rules(tenant_id) helper + # should be created in the alerts router or a shared service for this. + conn.execute(sa.text(""" + INSERT INTO alert_rules (id, tenant_id, name, metric, operator, threshold, duration_polls, severity, enabled, is_default) + SELECT gen_random_uuid(), t.id, 'High CPU Usage', 'cpu_load', 'gt', 90, 5, 'warning', TRUE, TRUE + FROM tenants t + """)) + conn.execute(sa.text(""" + INSERT INTO alert_rules (id, tenant_id, name, metric, operator, threshold, duration_polls, severity, enabled, is_default) + SELECT gen_random_uuid(), t.id, 'High Memory Usage', 'memory_used_pct', 'gt', 90, 5, 'warning', TRUE, TRUE + FROM tenants t + """)) + conn.execute(sa.text(""" + INSERT INTO alert_rules (id, tenant_id, name, metric, operator, threshold, duration_polls, severity, enabled, is_default) + SELECT gen_random_uuid(), t.id, 'High Disk Usage', 'disk_used_pct', 'gt', 85, 3, 'warning', TRUE, TRUE + FROM tenants t + """)) + + +def downgrade() -> None: + conn = op.get_bind() + + # Drop tables in reverse dependency order + conn.execute(sa.text("DROP TABLE IF EXISTS firmware_upgrade_jobs CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS firmware_versions CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS alert_events CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS alert_rule_channels CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS notification_channels CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS alert_rules CASCADE")) + + # Drop added columns + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS architecture")) + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS preferred_channel")) + conn.execute(sa.text("ALTER TABLE device_groups DROP COLUMN IF EXISTS preferred_channel")) diff --git a/backend/alembic/versions/006_advanced_features.py b/backend/alembic/versions/006_advanced_features.py new file mode 100644 index 0000000..af797f2 --- /dev/null +++ b/backend/alembic/versions/006_advanced_features.py @@ -0,0 +1,212 @@ +"""Add config templates, template push jobs, and device location columns. + +Revision ID: 006 +Revises: 005 +Create Date: 2026-02-25 + +This migration: +1. ALTERs devices table: adds latitude and longitude columns. +2. Creates config_templates table. +3. Creates config_template_tags table. +4. Creates template_push_jobs table. +5. Applies RLS policies on all three new tables. +6. Seeds starter templates for all existing tenants. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + conn = op.get_bind() + + # ========================================================================= + # ALTER devices TABLE — add latitude and longitude columns + # ========================================================================= + conn.execute(sa.text( + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION" + )) + conn.execute(sa.text( + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION" + )) + + # ========================================================================= + # CREATE config_templates TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS config_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + content TEXT NOT NULL, + variables JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, name) + ) + """)) + + # ========================================================================= + # CREATE config_template_tags TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS config_template_tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + template_id UUID NOT NULL REFERENCES config_templates(id) ON DELETE CASCADE, + UNIQUE(template_id, name) + ) + """)) + + # ========================================================================= + # CREATE template_push_jobs TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS template_push_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + template_id UUID REFERENCES config_templates(id) ON DELETE SET NULL, + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + rollout_id UUID, + rendered_content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + pre_push_backup_sha TEXT, + error_message TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """)) + + # ========================================================================= + # RLS POLICIES + # ========================================================================= + for table in ("config_templates", "config_template_tags", "template_push_jobs"): + conn.execute(sa.text(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text(f""" + CREATE POLICY {table}_tenant_isolation ON {table} + USING (tenant_id = current_setting('app.current_tenant')::uuid) + """)) + conn.execute(sa.text( + f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO app_user" + )) + conn.execute(sa.text(f"GRANT ALL ON {table} TO poller_user")) + + # ========================================================================= + # INDEXES + # ========================================================================= + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_config_templates_tenant " + "ON config_templates (tenant_id)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_config_template_tags_template " + "ON config_template_tags (template_id)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_template_push_jobs_tenant_rollout " + "ON template_push_jobs (tenant_id, rollout_id)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_template_push_jobs_device_status " + "ON template_push_jobs (device_id, status)" + )) + + # ========================================================================= + # SEED STARTER TEMPLATES for all existing tenants + # ========================================================================= + + # 1. Basic Firewall + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'Basic Firewall', + 'Standard firewall ruleset with WAN protection and LAN forwarding', + '/ip firewall filter +add chain=input connection-state=established,related action=accept +add chain=input connection-state=invalid action=drop +add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=8291 action=drop comment="Block Winbox from WAN" +add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=22 action=drop comment="Block SSH from WAN" +add chain=forward connection-state=established,related action=accept +add chain=forward connection-state=invalid action=drop +add chain=forward src-address={{ allowed_network }} action=accept +add chain=forward action=drop', + '[{"name":"wan_interface","type":"string","default":"ether1","description":"WAN-facing interface"},{"name":"allowed_network","type":"subnet","default":"192.168.1.0/24","description":"Allowed source network"}]'::jsonb + FROM tenants t + ON CONFLICT DO NOTHING + """)) + + # 2. DHCP Server Setup + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'DHCP Server Setup', + 'Configure DHCP server with address pool, DNS, and gateway', + '/ip pool add name=dhcp-pool ranges={{ pool_start }}-{{ pool_end }} +/ip dhcp-server network add address={{ gateway }}/24 gateway={{ gateway }} dns-server={{ dns_server }} +/ip dhcp-server add name=dhcp1 interface={{ interface }} address-pool=dhcp-pool disabled=no', + '[{"name":"pool_start","type":"ip","default":"192.168.1.100","description":"DHCP pool start address"},{"name":"pool_end","type":"ip","default":"192.168.1.254","description":"DHCP pool end address"},{"name":"gateway","type":"ip","default":"192.168.1.1","description":"Default gateway"},{"name":"dns_server","type":"ip","default":"8.8.8.8","description":"DNS server address"},{"name":"interface","type":"string","default":"bridge1","description":"Interface to serve DHCP on"}]'::jsonb + FROM tenants t + ON CONFLICT DO NOTHING + """)) + + # 3. Wireless AP Config + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'Wireless AP Config', + 'Configure wireless access point with WPA2 security', + '/interface wireless security-profiles add name=portal-wpa2 mode=dynamic-keys authentication-types=wpa2-psk wpa2-pre-shared-key={{ password }} +/interface wireless set wlan1 mode=ap-bridge ssid={{ ssid }} security-profile=portal-wpa2 frequency={{ frequency }} channel-width={{ channel_width }} disabled=no', + '[{"name":"ssid","type":"string","default":"MikroTik-AP","description":"Wireless network name"},{"name":"password","type":"string","default":"","description":"WPA2 pre-shared key (min 8 characters)"},{"name":"frequency","type":"integer","default":"2412","description":"Wireless frequency in MHz"},{"name":"channel_width","type":"string","default":"20/40mhz-XX","description":"Channel width setting"}]'::jsonb + FROM tenants t + ON CONFLICT DO NOTHING + """)) + + # 4. Initial Device Setup + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'Initial Device Setup', + 'Set device identity, NTP, DNS, and disable unused services', + '/system identity set name={{ device.hostname }} +/system ntp client set enabled=yes servers={{ ntp_server }} +/ip dns set servers={{ dns_servers }} allow-remote-requests=no +/ip service disable telnet,ftp,www,api-ssl +/ip service set ssh port=22 +/ip service set winbox port=8291', + '[{"name":"ntp_server","type":"ip","default":"pool.ntp.org","description":"NTP server address"},{"name":"dns_servers","type":"string","default":"8.8.8.8,8.8.4.4","description":"Comma-separated DNS servers"}]'::jsonb + FROM tenants t + ON CONFLICT DO NOTHING + """)) + + +def downgrade() -> None: + conn = op.get_bind() + + # Drop tables in reverse dependency order + conn.execute(sa.text("DROP TABLE IF EXISTS template_push_jobs CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS config_template_tags CASCADE")) + conn.execute(sa.text("DROP TABLE IF EXISTS config_templates CASCADE")) + + # Drop location columns from devices + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS latitude")) + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS longitude")) diff --git a/backend/alembic/versions/007_audit_logs.py b/backend/alembic/versions/007_audit_logs.py new file mode 100644 index 0000000..6ef33de --- /dev/null +++ b/backend/alembic/versions/007_audit_logs.py @@ -0,0 +1,82 @@ +"""Create audit_logs table with RLS policy. + +Revision ID: 007 +Revises: 006 +Create Date: 2026-03-02 + +This migration: +1. Creates audit_logs table for centralized audit trail. +2. Applies RLS policy for tenant isolation. +3. Creates indexes for fast paginated and filtered queries. +4. Grants SELECT, INSERT to app_user (read and write audit entries). +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + conn = op.get_bind() + + # ========================================================================= + # CREATE audit_logs TABLE + # ========================================================================= + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(100) NOT NULL, + resource_type VARCHAR(50), + resource_id VARCHAR(255), + device_id UUID REFERENCES devices(id) ON DELETE SET NULL, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + ip_address VARCHAR(45), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """)) + + # ========================================================================= + # RLS POLICY + # ========================================================================= + conn.execute(sa.text( + "ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY" + )) + conn.execute(sa.text(""" + CREATE POLICY audit_logs_tenant_isolation ON audit_logs + USING (tenant_id = current_setting('app.current_tenant')::uuid) + """)) + + # Grant SELECT + INSERT to app_user (no UPDATE/DELETE -- audit logs are immutable) + conn.execute(sa.text( + "GRANT SELECT, INSERT ON audit_logs TO app_user" + )) + # Poller user gets full access for cross-tenant audit logging + conn.execute(sa.text( + "GRANT ALL ON audit_logs TO poller_user" + )) + + # ========================================================================= + # INDEXES + # ========================================================================= + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created " + "ON audit_logs (tenant_id, created_at DESC)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_action " + "ON audit_logs (tenant_id, action)" + )) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text("DROP TABLE IF EXISTS audit_logs CASCADE")) diff --git a/backend/alembic/versions/008_maintenance_windows.py b/backend/alembic/versions/008_maintenance_windows.py new file mode 100644 index 0000000..814cb0f --- /dev/null +++ b/backend/alembic/versions/008_maintenance_windows.py @@ -0,0 +1,86 @@ +"""Add maintenance_windows table with RLS. + +Revision ID: 008 +Revises: 007 +Create Date: 2026-03-02 + +This migration: +1. Creates maintenance_windows table for scheduling maintenance periods. +2. Adds CHECK constraint (end_at > start_at). +3. Creates composite index on (tenant_id, start_at, end_at) for active window queries. +4. Applies RLS policy matching the standard tenant_id isolation pattern. +5. Grants permissions to app_user role. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + conn = op.get_bind() + + # ── 1. Create maintenance_windows table ──────────────────────────────── + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS maintenance_windows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + device_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + suppress_alerts BOOLEAN NOT NULL DEFAULT true, + notes TEXT, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT chk_maintenance_window_dates CHECK (end_at > start_at) + ) + """)) + + # ── 2. Composite index for active window queries ─────────────────────── + conn.execute(sa.text(""" + CREATE INDEX IF NOT EXISTS idx_maintenance_windows_tenant_time + ON maintenance_windows (tenant_id, start_at, end_at) + """)) + + # ── 3. RLS policy ───────────────────────────────────────────────────── + conn.execute(sa.text("ALTER TABLE maintenance_windows ENABLE ROW LEVEL SECURITY")) + + conn.execute(sa.text(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'maintenance_windows' AND policyname = 'maintenance_windows_tenant_isolation' + ) THEN + CREATE POLICY maintenance_windows_tenant_isolation ON maintenance_windows + USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid); + END IF; + END + $$ + """)) + + # ── 4. Grant permissions to app_user ─────────────────────────────────── + conn.execute(sa.text(""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_user') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON maintenance_windows TO app_user; + END IF; + END + $$ + """)) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text("DROP TABLE IF EXISTS maintenance_windows CASCADE")) diff --git a/backend/alembic/versions/009_api_keys.py b/backend/alembic/versions/009_api_keys.py new file mode 100644 index 0000000..47b18bf --- /dev/null +++ b/backend/alembic/versions/009_api_keys.py @@ -0,0 +1,93 @@ +"""Add api_keys table with RLS for tenant-scoped API key management. + +Revision ID: 009 +Revises: 008 +Create Date: 2026-03-02 + +This migration: +1. Creates api_keys table (UUID PK, tenant_id FK, user_id FK, key_hash, scopes JSONB). +2. Adds unique index on key_hash for O(1) validation lookups. +3. Adds composite index on (tenant_id, revoked_at) for listing active keys. +4. Applies RLS policy on tenant_id. +5. Grants SELECT, INSERT, UPDATE to app_user. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +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: + conn = op.get_bind() + + # 1. Create api_keys table + conn.execute( + sa.text(""" + CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + key_prefix VARCHAR(12) NOT NULL, + key_hash VARCHAR(64) NOT NULL, + scopes JSONB NOT NULL DEFAULT '[]'::jsonb, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ + ); + """) + ) + + # 2. Unique index on key_hash for fast validation lookups + conn.execute( + sa.text(""" + CREATE UNIQUE INDEX IF NOT EXISTS ix_api_keys_key_hash + ON api_keys (key_hash); + """) + ) + + # 3. Composite index for listing active keys per tenant + conn.execute( + sa.text(""" + CREATE INDEX IF NOT EXISTS ix_api_keys_tenant_revoked + ON api_keys (tenant_id, revoked_at); + """) + ) + + # 4. Enable RLS and create tenant isolation policy + conn.execute(sa.text("ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;")) + conn.execute(sa.text("ALTER TABLE api_keys FORCE ROW LEVEL SECURITY;")) + + conn.execute( + sa.text(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'api_keys' AND policyname = 'tenant_isolation' + ) THEN + CREATE POLICY tenant_isolation ON api_keys + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ); + END IF; + END $$; + """) + ) + + # 5. Grant permissions to app_user role + conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE ON api_keys TO app_user;")) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text("DROP TABLE IF EXISTS api_keys CASCADE;")) diff --git a/backend/alembic/versions/010_wireguard_vpn.py b/backend/alembic/versions/010_wireguard_vpn.py new file mode 100644 index 0000000..e034d4a --- /dev/null +++ b/backend/alembic/versions/010_wireguard_vpn.py @@ -0,0 +1,90 @@ +"""Add vpn_config and vpn_peers tables for WireGuard VPN management. + +Revision ID: 010 +Revises: 009 +Create Date: 2026-03-02 + +This migration: +1. Creates vpn_config table (one row per tenant — server keys, subnet, port). +2. Creates vpn_peers table (one row per device VPN connection). +3. Applies RLS policies on tenant_id. +4. Grants SELECT, INSERT, UPDATE, DELETE to app_user. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +# revision identifiers +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: + # ── vpn_config: one row per tenant ── + op.create_table( + "vpn_config", + sa.Column("id", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("server_private_key", sa.LargeBinary(), nullable=False), # AES-256-GCM encrypted + sa.Column("server_public_key", sa.String(64), nullable=False), + sa.Column("subnet", sa.String(32), nullable=False, server_default="10.10.0.0/24"), + sa.Column("server_port", sa.Integer(), nullable=False, server_default="51820"), + sa.Column("server_address", sa.String(32), nullable=False, server_default="10.10.0.1/24"), + sa.Column("endpoint", sa.String(255), nullable=True), # public hostname:port for devices to connect to + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="false"), + 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), + ) + + # ── vpn_peers: one per device VPN connection ── + op.create_table( + "vpn_peers", + sa.Column("id", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("device_id", UUID(as_uuid=True), sa.ForeignKey("devices.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("peer_private_key", sa.LargeBinary(), nullable=False), # AES-256-GCM encrypted + sa.Column("peer_public_key", sa.String(64), nullable=False), + sa.Column("preshared_key", sa.LargeBinary(), nullable=True), # AES-256-GCM encrypted, optional + sa.Column("assigned_ip", sa.String(32), nullable=False), # e.g. 10.10.0.2/24 + sa.Column("additional_allowed_ips", sa.String(512), nullable=True), # comma-separated subnets for site-to-site + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("last_handshake", sa.DateTime(timezone=True), 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), + ) + + # Indexes + op.create_index("ix_vpn_peers_tenant_id", "vpn_peers", ["tenant_id"]) + + # ── RLS policies ── + op.execute("ALTER TABLE vpn_config ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY vpn_config_tenant_isolation ON vpn_config + FOR ALL + TO app_user + USING (CAST(tenant_id AS text) = current_setting('app.current_tenant', true)) + """) + + op.execute("ALTER TABLE vpn_peers ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY vpn_peers_tenant_isolation ON vpn_peers + FOR ALL + TO app_user + USING (CAST(tenant_id AS text) = current_setting('app.current_tenant', true)) + """) + + # ── Grants ── + op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON vpn_config TO app_user") + op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON vpn_peers TO app_user") + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS vpn_peers_tenant_isolation ON vpn_peers") + op.execute("DROP POLICY IF EXISTS vpn_config_tenant_isolation ON vpn_config") + op.drop_table("vpn_peers") + op.drop_table("vpn_config") diff --git a/backend/alembic/versions/012_seed_starter_templates.py b/backend/alembic/versions/012_seed_starter_templates.py new file mode 100644 index 0000000..375fffe --- /dev/null +++ b/backend/alembic/versions/012_seed_starter_templates.py @@ -0,0 +1,169 @@ +"""Seed starter config templates for tenants missing them. + +Revision ID: 012 +Revises: 010 +Create Date: 2026-03-02 + +Re-seeds the 4 original starter templates from 006 plus a new comprehensive +'Basic Router' template for any tenants created after migration 006 ran. +Uses ON CONFLICT (tenant_id, name) DO NOTHING so existing templates are untouched. +""" + +revision = "012" +down_revision = "010" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade() -> None: + conn = op.get_bind() + + # 1. Basic Router — comprehensive starter for a typical SOHO/branch router + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'Basic Router', + 'Complete SOHO/branch router setup: WAN on ether1, LAN bridge, DHCP, DNS, NAT, basic firewall', + '/interface bridge add name=bridge-lan comment="LAN bridge" +/interface bridge port add bridge=bridge-lan interface=ether2 +/interface bridge port add bridge=bridge-lan interface=ether3 +/interface bridge port add bridge=bridge-lan interface=ether4 +/interface bridge port add bridge=bridge-lan interface=ether5 + +# WAN — DHCP client on ether1 +/ip dhcp-client add interface={{ wan_interface }} disabled=no comment="WAN uplink" + +# LAN address +/ip address add address={{ lan_gateway }}/{{ lan_cidr }} interface=bridge-lan + +# DNS +/ip dns set servers={{ dns_servers }} allow-remote-requests=yes + +# DHCP server for LAN +/ip pool add name=lan-pool ranges={{ dhcp_start }}-{{ dhcp_end }} +/ip dhcp-server network add address={{ lan_network }}/{{ lan_cidr }} gateway={{ lan_gateway }} dns-server={{ lan_gateway }} +/ip dhcp-server add name=lan-dhcp interface=bridge-lan address-pool=lan-pool disabled=no + +# NAT masquerade +/ip firewall nat add chain=srcnat out-interface={{ wan_interface }} action=masquerade + +# Firewall — input chain +/ip firewall filter +add chain=input connection-state=established,related action=accept +add chain=input connection-state=invalid action=drop +add chain=input in-interface={{ wan_interface }} action=drop comment="Drop all other WAN input" + +# Firewall — forward chain +add chain=forward connection-state=established,related action=accept +add chain=forward connection-state=invalid action=drop +add chain=forward in-interface=bridge-lan out-interface={{ wan_interface }} action=accept comment="Allow LAN to WAN" +add chain=forward action=drop comment="Drop everything else" + +# NTP +/system ntp client set enabled=yes servers={{ ntp_server }} + +# Identity +/system identity set name={{ device.hostname }}', + '[{"name":"wan_interface","type":"string","default":"ether1","description":"WAN-facing interface"},{"name":"lan_gateway","type":"ip","default":"192.168.88.1","description":"LAN gateway IP"},{"name":"lan_cidr","type":"integer","default":"24","description":"LAN subnet mask bits"},{"name":"lan_network","type":"ip","default":"192.168.88.0","description":"LAN network address"},{"name":"dhcp_start","type":"ip","default":"192.168.88.100","description":"DHCP pool start"},{"name":"dhcp_end","type":"ip","default":"192.168.88.254","description":"DHCP pool end"},{"name":"dns_servers","type":"string","default":"8.8.8.8,8.8.4.4","description":"Upstream DNS servers"},{"name":"ntp_server","type":"string","default":"pool.ntp.org","description":"NTP server"}]'::jsonb + FROM tenants t + WHERE NOT EXISTS ( + SELECT 1 FROM config_templates ct + WHERE ct.tenant_id = t.id AND ct.name = 'Basic Router' + ) + """)) + + # 2. Re-seed Basic Firewall (for tenants missing it) + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'Basic Firewall', + 'Standard firewall ruleset with WAN protection and LAN forwarding', + '/ip firewall filter +add chain=input connection-state=established,related action=accept +add chain=input connection-state=invalid action=drop +add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=8291 action=drop comment="Block Winbox from WAN" +add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=22 action=drop comment="Block SSH from WAN" +add chain=forward connection-state=established,related action=accept +add chain=forward connection-state=invalid action=drop +add chain=forward src-address={{ allowed_network }} action=accept +add chain=forward action=drop', + '[{"name":"wan_interface","type":"string","default":"ether1","description":"WAN-facing interface"},{"name":"allowed_network","type":"subnet","default":"192.168.88.0/24","description":"Allowed source network"}]'::jsonb + FROM tenants t + WHERE NOT EXISTS ( + SELECT 1 FROM config_templates ct + WHERE ct.tenant_id = t.id AND ct.name = 'Basic Firewall' + ) + """)) + + # 3. Re-seed DHCP Server Setup + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'DHCP Server Setup', + 'Configure DHCP server with address pool, DNS, and gateway', + '/ip pool add name=dhcp-pool ranges={{ pool_start }}-{{ pool_end }} +/ip dhcp-server network add address={{ gateway }}/24 gateway={{ gateway }} dns-server={{ dns_server }} +/ip dhcp-server add name=dhcp1 interface={{ interface }} address-pool=dhcp-pool disabled=no', + '[{"name":"pool_start","type":"ip","default":"192.168.88.100","description":"DHCP pool start address"},{"name":"pool_end","type":"ip","default":"192.168.88.254","description":"DHCP pool end address"},{"name":"gateway","type":"ip","default":"192.168.88.1","description":"Default gateway"},{"name":"dns_server","type":"ip","default":"8.8.8.8","description":"DNS server address"},{"name":"interface","type":"string","default":"bridge-lan","description":"Interface to serve DHCP on"}]'::jsonb + FROM tenants t + WHERE NOT EXISTS ( + SELECT 1 FROM config_templates ct + WHERE ct.tenant_id = t.id AND ct.name = 'DHCP Server Setup' + ) + """)) + + # 4. Re-seed Wireless AP Config + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'Wireless AP Config', + 'Configure wireless access point with WPA2 security', + '/interface wireless security-profiles add name=portal-wpa2 mode=dynamic-keys authentication-types=wpa2-psk wpa2-pre-shared-key={{ password }} +/interface wireless set wlan1 mode=ap-bridge ssid={{ ssid }} security-profile=portal-wpa2 frequency={{ frequency }} channel-width={{ channel_width }} disabled=no', + '[{"name":"ssid","type":"string","default":"MikroTik-AP","description":"Wireless network name"},{"name":"password","type":"string","default":"","description":"WPA2 pre-shared key (min 8 characters)"},{"name":"frequency","type":"integer","default":"2412","description":"Wireless frequency in MHz"},{"name":"channel_width","type":"string","default":"20/40mhz-XX","description":"Channel width setting"}]'::jsonb + FROM tenants t + WHERE NOT EXISTS ( + SELECT 1 FROM config_templates ct + WHERE ct.tenant_id = t.id AND ct.name = 'Wireless AP Config' + ) + """)) + + # 5. Re-seed Initial Device Setup + conn.execute(sa.text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + SELECT + gen_random_uuid(), + t.id, + 'Initial Device Setup', + 'Set device identity, NTP, DNS, and disable unused services', + '/system identity set name={{ device.hostname }} +/system ntp client set enabled=yes servers={{ ntp_server }} +/ip dns set servers={{ dns_servers }} allow-remote-requests=no +/ip service disable telnet,ftp,www,api-ssl +/ip service set ssh port=22 +/ip service set winbox port=8291', + '[{"name":"ntp_server","type":"ip","default":"pool.ntp.org","description":"NTP server address"},{"name":"dns_servers","type":"string","default":"8.8.8.8,8.8.4.4","description":"Comma-separated DNS servers"}]'::jsonb + FROM tenants t + WHERE NOT EXISTS ( + SELECT 1 FROM config_templates ct + WHERE ct.tenant_id = t.id AND ct.name = 'Initial Device Setup' + ) + """)) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text( + "DELETE FROM config_templates WHERE name = 'Basic Router'" + )) diff --git a/backend/alembic/versions/013_certificates.py b/backend/alembic/versions/013_certificates.py new file mode 100644 index 0000000..b29f1d0 --- /dev/null +++ b/backend/alembic/versions/013_certificates.py @@ -0,0 +1,203 @@ +"""Add certificate authority and device certificate tables. + +Revision ID: 013 +Revises: 012 +Create Date: 2026-03-03 + +Creates the `certificate_authorities` (one per tenant) and `device_certificates` +(one per device) tables for the Internal Certificate Authority feature. +Also adds a `tls_mode` column to the `devices` table to track per-device +TLS verification mode (insecure vs portal_ca). + +Both tables have RLS policies for tenant isolation, plus poller_user read +access (the poller needs CA cert PEM to verify device TLS connections). +""" + +revision = "013" +down_revision = "012" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +def upgrade() -> None: + # --- certificate_authorities table --- + op.create_table( + "certificate_authorities", + sa.Column( + "id", + UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + primary_key=True, + ), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ), + sa.Column("common_name", sa.String(255), nullable=False), + sa.Column("cert_pem", sa.Text(), nullable=False), + sa.Column("encrypted_private_key", sa.LargeBinary(), nullable=False), + sa.Column("serial_number", sa.String(64), nullable=False), + sa.Column("fingerprint_sha256", sa.String(95), nullable=False), + sa.Column( + "not_valid_before", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "not_valid_after", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + ) + + # --- device_certificates table --- + op.create_table( + "device_certificates", + sa.Column( + "id", + UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + primary_key=True, + ), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "device_id", + UUID(as_uuid=True), + sa.ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "ca_id", + UUID(as_uuid=True), + sa.ForeignKey("certificate_authorities.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("common_name", sa.String(255), nullable=False), + sa.Column("serial_number", sa.String(64), nullable=False), + sa.Column("fingerprint_sha256", sa.String(95), nullable=False), + sa.Column("cert_pem", sa.Text(), nullable=False), + sa.Column("encrypted_private_key", sa.LargeBinary(), nullable=False), + sa.Column( + "not_valid_before", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "not_valid_after", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "status", + sa.String(20), + nullable=False, + server_default="issued", + ), + sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + ) + + # --- Add tls_mode column to devices table --- + op.add_column( + "devices", + sa.Column( + "tls_mode", + sa.String(20), + nullable=False, + server_default="insecure", + ), + ) + + # --- RLS policies --- + conn = op.get_bind() + + # certificate_authorities RLS + conn.execute(sa.text( + "ALTER TABLE certificate_authorities ENABLE ROW LEVEL SECURITY" + )) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON certificate_authorities TO app_user" + )) + conn.execute(sa.text( + "CREATE POLICY tenant_isolation ON certificate_authorities FOR ALL " + "USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid) " + "WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid)" + )) + conn.execute(sa.text( + "GRANT SELECT ON certificate_authorities TO poller_user" + )) + + # device_certificates RLS + conn.execute(sa.text( + "ALTER TABLE device_certificates ENABLE ROW LEVEL SECURITY" + )) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON device_certificates TO app_user" + )) + conn.execute(sa.text( + "CREATE POLICY tenant_isolation ON device_certificates FOR ALL " + "USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid) " + "WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid)" + )) + conn.execute(sa.text( + "GRANT SELECT ON device_certificates TO poller_user" + )) + + +def downgrade() -> None: + conn = op.get_bind() + + # Drop RLS policies + conn.execute(sa.text( + "DROP POLICY IF EXISTS tenant_isolation ON device_certificates" + )) + conn.execute(sa.text( + "DROP POLICY IF EXISTS tenant_isolation ON certificate_authorities" + )) + + # Revoke grants + conn.execute(sa.text( + "REVOKE ALL ON device_certificates FROM app_user" + )) + conn.execute(sa.text( + "REVOKE ALL ON device_certificates FROM poller_user" + )) + conn.execute(sa.text( + "REVOKE ALL ON certificate_authorities FROM app_user" + )) + conn.execute(sa.text( + "REVOKE ALL ON certificate_authorities FROM poller_user" + )) + + # Drop tls_mode column from devices + op.drop_column("devices", "tls_mode") + + # Drop tables + op.drop_table("device_certificates") + op.drop_table("certificate_authorities") diff --git a/backend/alembic/versions/014_timescaledb_retention.py b/backend/alembic/versions/014_timescaledb_retention.py new file mode 100644 index 0000000..cb48a97 --- /dev/null +++ b/backend/alembic/versions/014_timescaledb_retention.py @@ -0,0 +1,50 @@ +"""Add TimescaleDB retention policies. + +Revision ID: 014 +Revises: 013 +Create Date: 2026-03-03 + +Adds retention (drop after 90 days) on all three hypertables: +interface_metrics, health_metrics, wireless_metrics. + +Note: Compression is skipped because TimescaleDB 2.17.x does not support +compression on tables with row-level security (RLS) policies. +Compression can be re-added when upgrading to TimescaleDB >= 2.19. + +Without retention policies the database grows ~5 GB/month unbounded. +""" + +revision = "014" +down_revision = "013" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +HYPERTABLES = [ + "interface_metrics", + "health_metrics", + "wireless_metrics", +] + + +def upgrade() -> None: + conn = op.get_bind() + + for table in HYPERTABLES: + # Drop chunks older than 90 days + conn.execute(sa.text( + f"SELECT add_retention_policy('{table}', INTERVAL '90 days')" + )) + + +def downgrade() -> None: + conn = op.get_bind() + + for table in HYPERTABLES: + # Remove retention policy + conn.execute(sa.text( + f"SELECT remove_retention_policy('{table}', if_exists => true)" + )) diff --git a/backend/alembic/versions/015_password_reset_tokens.py b/backend/alembic/versions/015_password_reset_tokens.py new file mode 100644 index 0000000..4fae0ea --- /dev/null +++ b/backend/alembic/versions/015_password_reset_tokens.py @@ -0,0 +1,62 @@ +"""Add password_reset_tokens table. + +Revision ID: 015 +Revises: 014 +Create Date: 2026-03-03 + +Stores one-time password reset tokens with expiry. Tokens are hashed +with SHA-256 so a database leak doesn't expose reset links. +""" + +revision = "015" +down_revision = "014" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +def upgrade() -> None: + op.create_table( + "password_reset_tokens", + sa.Column( + "id", + UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + primary_key=True, + ), + sa.Column( + "user_id", + UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "token_hash", + sa.String(64), + nullable=False, + unique=True, + index=True, + ), + sa.Column( + "expires_at", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "used_at", + sa.DateTime(timezone=True), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + ) + + +def downgrade() -> None: + op.drop_table("password_reset_tokens") diff --git a/backend/alembic/versions/016_zero_knowledge_schema.py b/backend/alembic/versions/016_zero_knowledge_schema.py new file mode 100644 index 0000000..38dd56c --- /dev/null +++ b/backend/alembic/versions/016_zero_knowledge_schema.py @@ -0,0 +1,207 @@ +"""Add zero-knowledge authentication schema. + +Revision ID: 016 +Revises: 015 +Create Date: 2026-03-03 + +Adds SRP columns to users, creates user_key_sets table for encrypted +key bundles, creates immutable key_access_log audit trail, and adds +vault key columns to tenants (Phase 29 preparation). + +Both new tables have RLS policies. key_access_log is append-only +(INSERT+SELECT only, no UPDATE/DELETE). +""" + +revision = "016" +down_revision = "015" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +def upgrade() -> None: + # --- Add SRP columns to users table --- + op.add_column( + "users", + sa.Column("srp_salt", sa.LargeBinary(), nullable=True), + ) + op.add_column( + "users", + sa.Column("srp_verifier", sa.LargeBinary(), nullable=True), + ) + op.add_column( + "users", + sa.Column( + "auth_version", + sa.SmallInteger(), + server_default=sa.text("1"), + nullable=False, + ), + ) + + # --- Create user_key_sets table --- + op.create_table( + "user_key_sets", + sa.Column( + "id", + UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + primary_key=True, + ), + sa.Column( + "user_id", + UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=True, # NULL for super_admin + ), + sa.Column("encrypted_private_key", sa.LargeBinary(), nullable=False), + sa.Column("private_key_nonce", sa.LargeBinary(), nullable=False), + sa.Column("encrypted_vault_key", sa.LargeBinary(), nullable=False), + sa.Column("vault_key_nonce", sa.LargeBinary(), nullable=False), + sa.Column("public_key", sa.LargeBinary(), nullable=False), + sa.Column( + "pbkdf2_iterations", + sa.Integer(), + server_default=sa.text("650000"), + nullable=False, + ), + sa.Column("pbkdf2_salt", sa.LargeBinary(), nullable=False), + sa.Column("hkdf_salt", sa.LargeBinary(), nullable=False), + sa.Column( + "key_version", + sa.Integer(), + server_default=sa.text("1"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + ) + + # --- Create key_access_log table (immutable audit trail) --- + op.create_table( + "key_access_log", + sa.Column( + "id", + UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + primary_key=True, + ), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", + UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("resource_type", sa.Text(), nullable=True), + sa.Column("resource_id", sa.Text(), nullable=True), + sa.Column("key_version", sa.Integer(), nullable=True), + sa.Column("ip_address", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + + # --- Add vault key columns to tenants (Phase 29 preparation) --- + op.add_column( + "tenants", + sa.Column("encrypted_vault_key", sa.LargeBinary(), nullable=True), + ) + op.add_column( + "tenants", + sa.Column( + "vault_key_version", + sa.Integer(), + server_default=sa.text("1"), + ), + ) + + # --- RLS policies --- + conn = op.get_bind() + + # user_key_sets RLS + conn.execute(sa.text( + "ALTER TABLE user_key_sets ENABLE ROW LEVEL SECURITY" + )) + conn.execute(sa.text( + "CREATE POLICY user_key_sets_tenant_isolation ON user_key_sets " + "USING (tenant_id::text = current_setting('app.current_tenant', true) " + "OR current_setting('app.current_tenant', true) = 'super_admin')" + )) + conn.execute(sa.text( + "GRANT SELECT, INSERT, UPDATE ON user_key_sets TO app_user" + )) + + # key_access_log RLS (append-only: INSERT+SELECT only, no UPDATE/DELETE) + conn.execute(sa.text( + "ALTER TABLE key_access_log ENABLE ROW LEVEL SECURITY" + )) + conn.execute(sa.text( + "CREATE POLICY key_access_log_tenant_isolation ON key_access_log " + "USING (tenant_id::text = current_setting('app.current_tenant', true) " + "OR current_setting('app.current_tenant', true) = 'super_admin')" + )) + conn.execute(sa.text( + "GRANT INSERT, SELECT ON key_access_log TO app_user" + )) + # poller_user needs INSERT to log key access events when decrypting credentials + conn.execute(sa.text( + "GRANT INSERT, SELECT ON key_access_log TO poller_user" + )) + + +def downgrade() -> None: + conn = op.get_bind() + + # Drop RLS policies + conn.execute(sa.text( + "DROP POLICY IF EXISTS key_access_log_tenant_isolation ON key_access_log" + )) + conn.execute(sa.text( + "DROP POLICY IF EXISTS user_key_sets_tenant_isolation ON user_key_sets" + )) + + # Revoke grants + conn.execute(sa.text("REVOKE ALL ON key_access_log FROM app_user")) + conn.execute(sa.text("REVOKE ALL ON key_access_log FROM poller_user")) + conn.execute(sa.text("REVOKE ALL ON user_key_sets FROM app_user")) + + # Drop vault key columns from tenants + op.drop_column("tenants", "vault_key_version") + op.drop_column("tenants", "encrypted_vault_key") + + # Drop tables + op.drop_table("key_access_log") + op.drop_table("user_key_sets") + + # Drop SRP columns from users + op.drop_column("users", "auth_version") + op.drop_column("users", "srp_verifier") + op.drop_column("users", "srp_salt") diff --git a/backend/alembic/versions/017_openbao_envelope_encryption.py b/backend/alembic/versions/017_openbao_envelope_encryption.py new file mode 100644 index 0000000..b032ceb --- /dev/null +++ b/backend/alembic/versions/017_openbao_envelope_encryption.py @@ -0,0 +1,90 @@ +"""OpenBao envelope encryption columns and key_access_log extensions. + +Revision ID: 017 +Revises: 016 +Create Date: 2026-03-03 + +Adds Transit ciphertext columns (TEXT) alongside existing BYTEA columns +for dual-write migration strategy. Extends key_access_log with device_id, +justification, and correlation_id for Phase 29 audit trail. +""" + +revision = "017" +down_revision = "016" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +def upgrade() -> None: + # --- Transit ciphertext columns (TEXT, alongside existing BYTEA) --- + + # devices: store OpenBao Transit ciphertext for credentials + op.add_column( + "devices", + sa.Column("encrypted_credentials_transit", sa.Text(), nullable=True), + ) + + # certificate_authorities: Transit-encrypted CA private keys + op.add_column( + "certificate_authorities", + sa.Column("encrypted_private_key_transit", sa.Text(), nullable=True), + ) + + # device_certificates: Transit-encrypted device cert private keys + op.add_column( + "device_certificates", + sa.Column("encrypted_private_key_transit", sa.Text(), nullable=True), + ) + + # notification_channels: Transit-encrypted SMTP password + op.add_column( + "notification_channels", + sa.Column("smtp_password_transit", sa.Text(), nullable=True), + ) + + # --- Tenant OpenBao key tracking --- + op.add_column( + "tenants", + sa.Column("openbao_key_name", sa.Text(), nullable=True), + ) + + # --- Extend key_access_log for Phase 29 --- + op.add_column( + "key_access_log", + sa.Column("device_id", UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "key_access_log", + sa.Column("justification", sa.Text(), nullable=True), + ) + op.add_column( + "key_access_log", + sa.Column("correlation_id", sa.Text(), nullable=True), + ) + + # Add FK constraint for device_id -> devices(id) (nullable, so no cascade needed) + op.create_foreign_key( + "fk_key_access_log_device_id", + "key_access_log", + "devices", + ["device_id"], + ["id"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_key_access_log_device_id", "key_access_log", type_="foreignkey" + ) + op.drop_column("key_access_log", "correlation_id") + op.drop_column("key_access_log", "justification") + op.drop_column("key_access_log", "device_id") + op.drop_column("tenants", "openbao_key_name") + op.drop_column("notification_channels", "smtp_password_transit") + op.drop_column("device_certificates", "encrypted_private_key_transit") + op.drop_column("certificate_authorities", "encrypted_private_key_transit") + op.drop_column("devices", "encrypted_credentials_transit") diff --git a/backend/alembic/versions/018_data_encryption.py b/backend/alembic/versions/018_data_encryption.py new file mode 100644 index 0000000..f892213 --- /dev/null +++ b/backend/alembic/versions/018_data_encryption.py @@ -0,0 +1,62 @@ +"""Data encryption columns for config backups and audit logs. + +Revision ID: 018 +Revises: 017 +Create Date: 2026-03-03 + +Adds encryption metadata columns to config_backup_runs (encryption_tier, +encryption_nonce) and encrypted_details TEXT column to audit_logs for +Transit-encrypted audit detail storage. +""" + +revision = "018" +down_revision = "017" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade() -> None: + # --- config_backup_runs: encryption metadata --- + + # NULL = plaintext, 1 = client-side AES-GCM, 2 = OpenBao Transit + op.add_column( + "config_backup_runs", + sa.Column( + "encryption_tier", + sa.SmallInteger(), + nullable=True, + comment="NULL=plaintext, 1=client-side AES-GCM, 2=OpenBao Transit", + ), + ) + + # 12-byte AES-GCM nonce for Tier 1 (client-side) backups + op.add_column( + "config_backup_runs", + sa.Column( + "encryption_nonce", + sa.LargeBinary(), + nullable=True, + comment="12-byte AES-GCM nonce for Tier 1 backups", + ), + ) + + # --- audit_logs: Transit-encrypted details --- + + op.add_column( + "audit_logs", + sa.Column( + "encrypted_details", + sa.Text(), + nullable=True, + comment="Transit-encrypted details JSON (vault:v1:...)", + ), + ) + + +def downgrade() -> None: + op.drop_column("audit_logs", "encrypted_details") + op.drop_column("config_backup_runs", "encryption_nonce") + op.drop_column("config_backup_runs", "encryption_tier") diff --git a/backend/alembic/versions/019_deprecate_bcrypt.py b/backend/alembic/versions/019_deprecate_bcrypt.py new file mode 100644 index 0000000..627a7cf --- /dev/null +++ b/backend/alembic/versions/019_deprecate_bcrypt.py @@ -0,0 +1,52 @@ +"""Deprecate bcrypt: add must_upgrade_auth flag and make hashed_password nullable. + +Revision ID: 019 +Revises: 018 +Create Date: 2026-03-03 + +Conservative migration that flags legacy bcrypt users for SRP upgrade +rather than dropping data. hashed_password is made nullable so SRP-only +users no longer need a dummy value. A future migration (post-v6.0) can +drop hashed_password once all users have upgraded. +""" + +import sqlalchemy as sa +from alembic import op + +revision = "019" +down_revision = "018" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add must_upgrade_auth flag + op.add_column( + "users", + sa.Column( + "must_upgrade_auth", + sa.Boolean(), + server_default="false", + nullable=False, + ), + ) + + # Flag all bcrypt-only users for upgrade (auth_version=1 and no SRP verifier) + op.execute( + "UPDATE users SET must_upgrade_auth = true " + "WHERE auth_version = 1 AND srp_verifier IS NULL" + ) + + # Make hashed_password nullable (SRP users don't need it) + op.alter_column("users", "hashed_password", nullable=True) + + +def downgrade() -> None: + # Restore NOT NULL (set a dummy value for any NULLs first) + op.execute( + "UPDATE users SET hashed_password = '$2b$12$placeholder' " + "WHERE hashed_password IS NULL" + ) + op.alter_column("users", "hashed_password", nullable=False) + + op.drop_column("users", "must_upgrade_auth") diff --git a/backend/alembic/versions/020_tls_mode_opt_in.py b/backend/alembic/versions/020_tls_mode_opt_in.py new file mode 100644 index 0000000..0d2b82b --- /dev/null +++ b/backend/alembic/versions/020_tls_mode_opt_in.py @@ -0,0 +1,51 @@ +"""Add opt-in plain-text TLS mode and change default from insecure to auto. + +Revision ID: 020 +Revises: 019 +Create Date: 2026-03-04 + +Reclassifies tls_mode values: +- 'auto': CA-verified -> InsecureSkipVerify (NO plain-text fallback) +- 'insecure': Skip directly to InsecureSkipVerify +- 'plain': Explicit opt-in for plain-text API (dangerous) +- 'portal_ca': Existing CA-verified mode (unchanged) + +Existing 'insecure' devices become 'auto' since the old behavior was +an implicit auto-fallback. portal_ca devices keep their mode. +""" + +import sqlalchemy as sa +from alembic import op + +revision = "020" +down_revision = "019" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Migrate existing 'insecure' devices to 'auto' (the new default). + # 'portal_ca' devices keep their mode (they already have CA verification). + op.execute("UPDATE devices SET tls_mode = 'auto' WHERE tls_mode = 'insecure'") + + # Change the server default from 'insecure' to 'auto' + op.alter_column( + "devices", + "tls_mode", + server_default="auto", + ) + + +def downgrade() -> None: + # Revert 'auto' devices back to 'insecure' + op.execute("UPDATE devices SET tls_mode = 'insecure' WHERE tls_mode = 'auto'") + + # Revert 'plain' devices to 'insecure' (plain didn't exist before) + op.execute("UPDATE devices SET tls_mode = 'insecure' WHERE tls_mode = 'plain'") + + # Restore old server default + op.alter_column( + "devices", + "tls_mode", + server_default="insecure", + ) diff --git a/backend/alembic/versions/021_system_tenant_for_audit.py b/backend/alembic/versions/021_system_tenant_for_audit.py new file mode 100644 index 0000000..0483f77 --- /dev/null +++ b/backend/alembic/versions/021_system_tenant_for_audit.py @@ -0,0 +1,44 @@ +"""Add system tenant for super_admin audit log entries. + +Revision ID: 021 +Revises: 020 +Create Date: 2026-03-04 + +The super_admin has NULL tenant_id, but audit_logs.tenant_id has a FK +to tenants and is NOT NULL. Code was using uuid.UUID(int=0) as a +substitute, but that row didn't exist — causing FK violations that +silently dropped every super_admin audit entry. + +This migration inserts a sentinel 'System (Internal)' tenant so +audit_logs can reference it. +""" + +from alembic import op + +revision = "021" +down_revision = "020" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + INSERT INTO tenants (id, name, description) + VALUES ( + '00000000-0000-0000-0000-000000000000', + 'System (Internal)', + 'Internal tenant for super_admin audit entries' + ) + ON CONFLICT (id) DO NOTHING + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DELETE FROM tenants + WHERE id = '00000000-0000-0000-0000-000000000000' + """ + ) diff --git a/backend/alembic/versions/022_rls_super_admin_devices.py b/backend/alembic/versions/022_rls_super_admin_devices.py new file mode 100644 index 0000000..6a4bfbb --- /dev/null +++ b/backend/alembic/versions/022_rls_super_admin_devices.py @@ -0,0 +1,49 @@ +"""Add super_admin bypass to devices, device_groups, device_tags RLS policies. + +Previously these tables only matched tenant_id, so super_admin context +('super_admin') returned zero rows. Users/tenants tables already had +the bypass — this brings device tables in line. + +Revision ID: 022 +Revises: 021 +Create Date: 2026-03-07 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "022" +down_revision = "021" +branch_labels = None +depends_on = None + +# Tables that need super_admin bypass added to their RLS policy +_TABLES = ["devices", "device_groups", "device_tags"] + + +def upgrade() -> None: + conn = op.get_bind() + for table in _TABLES: + conn.execute(sa.text(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")) + conn.execute(sa.text(f""" + CREATE POLICY tenant_isolation ON {table} + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """)) + + +def downgrade() -> None: + conn = op.get_bind() + for table in _TABLES: + conn.execute(sa.text(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")) + conn.execute(sa.text(f""" + CREATE POLICY tenant_isolation ON {table} + USING (tenant_id::text = current_setting('app.current_tenant', true)) + WITH CHECK (tenant_id::text = current_setting('app.current_tenant', true)) + """)) diff --git a/backend/alembic/versions/023_slack_notification_channel.py b/backend/alembic/versions/023_slack_notification_channel.py new file mode 100644 index 0000000..b0c8d9f --- /dev/null +++ b/backend/alembic/versions/023_slack_notification_channel.py @@ -0,0 +1,21 @@ +"""Add Slack notification channel support. + +Revision ID: 023 +Revises: 022 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "023" +down_revision = "022" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("notification_channels", sa.Column("slack_webhook_url", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("notification_channels", "slack_webhook_url") diff --git a/backend/alembic/versions/024_contact_email_and_offline_rule.py b/backend/alembic/versions/024_contact_email_and_offline_rule.py new file mode 100644 index 0000000..6c5e035 --- /dev/null +++ b/backend/alembic/versions/024_contact_email_and_offline_rule.py @@ -0,0 +1,41 @@ +"""Add contact_email to tenants and seed device_offline default alert rule. + +Revision ID: 024 +Revises: 023 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "024" +down_revision = "023" + + +def upgrade() -> None: + conn = op.get_bind() + + # 1. Add contact_email column to tenants + op.add_column("tenants", sa.Column("contact_email", sa.String(255), nullable=True)) + + # 2. Seed device_offline default alert rule for all existing tenants + conn.execute(sa.text(""" + INSERT INTO alert_rules (id, tenant_id, name, metric, operator, threshold, duration_polls, severity, enabled, is_default) + SELECT gen_random_uuid(), t.id, 'Device Offline', 'device_offline', 'eq', 1, 1, 'critical', TRUE, TRUE + FROM tenants t + WHERE t.id != '00000000-0000-0000-0000-000000000000' + AND NOT EXISTS ( + SELECT 1 FROM alert_rules ar + WHERE ar.tenant_id = t.id AND ar.metric = 'device_offline' AND ar.is_default = TRUE + ) + """)) + + +def downgrade() -> None: + conn = op.get_bind() + + conn.execute(sa.text(""" + DELETE FROM alert_rules WHERE metric = 'device_offline' AND is_default = TRUE + """)) + + op.drop_column("tenants", "contact_email") diff --git a/backend/alembic/versions/025_fix_key_access_log_device_fk.py b/backend/alembic/versions/025_fix_key_access_log_device_fk.py new file mode 100644 index 0000000..818c3b6 --- /dev/null +++ b/backend/alembic/versions/025_fix_key_access_log_device_fk.py @@ -0,0 +1,37 @@ +"""Fix key_access_log device_id FK to SET NULL on delete. + +Revision ID: 025 +Revises: 024 +""" + +from alembic import op + +revision = "025" +down_revision = "024" + + +def upgrade() -> None: + op.drop_constraint( + "fk_key_access_log_device_id", "key_access_log", type_="foreignkey" + ) + op.create_foreign_key( + "fk_key_access_log_device_id", + "key_access_log", + "devices", + ["device_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_key_access_log_device_id", "key_access_log", type_="foreignkey" + ) + op.create_foreign_key( + "fk_key_access_log_device_id", + "key_access_log", + "devices", + ["device_id"], + ["id"], + ) diff --git a/backend/alembic/versions/026_system_settings.py b/backend/alembic/versions/026_system_settings.py new file mode 100644 index 0000000..aca01f8 --- /dev/null +++ b/backend/alembic/versions/026_system_settings.py @@ -0,0 +1,41 @@ +"""Add system_settings table for instance-wide configuration. + +Revision ID: 026 +Revises: 025 +Create Date: 2026-03-08 +""" + +revision = "026" +down_revision = "025" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +def upgrade() -> None: + op.create_table( + "system_settings", + sa.Column("key", sa.String(255), primary_key=True), + sa.Column("value", sa.Text, nullable=True), + sa.Column("encrypted_value", sa.LargeBinary, nullable=True), + sa.Column("encrypted_value_transit", sa.Text, nullable=True), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_by", + UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_table("system_settings") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..6d6bd0f --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# TOD Backend diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..de358ad --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,177 @@ +"""Application configuration using Pydantic Settings.""" + +import base64 +import sys +from functools import lru_cache +from typing import Optional + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Known insecure default values that MUST NOT be used in non-dev environments. +# If any of these are detected in production/staging, the app refuses to start. +KNOWN_INSECURE_DEFAULTS: dict[str, list[str]] = { + "JWT_SECRET_KEY": [ + "change-this-in-production-use-a-long-random-string", + "dev-jwt-secret-change-in-production", + "CHANGE_ME_IN_PRODUCTION", + ], + "CREDENTIAL_ENCRYPTION_KEY": [ + "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=", + "CHANGE_ME_IN_PRODUCTION", + ], + "OPENBAO_TOKEN": [ + "dev-openbao-token", + "CHANGE_ME_IN_PRODUCTION", + ], +} + + +def validate_production_settings(settings: "Settings") -> None: + """Reject known-insecure defaults in non-dev environments. + + Called during app startup. Exits with code 1 and clear error message + if production is running with dev secrets. + """ + if settings.ENVIRONMENT == "dev": + return + + for field, insecure_values in KNOWN_INSECURE_DEFAULTS.items(): + actual = getattr(settings, field, None) + if actual in insecure_values: + print( + f"FATAL: {field} uses a known insecure default in '{settings.ENVIRONMENT}' environment.\n" + f"Generate a secure value and set it in your .env.prod file.\n" + f"For JWT_SECRET_KEY: python -c \"import secrets; print(secrets.token_urlsafe(64))\"\n" + f"For CREDENTIAL_ENCRYPTION_KEY: python -c \"import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())\"", + file=sys.stderr, + ) + sys.exit(1) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # Environment (dev | staging | production) + ENVIRONMENT: str = "dev" + + # Database + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/mikrotik" + # Sync URL used by Alembic only + SYNC_DATABASE_URL: str = "postgresql+psycopg2://postgres:postgres@localhost:5432/mikrotik" + + # App user for RLS enforcement (cannot bypass RLS) + APP_USER_DATABASE_URL: str = "postgresql+asyncpg://app_user:app_password@localhost:5432/mikrotik" + + # Database connection pool + DB_POOL_SIZE: int = 20 + DB_MAX_OVERFLOW: int = 40 + DB_ADMIN_POOL_SIZE: int = 10 + DB_ADMIN_MAX_OVERFLOW: int = 20 + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # NATS JetStream + NATS_URL: str = "nats://localhost:4222" + + # JWT configuration + JWT_SECRET_KEY: str = "change-this-in-production-use-a-long-random-string" + JWT_ALGORITHM: str = "HS256" + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # Credential encryption key — must be 32 bytes, base64-encoded in env + # Generate with: python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())" + CREDENTIAL_ENCRYPTION_KEY: str = "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=" + + # OpenBao Transit (KMS for per-tenant credential encryption) + OPENBAO_ADDR: str = "http://localhost:8200" + OPENBAO_TOKEN: str = "dev-openbao-token" + + # First admin bootstrap + FIRST_ADMIN_EMAIL: Optional[str] = None + FIRST_ADMIN_PASSWORD: Optional[str] = None + + # CORS origins (comma-separated) + CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173,http://localhost:8080" + + # Git store — PVC mount for bare git repos (one per tenant). + # In production: /data/git-store (Kubernetes PVC ReadWriteMany). + # In local dev: ./git-store (relative to cwd, created on first use). + GIT_STORE_PATH: str = "./git-store" + + # WireGuard config path — shared volume with the WireGuard container + WIREGUARD_CONFIG_PATH: str = "/data/wireguard" + + # Firmware cache + FIRMWARE_CACHE_DIR: str = "/data/firmware-cache" # PVC mount path + FIRMWARE_CHECK_INTERVAL_HOURS: int = 24 # How often to check for new versions + + # SMTP settings for transactional email (password reset, etc.) + SMTP_HOST: str = "localhost" + SMTP_PORT: int = 587 + SMTP_USER: Optional[str] = None + SMTP_PASSWORD: Optional[str] = None + SMTP_USE_TLS: bool = False + SMTP_FROM_ADDRESS: str = "noreply@mikrotik-portal.local" + + # Password reset + PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30 + APP_BASE_URL: str = "http://localhost:5173" + + # App settings + APP_NAME: str = "TOD - The Other Dude" + APP_VERSION: str = "0.1.0" + DEBUG: bool = False + + @field_validator("CREDENTIAL_ENCRYPTION_KEY") + @classmethod + def validate_encryption_key(cls, v: str) -> str: + """Ensure the key decodes to exactly 32 bytes. + + Note: CHANGE_ME_IN_PRODUCTION is allowed through this validator + because it fails the base64 length check. The production safety + check in validate_production_settings() catches it separately. + """ + if v == "CHANGE_ME_IN_PRODUCTION": + # Allow the placeholder through field validation -- the production + # safety check will reject it in non-dev environments. + return v + try: + key_bytes = base64.b64decode(v) + if len(key_bytes) != 32: + raise ValueError( + f"CREDENTIAL_ENCRYPTION_KEY must decode to exactly 32 bytes, got {len(key_bytes)}" + ) + except Exception as e: + raise ValueError(f"Invalid CREDENTIAL_ENCRYPTION_KEY: {e}") from e + return v + + def get_encryption_key_bytes(self) -> bytes: + """Return the encryption key as raw bytes.""" + return base64.b64decode(self.CREDENTIAL_ENCRYPTION_KEY) + + def get_cors_origins(self) -> list[str]: + """Return CORS origins as a list.""" + return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()] + + +@lru_cache() +def get_settings() -> Settings: + """Return cached settings instance. + + Validates that production environments do not use insecure defaults. + This runs once (cached) at startup before the app accepts requests. + """ + s = Settings() + validate_production_settings(s) + return s + + +settings = get_settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..321aca4 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,114 @@ +"""Database engine, session factory, and dependency injection.""" + +import uuid +from collections.abc import AsyncGenerator +from typing import Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + + +class Base(DeclarativeBase): + """Base class for all SQLAlchemy ORM models.""" + pass + + +# Primary engine using postgres superuser (for migrations/admin) +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_pre_ping=True, + pool_size=settings.DB_ADMIN_POOL_SIZE, + max_overflow=settings.DB_ADMIN_MAX_OVERFLOW, +) + +# App user engine (enforces RLS — no superuser bypass) +app_engine = create_async_engine( + settings.APP_USER_DATABASE_URL, + echo=settings.DEBUG, + pool_pre_ping=True, + pool_size=settings.DB_POOL_SIZE, + max_overflow=settings.DB_MAX_OVERFLOW, +) + +# Session factory for the app_user connection (RLS enforced) +AsyncSessionLocal = async_sessionmaker( + app_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + +# Admin session factory (for bootstrap/migrations only) +AdminAsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + Dependency that yields an async database session using app_user (RLS enforced). + + The tenant context (SET LOCAL app.current_tenant) must be set by + tenant_context middleware before any tenant-scoped queries. + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def get_admin_db() -> AsyncGenerator[AsyncSession, None]: + """ + Dependency that yields an admin database session (bypasses RLS). + USE ONLY for bootstrap operations and internal system tasks. + """ + async with AdminAsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def set_tenant_context(session: AsyncSession, tenant_id: Optional[str]) -> None: + """ + Set the PostgreSQL session variable for RLS enforcement. + + This MUST be called before any tenant-scoped query to activate RLS policies. + Uses SET LOCAL so the context resets at transaction end. + """ + if tenant_id: + # Allow 'super_admin' as a special RLS context value for cross-tenant access. + # Otherwise validate tenant_id is a valid UUID to prevent SQL injection. + # SET LOCAL cannot use parameterized queries in PostgreSQL. + if tenant_id != "super_admin": + try: + uuid.UUID(tenant_id) + except ValueError: + raise ValueError(f"Invalid tenant_id format: {tenant_id!r}") + await session.execute(text(f"SET LOCAL app.current_tenant = '{tenant_id}'")) + else: + # For super_admin users: set empty string which will not match any tenant + # The super_admin uses the admin engine which bypasses RLS + await session.execute(text("SET LOCAL app.current_tenant = ''")) diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py new file mode 100644 index 0000000..d6b8bf8 --- /dev/null +++ b/backend/app/logging_config.py @@ -0,0 +1,81 @@ +"""Structured logging configuration for the FastAPI backend. + +Uses structlog with two rendering modes: +- Dev mode (ENVIRONMENT=dev or DEBUG=true): colored console output +- Prod mode: machine-parseable JSON output + +Must be called once during app startup (in lifespan), NOT at module import time, +so tests can override the configuration. +""" + +import logging +import os + +import structlog + + +def configure_logging() -> None: + """Configure structlog for the FastAPI application. + + Dev mode: colored console output with human-readable formatting. + Prod mode: JSON output with machine-parseable fields. + + Must be called once during app startup (in lifespan), NOT at module import time, + so tests can override the configuration. + """ + is_dev = os.getenv("ENVIRONMENT", "dev") == "dev" + log_level_name = os.getenv("LOG_LEVEL", "debug" if is_dev else "info").upper() + log_level = getattr(logging, log_level_name, logging.INFO) + + shared_processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + if is_dev: + renderer = structlog.dev.ConsoleRenderer() + else: + renderer = structlog.processors.JSONRenderer() + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + # Capture stdlib loggers (uvicorn, SQLAlchemy, alembic) into structlog pipeline + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + + handler = logging.StreamHandler() + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(log_level) + + # Quiet down noisy libraries in dev + if is_dev: + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + + +def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger: + """Get a structlog bound logger. + + Use this instead of logging.getLogger() throughout the application. + """ + return structlog.get_logger(name) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c5c26a3 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,330 @@ +"""FastAPI application entry point.""" + +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +import structlog +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from starlette.responses import JSONResponse + +from app.config import settings +from app.logging_config import configure_logging +from app.middleware.rate_limit import setup_rate_limiting +from app.middleware.request_id import RequestIDMiddleware +from app.middleware.security_headers import SecurityHeadersMiddleware +from app.observability import check_health_ready, setup_instrumentator + +logger = structlog.get_logger(__name__) + + +async def run_migrations() -> None: + """Run Alembic migrations on startup.""" + import os + import subprocess + import sys + + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + capture_output=True, + text=True, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + ) + if result.returncode != 0: + logger.error("migration failed", stderr=result.stderr) + raise RuntimeError(f"Database migration failed: {result.stderr}") + logger.info("migrations applied successfully") + + +async def bootstrap_first_admin() -> None: + """Create the first super_admin user if no users exist.""" + if not settings.FIRST_ADMIN_EMAIL or not settings.FIRST_ADMIN_PASSWORD: + logger.info("FIRST_ADMIN_EMAIL/PASSWORD not set, skipping bootstrap") + return + + from sqlalchemy import select + + from app.database import AdminAsyncSessionLocal + from app.models.user import User, UserRole + from app.services.auth import hash_password + + async with AdminAsyncSessionLocal() as session: + # Check if any users exist (bypass RLS with admin session) + result = await session.execute(select(User).limit(1)) + existing_user = result.scalar_one_or_none() + + if existing_user: + logger.info("users already exist, skipping first admin bootstrap") + return + + # Create the first super_admin with bcrypt password. + # must_upgrade_auth=True triggers the SRP registration flow on first login. + admin = User( + email=settings.FIRST_ADMIN_EMAIL, + hashed_password=hash_password(settings.FIRST_ADMIN_PASSWORD), + name="Super Admin", + role=UserRole.SUPER_ADMIN.value, + tenant_id=None, # super_admin has no tenant + is_active=True, + must_upgrade_auth=True, + ) + session.add(admin) + await session.commit() + logger.info("created first super_admin", email=settings.FIRST_ADMIN_EMAIL) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Application lifespan: run migrations and bootstrap on startup.""" + from app.services.backup_scheduler import start_backup_scheduler, stop_backup_scheduler + from app.services.firmware_subscriber import start_firmware_subscriber, stop_firmware_subscriber + from app.services.metrics_subscriber import start_metrics_subscriber, stop_metrics_subscriber + from app.services.nats_subscriber import start_nats_subscriber, stop_nats_subscriber + from app.services.sse_manager import ensure_sse_streams + + # Configure structured logging FIRST -- before any other startup work + configure_logging() + + logger.info("starting TOD API") + + # Run database migrations + await run_migrations() + + # Bootstrap first admin user + await bootstrap_first_admin() + + # Start NATS subscriber for device status events. + # Wrapped in try/except so NATS failure doesn't prevent API startup -- + # allows running the API locally without NATS during frontend development. + nats_connection = None + try: + nats_connection = await start_nats_subscriber() + except Exception as exc: + logger.warning( + "NATS status subscriber could not start (API will run without it)", + error=str(exc), + ) + + # Start NATS subscriber for device metrics events (separate NATS connection). + # Same pattern -- failure is non-fatal so the API starts without full NATS stack. + metrics_nc = None + try: + metrics_nc = await start_metrics_subscriber() + except Exception as exc: + logger.warning( + "NATS metrics subscriber could not start (API will run without it)", + error=str(exc), + ) + + # Start NATS subscriber for device firmware events (separate NATS connection). + firmware_nc = None + try: + firmware_nc = await start_firmware_subscriber() + except Exception as exc: + logger.warning( + "NATS firmware subscriber could not start (API will run without it)", + error=str(exc), + ) + + # Ensure NATS streams for SSE event delivery exist (ALERT_EVENTS, OPERATION_EVENTS). + # Non-fatal -- API starts without SSE streams; they'll be created on first SSE connection. + try: + await ensure_sse_streams() + except Exception as exc: + logger.warning( + "SSE NATS streams could not be created (SSE will retry on connection)", + error=str(exc), + ) + + # Start APScheduler for automated nightly config backups. + # Non-fatal -- API starts and serves requests even without the scheduler. + try: + await start_backup_scheduler() + except Exception as exc: + logger.warning("backup scheduler could not start", error=str(exc)) + + # Register daily firmware version check (3am UTC) on the same scheduler. + try: + from app.services.firmware_service import schedule_firmware_checks + + schedule_firmware_checks() + except Exception as exc: + logger.warning("firmware check scheduler could not start", error=str(exc)) + + # Provision OpenBao Transit keys for existing tenants and migrate legacy credentials. + # Non-blocking: if OpenBao is unavailable, the dual-read path handles fallback. + if settings.OPENBAO_ADDR: + try: + from app.database import AdminAsyncSessionLocal + from app.services.key_service import provision_existing_tenants + + async with AdminAsyncSessionLocal() as openbao_session: + counts = await provision_existing_tenants(openbao_session) + logger.info( + "openbao tenant provisioning complete", + **{k: v for k, v in counts.items()}, + ) + except Exception as exc: + logger.warning( + "openbao tenant provisioning failed (will retry on next restart)", + error=str(exc), + ) + + # Recover stale push operations from previous API instance + try: + from app.services.restore_service import recover_stale_push_operations + from app.database import AdminAsyncSessionLocal as _AdminSession + + async with _AdminSession() as session: + await recover_stale_push_operations(session) + logger.info("push operation recovery check complete") + except Exception as e: + logger.error("push operation recovery failed (non-fatal): %s", e) + + # Config change subscriber (event-driven backups) + config_change_nc = None + try: + from app.services.config_change_subscriber import ( + start_config_change_subscriber, + stop_config_change_subscriber, + ) + config_change_nc = await start_config_change_subscriber() + except Exception as e: + logger.error("Config change subscriber failed to start (non-fatal): %s", e) + + # Push rollback/alert subscriber + push_rollback_nc = None + try: + from app.services.push_rollback_subscriber import ( + start_push_rollback_subscriber, + stop_push_rollback_subscriber, + ) + push_rollback_nc = await start_push_rollback_subscriber() + except Exception as e: + logger.error("Push rollback subscriber failed to start (non-fatal): %s", e) + + logger.info("startup complete, ready to serve requests") + yield + + # Shutdown + logger.info("shutting down TOD API") + await stop_backup_scheduler() + await stop_nats_subscriber(nats_connection) + await stop_metrics_subscriber(metrics_nc) + await stop_firmware_subscriber(firmware_nc) + if config_change_nc: + await stop_config_change_subscriber() + if push_rollback_nc: + await stop_push_rollback_subscriber() + + # Dispose database engine connections to release all pooled connections cleanly. + from app.database import app_engine, engine + + await app_engine.dispose() + await engine.dispose() + logger.info("database connections closed") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="The Other Dude — Fleet Management API", + docs_url="/docs" if settings.ENVIRONMENT == "dev" else None, + redoc_url="/redoc" if settings.ENVIRONMENT == "dev" else None, + lifespan=lifespan, + ) + + # Starlette processes middleware in LIFO order (last added = first to run). + # We want: Request -> RequestID -> CORS -> Route handler + # So add CORS first, then RequestID (it will wrap CORS). + app.add_middleware( + CORSMiddleware, + allow_origins=settings.get_cors_origins(), + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Request-ID"], + ) + app.add_middleware(SecurityHeadersMiddleware, environment=settings.ENVIRONMENT) + setup_rate_limiting(app) # Register 429 exception handler (no middleware added) + app.add_middleware(RequestIDMiddleware) + + # Include routers + from app.routers.alerts import router as alerts_router + from app.routers.auth import router as auth_router + from app.routers.sse import router as sse_router + from app.routers.config_backups import router as config_router + from app.routers.config_editor import router as config_editor_router + from app.routers.device_groups import router as device_groups_router + from app.routers.device_tags import router as device_tags_router + from app.routers.devices import router as devices_router + from app.routers.firmware import router as firmware_router + from app.routers.metrics import router as metrics_router + from app.routers.events import router as events_router + from app.routers.clients import router as clients_router + from app.routers.device_logs import router as device_logs_router + from app.routers.templates import router as templates_router + from app.routers.tenants import router as tenants_router + from app.routers.reports import router as reports_router + from app.routers.topology import router as topology_router + from app.routers.users import router as users_router + from app.routers.audit_logs import router as audit_logs_router + from app.routers.api_keys import router as api_keys_router + from app.routers.maintenance_windows import router as maintenance_windows_router + from app.routers.vpn import router as vpn_router + from app.routers.certificates import router as certificates_router + from app.routers.transparency import router as transparency_router + from app.routers.settings import router as settings_router + + app.include_router(auth_router, prefix="/api") + app.include_router(tenants_router, prefix="/api") + app.include_router(users_router, prefix="/api") + app.include_router(devices_router, prefix="/api") + app.include_router(device_groups_router, prefix="/api") + app.include_router(device_tags_router, prefix="/api") + app.include_router(metrics_router, prefix="/api") + app.include_router(config_router, prefix="/api") + app.include_router(firmware_router, prefix="/api") + app.include_router(alerts_router, prefix="/api") + app.include_router(config_editor_router, prefix="/api") + app.include_router(events_router, prefix="/api") + app.include_router(device_logs_router, prefix="/api") + app.include_router(templates_router, prefix="/api") + app.include_router(clients_router, prefix="/api") + app.include_router(topology_router, prefix="/api") + app.include_router(sse_router, prefix="/api") + app.include_router(audit_logs_router, prefix="/api") + app.include_router(reports_router, prefix="/api") + app.include_router(api_keys_router, prefix="/api") + app.include_router(maintenance_windows_router, prefix="/api") + app.include_router(vpn_router, prefix="/api") + app.include_router(certificates_router, prefix="/api/certificates", tags=["certificates"]) + app.include_router(transparency_router, prefix="/api") + app.include_router(settings_router, prefix="/api") + + # Health check endpoints + @app.get("/health", tags=["health"]) + async def health_check() -> dict: + """Liveness probe -- returns 200 if the process is alive.""" + return {"status": "ok", "version": settings.APP_VERSION} + + @app.get("/health/ready", tags=["health"]) + async def health_ready() -> JSONResponse: + """Readiness probe -- returns 200 only when PostgreSQL, Redis, and NATS are healthy.""" + result = await check_health_ready() + status_code = 200 if result["status"] == "healthy" else 503 + return JSONResponse(content=result, status_code=status_code) + + @app.get("/api/health", tags=["health"]) + async def api_health_check() -> dict: + """Backward-compatible health endpoint under /api prefix.""" + return {"status": "ok", "version": settings.APP_VERSION} + + # Prometheus metrics instrumentation -- MUST be after routers so all routes are captured + setup_instrumentator(app) + + return app + + +app = create_app() diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..b437a94 --- /dev/null +++ b/backend/app/middleware/__init__.py @@ -0,0 +1 @@ +"""FastAPI middleware and dependencies for auth, tenant context, and RBAC.""" diff --git a/backend/app/middleware/rate_limit.py b/backend/app/middleware/rate_limit.py new file mode 100644 index 0000000..184c913 --- /dev/null +++ b/backend/app/middleware/rate_limit.py @@ -0,0 +1,48 @@ +"""Rate limiting middleware using slowapi with Redis backend. + +Per-route rate limits only -- no global limits to avoid blocking the +Go poller, NATS subscribers, and health check endpoints. + +Rate limit data uses Redis DB 1 (separate from app data in DB 0). +""" + +from fastapi import FastAPI +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from app.config import settings + + +def _get_redis_url() -> str: + """Return Redis URL pointing to DB 1 for rate limit storage. + + Keeps rate limit counters separate from application data in DB 0. + """ + url = settings.REDIS_URL + if url.endswith("/0"): + return url[:-2] + "/1" + # If no DB specified or different DB, append /1 + if url.rstrip("/").split("/")[-1].isdigit(): + # Replace existing DB number + parts = url.rsplit("/", 1) + return parts[0] + "/1" + return url.rstrip("/") + "/1" + + +limiter = Limiter( + key_func=get_remote_address, + storage_uri=_get_redis_url(), + default_limits=[], # No global limits -- per-route only +) + + +def setup_rate_limiting(app: FastAPI) -> None: + """Register the rate limiter on the FastAPI app. + + This sets app.state.limiter (required by slowapi) and registers + the 429 exception handler. It does NOT add middleware -- the + @limiter.limit() decorators handle actual limiting per-route. + """ + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) diff --git a/backend/app/middleware/rbac.py b/backend/app/middleware/rbac.py new file mode 100644 index 0000000..ca6129a --- /dev/null +++ b/backend/app/middleware/rbac.py @@ -0,0 +1,186 @@ +""" +Role-Based Access Control (RBAC) middleware. + +Provides dependency factories for enforcing role-based access control +on FastAPI routes. Roles are hierarchical: + + super_admin > tenant_admin > operator > viewer + +Role permissions per plan TENANT-04/05/06: + - viewer: GET endpoints only (read-only) + - operator: GET + device/config management endpoints + - tenant_admin: full access within their tenant + - super_admin: full access across all tenants +""" + +from typing import Callable + +from fastapi import Depends, HTTPException, Request, status +from fastapi.params import Depends as DependsClass + +from app.middleware.tenant_context import CurrentUser, get_current_user + +# Role hierarchy (higher index = more privilege) +# api_key is at operator level for RBAC checks; fine-grained access controlled by scopes. +ROLE_HIERARCHY = { + "viewer": 0, + "api_key": 1, + "operator": 1, + "tenant_admin": 2, + "super_admin": 3, +} + + +def _get_role_level(role: str) -> int: + """Return numeric privilege level for a role string.""" + return ROLE_HIERARCHY.get(role, -1) + + +def require_role(*allowed_roles: str) -> Callable: + """ + FastAPI dependency factory that checks the current user's role. + + Usage: + @router.post("/items", dependencies=[Depends(require_role("tenant_admin", "super_admin"))]) + + Args: + *allowed_roles: Role strings that are permitted to access the endpoint + + Returns: + FastAPI dependency that raises 403 if the role is insufficient + """ + async def dependency( + current_user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if current_user.role not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied. Required roles: {', '.join(allowed_roles)}. " + f"Your role: {current_user.role}", + ) + return current_user + + return dependency + + +def require_min_role(min_role: str) -> Callable: + """ + Dependency factory that allows any role at or above the minimum level. + + Usage: + @router.get("/items", dependencies=[Depends(require_min_role("operator"))]) + # Allows: operator, tenant_admin, super_admin + # Denies: viewer + """ + min_level = _get_role_level(min_role) + + async def dependency( + current_user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + user_level = _get_role_level(current_user.role) + if user_level < min_level: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied. Minimum required role: {min_role}. " + f"Your role: {current_user.role}", + ) + return current_user + + return dependency + + +def require_write_access() -> Callable: + """ + Dependency that enforces viewer read-only restriction. + + Viewers are NOT allowed on POST/PUT/PATCH/DELETE endpoints. + Call this on any mutating endpoint to deny viewers. + """ + async def dependency( + request: Request, + current_user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + if current_user.role == "viewer": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Viewers have read-only access. " + "Contact your administrator to request elevated permissions.", + ) + return current_user + + return dependency + + +def require_scope(scope: str) -> DependsClass: + """FastAPI dependency that checks API key scopes. + + No-op for regular users (JWT auth) -- scopes only apply to API keys. + For API key users: checks that the required scope is in the key's scope list. + + Returns a Depends() instance so it can be used in dependency lists: + @router.get("/items", dependencies=[require_scope("devices:read")]) + + Args: + scope: Required scope string (e.g. "devices:read", "config:write"). + + Raises: + HTTPException 403 if the API key is missing the required scope. + """ + async def _check_scope( + current_user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if current_user.role == "api_key": + if not current_user.scopes or scope not in current_user.scopes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"API key missing required scope: {scope}", + ) + return current_user + + return Depends(_check_scope) + + +# Pre-built convenience dependencies + +async def require_super_admin( + current_user: CurrentUser = Depends(get_current_user), +) -> CurrentUser: + """Require super_admin role (portal-wide admin).""" + if current_user.role != "super_admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied. Super admin role required.", + ) + return current_user + + +async def require_tenant_admin_or_above( + current_user: CurrentUser = Depends(get_current_user), +) -> CurrentUser: + """Require tenant_admin or super_admin role.""" + if current_user.role not in ("tenant_admin", "super_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied. Tenant admin or higher role required.", + ) + return current_user + + +async def require_operator_or_above( + current_user: CurrentUser = Depends(get_current_user), +) -> CurrentUser: + """Require operator, tenant_admin, or super_admin role.""" + if current_user.role not in ("operator", "tenant_admin", "super_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied. Operator or higher role required.", + ) + return current_user + + +async def require_authenticated( + current_user: CurrentUser = Depends(get_current_user), +) -> CurrentUser: + """Require any authenticated user (viewer and above).""" + return current_user diff --git a/backend/app/middleware/request_id.py b/backend/app/middleware/request_id.py new file mode 100644 index 0000000..1e48300 --- /dev/null +++ b/backend/app/middleware/request_id.py @@ -0,0 +1,67 @@ +"""Request ID middleware for structured logging context. + +Generates or extracts a request ID for every incoming request and binds it +(along with tenant_id from JWT) to structlog's contextvars so that all log +lines emitted during the request include these correlation fields. +""" + +import uuid + +import structlog +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """Middleware that binds request_id and tenant_id to structlog context.""" + + async def dispatch(self, request: Request, call_next): + # CRITICAL: Clear stale context from previous request to prevent leaks + structlog.contextvars.clear_contextvars() + + # Generate or extract request ID + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + + # Best-effort tenant_id extraction from JWT (does not fail if no token) + tenant_id = self._extract_tenant_id(request) + + # Bind to structlog context -- all subsequent log calls include these fields + structlog.contextvars.bind_contextvars( + request_id=request_id, + tenant_id=tenant_id, + ) + + response: Response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + def _extract_tenant_id(self, request: Request) -> str | None: + """Best-effort extraction of tenant_id from JWT. + + Looks in cookies first (access_token), then Authorization header. + Returns None if no valid token is found -- this is fine for + unauthenticated endpoints like /login. + """ + token = request.cookies.get("access_token") + if not token: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + + if not token: + return None + + try: + from jose import jwt as jose_jwt + + from app.config import settings + + payload = jose_jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + ) + return payload.get("tenant_id") + except Exception: + return None diff --git a/backend/app/middleware/security_headers.py b/backend/app/middleware/security_headers.py new file mode 100644 index 0000000..c3a0ec3 --- /dev/null +++ b/backend/app/middleware/security_headers.py @@ -0,0 +1,79 @@ +"""Security response headers middleware. + +Adds standard security headers to all API responses: +- X-Content-Type-Options: nosniff (prevent MIME sniffing) +- X-Frame-Options: DENY (prevent clickjacking) +- Referrer-Policy: strict-origin-when-cross-origin +- Cache-Control: no-store (prevent browser caching of API responses) +- Strict-Transport-Security (HSTS, production only -- breaks plain HTTP dev) +- Content-Security-Policy (strict in production, relaxed for dev HMR) + +CSP directives: +- script-src 'self' (production) blocks inline scripts -- XSS mitigation +- style-src 'unsafe-inline' required for Tailwind, Framer Motion, Radix, Sonner +- connect-src includes wss:/ws: for SSE and WebSocket connections +- Dev mode adds 'unsafe-inline' and 'unsafe-eval' for Vite HMR +""" + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +# Production CSP: strict -- no inline scripts allowed +_CSP_PRODUCTION = "; ".join([ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'self' wss: ws:", + "worker-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +]) + +# Development CSP: relaxed for Vite HMR (hot module replacement) +_CSP_DEV = "; ".join([ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'self' http://localhost:* ws://localhost:* wss:", + "worker-src 'self' blob:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +]) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Add security headers to every API response.""" + + def __init__(self, app, environment: str = "dev"): + super().__init__(app) + self.is_production = environment != "dev" + + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + + # Always-on security headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Cache-Control"] = "no-store" + + # Content-Security-Policy (environment-aware) + if self.is_production: + response.headers["Content-Security-Policy"] = _CSP_PRODUCTION + else: + response.headers["Content-Security-Policy"] = _CSP_DEV + + # HSTS only in production (plain HTTP in dev would be blocked) + if self.is_production: + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + + return response diff --git a/backend/app/middleware/tenant_context.py b/backend/app/middleware/tenant_context.py new file mode 100644 index 0000000..438ccae --- /dev/null +++ b/backend/app/middleware/tenant_context.py @@ -0,0 +1,177 @@ +""" +Tenant context middleware and current user dependency. + +Extracts JWT from Authorization header (Bearer token) or httpOnly cookie, +validates it, and provides current user context for request handlers. + +For tenant-scoped users: sets SET LOCAL app.current_tenant on the DB session. +For super_admin: uses special 'super_admin' context that grants cross-tenant access. +""" + +import uuid +from typing import Annotated, Optional + +from fastapi import Cookie, Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.services.auth import verify_token + +# Optional HTTP Bearer scheme (won't raise 403 automatically — we handle auth ourselves) +bearer_scheme = HTTPBearer(auto_error=False) + + +class CurrentUser: + """Represents the currently authenticated user extracted from JWT or API key.""" + + def __init__( + self, + user_id: uuid.UUID, + tenant_id: Optional[uuid.UUID], + role: str, + scopes: Optional[list[str]] = None, + ) -> None: + self.user_id = user_id + self.tenant_id = tenant_id + self.role = role + self.scopes = scopes + + @property + def is_super_admin(self) -> bool: + return self.role == "super_admin" + + @property + def is_api_key(self) -> bool: + return self.role == "api_key" + + def __repr__(self) -> str: + return f"" + + +def _extract_token( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials], + access_token: Optional[str], +) -> Optional[str]: + """ + Extract JWT token from Authorization header or httpOnly cookie. + + Priority: Authorization header > cookie. + """ + if credentials and credentials.scheme.lower() == "bearer": + return credentials.credentials + + if access_token: + return access_token + + return None + + +async def get_current_user( + request: Request, + credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)] = None, + access_token: Annotated[Optional[str], Cookie()] = None, + db: AsyncSession = Depends(get_db), +) -> CurrentUser: + """ + FastAPI dependency that extracts and validates the current user from JWT. + + Supports both Bearer token (Authorization header) and httpOnly cookie. + Sets the tenant context on the database session for RLS enforcement. + + Raises: + HTTPException 401: If no token provided or token is invalid + """ + token = _extract_token(request, credentials, access_token) + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # API key authentication: detect mktp_ prefix and validate via api_key_service + if token.startswith("mktp_"): + from app.services.api_key_service import validate_api_key + + key_data = await validate_api_key(token) + if not key_data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid, expired, or revoked API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + tenant_id = key_data["tenant_id"] + # Set tenant context on the request-scoped DB session for RLS + await set_tenant_context(db, str(tenant_id)) + + return CurrentUser( + user_id=key_data["user_id"], + tenant_id=tenant_id, + role="api_key", + scopes=key_data["scopes"], + ) + + # Decode and validate the JWT + payload = verify_token(token, expected_type="access") + + user_id_str = payload.get("sub") + tenant_id_str = payload.get("tenant_id") + role = payload.get("role") + + if not user_id_str or not role: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + user_id = uuid.UUID(user_id_str) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + ) + + tenant_id: Optional[uuid.UUID] = None + if tenant_id_str: + try: + tenant_id = uuid.UUID(tenant_id_str) + except ValueError: + pass + + # Set the tenant context on the database session for RLS enforcement + if role == "super_admin": + # super_admin uses special context that grants cross-tenant access + await set_tenant_context(db, "super_admin") + elif tenant_id: + await set_tenant_context(db, str(tenant_id)) + else: + # Non-super_admin without tenant — deny access + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token: no tenant context", + ) + + return CurrentUser( + user_id=user_id, + tenant_id=tenant_id, + role=role, + ) + + +async def get_optional_current_user( + request: Request, + credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)] = None, + access_token: Annotated[Optional[str], Cookie()] = None, + db: AsyncSession = Depends(get_db), +) -> Optional[CurrentUser]: + """Same as get_current_user but returns None instead of raising 401.""" + try: + return await get_current_user(request, credentials, access_token, db) + except HTTPException: + return None diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..3f79d00 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,35 @@ +"""SQLAlchemy ORM models.""" + +from app.models.tenant import Tenant +from app.models.user import User, UserRole +from app.models.device import Device, DeviceGroup, DeviceTag, DeviceGroupMembership, DeviceTagAssignment, DeviceStatus +from app.models.alert import AlertRule, NotificationChannel, AlertRuleChannel, AlertEvent +from app.models.firmware import FirmwareVersion, FirmwareUpgradeJob +from app.models.config_template import ConfigTemplate, ConfigTemplateTag, TemplatePushJob +from app.models.audit_log import AuditLog +from app.models.maintenance_window import MaintenanceWindow +from app.models.api_key import ApiKey + +__all__ = [ + "Tenant", + "User", + "UserRole", + "Device", + "DeviceGroup", + "DeviceTag", + "DeviceGroupMembership", + "DeviceTagAssignment", + "DeviceStatus", + "AlertRule", + "NotificationChannel", + "AlertRuleChannel", + "AlertEvent", + "FirmwareVersion", + "FirmwareUpgradeJob", + "ConfigTemplate", + "ConfigTemplateTag", + "TemplatePushJob", + "AuditLog", + "MaintenanceWindow", + "ApiKey", +] diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..cd798f8 --- /dev/null +++ b/backend/app/models/alert.py @@ -0,0 +1,177 @@ +"""Alert system ORM models: rules, notification channels, and alert events.""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Integer, + LargeBinary, + Numeric, + Text, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AlertRule(Base): + """Configurable alert threshold rule. + + Rules can be tenant-wide (device_id=NULL), device-specific, or group-scoped. + When a metric breaches the threshold for duration_polls consecutive polls, + an alert fires. + """ + __tablename__ = "alert_rules" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + device_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=True, + ) + group_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("device_groups.id", ondelete="SET NULL"), + nullable=True, + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + metric: Mapped[str] = mapped_column(Text, nullable=False) + operator: Mapped[str] = mapped_column(Text, nullable=False) + threshold: Mapped[float] = mapped_column(Numeric, nullable=False) + duration_polls: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") + severity: Mapped[str] = mapped_column(Text, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" + + +class NotificationChannel(Base): + """Email, webhook, or Slack notification destination.""" + __tablename__ = "notification_channels" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + channel_type: Mapped[str] = mapped_column(Text, nullable=False) # "email", "webhook", or "slack" + # SMTP fields (email channels) + smtp_host: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + smtp_user: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_password: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) # AES-256-GCM encrypted + smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + from_address: Mapped[str | None] = mapped_column(Text, nullable=True) + to_address: Mapped[str | None] = mapped_column(Text, nullable=True) + # Webhook fields + webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True) + # Slack fields + slack_webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True) + # OpenBao Transit ciphertext (dual-write migration) + smtp_password_transit: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" + + +class AlertRuleChannel(Base): + """Many-to-many association between alert rules and notification channels.""" + __tablename__ = "alert_rule_channels" + + rule_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("alert_rules.id", ondelete="CASCADE"), + primary_key=True, + ) + channel_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("notification_channels.id", ondelete="CASCADE"), + primary_key=True, + ) + + +class AlertEvent(Base): + """Record of an alert firing, resolving, or flapping. + + rule_id is NULL for system-level alerts (e.g., device offline). + """ + __tablename__ = "alert_events" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + rule_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("alert_rules.id", ondelete="SET NULL"), + nullable=True, + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + status: Mapped[str] = mapped_column(Text, nullable=False) # "firing", "resolved", "flapping" + severity: Mapped[str] = mapped_column(Text, nullable=False) + metric: Mapped[str | None] = mapped_column(Text, nullable=True) + value: Mapped[float | None] = mapped_column(Numeric, nullable=True) + threshold: Mapped[float | None] = mapped_column(Numeric, nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + is_flapping: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + acknowledged_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + silenced_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + fired_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py new file mode 100644 index 0000000..bef874a --- /dev/null +++ b/backend/app/models/api_key.py @@ -0,0 +1,60 @@ +"""API key ORM model for tenant-scoped programmatic access.""" + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ApiKey(Base): + """Tracks API keys for programmatic access to the portal. + + Keys are stored as SHA-256 hashes (never plaintext). + Scoped permissions limit what each key can do. + Revocation is soft-delete (sets revoked_at, row preserved for audit). + """ + + __tablename__ = "api_keys" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + key_prefix: Mapped[str] = mapped_column(Text, nullable=False) + key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + scopes: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'::jsonb") + expires_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + last_used_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + revoked_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..e58f1f2 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,59 @@ +"""Audit log model for centralized audit trail.""" + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AuditLog(Base): + """Records all auditable actions in the system (config changes, CRUD, auth events).""" + + __tablename__ = "audit_logs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + action: Mapped[str] = mapped_column(String(100), nullable=False) + resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + device_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="SET NULL"), + nullable=True, + ) + details: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + server_default="{}", + ) + # Transit-encrypted details JSON (vault:v1:...) — set when details are encrypted + encrypted_details: Mapped[str | None] = mapped_column(Text, nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/certificate.py b/backend/app/models/certificate.py new file mode 100644 index 0000000..98149f8 --- /dev/null +++ b/backend/app/models/certificate.py @@ -0,0 +1,140 @@ +"""Certificate Authority and Device Certificate ORM models. + +Supports the Internal Certificate Authority feature: +- CertificateAuthority: one per tenant, stores encrypted CA private key + public cert +- DeviceCertificate: per-device signed certificate with lifecycle status tracking +""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, LargeBinary, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class CertificateAuthority(Base): + """Per-tenant root Certificate Authority. + + Each tenant has at most one CA. The CA private key is encrypted with + AES-256-GCM before storage (using the same pattern as device credentials). + The public cert_pem is not sensitive and can be distributed freely. + """ + + __tablename__ = "certificate_authorities" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + common_name: Mapped[str] = mapped_column(String(255), nullable=False) + cert_pem: Mapped[str] = mapped_column(Text, nullable=False) + encrypted_private_key: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + serial_number: Mapped[str] = mapped_column(String(64), nullable=False) + fingerprint_sha256: Mapped[str] = mapped_column(String(95), nullable=False) + not_valid_before: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + not_valid_after: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + # OpenBao Transit ciphertext (dual-write migration) + encrypted_private_key_transit: Mapped[str | None] = mapped_column( + Text, nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class DeviceCertificate(Base): + """Per-device TLS certificate signed by the tenant's CA. + + Status lifecycle: + issued -> deploying -> deployed -> expiring -> expired + \\-> revoked + \\-> superseded (when rotated) + """ + + __tablename__ = "device_certificates" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ) + ca_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("certificate_authorities.id", ondelete="CASCADE"), + nullable=False, + ) + common_name: Mapped[str] = mapped_column(String(255), nullable=False) + serial_number: Mapped[str] = mapped_column(String(64), nullable=False) + fingerprint_sha256: Mapped[str] = mapped_column(String(95), nullable=False) + cert_pem: Mapped[str] = mapped_column(Text, nullable=False) + encrypted_private_key: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + not_valid_before: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + not_valid_after: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + # OpenBao Transit ciphertext (dual-write migration) + encrypted_private_key_transit: Mapped[str | None] = mapped_column( + Text, nullable=True + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="issued" + ) + deployed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/app/models/config_backup.py b/backend/app/models/config_backup.py new file mode 100644 index 0000000..fe09d3b --- /dev/null +++ b/backend/app/models/config_backup.py @@ -0,0 +1,178 @@ +"""SQLAlchemy models for config backup tables.""" + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, SmallInteger, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ConfigBackupRun(Base): + """Metadata for a single config backup run. + + The actual config content (export.rsc and backup.bin) lives in the tenant's + bare git repository at GIT_STORE_PATH/{tenant_id}.git. This table provides + the timeline view and per-run metadata without duplicating file content in + PostgreSQL. + """ + + __tablename__ = "config_backup_runs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + # Git commit hash in the tenant's bare repo where this backup is stored. + commit_sha: Mapped[str] = mapped_column(Text, nullable=False) + # Trigger type: 'scheduled' | 'manual' | 'pre-restore' | 'checkpoint' | 'config-change' + trigger_type: Mapped[str] = mapped_column(String(20), nullable=False) + # Lines added/removed vs the prior export.rsc for this device. + # NULL for the first backup (no prior version to diff against). + lines_added: Mapped[int | None] = mapped_column(Integer, nullable=True) + lines_removed: Mapped[int | None] = mapped_column(Integer, nullable=True) + # Encryption metadata: NULL=plaintext, 1=client-side AES-GCM, 2=OpenBao Transit + encryption_tier: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) + # 12-byte AES-GCM nonce for Tier 1 (client-side) backups; NULL for plaintext/Transit + encryption_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class ConfigBackupSchedule(Base): + """Per-tenant default and per-device override backup schedule config. + + A row with device_id=NULL is the tenant-level default (daily at 2am). + A row with a specific device_id overrides the tenant default for that device. + """ + + __tablename__ = "config_backup_schedules" + __table_args__ = ( + UniqueConstraint("tenant_id", "device_id", name="uq_backup_schedule_tenant_device"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + # NULL = tenant-level default schedule; non-NULL = device-specific override. + device_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=True, + ) + # Standard cron expression (5 fields). Default: daily at 2am UTC. + cron_expression: Mapped[str] = mapped_column( + String(100), + nullable=False, + default="0 2 * * *", + server_default="0 2 * * *", + ) + enabled: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True, + server_default="TRUE", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + scope = f"device={self.device_id}" if self.device_id else f"tenant={self.tenant_id}" + return f"" + + +class ConfigPushOperation(Base): + """Tracks pending two-phase config push operations for panic-revert recovery. + + Before pushing a config, a row is inserted with status='pending_verification'. + If the API pod restarts during the 60-second verification window, the startup + handler checks this table and either commits (deletes the RouterOS scheduler + job) or marks the operation as 'failed'. This prevents the panic-revert + scheduler from firing and reverting a successful push after an API restart. + + See Pitfall 6 in 04-RESEARCH.md for the full failure scenario. + """ + + __tablename__ = "config_push_operations" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + # Git commit SHA we'd revert to if the push fails. + pre_push_commit_sha: Mapped[str] = mapped_column(Text, nullable=False) + # RouterOS scheduler job name created on the device for panic-revert. + scheduler_name: Mapped[str] = mapped_column(String(255), nullable=False) + # 'pending_verification' | 'committed' | 'reverted' | 'failed' + status: Mapped[str] = mapped_column( + String(30), + nullable=False, + default="pending_verification", + server_default="pending_verification", + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/app/models/config_template.py b/backend/app/models/config_template.py new file mode 100644 index 0000000..b375181 --- /dev/null +++ b/backend/app/models/config_template.py @@ -0,0 +1,153 @@ +"""Config template, template tag, and template push job models.""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ConfigTemplate(Base): + __tablename__ = "config_templates" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_config_templates_tenant_name"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + variables: Mapped[list] = mapped_column(JSON, nullable=False, default=list, server_default="[]") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + tenant: Mapped["Tenant"] = relationship("Tenant") # type: ignore[name-defined] + tags: Mapped[list["ConfigTemplateTag"]] = relationship( + "ConfigTemplateTag", back_populates="template", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class ConfigTemplateTag(Base): + __tablename__ = "config_template_tags" + __table_args__ = ( + UniqueConstraint("template_id", "name", name="uq_config_template_tags_template_name"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + template_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("config_templates.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Relationships + template: Mapped["ConfigTemplate"] = relationship( + "ConfigTemplate", back_populates="tags" + ) + + def __repr__(self) -> str: + return f"" + + +class TemplatePushJob(Base): + __tablename__ = "template_push_jobs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + template_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("config_templates.id", ondelete="SET NULL"), + nullable=True, + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ) + rollout_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + rendered_content: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column( + Text, + nullable=False, + default="pending", + server_default="pending", + ) + pre_push_backup_sha: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + template: Mapped["ConfigTemplate | None"] = relationship("ConfigTemplate") + device: Mapped["Device"] = relationship("Device") # type: ignore[name-defined] + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000..f4bfb4d --- /dev/null +++ b/backend/app/models/device.py @@ -0,0 +1,214 @@ +"""Device, DeviceGroup, DeviceTag, and membership models.""" + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Integer, + LargeBinary, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class DeviceStatus(str, Enum): + """Device connection status.""" + UNKNOWN = "unknown" + ONLINE = "online" + OFFLINE = "offline" + + +class Device(Base): + __tablename__ = "devices" + __table_args__ = ( + UniqueConstraint("tenant_id", "hostname", name="uq_devices_tenant_hostname"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + hostname: Mapped[str] = mapped_column(String(255), nullable=False) + ip_address: Mapped[str] = mapped_column(String(45), nullable=False) # IPv4 or IPv6 + api_port: Mapped[int] = mapped_column(Integer, default=8728, nullable=False) + api_ssl_port: Mapped[int] = mapped_column(Integer, default=8729, nullable=False) + model: Mapped[str | None] = mapped_column(String(255), nullable=True) + serial_number: Mapped[str | None] = mapped_column(String(255), nullable=True) + firmware_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + routeros_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + routeros_major_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + last_cpu_load: Mapped[int | None] = mapped_column(Integer, nullable=True) + last_memory_used_pct: Mapped[int | None] = mapped_column(Integer, nullable=True) + architecture: Mapped[str | None] = mapped_column(Text, nullable=True) # CPU arch (arm, arm64, mipsbe, etc.) + preferred_channel: Mapped[str] = mapped_column( + Text, default="stable", server_default="stable", nullable=False + ) # Firmware release channel + last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # AES-256-GCM encrypted credentials (username + password JSON) + encrypted_credentials: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + # OpenBao Transit ciphertext (dual-write migration) + encrypted_credentials_transit: Mapped[str | None] = mapped_column(Text, nullable=True) + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + status: Mapped[str] = mapped_column( + String(20), + default=DeviceStatus.UNKNOWN.value, + nullable=False, + ) + tls_mode: Mapped[str] = mapped_column( + String(20), + default="auto", + server_default="auto", + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="devices") # type: ignore[name-defined] + group_memberships: Mapped[list["DeviceGroupMembership"]] = relationship( + "DeviceGroupMembership", back_populates="device", cascade="all, delete-orphan" + ) + tag_assignments: Mapped[list["DeviceTagAssignment"]] = relationship( + "DeviceTagAssignment", back_populates="device", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class DeviceGroup(Base): + __tablename__ = "device_groups" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_device_groups_tenant_name"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + preferred_channel: Mapped[str] = mapped_column( + Text, default="stable", server_default="stable", nullable=False + ) # Firmware release channel for the group + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="device_groups") # type: ignore[name-defined] + memberships: Mapped[list["DeviceGroupMembership"]] = relationship( + "DeviceGroupMembership", back_populates="group", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class DeviceTag(Base): + __tablename__ = "device_tags" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_device_tags_tenant_name"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # hex color e.g. #FF5733 + + # Relationships + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="device_tags") # type: ignore[name-defined] + assignments: Mapped[list["DeviceTagAssignment"]] = relationship( + "DeviceTagAssignment", back_populates="tag", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class DeviceGroupMembership(Base): + __tablename__ = "device_group_memberships" + + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + primary_key=True, + ) + group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("device_groups.id", ondelete="CASCADE"), + primary_key=True, + ) + + # Relationships + device: Mapped["Device"] = relationship("Device", back_populates="group_memberships") + group: Mapped["DeviceGroup"] = relationship("DeviceGroup", back_populates="memberships") + + +class DeviceTagAssignment(Base): + __tablename__ = "device_tag_assignments" + + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + primary_key=True, + ) + tag_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("device_tags.id", ondelete="CASCADE"), + primary_key=True, + ) + + # Relationships + device: Mapped["Device"] = relationship("Device", back_populates="tag_assignments") + tag: Mapped["DeviceTag"] = relationship("DeviceTag", back_populates="assignments") diff --git a/backend/app/models/firmware.py b/backend/app/models/firmware.py new file mode 100644 index 0000000..67385c5 --- /dev/null +++ b/backend/app/models/firmware.py @@ -0,0 +1,102 @@ +"""Firmware version tracking and upgrade job ORM models.""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + Integer, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ForeignKey + +from app.database import Base + + +class FirmwareVersion(Base): + """Cached firmware version from MikroTik download server or poller discovery. + + Not tenant-scoped — firmware versions are global data shared across all tenants. + """ + __tablename__ = "firmware_versions" + __table_args__ = ( + UniqueConstraint("architecture", "channel", "version"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + architecture: Mapped[str] = mapped_column(Text, nullable=False) + channel: Mapped[str] = mapped_column(Text, nullable=False) # "stable", "long-term", "testing" + version: Mapped[str] = mapped_column(Text, nullable=False) + npk_url: Mapped[str] = mapped_column(Text, nullable=False) + npk_local_path: Mapped[str | None] = mapped_column(Text, nullable=True) + npk_size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + checked_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" + + +class FirmwareUpgradeJob(Base): + """Tracks a firmware upgrade operation for a single device. + + Multiple jobs can share a rollout_group_id for mass upgrades. + """ + __tablename__ = "firmware_upgrade_jobs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ) + rollout_group_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + target_version: Mapped[str] = mapped_column(Text, nullable=False) + architecture: Mapped[str] = mapped_column(Text, nullable=False) + channel: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column( + Text, nullable=False, default="pending", server_default="pending" + ) + pre_upgrade_backup_sha: Mapped[str | None] = mapped_column(Text, nullable=True) + scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + confirmed_major_upgrade: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/key_set.py b/backend/app/models/key_set.py new file mode 100644 index 0000000..4124515 --- /dev/null +++ b/backend/app/models/key_set.py @@ -0,0 +1,134 @@ +"""Key set and key access log models for zero-knowledge architecture.""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class UserKeySet(Base): + """Encrypted key bundle for a user. + + Stores the RSA private key (wrapped by AUK), tenant vault key + (wrapped by AUK), RSA public key, and key derivation salts. + One key set per user (UNIQUE on user_id). + """ + + __tablename__ = "user_key_sets" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + tenant_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=True, # NULL for super_admin + ) + encrypted_private_key: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + private_key_nonce: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + encrypted_vault_key: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + vault_key_nonce: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + public_key: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + pbkdf2_iterations: Mapped[int] = mapped_column( + Integer, + server_default=func.literal_column("650000"), + nullable=False, + ) + pbkdf2_salt: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + hkdf_salt: Mapped[bytes] = mapped_column( + LargeBinary, nullable=False + ) + key_version: Mapped[int] = mapped_column( + Integer, + server_default=func.literal_column("1"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + user: Mapped["User"] = relationship("User") # type: ignore[name-defined] + tenant: Mapped["Tenant | None"] = relationship("Tenant") # type: ignore[name-defined] + + def __repr__(self) -> str: + return f"" + + +class KeyAccessLog(Base): + """Immutable audit trail for key operations. + + Append-only: INSERT+SELECT only, no UPDATE/DELETE via RLS. + """ + + __tablename__ = "key_access_log" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + action: Mapped[str] = mapped_column(Text, nullable=False) + resource_type: Mapped[str | None] = mapped_column(Text, nullable=True) + resource_id: Mapped[str | None] = mapped_column(Text, nullable=True) + key_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + ip_address: Mapped[str | None] = mapped_column(Text, nullable=True) + # Phase 29 extensions for device credential access tracking + device_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id"), + nullable=True, + ) + justification: Mapped[str | None] = mapped_column(Text, nullable=True) + correlation_id: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/maintenance_window.py b/backend/app/models/maintenance_window.py new file mode 100644 index 0000000..ec3a9f8 --- /dev/null +++ b/backend/app/models/maintenance_window.py @@ -0,0 +1,74 @@ +"""Maintenance window ORM model for scheduled maintenance periods. + +Maintenance windows allow operators to define time periods during which +alerts are suppressed for specific devices (or all devices in a tenant). +""" + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Text, VARCHAR, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class MaintenanceWindow(Base): + """Scheduled maintenance window with optional alert suppression. + + device_ids is a JSONB array of device UUID strings. + An empty array means "all devices in tenant". + """ + __tablename__ = "maintenance_windows" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(VARCHAR(200), nullable=False) + device_ids: Mapped[list] = mapped_column( + JSONB, + nullable=False, + server_default="'[]'::jsonb", + ) + start_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + end_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + suppress_alerts: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True, + server_default="true", + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py new file mode 100644 index 0000000..4271be4 --- /dev/null +++ b/backend/app/models/tenant.py @@ -0,0 +1,49 @@ +"""Tenant model — represents an MSP client organization.""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, LargeBinary, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Tenant(Base): + __tablename__ = "tenants" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Zero-knowledge key management (Phase 28+29) + encrypted_vault_key: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + vault_key_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + openbao_key_name: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Relationships — passive_deletes=True lets the DB ON DELETE CASCADE handle cleanup + users: Mapped[list["User"]] = relationship("User", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined] + devices: Mapped[list["Device"]] = relationship("Device", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined] + device_groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined] + device_tags: Mapped[list["DeviceTag"]] = relationship("DeviceTag", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined] + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8b43f4b --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,74 @@ +"""User model with role-based access control.""" + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import Boolean, DateTime, ForeignKey, LargeBinary, SmallInteger, String, func, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class UserRole(str, Enum): + """User roles with increasing privilege levels.""" + SUPER_ADMIN = "super_admin" + TENANT_ADMIN = "tenant_admin" + OPERATOR = "operator" + VIEWER = "viewer" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column( + String(50), + nullable=False, + default=UserRole.VIEWER.value, + ) + # tenant_id is nullable for super_admin users (portal-wide role) + tenant_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + # SRP zero-knowledge authentication columns (nullable during migration period) + srp_salt: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + srp_verifier: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + auth_version: Mapped[int] = mapped_column( + SmallInteger, server_default=text("1"), nullable=False + ) # 1=bcrypt legacy, 2=SRP + must_upgrade_auth: Mapped[bool] = mapped_column( + Boolean, server_default=text("false"), nullable=False + ) # True for bcrypt users who need SRP upgrade + + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="users") # type: ignore[name-defined] + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/vpn.py b/backend/app/models/vpn.py new file mode 100644 index 0000000..0f531f4 --- /dev/null +++ b/backend/app/models/vpn.py @@ -0,0 +1,85 @@ +"""VPN configuration and peer models for WireGuard management.""" + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class VpnConfig(Base): + """Per-tenant WireGuard server configuration.""" + + __tablename__ = "vpn_config" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + server_private_key: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + server_public_key: Mapped[str] = mapped_column(String(64), nullable=False) + subnet: Mapped[str] = mapped_column(String(32), nullable=False, server_default="10.10.0.0/24") + server_port: Mapped[int] = mapped_column(Integer, nullable=False, server_default="51820") + server_address: Mapped[str] = mapped_column(String(32), nullable=False, server_default="10.10.0.1/24") + endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, onupdate=func.now() + ) + + # Peers are queried separately via tenant_id — no ORM relationship needed + + +class VpnPeer(Base): + """WireGuard peer representing a device's VPN connection.""" + + __tablename__ = "vpn_peers" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + peer_private_key: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + peer_public_key: Mapped[str] = mapped_column(String(64), nullable=False) + preshared_key: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True) + assigned_ip: Mapped[str] = mapped_column(String(32), nullable=False) + additional_allowed_ips: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) + is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + last_handshake: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, onupdate=func.now() + ) + + # Config is queried separately via tenant_id — no ORM relationship needed diff --git a/backend/app/observability.py b/backend/app/observability.py new file mode 100644 index 0000000..17befe5 --- /dev/null +++ b/backend/app/observability.py @@ -0,0 +1,140 @@ +"""Prometheus metrics and health check infrastructure. + +Provides: +- setup_instrumentator(): Configures Prometheus auto-instrumentation for FastAPI +- check_health_ready(): Verifies PostgreSQL, Redis, and NATS connectivity for readiness probes +""" + +import asyncio +import time + +import structlog +from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator + +logger = structlog.get_logger(__name__) + + +def setup_instrumentator(app: FastAPI) -> Instrumentator: + """Configure and mount Prometheus metrics instrumentation. + + Auto-instruments all HTTP endpoints with: + - http_requests_total (counter) by method, handler, status_code + - http_request_duration_seconds (histogram) by method, handler + - http_requests_in_progress (gauge) + + The /metrics endpoint is mounted at root level (not under /api prefix). + Labels use handler templates (e.g., /api/tenants/{tenant_id}/...) not + resolved paths, ensuring bounded cardinality. + + Must be called AFTER all routers are included so all routes are captured. + """ + instrumentator = Instrumentator( + should_group_status_codes=False, + should_ignore_untemplated=True, + excluded_handlers=["/health", "/health/ready", "/metrics", "/api/health"], + should_respect_env_var=False, + ) + instrumentator.instrument(app) + instrumentator.expose(app, include_in_schema=False, should_gzip=True) + logger.info("prometheus instrumentation enabled", endpoint="/metrics") + return instrumentator + + +async def check_health_ready() -> dict: + """Check readiness by verifying all critical dependencies. + + Checks PostgreSQL, Redis, and NATS connectivity with 5-second timeouts. + Returns a structured result with per-dependency status and latency. + + Returns: + dict with "status" ("healthy"|"unhealthy"), "version", and "checks" + containing per-dependency results. + """ + from app.config import settings + + checks: dict[str, dict] = {} + all_healthy = True + + # PostgreSQL check + checks["postgres"] = await _check_postgres() + if checks["postgres"]["status"] != "up": + all_healthy = False + + # Redis check + checks["redis"] = await _check_redis(settings.REDIS_URL) + if checks["redis"]["status"] != "up": + all_healthy = False + + # NATS check + checks["nats"] = await _check_nats(settings.NATS_URL) + if checks["nats"]["status"] != "up": + all_healthy = False + + return { + "status": "healthy" if all_healthy else "unhealthy", + "version": settings.APP_VERSION, + "checks": checks, + } + + +async def _check_postgres() -> dict: + """Verify PostgreSQL connectivity via the admin engine.""" + start = time.monotonic() + try: + from sqlalchemy import text + + from app.database import engine + + async with engine.connect() as conn: + await asyncio.wait_for( + conn.execute(text("SELECT 1")), + timeout=5.0, + ) + latency_ms = round((time.monotonic() - start) * 1000) + return {"status": "up", "latency_ms": latency_ms, "error": None} + except Exception as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("health check: postgres failed", error=str(exc)) + return {"status": "down", "latency_ms": latency_ms, "error": str(exc)} + + +async def _check_redis(redis_url: str) -> dict: + """Verify Redis connectivity.""" + start = time.monotonic() + try: + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, socket_connect_timeout=5) + try: + await asyncio.wait_for(client.ping(), timeout=5.0) + finally: + await client.aclose() + latency_ms = round((time.monotonic() - start) * 1000) + return {"status": "up", "latency_ms": latency_ms, "error": None} + except Exception as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("health check: redis failed", error=str(exc)) + return {"status": "down", "latency_ms": latency_ms, "error": str(exc)} + + +async def _check_nats(nats_url: str) -> dict: + """Verify NATS connectivity.""" + start = time.monotonic() + try: + import nats + + nc = await asyncio.wait_for( + nats.connect(nats_url), + timeout=5.0, + ) + try: + await nc.drain() + except Exception: + pass + latency_ms = round((time.monotonic() - start) * 1000) + return {"status": "up", "latency_ms": latency_ms, "error": None} + except Exception as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("health check: nats failed", error=str(exc)) + return {"status": "down", "latency_ms": latency_ms, "error": str(exc)} diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..b58ae3e --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI routers for all API endpoints.""" diff --git a/backend/app/routers/alerts.py b/backend/app/routers/alerts.py new file mode 100644 index 0000000..02dad2d --- /dev/null +++ b/backend/app/routers/alerts.py @@ -0,0 +1,1088 @@ +"""Alert management API endpoints. + +Tenant-scoped routes under /api/tenants/{tenant_id}/ for: +- Alert rules CRUD (list, create, update, delete, toggle) +- Notification channels CRUD (list, create, update, delete, test) +- Alert events listing with pagination and filtering +- Active alert count for nav badge +- Acknowledge and silence actions +- Device-scoped alert listing + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: viewer = read-only (GET); operator and above = write (POST/PUT/PATCH/DELETE). +""" + +import base64 +import logging +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +import re + +from pydantic import BaseModel, ConfigDict, model_validator +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.rate_limit import limiter +from app.middleware.rbac import require_scope +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.services.audit_service import log_action + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["alerts"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +def _require_write(current_user: CurrentUser) -> None: + """Raise 403 if user is a viewer (read-only).""" + if current_user.role == "viewer": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Viewers have read-only access.", + ) + + +EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + +ALLOWED_METRICS = { + "cpu_load", "memory_used_pct", "disk_used_pct", "temperature", + "signal_strength", "ccq", "client_count", +} +ALLOWED_OPERATORS = {"gt", "lt", "gte", "lte"} +ALLOWED_SEVERITIES = {"critical", "warning", "info"} + + +# --------------------------------------------------------------------------- +# Request/response models +# --------------------------------------------------------------------------- + + +class AlertRuleCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + metric: str + operator: str + threshold: float + duration_polls: int = 1 + severity: str = "warning" + device_id: Optional[str] = None + group_id: Optional[str] = None + channel_ids: list[str] = [] + enabled: bool = True + + +class AlertRuleUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + metric: str + operator: str + threshold: float + duration_polls: int = 1 + severity: str = "warning" + device_id: Optional[str] = None + group_id: Optional[str] = None + channel_ids: list[str] = [] + enabled: bool = True + + +class ChannelCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + channel_type: str # "email", "webhook", or "slack" + smtp_host: Optional[str] = None + smtp_port: Optional[int] = None + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None # plaintext — will be encrypted before storage + smtp_use_tls: bool = False + from_address: Optional[str] = None + to_address: Optional[str] = None + webhook_url: Optional[str] = None + slack_webhook_url: Optional[str] = None + + @model_validator(mode="after") + def validate_email_fields(self): + if self.channel_type == "email": + missing = [] + if not self.smtp_host: + missing.append("smtp_host") + if not self.smtp_port: + missing.append("smtp_port") + if not self.to_address: + missing.append("to_address") + if missing: + raise ValueError(f"Email channels require: {', '.join(missing)}") + if self.to_address and not EMAIL_REGEX.match(self.to_address): + raise ValueError(f"Invalid email address: {self.to_address}") + if self.from_address and not EMAIL_REGEX.match(self.from_address): + raise ValueError(f"Invalid from address: {self.from_address}") + return self + + +class ChannelUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + channel_type: str + smtp_host: Optional[str] = None + smtp_port: Optional[int] = None + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None # if None, keep existing + smtp_use_tls: bool = False + from_address: Optional[str] = None + to_address: Optional[str] = None + webhook_url: Optional[str] = None + slack_webhook_url: Optional[str] = None + + +class SilenceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + duration_minutes: int + + +# ========================================================================= +# ALERT RULES CRUD +# ========================================================================= + + +@router.get( + "/tenants/{tenant_id}/alert-rules", + summary="List all alert rules for tenant", + dependencies=[require_scope("alerts:read")], +) +async def list_alert_rules( + tenant_id: uuid.UUID, + enabled: Optional[bool] = Query(None), + metric: Optional[str] = Query(None), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + await _check_tenant_access(current_user, tenant_id, db) + + filters = ["1=1"] + params: dict[str, Any] = {} + + if enabled is not None: + filters.append("ar.enabled = :enabled") + params["enabled"] = enabled + if metric: + filters.append("ar.metric = :metric") + params["metric"] = metric + + where = " AND ".join(filters) + + result = await db.execute( + text(f""" + SELECT ar.id, ar.tenant_id, ar.device_id, ar.group_id, + ar.name, ar.metric, ar.operator, ar.threshold, + ar.duration_polls, ar.severity, ar.enabled, ar.is_default, + ar.created_at, + COALESCE( + (SELECT json_agg(arc.channel_id) + FROM alert_rule_channels arc + WHERE arc.rule_id = ar.id), + '[]'::json + ) AS channel_ids + FROM alert_rules ar + WHERE {where} + ORDER BY ar.created_at DESC + """), + params, + ) + + rows = result.fetchall() + return [ + { + "id": str(row[0]), + "tenant_id": str(row[1]), + "device_id": str(row[2]) if row[2] else None, + "group_id": str(row[3]) if row[3] else None, + "name": row[4], + "metric": row[5], + "operator": row[6], + "threshold": float(row[7]), + "duration_polls": row[8], + "severity": row[9], + "enabled": row[10], + "is_default": row[11], + "created_at": row[12].isoformat() if row[12] else None, + "channel_ids": [str(c) for c in (row[13] if isinstance(row[13], list) else [])], + } + for row in rows + ] + + +@router.post( + "/tenants/{tenant_id}/alert-rules", + summary="Create alert rule", + status_code=status.HTTP_201_CREATED, +) +@limiter.limit("20/minute") +async def create_alert_rule( + request: Request, + tenant_id: uuid.UUID, + body: AlertRuleCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + if body.metric not in ALLOWED_METRICS: + raise HTTPException(422, f"metric must be one of: {', '.join(sorted(ALLOWED_METRICS))}") + if body.operator not in ALLOWED_OPERATORS: + raise HTTPException(422, f"operator must be one of: {', '.join(sorted(ALLOWED_OPERATORS))}") + if body.severity not in ALLOWED_SEVERITIES: + raise HTTPException(422, f"severity must be one of: {', '.join(sorted(ALLOWED_SEVERITIES))}") + + rule_id = str(uuid.uuid4()) + + await db.execute( + text(""" + INSERT INTO alert_rules + (id, tenant_id, device_id, group_id, name, metric, operator, + threshold, duration_polls, severity, enabled) + VALUES + (CAST(:id AS uuid), CAST(:tenant_id AS uuid), + CAST(:device_id AS uuid), CAST(:group_id AS uuid), + :name, :metric, :operator, :threshold, :duration_polls, + :severity, :enabled) + """), + { + "id": rule_id, + "tenant_id": str(tenant_id), + "device_id": body.device_id, + "group_id": body.group_id, + "name": body.name, + "metric": body.metric, + "operator": body.operator, + "threshold": body.threshold, + "duration_polls": body.duration_polls, + "severity": body.severity, + "enabled": body.enabled, + }, + ) + + # Create channel associations + for ch_id in body.channel_ids: + await db.execute( + text(""" + INSERT INTO alert_rule_channels (rule_id, channel_id) + VALUES (CAST(:rule_id AS uuid), CAST(:channel_id AS uuid)) + """), + {"rule_id": rule_id, "channel_id": ch_id}, + ) + + await db.commit() + + try: + await log_action( + db, tenant_id, current_user.user_id, "alert_rule_create", + resource_type="alert_rule", resource_id=rule_id, + details={"name": body.name, "metric": body.metric, "severity": body.severity}, + ) + except Exception: + pass + + return { + "id": rule_id, + "tenant_id": str(tenant_id), + "name": body.name, + "metric": body.metric, + "operator": body.operator, + "threshold": body.threshold, + "duration_polls": body.duration_polls, + "severity": body.severity, + "enabled": body.enabled, + "channel_ids": body.channel_ids, + } + + +@router.put( + "/tenants/{tenant_id}/alert-rules/{rule_id}", + summary="Update alert rule", +) +@limiter.limit("20/minute") +async def update_alert_rule( + request: Request, + tenant_id: uuid.UUID, + rule_id: uuid.UUID, + body: AlertRuleUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + if body.metric not in ALLOWED_METRICS: + raise HTTPException(422, f"metric must be one of: {', '.join(sorted(ALLOWED_METRICS))}") + if body.operator not in ALLOWED_OPERATORS: + raise HTTPException(422, f"operator must be one of: {', '.join(sorted(ALLOWED_OPERATORS))}") + if body.severity not in ALLOWED_SEVERITIES: + raise HTTPException(422, f"severity must be one of: {', '.join(sorted(ALLOWED_SEVERITIES))}") + + result = await db.execute( + text(""" + UPDATE alert_rules + SET name = :name, metric = :metric, operator = :operator, + threshold = :threshold, duration_polls = :duration_polls, + severity = :severity, device_id = CAST(:device_id AS uuid), + group_id = CAST(:group_id AS uuid), enabled = :enabled + WHERE id = CAST(:rule_id AS uuid) + RETURNING id + """), + { + "rule_id": str(rule_id), + "name": body.name, + "metric": body.metric, + "operator": body.operator, + "threshold": body.threshold, + "duration_polls": body.duration_polls, + "severity": body.severity, + "device_id": body.device_id, + "group_id": body.group_id, + "enabled": body.enabled, + }, + ) + if not result.fetchone(): + raise HTTPException(404, "Alert rule not found") + + # Replace channel associations + await db.execute( + text("DELETE FROM alert_rule_channels WHERE rule_id = CAST(:rule_id AS uuid)"), + {"rule_id": str(rule_id)}, + ) + for ch_id in body.channel_ids: + await db.execute( + text(""" + INSERT INTO alert_rule_channels (rule_id, channel_id) + VALUES (CAST(:rule_id AS uuid), CAST(:channel_id AS uuid)) + """), + {"rule_id": str(rule_id), "channel_id": ch_id}, + ) + + await db.commit() + + try: + await log_action( + db, tenant_id, current_user.user_id, "alert_rule_update", + resource_type="alert_rule", resource_id=str(rule_id), + details={"name": body.name, "metric": body.metric, "severity": body.severity}, + ) + except Exception: + pass + + return { + "id": str(rule_id), + "name": body.name, + "metric": body.metric, + "operator": body.operator, + "threshold": body.threshold, + "duration_polls": body.duration_polls, + "severity": body.severity, + "enabled": body.enabled, + "channel_ids": body.channel_ids, + } + + +@router.delete( + "/tenants/{tenant_id}/alert-rules/{rule_id}", + summary="Delete alert rule", + status_code=status.HTTP_204_NO_CONTENT, +) +@limiter.limit("5/minute") +async def delete_alert_rule( + request: Request, + tenant_id: uuid.UUID, + rule_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + # Prevent deletion of default rules + check = await db.execute( + text("SELECT is_default FROM alert_rules WHERE id = CAST(:id AS uuid)"), + {"id": str(rule_id)}, + ) + row = check.fetchone() + if not row: + raise HTTPException(404, "Alert rule not found") + if row[0]: + raise HTTPException(422, "Cannot delete default alert rules. Disable them instead.") + + await db.execute( + text("DELETE FROM alert_rules WHERE id = CAST(:id AS uuid)"), + {"id": str(rule_id)}, + ) + await db.commit() + + try: + await log_action( + db, tenant_id, current_user.user_id, "alert_rule_delete", + resource_type="alert_rule", resource_id=str(rule_id), + ) + except Exception: + pass + + +@router.patch( + "/tenants/{tenant_id}/alert-rules/{rule_id}/toggle", + summary="Toggle alert rule enabled/disabled", +) +@limiter.limit("20/minute") +async def toggle_alert_rule( + request: Request, + tenant_id: uuid.UUID, + rule_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + result = await db.execute( + text(""" + UPDATE alert_rules SET enabled = NOT enabled + WHERE id = CAST(:id AS uuid) + RETURNING id, enabled + """), + {"id": str(rule_id)}, + ) + row = result.fetchone() + if not row: + raise HTTPException(404, "Alert rule not found") + await db.commit() + + return {"id": str(row[0]), "enabled": row[1]} + + +# ========================================================================= +# NOTIFICATION CHANNELS CRUD +# ========================================================================= + + +class SMTPTestRequest(BaseModel): + smtp_host: str + smtp_port: int = 587 + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None + smtp_use_tls: bool = False + from_address: str = "alerts@example.com" + to_address: str + + +@router.post( + "/tenants/{tenant_id}/notification-channels/test-smtp", + summary="Test SMTP settings before creating a channel", +) +async def test_channel_smtp( + tenant_id: uuid.UUID, + data: SMTPTestRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Test SMTP settings before creating a channel.""" + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + from app.services.email_service import SMTPConfig, send_test_email + + config = SMTPConfig( + host=data.smtp_host, + port=data.smtp_port, + user=data.smtp_user, + password=data.smtp_password, + use_tls=data.smtp_use_tls, + from_address=data.from_address, + ) + result = await send_test_email(data.to_address, config) + if not result["success"]: + raise HTTPException(status_code=400, detail=result["message"]) + return result + + +@router.get( + "/tenants/{tenant_id}/notification-channels", + summary="List notification channels for tenant", + dependencies=[require_scope("alerts:read")], +) +async def list_notification_channels( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT id, tenant_id, name, channel_type, + smtp_host, smtp_port, smtp_user, smtp_use_tls, + from_address, to_address, webhook_url, + created_at, slack_webhook_url + FROM notification_channels + ORDER BY created_at DESC + """) + ) + + return [ + { + "id": str(row[0]), + "tenant_id": str(row[1]), + "name": row[2], + "channel_type": row[3], + "smtp_host": row[4], + "smtp_port": row[5], + "smtp_user": row[6], + "smtp_use_tls": row[7], + "from_address": row[8], + "to_address": row[9], + "webhook_url": row[10], + "created_at": row[11].isoformat() if row[11] else None, + "slack_webhook_url": row[12], + } + for row in result.fetchall() + ] + + +@router.post( + "/tenants/{tenant_id}/notification-channels", + summary="Create notification channel", + status_code=status.HTTP_201_CREATED, +) +@limiter.limit("20/minute") +async def create_notification_channel( + request: Request, + tenant_id: uuid.UUID, + body: ChannelCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + if body.channel_type not in ("email", "webhook", "slack"): + raise HTTPException(422, "channel_type must be 'email', 'webhook', or 'slack'") + + channel_id = str(uuid.uuid4()) + + from app.services.crypto import encrypt_credentials_transit + + # Encrypt SMTP password via Transit if provided + encrypted_password_transit = None + if body.smtp_password: + encrypted_password_transit = await encrypt_credentials_transit( + body.smtp_password, str(tenant_id), + ) + + await db.execute( + text(""" + INSERT INTO notification_channels + (id, tenant_id, name, channel_type, smtp_host, smtp_port, + smtp_user, smtp_password_transit, smtp_use_tls, from_address, + to_address, webhook_url, slack_webhook_url) + VALUES + (CAST(:id AS uuid), CAST(:tenant_id AS uuid), + :name, :channel_type, :smtp_host, :smtp_port, + :smtp_user, :smtp_password_transit, :smtp_use_tls, + :from_address, :to_address, :webhook_url, + :slack_webhook_url) + """), + { + "id": channel_id, + "tenant_id": str(tenant_id), + "name": body.name, + "channel_type": body.channel_type, + "smtp_host": body.smtp_host, + "smtp_port": body.smtp_port, + "smtp_user": body.smtp_user, + "smtp_password_transit": encrypted_password_transit, + "smtp_use_tls": body.smtp_use_tls, + "from_address": body.from_address, + "to_address": body.to_address, + "webhook_url": body.webhook_url, + "slack_webhook_url": body.slack_webhook_url, + }, + ) + await db.commit() + + return { + "id": channel_id, + "tenant_id": str(tenant_id), + "name": body.name, + "channel_type": body.channel_type, + "smtp_host": body.smtp_host, + "smtp_port": body.smtp_port, + "smtp_user": body.smtp_user, + "smtp_use_tls": body.smtp_use_tls, + "from_address": body.from_address, + "to_address": body.to_address, + "webhook_url": body.webhook_url, + "slack_webhook_url": body.slack_webhook_url, + } + + +@router.put( + "/tenants/{tenant_id}/notification-channels/{channel_id}", + summary="Update notification channel", +) +@limiter.limit("20/minute") +async def update_notification_channel( + request: Request, + tenant_id: uuid.UUID, + channel_id: uuid.UUID, + body: ChannelUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + if body.channel_type not in ("email", "webhook", "slack"): + raise HTTPException(422, "channel_type must be 'email', 'webhook', or 'slack'") + + from app.services.crypto import encrypt_credentials_transit + + # Build SET clauses dynamically based on which secrets are provided + set_parts = [ + "name = :name", "channel_type = :channel_type", + "smtp_host = :smtp_host", "smtp_port = :smtp_port", + "smtp_user = :smtp_user", "smtp_use_tls = :smtp_use_tls", + "from_address = :from_address", "to_address = :to_address", + "webhook_url = :webhook_url", + "slack_webhook_url = :slack_webhook_url", + ] + params: dict[str, Any] = { + "id": str(channel_id), + "name": body.name, + "channel_type": body.channel_type, + "smtp_host": body.smtp_host, + "smtp_port": body.smtp_port, + "smtp_user": body.smtp_user, + "smtp_use_tls": body.smtp_use_tls, + "from_address": body.from_address, + "to_address": body.to_address, + "webhook_url": body.webhook_url, + "slack_webhook_url": body.slack_webhook_url, + } + + if body.smtp_password: + set_parts.append("smtp_password_transit = :smtp_password_transit") + params["smtp_password_transit"] = await encrypt_credentials_transit( + body.smtp_password, str(tenant_id), + ) + # Clear legacy column + set_parts.append("smtp_password = NULL") + + set_clause = ", ".join(set_parts) + result = await db.execute( + text(f""" + UPDATE notification_channels + SET {set_clause} + WHERE id = CAST(:id AS uuid) + RETURNING id + """), + params, + ) + + if not result.fetchone(): + raise HTTPException(404, "Notification channel not found") + await db.commit() + + return { + "id": str(channel_id), + "name": body.name, + "channel_type": body.channel_type, + "smtp_host": body.smtp_host, + "smtp_port": body.smtp_port, + "smtp_user": body.smtp_user, + "smtp_use_tls": body.smtp_use_tls, + "from_address": body.from_address, + "to_address": body.to_address, + "webhook_url": body.webhook_url, + "slack_webhook_url": body.slack_webhook_url, + } + + +@router.delete( + "/tenants/{tenant_id}/notification-channels/{channel_id}", + summary="Delete notification channel", + status_code=status.HTTP_204_NO_CONTENT, +) +@limiter.limit("5/minute") +async def delete_notification_channel( + request: Request, + tenant_id: uuid.UUID, + channel_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + result = await db.execute( + text("DELETE FROM notification_channels WHERE id = CAST(:id AS uuid) RETURNING id"), + {"id": str(channel_id)}, + ) + if not result.fetchone(): + raise HTTPException(404, "Notification channel not found") + await db.commit() + + +@router.post( + "/tenants/{tenant_id}/notification-channels/{channel_id}/test", + summary="Send test notification via channel", +) +@limiter.limit("5/minute") +async def test_notification_channel( + request: Request, + tenant_id: uuid.UUID, + channel_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + # Fetch channel as dict for notification_service + result = await db.execute( + text(""" + SELECT id, tenant_id, name, channel_type, + smtp_host, smtp_port, smtp_user, smtp_password, + smtp_use_tls, from_address, to_address, + webhook_url, smtp_password_transit, + slack_webhook_url + FROM notification_channels + WHERE id = CAST(:id AS uuid) + """), + {"id": str(channel_id)}, + ) + row = result.fetchone() + if not row: + raise HTTPException(404, "Notification channel not found") + + channel = { + "id": str(row[0]), + "tenant_id": str(row[1]), + "name": row[2], + "channel_type": row[3], + "smtp_host": row[4], + "smtp_port": row[5], + "smtp_user": row[6], + "smtp_password": row[7], + "smtp_use_tls": row[8], + "from_address": row[9], + "to_address": row[10], + "webhook_url": row[11], + "smtp_password_transit": row[12], + "slack_webhook_url": row[13], + } + + from app.services.notification_service import send_test_notification + try: + success = await send_test_notification(channel) + if success: + return {"status": "ok", "message": "Test notification sent successfully"} + else: + raise HTTPException(422, "Test notification delivery failed") + except HTTPException: + raise + except Exception as exc: + raise HTTPException(422, f"Test notification failed: {str(exc)}") + + +# ========================================================================= +# ALERT EVENTS (read + actions) +# ========================================================================= + + +@router.get( + "/tenants/{tenant_id}/alerts", + summary="List alert events with filtering and pagination", + dependencies=[require_scope("alerts:read")], +) +async def list_alerts( + tenant_id: uuid.UUID, + alert_status: Optional[str] = Query(None, alias="status"), + severity: Optional[str] = Query(None), + device_id: Optional[str] = Query(None), + rule_id: Optional[str] = Query(None), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + filters = ["1=1"] + params: dict[str, Any] = {} + + if alert_status: + filters.append("ae.status = :status") + params["status"] = alert_status + if severity: + filters.append("ae.severity = :severity") + params["severity"] = severity + if device_id: + filters.append("ae.device_id = CAST(:device_id AS uuid)") + params["device_id"] = device_id + if rule_id: + filters.append("ae.rule_id = CAST(:rule_id AS uuid)") + params["rule_id"] = rule_id + if start_date: + filters.append("ae.fired_at >= CAST(:start_date AS timestamptz)") + params["start_date"] = start_date + if end_date: + filters.append("ae.fired_at <= CAST(:end_date AS timestamptz)") + params["end_date"] = end_date + + where = " AND ".join(filters) + offset = (page - 1) * per_page + + # Get total count + count_result = await db.execute( + text(f"SELECT COUNT(*) FROM alert_events ae WHERE {where}"), + params, + ) + total = count_result.scalar() or 0 + + # Get page of results with device hostname and rule name + result = await db.execute( + text(f""" + SELECT ae.id, ae.rule_id, ae.device_id, ae.tenant_id, + ae.status, ae.severity, ae.metric, ae.value, + ae.threshold, ae.message, ae.is_flapping, + ae.acknowledged_at, ae.silenced_until, + ae.fired_at, ae.resolved_at, + d.hostname AS device_hostname, + ar.name AS rule_name + FROM alert_events ae + LEFT JOIN devices d ON d.id = ae.device_id + LEFT JOIN alert_rules ar ON ar.id = ae.rule_id + WHERE {where} + ORDER BY ae.fired_at DESC + LIMIT :limit OFFSET :offset + """), + {**params, "limit": per_page, "offset": offset}, + ) + + items = [ + { + "id": str(row[0]), + "rule_id": str(row[1]) if row[1] else None, + "device_id": str(row[2]), + "tenant_id": str(row[3]), + "status": row[4], + "severity": row[5], + "metric": row[6], + "value": float(row[7]) if row[7] is not None else None, + "threshold": float(row[8]) if row[8] is not None else None, + "message": row[9], + "is_flapping": row[10], + "acknowledged_at": row[11].isoformat() if row[11] else None, + "silenced_until": row[12].isoformat() if row[12] else None, + "fired_at": row[13].isoformat() if row[13] else None, + "resolved_at": row[14].isoformat() if row[14] else None, + "device_hostname": row[15], + "rule_name": row[16], + } + for row in result.fetchall() + ] + + return { + "items": items, + "total": total, + "page": page, + "per_page": per_page, + } + + +@router.get( + "/tenants/{tenant_id}/alerts/active-count", + summary="Get count of active (firing) alerts for nav badge", + dependencies=[require_scope("alerts:read")], +) +async def get_active_alert_count( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, int]: + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT COUNT(*) FROM alert_events + WHERE status = 'firing' + AND resolved_at IS NULL + AND (silenced_until IS NULL OR silenced_until < NOW()) + """) + ) + count = result.scalar() or 0 + return {"count": count} + + +@router.post( + "/tenants/{tenant_id}/alerts/{alert_id}/acknowledge", + summary="Acknowledge an active alert", +) +@limiter.limit("20/minute") +async def acknowledge_alert( + request: Request, + tenant_id: uuid.UUID, + alert_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + result = await db.execute( + text(""" + UPDATE alert_events + SET acknowledged_at = NOW(), acknowledged_by = CAST(:user_id AS uuid) + WHERE id = CAST(:id AS uuid) + RETURNING id + """), + {"id": str(alert_id), "user_id": str(current_user.user_id)}, + ) + if not result.fetchone(): + raise HTTPException(404, "Alert not found") + await db.commit() + + return {"status": "ok", "message": "Alert acknowledged"} + + +@router.post( + "/tenants/{tenant_id}/alerts/{alert_id}/silence", + summary="Silence an alert for a specified duration", +) +@limiter.limit("20/minute") +async def silence_alert( + request: Request, + tenant_id: uuid.UUID, + alert_id: uuid.UUID, + body: SilenceRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + await _check_tenant_access(current_user, tenant_id, db) + _require_write(current_user) + + if body.duration_minutes < 1: + raise HTTPException(422, "duration_minutes must be at least 1") + + result = await db.execute( + text(""" + UPDATE alert_events + SET silenced_until = NOW() + (:minutes || ' minutes')::interval + WHERE id = CAST(:id AS uuid) + RETURNING id + """), + {"id": str(alert_id), "minutes": str(body.duration_minutes)}, + ) + if not result.fetchone(): + raise HTTPException(404, "Alert not found") + await db.commit() + + return {"status": "ok", "message": f"Alert silenced for {body.duration_minutes} minutes"} + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/alerts", + summary="List alerts for a specific device", + dependencies=[require_scope("alerts:read")], +) +async def list_device_alerts( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + alert_status: Optional[str] = Query(None, alias="status"), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + filters = ["ae.device_id = CAST(:device_id AS uuid)"] + params: dict[str, Any] = {"device_id": str(device_id)} + + if alert_status: + filters.append("ae.status = :status") + params["status"] = alert_status + + where = " AND ".join(filters) + offset = (page - 1) * per_page + + count_result = await db.execute( + text(f"SELECT COUNT(*) FROM alert_events ae WHERE {where}"), + params, + ) + total = count_result.scalar() or 0 + + result = await db.execute( + text(f""" + SELECT ae.id, ae.rule_id, ae.device_id, ae.tenant_id, + ae.status, ae.severity, ae.metric, ae.value, + ae.threshold, ae.message, ae.is_flapping, + ae.acknowledged_at, ae.silenced_until, + ae.fired_at, ae.resolved_at, + ar.name AS rule_name + FROM alert_events ae + LEFT JOIN alert_rules ar ON ar.id = ae.rule_id + WHERE {where} + ORDER BY ae.fired_at DESC + LIMIT :limit OFFSET :offset + """), + {**params, "limit": per_page, "offset": offset}, + ) + + items = [ + { + "id": str(row[0]), + "rule_id": str(row[1]) if row[1] else None, + "device_id": str(row[2]), + "tenant_id": str(row[3]), + "status": row[4], + "severity": row[5], + "metric": row[6], + "value": float(row[7]) if row[7] is not None else None, + "threshold": float(row[8]) if row[8] is not None else None, + "message": row[9], + "is_flapping": row[10], + "acknowledged_at": row[11].isoformat() if row[11] else None, + "silenced_until": row[12].isoformat() if row[12] else None, + "fired_at": row[13].isoformat() if row[13] else None, + "resolved_at": row[14].isoformat() if row[14] else None, + "rule_name": row[15], + } + for row in result.fetchall() + ] + + return { + "items": items, + "total": total, + "page": page, + "per_page": per_page, + } diff --git a/backend/app/routers/api_keys.py b/backend/app/routers/api_keys.py new file mode 100644 index 0000000..e070a72 --- /dev/null +++ b/backend/app/routers/api_keys.py @@ -0,0 +1,172 @@ +"""API key management endpoints. + +Tenant-scoped routes under /api/tenants/{tenant_id}/api-keys: +- List all keys (active + revoked) +- Create new key (returns plaintext once) +- Revoke key (soft delete) + +RBAC: tenant_admin or above for all operations. +RLS enforced via get_db() (app_user engine with tenant context). +""" + +import uuid +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.rbac import require_min_role +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.services.api_key_service import ( + ALLOWED_SCOPES, + create_api_key, + list_api_keys, + revoke_api_key, +) + +router = APIRouter(tags=["api-keys"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +# --------------------------------------------------------------------------- +# Request/response schemas +# --------------------------------------------------------------------------- + + +class ApiKeyCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + scopes: list[str] + expires_at: Optional[datetime] = None + + +class ApiKeyResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + name: str + key_prefix: str + scopes: list[str] + expires_at: Optional[str] = None + last_used_at: Optional[str] = None + created_at: str + revoked_at: Optional[str] = None + + +class ApiKeyCreateResponse(ApiKeyResponse): + """Extended response that includes the plaintext key (shown once).""" + + key: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/tenants/{tenant_id}/api-keys", response_model=list[ApiKeyResponse]) +async def list_keys( + tenant_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), +) -> list[dict]: + """List all API keys for a tenant.""" + await _check_tenant_access(current_user, tenant_id, db) + keys = await list_api_keys(db, tenant_id) + # Convert UUID ids to strings for response + for k in keys: + k["id"] = str(k["id"]) + return keys + + +@router.post( + "/tenants/{tenant_id}/api-keys", + response_model=ApiKeyCreateResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_key( + tenant_id: uuid.UUID, + body: ApiKeyCreate, + db: AsyncSession = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), +) -> dict: + """Create a new API key. The plaintext key is returned only once.""" + await _check_tenant_access(current_user, tenant_id, db) + + # Validate scopes against allowed list + invalid_scopes = set(body.scopes) - ALLOWED_SCOPES + if invalid_scopes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid scopes: {', '.join(sorted(invalid_scopes))}. " + f"Allowed: {', '.join(sorted(ALLOWED_SCOPES))}", + ) + + if not body.scopes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one scope is required.", + ) + + result = await create_api_key( + db=db, + tenant_id=tenant_id, + user_id=current_user.user_id, + name=body.name, + scopes=body.scopes, + expires_at=body.expires_at, + ) + + return { + "id": str(result["id"]), + "name": result["name"], + "key_prefix": result["key_prefix"], + "key": result["key"], + "scopes": result["scopes"], + "expires_at": result["expires_at"].isoformat() if result["expires_at"] else None, + "last_used_at": None, + "created_at": result["created_at"].isoformat() if result["created_at"] else None, + "revoked_at": None, + } + + +@router.delete("/tenants/{tenant_id}/api-keys/{key_id}", status_code=status.HTTP_200_OK) +async def revoke_key( + tenant_id: uuid.UUID, + key_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), +) -> dict: + """Revoke an API key (soft delete -- sets revoked_at timestamp).""" + await _check_tenant_access(current_user, tenant_id, db) + + success = await revoke_api_key(db, tenant_id, key_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="API key not found or already revoked.", + ) + + return {"status": "revoked", "key_id": str(key_id)} diff --git a/backend/app/routers/audit_logs.py b/backend/app/routers/audit_logs.py new file mode 100644 index 0000000..a769b3a --- /dev/null +++ b/backend/app/routers/audit_logs.py @@ -0,0 +1,294 @@ +"""Audit log API endpoints. + +Tenant-scoped routes under /api/tenants/{tenant_id}/ for: +- Paginated, filterable audit log listing +- CSV export of audit logs + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: operator and above can view audit logs. + +Phase 30: Audit log details are encrypted at rest via Transit (Tier 2). +When encrypted_details is set, the router decrypts via Transit on-demand +and returns the plaintext details in the response. Structural fields +(action, resource_type, timestamp, ip_address) are always plaintext. +""" + +import asyncio +import csv +import io +import json +import logging +import uuid +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy import and_, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.tenant_context import CurrentUser, get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["audit-logs"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +def _require_operator(current_user: CurrentUser) -> None: + """Raise 403 if user does not have at least operator role.""" + allowed = {"super_admin", "admin", "operator"} + if current_user.role not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="At least operator role required to view audit logs.", + ) + + +async def _decrypt_audit_details( + encrypted_details: str | None, + plaintext_details: dict[str, Any] | None, + tenant_id: str, +) -> dict[str, Any]: + """Decrypt encrypted audit log details via Transit, falling back to plaintext. + + Priority: + 1. If encrypted_details is set, decrypt via Transit and parse as JSON. + 2. If decryption fails, return plaintext details as fallback. + 3. If neither available, return empty dict. + """ + if encrypted_details: + try: + from app.services.crypto import decrypt_data_transit + + decrypted_json = await decrypt_data_transit(encrypted_details, tenant_id) + return json.loads(decrypted_json) + except Exception: + logger.warning( + "Failed to decrypt audit details for tenant %s, using plaintext fallback", + tenant_id, + exc_info=True, + ) + # Fall through to plaintext + return plaintext_details if plaintext_details else {} + + +async def _decrypt_details_batch( + rows: list[Any], + tenant_id: str, +) -> list[dict[str, Any]]: + """Decrypt encrypted_details for a batch of audit log rows concurrently. + + Uses asyncio.gather with limited concurrency to avoid overwhelming OpenBao. + Rows without encrypted_details return their plaintext details directly. + """ + semaphore = asyncio.Semaphore(10) # Limit concurrent Transit calls + + async def _decrypt_one(row: Any) -> dict[str, Any]: + async with semaphore: + return await _decrypt_audit_details( + row.get("encrypted_details"), + row.get("details"), + tenant_id, + ) + + return list(await asyncio.gather(*[_decrypt_one(row) for row in rows])) + + +# --------------------------------------------------------------------------- +# Response models +# --------------------------------------------------------------------------- + + +class AuditLogItem(BaseModel): + id: str + user_email: Optional[str] = None + action: str + resource_type: Optional[str] = None + resource_id: Optional[str] = None + device_name: Optional[str] = None + details: dict[str, Any] = {} + ip_address: Optional[str] = None + created_at: str + + +class AuditLogResponse(BaseModel): + items: list[AuditLogItem] + total: int + page: int + per_page: int + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/audit-logs", + response_model=AuditLogResponse, + summary="List audit logs with pagination and filters", +) +async def list_audit_logs( + tenant_id: uuid.UUID, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=100), + action: Optional[str] = Query(default=None), + user_id: Optional[uuid.UUID] = Query(default=None), + device_id: Optional[uuid.UUID] = Query(default=None), + date_from: Optional[datetime] = Query(default=None), + date_to: Optional[datetime] = Query(default=None), + format: Optional[str] = Query(default=None, description="Set to 'csv' for CSV export"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + _require_operator(current_user) + await _check_tenant_access(current_user, tenant_id, db) + + # Build filter conditions using parameterized text fragments + conditions = [text("a.tenant_id = :tenant_id")] + params: dict[str, Any] = {"tenant_id": str(tenant_id)} + + if action: + conditions.append(text("a.action = :action")) + params["action"] = action + + if user_id: + conditions.append(text("a.user_id = :user_id")) + params["user_id"] = str(user_id) + + if device_id: + conditions.append(text("a.device_id = :device_id")) + params["device_id"] = str(device_id) + + if date_from: + conditions.append(text("a.created_at >= :date_from")) + params["date_from"] = date_from.isoformat() + + if date_to: + conditions.append(text("a.created_at <= :date_to")) + params["date_to"] = date_to.isoformat() + + where_clause = and_(*conditions) + + # Shared SELECT columns for data queries + _data_columns = text( + "a.id, u.email AS user_email, a.action, a.resource_type, " + "a.resource_id, d.hostname AS device_name, a.details, " + "a.encrypted_details, a.ip_address, a.created_at" + ) + _data_from = text( + "audit_logs a " + "LEFT JOIN users u ON a.user_id = u.id " + "LEFT JOIN devices d ON a.device_id = d.id" + ) + + # Count total + count_result = await db.execute( + select(func.count()).select_from(text("audit_logs a")).where(where_clause), + params, + ) + total = count_result.scalar() or 0 + + # CSV export -- no pagination limit + if format == "csv": + result = await db.execute( + select(_data_columns) + .select_from(_data_from) + .where(where_clause) + .order_by(text("a.created_at DESC")), + params, + ) + all_rows = result.mappings().all() + + # Decrypt encrypted details concurrently + decrypted_details = await _decrypt_details_batch( + all_rows, str(tenant_id) + ) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "ID", "User Email", "Action", "Resource Type", + "Resource ID", "Device", "Details", "IP Address", "Timestamp", + ]) + for row, details in zip(all_rows, decrypted_details): + details_str = json.dumps(details) if details else "{}" + writer.writerow([ + str(row["id"]), + row["user_email"] or "", + row["action"], + row["resource_type"] or "", + row["resource_id"] or "", + row["device_name"] or "", + details_str, + row["ip_address"] or "", + str(row["created_at"]), + ]) + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=audit-logs.csv"}, + ) + + # Paginated query + offset = (page - 1) * per_page + params["limit"] = per_page + params["offset"] = offset + + result = await db.execute( + select(_data_columns) + .select_from(_data_from) + .where(where_clause) + .order_by(text("a.created_at DESC")) + .limit(per_page) + .offset(offset), + params, + ) + rows = result.mappings().all() + + # Decrypt encrypted details concurrently (skips rows without encrypted_details) + decrypted_details = await _decrypt_details_batch(rows, str(tenant_id)) + + items = [ + AuditLogItem( + id=str(row["id"]), + user_email=row["user_email"], + action=row["action"], + resource_type=row["resource_type"], + resource_id=row["resource_id"], + device_name=row["device_name"], + details=details, + ip_address=row["ip_address"], + created_at=row["created_at"].isoformat() if row["created_at"] else "", + ) + for row, details in zip(rows, decrypted_details) + ] + + return AuditLogResponse( + items=items, + total=total, + page=page, + per_page=per_page, + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..1aedccf --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,1052 @@ +""" +Authentication endpoints. + +POST /api/auth/login — email/password login, returns JWT tokens +POST /api/auth/refresh — refresh access token using refresh token +POST /api/auth/logout — clear httpOnly cookie +GET /api/auth/me — return current user info +POST /api/auth/forgot-password — send password reset email +POST /api/auth/reset-password — reset password with token +POST /api/auth/srp/init — SRP Step 1: return salt and server ephemeral B +POST /api/auth/srp/verify — SRP Step 2: verify client proof M1, return tokens +GET /api/auth/emergency-kit-template — generate Emergency Kit PDF (without Secret Key) +POST /api/auth/register-srp — store SRP verifier and encrypted key set +""" + +import base64 +import hashlib +import io +import json +import logging +import secrets +import uuid +from datetime import UTC, datetime, timedelta +from typing import Optional + +import redis.asyncio as aioredis +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from fastapi.responses import JSONResponse, StreamingResponse +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.requests import Request as StarletteRequest + +from app.config import settings +from app.database import AdminAsyncSessionLocal, get_admin_db +from app.services.audit_service import log_action +from app.services.srp_service import srp_init, srp_verify +from app.services.key_service import get_user_key_set, log_key_access, store_user_key_set +from app.middleware.rate_limit import limiter +from app.middleware.rbac import require_authenticated +from app.middleware.tenant_context import CurrentUser +from app.models.user import User +from app.schemas.auth import ( + ChangePasswordRequest, + DeleteAccountRequest, + DeleteAccountResponse, + ForgotPasswordRequest, + LoginRequest, + MessageResponse, + RefreshRequest, + ResetPasswordRequest, + SRPInitRequest, + SRPInitResponse, + SRPRegisterRequest, + SRPVerifyRequest, + SRPVerifyResponse, + TokenResponse, + UserMeResponse, +) +from app.services.account_service import delete_user_account, export_user_data +from app.services.auth import ( + create_access_token, + create_refresh_token, + hash_password, + is_token_revoked, + revoke_user_tokens, + verify_password, + verify_token, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# Access token cookie settings +ACCESS_TOKEN_COOKIE = "access_token" +ACCESS_TOKEN_MAX_AGE = 15 * 60 # 15 minutes in seconds + +# Cookie Secure flag requires HTTPS. Safari strictly enforces this — +# it silently drops Secure cookies over plain HTTP, unlike Chrome +# which exempts localhost. Auto-detect from CORS origins: if all +# origins are HTTPS, enable Secure; otherwise disable it. +_COOKIE_SECURE = all( + o.startswith("https://") for o in (settings.CORS_ORIGINS or "").split(",") if o.strip() +) + +# ─── Redis for SRP Sessions ────────────────────────────────────────────────── + +_redis: aioredis.Redis | None = None + + +async def get_redis() -> aioredis.Redis: + """Lazily initialise and return the SRP Redis client.""" + global _redis + if _redis is None: + _redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True) + return _redis + + +# ─── SRP Zero-Knowledge Authentication ─────────────────────────────────────── + + +@router.post("/srp/init", response_model=SRPInitResponse, summary="SRP Step 1: return salt and server ephemeral B") +@limiter.limit("5/minute") +async def srp_init_endpoint( + request: StarletteRequest, + body: SRPInitRequest, + db: AsyncSession = Depends(get_admin_db), +) -> SRPInitResponse: + """SRP Step 1: Return salt and server ephemeral B. + + Anti-enumeration: returns a deterministic fake response if the user + does not exist or has no SRP credentials. The fake response is + derived from a hash of the email so it is consistent for repeated + queries against the same unknown address. + """ + # Look up user (case-insensitive) + result = await db.execute(select(User).where(User.email == body.email.lower())) + user = result.scalar_one_or_none() + + # Anti-enumeration: return fake salt/B if user not found or not SRP-enrolled + if not user or not user.srp_verifier: + fake_hash = hashlib.sha256(f"srp-fake-{body.email}".encode()).hexdigest() + return SRPInitResponse( + salt=fake_hash[:64], + server_public=fake_hash * 8, # 512 hex chars (256 bytes) + session_id=secrets.token_urlsafe(16), + pbkdf2_salt=base64.b64encode(bytes.fromhex(fake_hash[:64])).decode(), + hkdf_salt=base64.b64encode(bytes.fromhex(fake_hash[:64])).decode(), + ) + + # Fetch key derivation salts from user_key_sets (needed by client BEFORE SRP verify) + key_set = await get_user_key_set(db, user.id) + + # Generate server ephemeral + try: + server_public, server_private = await srp_init( + user.email, user.srp_verifier.hex() + ) + except ValueError as e: + logger.error("SRP init failed for %s: %s", user.email, e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Authentication initialization failed. Please try again.", + ) + + # Store session in Redis with 60s TTL + session_id = secrets.token_urlsafe(16) + redis = await get_redis() + session_data = json.dumps({ + "email": user.email, + "server_private": server_private, + "srp_verifier_hex": user.srp_verifier.hex(), + "srp_salt_hex": user.srp_salt.hex(), + "user_id": str(user.id), + }) + await redis.set(f"srp:session:{session_id}", session_data, ex=60) + + return SRPInitResponse( + salt=user.srp_salt.hex(), + server_public=server_public, + session_id=session_id, + pbkdf2_salt=base64.b64encode(key_set.pbkdf2_salt).decode() if key_set else "", + hkdf_salt=base64.b64encode(key_set.hkdf_salt).decode() if key_set else "", + ) + + +@router.post("/srp/verify", response_model=SRPVerifyResponse, summary="SRP Step 2: verify client proof and return tokens") +@limiter.limit("5/minute") +async def srp_verify_endpoint( + request: StarletteRequest, + body: SRPVerifyRequest, + response: Response, + db: AsyncSession = Depends(get_admin_db), +) -> SRPVerifyResponse: + """SRP Step 2: Verify client proof M1, return server proof M2 + JWT tokens. + + The session is consumed (deleted from Redis) immediately on retrieval + to enforce single-use. If the proof is invalid, the session cannot + be retried — the client must restart from /srp/init. + """ + invalid_error = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + + # Retrieve session from Redis + redis = await get_redis() + session_raw = await redis.get(f"srp:session:{body.session_id}") + if not session_raw: + raise invalid_error + + # Delete session immediately (one-use) + await redis.delete(f"srp:session:{body.session_id}") + + session = json.loads(session_raw) + + # Verify email matches + if session["email"] != body.email.lower(): + raise invalid_error + + # Run SRP verification + try: + is_valid, server_proof = await srp_verify( + email=session["email"], + srp_verifier_hex=session["srp_verifier_hex"], + server_private=session["server_private"], + client_public=body.client_public, + client_proof=body.client_proof, + srp_salt_hex=session["srp_salt_hex"], + ) + except ValueError as e: + logger.error("SRP verify failed for %s: %s", session["email"], e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Authentication verification failed. Please try again.", + ) + + if not is_valid: + raise invalid_error + + # Fetch user for token creation + user_id = uuid.UUID(session["user_id"]) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise invalid_error + + # Create JWT tokens (same as existing login) + access_token = create_access_token(user.id, user.tenant_id, user.role) + refresh_token = create_refresh_token(user.id) + + # Update last_login and clear upgrade flag on successful SRP login + await db.execute( + update(User).where(User.id == user.id).values( + last_login=datetime.now(UTC), + must_upgrade_auth=False, + ) + ) + await db.commit() + + # Set cookie (same as existing login) + response.set_cookie( + key=ACCESS_TOKEN_COOKIE, + value=access_token, + max_age=ACCESS_TOKEN_MAX_AGE, + httponly=True, + secure=_COOKIE_SECURE, + samesite="lax", + ) + + # Fetch encrypted key set + key_set = await get_user_key_set(db, user.id) + encrypted_key_set = None + if key_set: + encrypted_key_set = { + "encrypted_private_key": base64.b64encode(key_set.encrypted_private_key).decode(), + "private_key_nonce": base64.b64encode(key_set.private_key_nonce).decode(), + "encrypted_vault_key": base64.b64encode(key_set.encrypted_vault_key).decode(), + "vault_key_nonce": base64.b64encode(key_set.vault_key_nonce).decode(), + "public_key": base64.b64encode(key_set.public_key).decode(), + "pbkdf2_salt": base64.b64encode(key_set.pbkdf2_salt).decode(), + "hkdf_salt": base64.b64encode(key_set.hkdf_salt).decode(), + "pbkdf2_iterations": key_set.pbkdf2_iterations, + } + + # Audit log + try: + async with AdminAsyncSessionLocal() as audit_db: + await log_action( + audit_db, + tenant_id=user.tenant_id or uuid.UUID(int=0), + user_id=user.id, + action="login_srp", + resource_type="auth", + details={"email": user.email, "role": user.role}, + ip_address=request.client.host if request.client else None, + ) + await audit_db.commit() + except Exception: + pass + + return SRPVerifyResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + server_proof=server_proof or "", + encrypted_key_set=encrypted_key_set, + ) + + +@router.post("/login", response_model=TokenResponse, summary="Authenticate with email and password") +@limiter.limit("5/minute") +async def login( + request: StarletteRequest, + body: LoginRequest, + response: Response, + db: AsyncSession = Depends(get_admin_db), +) -> TokenResponse: + """ + Login entry point — redirects to SRP for all enrolled users. + + For SRP-enrolled users: returns 409 srp_required (frontend auto-switches). + For legacy bcrypt users (must_upgrade_auth=True): verifies bcrypt password + and returns a temporary session with auth_upgrade_required=True so the + frontend can register SRP credentials before completing login. + + Anti-enumeration: dummy verify_password for unknown users preserves timing. + Rate limited to 5 requests per minute per IP. + """ + # Look up user by email (case-insensitive) + result = await db.execute( + select(User).where(User.email == body.email.lower()) + ) + user = result.scalar_one_or_none() + + # Generic error — do not reveal whether email exists (no user enumeration) + invalid_credentials_error = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user: + # Perform dummy verification to prevent timing attacks + verify_password("dummy", "$2b$12$/MSofyKqE3MkwXyzhigw.OHIefMM.qb5xGt/t9OAwbxgDGnyZjmrG") + raise invalid_credentials_error + + if not user.is_active: + # Still run dummy verify for timing consistency + verify_password("dummy", "$2b$12$/MSofyKqE3MkwXyzhigw.OHIefMM.qb5xGt/t9OAwbxgDGnyZjmrG") + raise invalid_credentials_error + + # SRP-enrolled users: redirect to SRP flow + if user.srp_verifier is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="srp_required", + headers={"X-Auth-Method": "srp"}, + ) + + # Bcrypt user (auth_version 1) — verify password + if user.hashed_password: + if not verify_password(body.password, user.hashed_password): + raise invalid_credentials_error + + # Correct bcrypt password — issue session + access_token = create_access_token( + user_id=user.id, + tenant_id=user.tenant_id, + role=user.role, + ) + refresh = create_refresh_token(user.id) + + response.set_cookie( + key=ACCESS_TOKEN_COOKIE, + value=access_token, + max_age=ACCESS_TOKEN_MAX_AGE, + httponly=True, + secure=_COOKIE_SECURE, + samesite="lax", + ) + + # Update last_login + await db.execute( + update(User).where(User.id == user.id).values( + last_login=datetime.now(UTC), + ) + ) + await db.commit() + + # Audit log (fire-and-forget) + try: + async with AdminAsyncSessionLocal() as audit_db: + await log_action( + audit_db, + tenant_id=user.tenant_id or uuid.UUID(int=0), + user_id=user.id, + action="login_upgrade" if user.must_upgrade_auth else "login", + resource_type="auth", + details={"email": user.email, **({"upgrade": "bcrypt_to_srp"} if user.must_upgrade_auth else {})}, + ip_address=request.client.host if request.client else None, + ) + await audit_db.commit() + except Exception: + pass + + return TokenResponse( + access_token=access_token, + refresh_token=refresh if not user.must_upgrade_auth else "", + token_type="bearer", + auth_upgrade_required=user.must_upgrade_auth, + ) + + # No valid credentials at all + raise invalid_credentials_error + + +@router.post("/refresh", response_model=TokenResponse, summary="Refresh access token") +@limiter.limit("10/minute") +async def refresh_token( + request: StarletteRequest, + body: RefreshRequest, + response: Response, + db: AsyncSession = Depends(get_admin_db), + redis: aioredis.Redis = Depends(get_redis), +) -> TokenResponse: + """ + Exchange a valid refresh token for a new access token. + Rate limited to 10 requests per minute per IP. + """ + # Validate refresh token + payload = verify_token(body.refresh_token, expected_type="refresh") + + user_id_str = payload.get("sub") + if not user_id_str: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) + + try: + user_id = uuid.UUID(user_id_str) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) + + # Check if token was revoked (issued before logout) + issued_at = payload.get("iat", 0) + if await is_token_revoked(redis, user_id_str, float(issued_at)): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has been revoked", + ) + + # Fetch current user state from DB + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + ) + + # Issue new tokens + new_access_token = create_access_token( + user_id=user.id, + tenant_id=user.tenant_id, + role=user.role, + ) + new_refresh_token = create_refresh_token(user_id=user.id) + + # Update cookie + response.set_cookie( + key=ACCESS_TOKEN_COOKIE, + value=new_access_token, + max_age=ACCESS_TOKEN_MAX_AGE, + httponly=True, + secure=_COOKIE_SECURE, + samesite="lax", + ) + + return TokenResponse( + access_token=new_access_token, + refresh_token=new_refresh_token, + token_type="bearer", + ) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, summary="Log out and clear session cookie") +@limiter.limit("10/minute") +async def logout( + request: StarletteRequest, + response: Response, + current_user: CurrentUser = Depends(require_authenticated), + redis: aioredis.Redis = Depends(get_redis), +) -> None: + """Clear the httpOnly access token cookie and revoke all refresh tokens.""" + # Revoke all refresh tokens for this user + await revoke_user_tokens(redis, str(current_user.user_id)) + + # Audit log for logout + try: + tenant_id = current_user.tenant_id or uuid.UUID(int=0) + async with AdminAsyncSessionLocal() as audit_db: + await log_action( + audit_db, tenant_id, current_user.user_id, "logout", + resource_type="auth", + ip_address=request.client.host if request.client else None, + ) + await audit_db.commit() + except Exception: + pass # Fire-and-forget: never fail logout + + response.delete_cookie( + key=ACCESS_TOKEN_COOKIE, + httponly=True, + secure=_COOKIE_SECURE, + samesite="lax", + ) + + +@router.post("/change-password", response_model=MessageResponse, summary="Change password for authenticated user") +@limiter.limit("3/minute") +async def change_password( + request: StarletteRequest, + body: ChangePasswordRequest, + current_user: CurrentUser = Depends(require_authenticated), + db: AsyncSession = Depends(get_admin_db), + redis: aioredis.Redis = Depends(get_redis), +) -> MessageResponse: + """Change the current user's password. Revokes all existing sessions.""" + result = await db.execute(select(User).where(User.id == current_user.user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # For SRP users (auth_version 2): client must provide new salt, verifier, and key bundle + if user.auth_version == 2: + if not body.new_srp_salt or not body.new_srp_verifier: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="SRP users must provide new salt and verifier", + ) + # Update SRP credentials + user.srp_salt = bytes.fromhex(body.new_srp_salt) + user.srp_verifier = bytes.fromhex(body.new_srp_verifier) + + # Also update bcrypt hash as a login fallback if SRP ever fails + # (e.g., crypto.subtle unavailable on HTTP, stale Secret Key, etc.) + if body.new_password: + user.hashed_password = hash_password(body.new_password) + + # Update re-wrapped key bundle if provided + if body.encrypted_private_key and body.pbkdf2_salt: + existing_ks = await get_user_key_set(db, user.id) + if existing_ks: + existing_ks.encrypted_private_key = base64.b64decode(body.encrypted_private_key) + existing_ks.private_key_nonce = base64.b64decode(body.private_key_nonce or "") + existing_ks.encrypted_vault_key = base64.b64decode(body.encrypted_vault_key or "") + existing_ks.vault_key_nonce = base64.b64decode(body.vault_key_nonce or "") + existing_ks.public_key = base64.b64decode(body.public_key or "") + existing_ks.pbkdf2_salt = base64.b64decode(body.pbkdf2_salt) + existing_ks.hkdf_salt = base64.b64decode(body.hkdf_salt or "") + else: + # Legacy bcrypt user — verify current password + if not user.hashed_password or not verify_password(body.current_password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + user.hashed_password = hash_password(body.new_password) + + # Revoke all existing sessions + await revoke_user_tokens(redis, str(user.id)) + + await db.commit() + + # Audit log + try: + async with AdminAsyncSessionLocal() as audit_db: + await log_action( + audit_db, + tenant_id=user.tenant_id or uuid.UUID(int=0), + user_id=user.id, + action="password_change", + resource_type="user", + details={"ip": request.client.host if request.client else None}, + ip_address=request.client.host if request.client else None, + ) + await audit_db.commit() + except Exception: + pass + + return MessageResponse(message="Password changed successfully. Please sign in again.") + + +@router.get("/me", response_model=UserMeResponse, summary="Get current user profile") +async def get_me( + current_user: CurrentUser = Depends(require_authenticated), + db: AsyncSession = Depends(get_admin_db), +) -> UserMeResponse: + """Return current user info from JWT payload.""" + # Fetch from DB to get latest data + result = await db.execute(select(User).where(User.id == current_user.user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return UserMeResponse( + id=user.id, + email=user.email, + name=user.name, + role=user.role, + tenant_id=user.tenant_id, + auth_version=user.auth_version or 1, + ) + + +# ─── Account Self-Service (Deletion & Export) ───────────────────────────────── + + +@router.delete( + "/delete-my-account", + response_model=DeleteAccountResponse, + summary="Delete your own account and erase all PII", +) +@limiter.limit("1/minute") +async def delete_my_account( + request: StarletteRequest, + body: DeleteAccountRequest, + response: Response, + current_user: CurrentUser = Depends(require_authenticated), + db: AsyncSession = Depends(get_admin_db), +) -> DeleteAccountResponse: + """Permanently delete the authenticated user's account. + + Performs full PII erasure: anonymizes audit logs, scrubs encrypted + details, and hard-deletes the user row (CASCADE handles related + tables). Requires typing 'DELETE' as confirmation. + """ + from sqlalchemy import text as sa_text + + # Validate confirmation + if body.confirmation != "DELETE": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="You must type 'DELETE' to confirm account deletion.", + ) + + # Super admin protection: cannot delete last super admin + if current_user.role == "super_admin": + result = await db.execute( + sa_text( + "SELECT COUNT(*) AS cnt FROM users " + "WHERE role = 'super_admin' AND is_active = true " + "AND id != :current_user_id" + ), + {"current_user_id": current_user.user_id}, + ) + other_admins = result.scalar_one() + if other_admins == 0: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete the last super admin account. Transfer the role first.", + ) + + # Fetch user email BEFORE deletion (needed for audit hash) + result = await db.execute( + sa_text("SELECT email FROM users WHERE id = :user_id"), + {"user_id": current_user.user_id}, + ) + email_row = result.mappings().first() + if not email_row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found.", + ) + user_email = email_row["email"] + + # Perform account deletion + await delete_user_account( + db=db, + user_id=current_user.user_id, + tenant_id=current_user.tenant_id, + user_email=user_email, + ) + + # Clear access token cookie (same pattern as logout) + response.delete_cookie( + key=ACCESS_TOKEN_COOKIE, + httponly=True, + secure=_COOKIE_SECURE, + samesite="lax", + ) + + return DeleteAccountResponse( + message="Account deleted successfully. All personal data has been erased.", + deleted=True, + ) + + +@router.get( + "/export-my-data", + summary="Export all your personal data (GDPR Art. 20)", +) +@limiter.limit("3/minute") +async def export_my_data( + request: StarletteRequest, + current_user: CurrentUser = Depends(require_authenticated), + db: AsyncSession = Depends(get_admin_db), +) -> JSONResponse: + """Export all personal data for the authenticated user. + + Returns a JSON file containing user profile, API keys, audit logs, + and key access log entries. Complies with GDPR Article 20 + (Right to Data Portability). + """ + data = await export_user_data( + db=db, + user_id=current_user.user_id, + tenant_id=current_user.tenant_id, + ) + + # Audit log the export action + try: + async with AdminAsyncSessionLocal() as audit_db: + await log_action( + audit_db, + tenant_id=current_user.tenant_id or uuid.UUID(int=0), + user_id=current_user.user_id, + action="data_export", + resource_type="user", + details={"type": "gdpr_art20"}, + ip_address=request.client.host if request.client else None, + ) + await audit_db.commit() + except Exception: + pass # Fire-and-forget: never fail the export + + return JSONResponse( + content=data, + headers={ + "Content-Disposition": 'attachment; filename="my-data-export.json"', + }, + ) + + +# ─── Emergency Kit & SRP Registration ───────────────────────────────────────── + + +@router.get("/emergency-kit-template", summary="Generate Emergency Kit PDF template") +@limiter.limit("3/minute") +async def get_emergency_kit_template( + request: StarletteRequest, + current_user: CurrentUser = Depends(require_authenticated), + db: AsyncSession = Depends(get_admin_db), +) -> StreamingResponse: + """Generate Emergency Kit PDF template (without Secret Key). + + The Secret Key is injected client-side. This endpoint returns + a PDF with a placeholder that the browser fills in before + the user downloads it. + """ + from app.services.emergency_kit_service import generate_emergency_kit_template + + result = await db.execute(select(User).where(User.id == current_user.user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + pdf_bytes = await generate_emergency_kit_template(email=user.email) + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={ + "Content-Disposition": 'attachment; filename="MikroTik-Portal-Emergency-Kit.pdf"', + }, + ) + + +@router.post("/register-srp", response_model=MessageResponse, summary="Register SRP credentials for a user") +@limiter.limit("3/minute") +async def register_srp( + request: StarletteRequest, + body: SRPRegisterRequest, + current_user: CurrentUser = Depends(require_authenticated), + db: AsyncSession = Depends(get_admin_db), +) -> MessageResponse: + """Store SRP verifier and encrypted key set for the current user. + + Called after client-side key generation during initial setup + or when upgrading from bcrypt to SRP. + """ + result = await db.execute(select(User).where(User.id == current_user.user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.srp_verifier is not None: + raise HTTPException(status_code=409, detail="SRP already registered") + + # Update user with SRP credentials and clear upgrade flag + await db.execute( + update(User).where(User.id == user.id).values( + srp_salt=bytes.fromhex(body.srp_salt), + srp_verifier=bytes.fromhex(body.srp_verifier), + auth_version=2, + must_upgrade_auth=False, + ) + ) + + # Store encrypted key set + await store_user_key_set( + db=db, + user_id=user.id, + tenant_id=user.tenant_id, + encrypted_private_key=base64.b64decode(body.encrypted_private_key), + private_key_nonce=base64.b64decode(body.private_key_nonce), + encrypted_vault_key=base64.b64decode(body.encrypted_vault_key), + vault_key_nonce=base64.b64decode(body.vault_key_nonce), + public_key=base64.b64decode(body.public_key), + pbkdf2_salt=base64.b64decode(body.pbkdf2_salt), + hkdf_salt=base64.b64decode(body.hkdf_salt), + ) + + await db.commit() + + # Audit log + try: + async with AdminAsyncSessionLocal() as audit_db: + await log_key_access( + audit_db, user.tenant_id or uuid.UUID(int=0), user.id, + "create_key_set", resource_type="user_key_set", + ip_address=request.client.host if request.client else None, + ) + await audit_db.commit() + except Exception: + pass + + return MessageResponse(message="SRP credentials registered successfully") + + +# ─── SSE Exchange Tokens ───────────────────────────────────────────────────── + + +@router.post("/sse-token", summary="Issue a short-lived SSE exchange token") +async def create_sse_token( + current_user: CurrentUser = Depends(require_authenticated), + redis: aioredis.Redis = Depends(get_redis), +) -> dict: + """Issue a 30-second, single-use token for SSE connections. + + Replaces sending the full JWT in the SSE URL query parameter. + The returned token is stored in Redis with user context and a 30s TTL. + The SSE endpoint retrieves and deletes it on first use (single-use). + """ + token = secrets.token_urlsafe(32) + key = f"sse_token:{token}" + # Store user context for the SSE endpoint to retrieve + await redis.set(key, json.dumps({ + "user_id": str(current_user.user_id), + "tenant_id": str(current_user.tenant_id) if current_user.tenant_id else None, + "role": current_user.role, + }), ex=30) # 30 second TTL + return {"token": token} + + +# ─── Password Reset ────────────────────────────────────────────────────────── + + +def _hash_token(token: str) -> str: + """SHA-256 hash a reset token so plaintext is never stored.""" + return hashlib.sha256(token.encode()).hexdigest() + + +async def _send_reset_email(email: str, token: str) -> None: + """Send password reset email via unified email service.""" + from app.routers.settings import get_smtp_config + from app.services.email_service import send_email + + reset_url = f"{settings.APP_BASE_URL}/reset-password?token={token}" + expire_mins = settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES + + plain = ( + f"You requested a password reset for The Other Dude.\n\n" + f"Click the link below to reset your password (valid for {expire_mins} minutes):\n\n" + f"{reset_url}\n\n" + f"If you did not request this, you can safely ignore this email." + ) + + html = f""" +
+
+

Password Reset

+
+
+

You requested a password reset for The Other Dude.

+

Click the button below to reset your password. This link is valid for {expire_mins} minutes.

+ +

+ If you did not request this, you can safely ignore this email. +

+

+ TOD — Fleet Management for MikroTik RouterOS +

+
+
+ """ + + smtp_config = await get_smtp_config() + await send_email(email, "TOD — Password Reset", html, plain, smtp_config) + + +@router.post( + "/forgot-password", + response_model=MessageResponse, + summary="Request password reset email", +) +@limiter.limit("3/minute") +async def forgot_password( + request: StarletteRequest, + body: ForgotPasswordRequest, + db: AsyncSession = Depends(get_admin_db), +) -> MessageResponse: + """Send a password reset link if the email exists. + + Always returns success to prevent user enumeration. + Rate limited to 3 requests per minute per IP. + """ + generic_msg = "If an account with that email exists, a reset link has been sent." + + result = await db.execute( + select(User).where(User.email == body.email.lower()) + ) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + return MessageResponse(message=generic_msg) + + # Generate a secure token + raw_token = secrets.token_urlsafe(32) + token_hash = _hash_token(raw_token) + expires_at = datetime.now(UTC) + timedelta( + minutes=settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES + ) + + # Insert token record (using raw SQL to avoid importing the model globally) + from sqlalchemy import text + + await db.execute( + text( + "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) " + "VALUES (:user_id, :token_hash, :expires_at)" + ), + {"user_id": user.id, "token_hash": token_hash, "expires_at": expires_at}, + ) + await db.commit() + + # Send email (best-effort) + try: + await _send_reset_email(user.email, raw_token) + except Exception as e: + logger.warning("Failed to send password reset email to %s: %s", user.email, e) + + return MessageResponse(message=generic_msg) + + +@router.post( + "/reset-password", + response_model=MessageResponse, + summary="Reset password with token", +) +@limiter.limit("5/minute") +async def reset_password( + request: StarletteRequest, + body: ResetPasswordRequest, + db: AsyncSession = Depends(get_admin_db), +) -> MessageResponse: + """Validate the reset token and update the user's password. + + Rate limited to 5 requests per minute per IP. + """ + from sqlalchemy import text + + token_hash = _hash_token(body.token) + + # Find the token record + result = await db.execute( + text( + "SELECT id, user_id, expires_at, used_at " + "FROM password_reset_tokens " + "WHERE token_hash = :token_hash" + ), + {"token_hash": token_hash}, + ) + row = result.mappings().first() + + if not row: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired reset token.", + ) + + if row["used_at"] is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This reset link has already been used.", + ) + + if row["expires_at"] < datetime.now(UTC): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired reset token.", + ) + + # Validate password strength (minimum 8 characters) + if len(body.new_password) < 8: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Password must be at least 8 characters.", + ) + + # Update the user's password and clear SRP credentials for re-registration. + # The bcrypt hash is kept as a temporary credential for the upgrade flow: + # user logs in with bcrypt -> gets temp session -> registers SRP -> done. + new_hash = hash_password(body.new_password) + await db.execute( + text( + "UPDATE users SET hashed_password = :pw, auth_version = 1, " + "must_upgrade_auth = true, srp_salt = NULL, srp_verifier = NULL, " + "updated_at = now() WHERE id = :uid" + ), + {"pw": new_hash, "uid": row["user_id"]}, + ) + + # Mark token as used + await db.execute( + text("UPDATE password_reset_tokens SET used_at = now() WHERE id = :tid"), + {"tid": row["id"]}, + ) + + await db.commit() + + # Audit log + try: + async with AdminAsyncSessionLocal() as audit_db: + await log_action( + audit_db, + tenant_id=uuid.UUID(int=0), + user_id=row["user_id"], + action="password_reset", + resource_type="auth", + ip_address=request.client.host if request.client else None, + ) + await audit_db.commit() + except Exception: + pass + + return MessageResponse(message="Password has been reset successfully.") diff --git a/backend/app/routers/certificates.py b/backend/app/routers/certificates.py new file mode 100644 index 0000000..effe93f --- /dev/null +++ b/backend/app/routers/certificates.py @@ -0,0 +1,763 @@ +"""Certificate Authority management API endpoints. + +Provides the full certificate lifecycle for tenant CAs: +- CA initialization and info retrieval +- Per-device certificate signing +- Certificate deployment via NATS to Go poller (SFTP + RouterOS import) +- Bulk deployment across multiple devices +- Certificate rotation and revocation + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: viewer = read-only (GET); tenant_admin and above = mutating actions. +""" + +import json +import logging +import uuid +from datetime import datetime, timezone + +import nats +import nats.aio.client +import nats.errors +import structlog +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import PlainTextResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db, set_tenant_context +from app.middleware.rate_limit import limiter +from app.middleware.rbac import require_min_role +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.models.certificate import CertificateAuthority, DeviceCertificate +from app.models.device import Device +from app.schemas.certificate import ( + BulkCertDeployRequest, + CACreateRequest, + CAResponse, + CertDeployResponse, + CertSignRequest, + DeviceCertResponse, +) +from app.services.audit_service import log_action +from app.services.ca_service import ( + generate_ca, + get_ca_for_tenant, + get_cert_for_deploy, + get_device_certs, + sign_device_cert, + update_cert_status, +) + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["certificates"]) + +# Module-level NATS connection for cert deployment (lazy initialized) +_nc: nats.aio.client.Client | None = None + + +async def _get_nats() -> nats.aio.client.Client: + """Get or create a NATS connection for certificate deployment requests.""" + global _nc + if _nc is None or _nc.is_closed: + _nc = await nats.connect(settings.NATS_URL) + logger.info("Certificate NATS connection established") + return _nc + + +async def _deploy_cert_via_nats( + device_id: str, + cert_pem: str, + key_pem: str, + cert_name: str, + ssh_port: int = 22, +) -> dict: + """Send a certificate deployment request to the Go poller via NATS. + + Args: + device_id: Target device UUID string. + cert_pem: PEM-encoded device certificate. + key_pem: PEM-encoded device private key (decrypted). + cert_name: Name for the cert on the device (e.g., "portal-device-cert"). + ssh_port: SSH port for SFTP upload (default 22). + + Returns: + Dict with success, cert_name_on_device, and error fields. + """ + nc = await _get_nats() + payload = json.dumps({ + "device_id": device_id, + "cert_pem": cert_pem, + "key_pem": key_pem, + "cert_name": cert_name, + "ssh_port": ssh_port, + }).encode() + + try: + reply = await nc.request( + f"cert.deploy.{device_id}", + payload, + timeout=60.0, + ) + return json.loads(reply.data) + except nats.errors.TimeoutError: + return { + "success": False, + "error": "Certificate deployment timed out -- device may be offline or unreachable", + } + except Exception as exc: + logger.error("NATS cert deploy request failed", device_id=device_id, error=str(exc)) + return {"success": False, "error": str(exc)} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _get_device_for_tenant( + db: AsyncSession, device_id: uuid.UUID, current_user: CurrentUser +) -> Device: + """Fetch a device and verify tenant ownership.""" + result = await db.execute( + select(Device).where(Device.id == device_id) + ) + device = result.scalar_one_or_none() + if device is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device {device_id} not found", + ) + return device + + +async def _get_tenant_id( + current_user: CurrentUser, + db: AsyncSession, + tenant_id_override: uuid.UUID | None = None, +) -> uuid.UUID: + """Extract tenant_id from the current user, handling super_admin. + + Super admins must provide tenant_id_override (from query param). + Regular users use their own tenant_id. + """ + if current_user.is_super_admin: + if tenant_id_override is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Super admin must provide tenant_id query parameter.", + ) + # Set RLS context for the selected tenant + await set_tenant_context(db, str(tenant_id_override)) + return tenant_id_override + if current_user.tenant_id is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No tenant context available.", + ) + return current_user.tenant_id + + +async def _get_cert_with_tenant_check( + db: AsyncSession, cert_id: uuid.UUID, tenant_id: uuid.UUID +) -> DeviceCertificate: + """Fetch a device certificate and verify tenant ownership.""" + result = await db.execute( + select(DeviceCertificate).where(DeviceCertificate.id == cert_id) + ) + cert = result.scalar_one_or_none() + if cert is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Certificate {cert_id} not found", + ) + # RLS should enforce this, but double-check + if cert.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Certificate {cert_id} not found", + ) + return cert + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.post( + "/ca", + response_model=CAResponse, + status_code=status.HTTP_201_CREATED, + summary="Initialize a Certificate Authority for the tenant", +) +@limiter.limit("5/minute") +async def create_ca( + request: Request, + body: CACreateRequest, + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), + db: AsyncSession = Depends(get_db), +) -> CAResponse: + """Generate a self-signed root CA for the tenant. + + Each tenant may have at most one CA. Returns 409 if a CA already exists. + """ + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + + # Check if CA already exists + existing = await get_ca_for_tenant(db, tenant_id) + if existing is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Tenant already has a Certificate Authority. Delete it before creating a new one.", + ) + + ca = await generate_ca( + db, + tenant_id, + body.common_name, + body.validity_years, + settings.get_encryption_key_bytes(), + ) + + try: + await log_action( + db, tenant_id, current_user.user_id, "ca_create", + resource_type="certificate_authority", resource_id=str(ca.id), + details={"common_name": body.common_name, "validity_years": body.validity_years}, + ) + except Exception: + pass + + logger.info("CA created", tenant_id=str(tenant_id), ca_id=str(ca.id)) + return CAResponse.model_validate(ca) + + +@router.get( + "/ca", + response_model=CAResponse, + summary="Get tenant CA information", +) +async def get_ca( + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> CAResponse: + """Return the tenant's CA public information (no private key).""" + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + ca = await get_ca_for_tenant(db, tenant_id) + if ca is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No Certificate Authority configured for this tenant.", + ) + return CAResponse.model_validate(ca) + + +@router.get( + "/ca/pem", + response_class=PlainTextResponse, + summary="Download the CA public certificate (PEM)", +) +async def get_ca_pem( + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> PlainTextResponse: + """Return the CA's public certificate in PEM format. + + Users can import this into their trust store to validate device connections. + """ + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + ca = await get_ca_for_tenant(db, tenant_id) + if ca is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No Certificate Authority configured for this tenant.", + ) + return PlainTextResponse( + content=ca.cert_pem, + media_type="application/x-pem-file", + headers={"Content-Disposition": "attachment; filename=portal-ca.pem"}, + ) + + +@router.post( + "/sign", + response_model=DeviceCertResponse, + status_code=status.HTTP_201_CREATED, + summary="Sign a certificate for a device", +) +@limiter.limit("20/minute") +async def sign_cert( + request: Request, + body: CertSignRequest, + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), + db: AsyncSession = Depends(get_db), +) -> DeviceCertResponse: + """Sign a per-device TLS certificate using the tenant's CA. + + The device must belong to the tenant. The cert uses CN=hostname, SAN=IP+DNS. + """ + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + + # Verify device belongs to tenant (RLS enforces, but also get device data) + device = await _get_device_for_tenant(db, body.device_id, current_user) + + # Get tenant CA + ca = await get_ca_for_tenant(db, tenant_id) + if ca is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No Certificate Authority configured. Initialize a CA first.", + ) + + cert = await sign_device_cert( + db, + ca, + body.device_id, + device.hostname, + device.ip_address, + body.validity_days, + settings.get_encryption_key_bytes(), + ) + + try: + await log_action( + db, tenant_id, current_user.user_id, "cert_sign", + resource_type="device_certificate", resource_id=str(cert.id), + device_id=body.device_id, + details={"hostname": device.hostname, "validity_days": body.validity_days}, + ) + except Exception: + pass + + logger.info("Device cert signed", device_id=str(body.device_id), cert_id=str(cert.id)) + return DeviceCertResponse.model_validate(cert) + + +@router.post( + "/{cert_id}/deploy", + response_model=CertDeployResponse, + summary="Deploy a signed certificate to a device", +) +@limiter.limit("20/minute") +async def deploy_cert( + request: Request, + cert_id: uuid.UUID, + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), + db: AsyncSession = Depends(get_db), +) -> CertDeployResponse: + """Deploy a signed certificate to a device via NATS/SFTP. + + The Go poller receives the cert, uploads it via SFTP, imports it, + and assigns it to the api-ssl service on the RouterOS device. + """ + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + cert = await _get_cert_with_tenant_check(db, cert_id, tenant_id) + + # Update status to deploying + try: + await update_cert_status(db, cert_id, "deploying") + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) + + # Get decrypted cert data for deployment + try: + cert_pem, key_pem, _ca_cert_pem = await get_cert_for_deploy( + db, cert_id, settings.get_encryption_key_bytes() + ) + except ValueError as e: + # Rollback status + await update_cert_status(db, cert_id, "issued") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to prepare cert for deployment: {e}", + ) + + # Flush DB changes before NATS call so deploying status is persisted + await db.flush() + + # Send deployment command via NATS + result = await _deploy_cert_via_nats( + device_id=str(cert.device_id), + cert_pem=cert_pem, + key_pem=key_pem, + cert_name="portal-device-cert", + ) + + if result.get("success"): + # Update cert status to deployed + await update_cert_status(db, cert_id, "deployed") + + # Update device tls_mode to portal_ca + device_result = await db.execute( + select(Device).where(Device.id == cert.device_id) + ) + device = device_result.scalar_one_or_none() + if device is not None: + device.tls_mode = "portal_ca" + + try: + await log_action( + db, tenant_id, current_user.user_id, "cert_deploy", + resource_type="device_certificate", resource_id=str(cert_id), + device_id=cert.device_id, + details={"cert_name_on_device": result.get("cert_name_on_device")}, + ) + except Exception: + pass + + logger.info( + "Certificate deployed successfully", + cert_id=str(cert_id), + device_id=str(cert.device_id), + cert_name_on_device=result.get("cert_name_on_device"), + ) + + return CertDeployResponse( + success=True, + device_id=cert.device_id, + cert_name_on_device=result.get("cert_name_on_device"), + ) + else: + # Rollback status to issued + await update_cert_status(db, cert_id, "issued") + + logger.warning( + "Certificate deployment failed", + cert_id=str(cert_id), + device_id=str(cert.device_id), + error=result.get("error"), + ) + + return CertDeployResponse( + success=False, + device_id=cert.device_id, + error=result.get("error"), + ) + + +@router.post( + "/deploy/bulk", + response_model=list[CertDeployResponse], + summary="Bulk deploy certificates to multiple devices", +) +@limiter.limit("5/minute") +async def bulk_deploy( + request: Request, + body: BulkCertDeployRequest, + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), + db: AsyncSession = Depends(get_db), +) -> list[CertDeployResponse]: + """Deploy certificates to multiple devices sequentially. + + For each device: signs a cert if none exists (status=issued), then deploys. + Sequential deployment per project patterns (no concurrent NATS calls). + """ + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + + # Get tenant CA + ca = await get_ca_for_tenant(db, tenant_id) + if ca is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No Certificate Authority configured. Initialize a CA first.", + ) + + results: list[CertDeployResponse] = [] + + for device_id in body.device_ids: + try: + # Get device info + device = await _get_device_for_tenant(db, device_id, current_user) + + # Check if device already has an issued cert + existing_certs = await get_device_certs(db, tenant_id, device_id) + issued_cert = None + for c in existing_certs: + if c.status == "issued": + issued_cert = c + break + + # Sign a new cert if none exists in issued state + if issued_cert is None: + issued_cert = await sign_device_cert( + db, + ca, + device_id, + device.hostname, + device.ip_address, + 730, # Default 2 years + settings.get_encryption_key_bytes(), + ) + await db.flush() + + # Deploy the cert + await update_cert_status(db, issued_cert.id, "deploying") + + cert_pem, key_pem, _ca_cert_pem = await get_cert_for_deploy( + db, issued_cert.id, settings.get_encryption_key_bytes() + ) + + await db.flush() + + result = await _deploy_cert_via_nats( + device_id=str(device_id), + cert_pem=cert_pem, + key_pem=key_pem, + cert_name="portal-device-cert", + ) + + if result.get("success"): + await update_cert_status(db, issued_cert.id, "deployed") + device.tls_mode = "portal_ca" + + results.append(CertDeployResponse( + success=True, + device_id=device_id, + cert_name_on_device=result.get("cert_name_on_device"), + )) + else: + await update_cert_status(db, issued_cert.id, "issued") + results.append(CertDeployResponse( + success=False, + device_id=device_id, + error=result.get("error"), + )) + + except HTTPException as e: + results.append(CertDeployResponse( + success=False, + device_id=device_id, + error=e.detail, + )) + except Exception as e: + logger.error("Bulk deploy error", device_id=str(device_id), error=str(e)) + results.append(CertDeployResponse( + success=False, + device_id=device_id, + error=str(e), + )) + + try: + await log_action( + db, tenant_id, current_user.user_id, "cert_bulk_deploy", + resource_type="device_certificate", + details={ + "device_count": len(body.device_ids), + "successful": sum(1 for r in results if r.success), + "failed": sum(1 for r in results if not r.success), + }, + ) + except Exception: + pass + + return results + + +@router.get( + "/devices", + response_model=list[DeviceCertResponse], + summary="List device certificates", +) +async def list_device_certs( + device_id: uuid.UUID | None = Query(None, description="Filter by device ID"), + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> list[DeviceCertResponse]: + """List device certificates for the tenant. + + Optionally filter by device_id. Excludes superseded certs. + """ + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + certs = await get_device_certs(db, tenant_id, device_id) + return [DeviceCertResponse.model_validate(c) for c in certs] + + +@router.post( + "/{cert_id}/revoke", + response_model=DeviceCertResponse, + summary="Revoke a device certificate", +) +@limiter.limit("5/minute") +async def revoke_cert( + request: Request, + cert_id: uuid.UUID, + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), + db: AsyncSession = Depends(get_db), +) -> DeviceCertResponse: + """Revoke a device certificate and reset the device TLS mode to insecure.""" + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + cert = await _get_cert_with_tenant_check(db, cert_id, tenant_id) + + try: + updated_cert = await update_cert_status(db, cert_id, "revoked") + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) + + # Reset device tls_mode to insecure + device_result = await db.execute( + select(Device).where(Device.id == cert.device_id) + ) + device = device_result.scalar_one_or_none() + if device is not None: + device.tls_mode = "insecure" + + try: + await log_action( + db, tenant_id, current_user.user_id, "cert_revoke", + resource_type="device_certificate", resource_id=str(cert_id), + device_id=cert.device_id, + ) + except Exception: + pass + + logger.info("Certificate revoked", cert_id=str(cert_id), device_id=str(cert.device_id)) + return DeviceCertResponse.model_validate(updated_cert) + + +@router.post( + "/{cert_id}/rotate", + response_model=CertDeployResponse, + summary="Rotate a device certificate", +) +@limiter.limit("5/minute") +async def rotate_cert( + request: Request, + cert_id: uuid.UUID, + tenant_id: uuid.UUID | None = Query(None, description="Tenant ID (required for super_admin)"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("tenant_admin")), + db: AsyncSession = Depends(get_db), +) -> CertDeployResponse: + """Rotate a device certificate: supersede the old cert, sign a new one, and deploy it. + + This is equivalent to: mark old cert as superseded, sign new cert, deploy new cert. + """ + tenant_id = await _get_tenant_id(current_user, db, tenant_id) + old_cert = await _get_cert_with_tenant_check(db, cert_id, tenant_id) + + # Get the device for hostname/IP + device_result = await db.execute( + select(Device).where(Device.id == old_cert.device_id) + ) + device = device_result.scalar_one_or_none() + if device is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device {old_cert.device_id} not found", + ) + + # Get tenant CA + ca = await get_ca_for_tenant(db, tenant_id) + if ca is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No Certificate Authority configured.", + ) + + # Mark old cert as superseded + try: + await update_cert_status(db, cert_id, "superseded") + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) + + # Sign new cert + new_cert = await sign_device_cert( + db, + ca, + old_cert.device_id, + device.hostname, + device.ip_address, + 730, # Default 2 years + settings.get_encryption_key_bytes(), + ) + await db.flush() + + # Deploy new cert + await update_cert_status(db, new_cert.id, "deploying") + + cert_pem, key_pem, _ca_cert_pem = await get_cert_for_deploy( + db, new_cert.id, settings.get_encryption_key_bytes() + ) + + await db.flush() + + result = await _deploy_cert_via_nats( + device_id=str(old_cert.device_id), + cert_pem=cert_pem, + key_pem=key_pem, + cert_name="portal-device-cert", + ) + + if result.get("success"): + await update_cert_status(db, new_cert.id, "deployed") + device.tls_mode = "portal_ca" + + try: + await log_action( + db, tenant_id, current_user.user_id, "cert_rotate", + resource_type="device_certificate", resource_id=str(new_cert.id), + device_id=old_cert.device_id, + details={ + "old_cert_id": str(cert_id), + "cert_name_on_device": result.get("cert_name_on_device"), + }, + ) + except Exception: + pass + + logger.info( + "Certificate rotated successfully", + old_cert_id=str(cert_id), + new_cert_id=str(new_cert.id), + device_id=str(old_cert.device_id), + ) + + return CertDeployResponse( + success=True, + device_id=old_cert.device_id, + cert_name_on_device=result.get("cert_name_on_device"), + ) + else: + # Rollback: mark new cert as issued (deploy failed) + await update_cert_status(db, new_cert.id, "issued") + + logger.warning( + "Certificate rotation deploy failed", + new_cert_id=str(new_cert.id), + device_id=str(old_cert.device_id), + error=result.get("error"), + ) + + return CertDeployResponse( + success=False, + device_id=old_cert.device_id, + error=result.get("error"), + ) diff --git a/backend/app/routers/clients.py b/backend/app/routers/clients.py new file mode 100644 index 0000000..c66f096 --- /dev/null +++ b/backend/app/routers/clients.py @@ -0,0 +1,297 @@ +""" +Client device discovery API endpoint. + +Fetches ARP, DHCP lease, and wireless registration data from a RouterOS device +via the NATS command proxy, merges by MAC address, and returns a unified client list. + +All routes are tenant-scoped under: + /api/tenants/{tenant_id}/devices/{device_id}/clients + +RLS is enforced via get_db() (app_user engine with tenant context). +RBAC: viewer and above (read-only operation). +""" + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import Any + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rbac import require_min_role +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.models.device import Device +from app.services import routeros_proxy + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["clients"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + from app.database import set_tenant_context + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + + +async def _check_device_online( + db: AsyncSession, device_id: uuid.UUID +) -> Device: + """Verify the device exists and is online. Returns the Device object.""" + result = await db.execute( + select(Device).where(Device.id == device_id) # type: ignore[arg-type] + ) + device = result.scalar_one_or_none() + if device is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device {device_id} not found", + ) + if device.status != "online": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Device is offline -- client discovery requires a live connection.", + ) + return device + + +# --------------------------------------------------------------------------- +# MAC-address merge logic +# --------------------------------------------------------------------------- + + +def _normalize_mac(mac: str) -> str: + """Normalize a MAC address to uppercase colon-separated format.""" + return mac.strip().upper().replace("-", ":") + + +def _merge_client_data( + arp_data: list[dict[str, Any]], + dhcp_data: list[dict[str, Any]], + wireless_data: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Merge ARP, DHCP lease, and wireless registration data by MAC address. + + ARP entries are the base. DHCP enriches with hostname. Wireless enriches + with signal/tx/rx/uptime and marks the client as wireless. + """ + # Index DHCP leases by MAC + dhcp_by_mac: dict[str, dict[str, Any]] = {} + for lease in dhcp_data: + mac_raw = lease.get("mac-address") or lease.get("active-mac-address", "") + if mac_raw: + dhcp_by_mac[_normalize_mac(mac_raw)] = lease + + # Index wireless registrations by MAC + wireless_by_mac: dict[str, dict[str, Any]] = {} + for reg in wireless_data: + mac_raw = reg.get("mac-address", "") + if mac_raw: + wireless_by_mac[_normalize_mac(mac_raw)] = reg + + # Track which MACs we've already processed (from ARP) + seen_macs: set[str] = set() + clients: list[dict[str, Any]] = [] + + # Start with ARP entries as base + for entry in arp_data: + mac_raw = entry.get("mac-address", "") + if not mac_raw: + continue + mac = _normalize_mac(mac_raw) + if mac in seen_macs: + continue + seen_macs.add(mac) + + # Determine status: ARP complete flag or dynamic flag + is_complete = entry.get("complete", "true").lower() == "true" + arp_status = "reachable" if is_complete else "stale" + + client: dict[str, Any] = { + "mac": mac, + "ip": entry.get("address", ""), + "interface": entry.get("interface", ""), + "hostname": None, + "status": arp_status, + "signal_strength": None, + "tx_rate": None, + "rx_rate": None, + "uptime": None, + "is_wireless": False, + } + + # Enrich with DHCP data + dhcp = dhcp_by_mac.get(mac) + if dhcp: + client["hostname"] = dhcp.get("host-name") or None + dhcp_status = dhcp.get("status", "") + if dhcp_status: + client["dhcp_status"] = dhcp_status + + # Enrich with wireless data + wireless = wireless_by_mac.get(mac) + if wireless: + client["is_wireless"] = True + client["signal_strength"] = wireless.get("signal-strength") or None + client["tx_rate"] = wireless.get("tx-rate") or None + client["rx_rate"] = wireless.get("rx-rate") or None + client["uptime"] = wireless.get("uptime") or None + + clients.append(client) + + # Also include DHCP-only entries (no ARP match -- e.g. expired leases) + for mac, lease in dhcp_by_mac.items(): + if mac in seen_macs: + continue + seen_macs.add(mac) + + client = { + "mac": mac, + "ip": lease.get("active-address") or lease.get("address", ""), + "interface": lease.get("active-server") or "", + "hostname": lease.get("host-name") or None, + "status": "stale", # No ARP entry = not actively reachable + "signal_strength": None, + "tx_rate": None, + "rx_rate": None, + "uptime": None, + "is_wireless": mac in wireless_by_mac, + } + + wireless = wireless_by_mac.get(mac) + if wireless: + client["signal_strength"] = wireless.get("signal-strength") or None + client["tx_rate"] = wireless.get("tx-rate") or None + client["rx_rate"] = wireless.get("rx-rate") or None + client["uptime"] = wireless.get("uptime") or None + + clients.append(client) + + return clients + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/clients", + summary="List connected client devices (ARP + DHCP + wireless)", +) +async def list_clients( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Discover all client devices connected to a MikroTik device. + + Fetches ARP table, DHCP server leases, and wireless registration table + in parallel, then merges by MAC address into a unified client list. + + Wireless fetch failure is non-fatal (device may not have wireless interfaces). + DHCP fetch failure is non-fatal (device may not run a DHCP server). + ARP fetch failure is fatal (core data source). + """ + await _check_tenant_access(current_user, tenant_id, db) + await _check_device_online(db, device_id) + + device_id_str = str(device_id) + + # Fetch all three sources in parallel + arp_result, dhcp_result, wireless_result = await asyncio.gather( + routeros_proxy.execute_command(device_id_str, "/ip/arp/print"), + routeros_proxy.execute_command(device_id_str, "/ip/dhcp-server/lease/print"), + routeros_proxy.execute_command( + device_id_str, "/interface/wireless/registration-table/print" + ), + return_exceptions=True, + ) + + # ARP is required -- if it failed, return 502 + if isinstance(arp_result, Exception): + logger.error("ARP fetch exception", device_id=device_id_str, error=str(arp_result)) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch ARP table: {arp_result}", + ) + if not arp_result.get("success"): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=arp_result.get("error", "Failed to fetch ARP table"), + ) + + arp_data: list[dict[str, Any]] = arp_result.get("data", []) + + # DHCP is optional -- log warning and continue with empty data + dhcp_data: list[dict[str, Any]] = [] + if isinstance(dhcp_result, Exception): + logger.warning( + "DHCP fetch exception (continuing without DHCP data)", + device_id=device_id_str, + error=str(dhcp_result), + ) + elif not dhcp_result.get("success"): + logger.warning( + "DHCP fetch failed (continuing without DHCP data)", + device_id=device_id_str, + error=dhcp_result.get("error"), + ) + else: + dhcp_data = dhcp_result.get("data", []) + + # Wireless is optional -- many devices have no wireless interfaces + wireless_data: list[dict[str, Any]] = [] + if isinstance(wireless_result, Exception): + logger.warning( + "Wireless fetch exception (device may not have wireless interfaces)", + device_id=device_id_str, + error=str(wireless_result), + ) + elif not wireless_result.get("success"): + logger.warning( + "Wireless fetch failed (device may not have wireless interfaces)", + device_id=device_id_str, + error=wireless_result.get("error"), + ) + else: + wireless_data = wireless_result.get("data", []) + + # Merge by MAC address + clients = _merge_client_data(arp_data, dhcp_data, wireless_data) + + logger.info( + "client_discovery_complete", + device_id=device_id_str, + tenant_id=str(tenant_id), + arp_count=len(arp_data), + dhcp_count=len(dhcp_data), + wireless_count=len(wireless_data), + merged_count=len(clients), + ) + + return { + "clients": clients, + "device_id": device_id_str, + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/backend/app/routers/config_backups.py b/backend/app/routers/config_backups.py new file mode 100644 index 0000000..c13e963 --- /dev/null +++ b/backend/app/routers/config_backups.py @@ -0,0 +1,745 @@ +""" +Config backup API endpoints. + +All routes are tenant-scoped under: + /api/tenants/{tenant_id}/devices/{device_id}/config/ + +Provides: + - GET /backups — list backup timeline + - POST /backups — trigger manual backup + - POST /checkpoint — create a checkpoint (restore point) + - GET /backups/{sha}/export — retrieve export.rsc text + - GET /backups/{sha}/binary — download backup.bin + - POST /preview-restore — preview impact analysis before restore + - POST /restore — restore a config version (two-phase panic-revert) + - POST /emergency-rollback — rollback to most recent pre-push backup + - GET /schedules — view effective backup schedule + - PUT /schedules — create/update device-specific schedule override + +RLS is enforced via get_db() (app_user engine with tenant context). +RBAC: viewer = read-only (GET); operator and above = write (POST/PUT). +""" + +import asyncio +import logging +import uuid +from datetime import timezone, datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import Response +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rate_limit import limiter +from app.middleware.rbac import require_min_role, require_scope +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.models.config_backup import ConfigBackupRun, ConfigBackupSchedule +from app.config import settings +from app.models.device import Device +from app.services import backup_service, git_store +from app.services import restore_service +from app.services.crypto import decrypt_credentials_hybrid +from app.services.rsc_parser import parse_rsc, validate_rsc, compute_impact + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["config-backups"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """ + Verify the current user is allowed to access the given tenant. + + - super_admin can access any tenant — re-sets DB tenant context to target tenant. + - All other roles must match their own tenant_id. + """ + if current_user.is_super_admin: + from app.database import set_tenant_context + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + + +# --------------------------------------------------------------------------- +# Request/Response schemas +# --------------------------------------------------------------------------- + + +class RestoreRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + commit_sha: str + + +class ScheduleUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + cron_expression: str + enabled: bool + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/config/backups", + summary="List backup timeline for a device", + dependencies=[require_scope("config:read")], +) +async def list_backups( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """Return backup timeline for a device, newest first. + + Each entry includes: id, commit_sha, trigger_type, lines_added, + lines_removed, and created_at. + """ + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + select(ConfigBackupRun) + .where( + ConfigBackupRun.device_id == device_id, # type: ignore[arg-type] + ConfigBackupRun.tenant_id == tenant_id, # type: ignore[arg-type] + ) + .order_by(ConfigBackupRun.created_at.desc()) + ) + runs = result.scalars().all() + + return [ + { + "id": str(run.id), + "commit_sha": run.commit_sha, + "trigger_type": run.trigger_type, + "lines_added": run.lines_added, + "lines_removed": run.lines_removed, + "encryption_tier": run.encryption_tier, + "created_at": run.created_at.isoformat(), + } + for run in runs + ] + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config/backups", + summary="Trigger a manual config backup", + status_code=status.HTTP_201_CREATED, + dependencies=[require_scope("config:write")], +) +@limiter.limit("20/minute") +async def trigger_backup( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Trigger an immediate manual backup for a device. + + Captures export.rsc and backup.bin via SSH, commits to the tenant's + git store, and records a ConfigBackupRun with trigger_type='manual'. + Returns the backup metadata dict. + """ + await _check_tenant_access(current_user, tenant_id, db) + + try: + result = await backup_service.run_backup( + device_id=str(device_id), + tenant_id=str(tenant_id), + trigger_type="manual", + db_session=db, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except Exception as exc: + logger.error( + "Manual backup failed for device %s tenant %s: %s", + device_id, + tenant_id, + exc, + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Backup failed: {exc}", + ) from exc + + return result + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config/checkpoint", + summary="Create a checkpoint (restore point) of the current config", + dependencies=[require_scope("config:write")], +) +@limiter.limit("5/minute") +async def create_checkpoint( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Create a checkpoint (restore point) of the current device config. + + Identical to a manual backup but tagged with trigger_type='checkpoint'. + Checkpoints serve as named restore points that operators create before + making risky changes, so they can easily roll back. + """ + await _check_tenant_access(current_user, tenant_id, db) + + try: + result = await backup_service.run_backup( + device_id=str(device_id), + tenant_id=str(tenant_id), + trigger_type="checkpoint", + db_session=db, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except Exception as exc: + logger.error( + "Checkpoint backup failed for device %s tenant %s: %s", + device_id, + tenant_id, + exc, + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Checkpoint failed: {exc}", + ) from exc + + return result + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/config/backups/{commit_sha}/export", + summary="Get export.rsc text for a specific backup", + response_class=Response, + dependencies=[require_scope("config:read")], +) +async def get_export( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + commit_sha: str, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> Response: + """Return the raw /export compact text for a specific backup version. + + For encrypted backups (encryption_tier != NULL), the Transit ciphertext + stored in git is decrypted on-demand before returning plaintext. + Legacy plaintext backups (encryption_tier = NULL) are returned as-is. + + Content-Type: text/plain + """ + await _check_tenant_access(current_user, tenant_id, db) + + loop = asyncio.get_event_loop() + try: + content_bytes = await loop.run_in_executor( + None, + git_store.read_file, + str(tenant_id), + commit_sha, + str(device_id), + "export.rsc", + ) + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Backup version not found: {exc}", + ) from exc + + # Check if this backup is encrypted — decrypt via Transit if so + result = await db.execute( + select(ConfigBackupRun).where( + ConfigBackupRun.commit_sha == commit_sha, + ConfigBackupRun.device_id == device_id, + ) + ) + backup_run = result.scalar_one_or_none() + if backup_run and backup_run.encryption_tier: + try: + from app.services.crypto import decrypt_data_transit + + plaintext = await decrypt_data_transit( + content_bytes.decode("utf-8"), str(tenant_id) + ) + content_bytes = plaintext.encode("utf-8") + except Exception as dec_err: + logger.error( + "Failed to decrypt export for device %s sha %s: %s", + device_id, commit_sha, dec_err, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to decrypt backup content", + ) from dec_err + + return Response(content=content_bytes, media_type="text/plain") + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/config/backups/{commit_sha}/binary", + summary="Download backup.bin for a specific backup", + response_class=Response, + dependencies=[require_scope("config:read")], +) +async def get_binary( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + commit_sha: str, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> Response: + """Download the RouterOS binary backup file for a specific backup version. + + For encrypted backups, the Transit ciphertext is decrypted and the + base64-encoded binary is decoded back to raw bytes before returning. + Legacy plaintext backups are returned as-is. + + Content-Type: application/octet-stream (attachment download). + """ + await _check_tenant_access(current_user, tenant_id, db) + + loop = asyncio.get_event_loop() + try: + content_bytes = await loop.run_in_executor( + None, + git_store.read_file, + str(tenant_id), + commit_sha, + str(device_id), + "backup.bin", + ) + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Backup version not found: {exc}", + ) from exc + + # Check if this backup is encrypted — decrypt via Transit if so + result = await db.execute( + select(ConfigBackupRun).where( + ConfigBackupRun.commit_sha == commit_sha, + ConfigBackupRun.device_id == device_id, + ) + ) + backup_run = result.scalar_one_or_none() + if backup_run and backup_run.encryption_tier: + try: + import base64 as b64 + + from app.services.crypto import decrypt_data_transit + + # Transit ciphertext -> base64-encoded binary -> raw bytes + b64_plaintext = await decrypt_data_transit( + content_bytes.decode("utf-8"), str(tenant_id) + ) + content_bytes = b64.b64decode(b64_plaintext) + except Exception as dec_err: + logger.error( + "Failed to decrypt binary backup for device %s sha %s: %s", + device_id, commit_sha, dec_err, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to decrypt backup content", + ) from dec_err + + return Response( + content=content_bytes, + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="backup-{commit_sha[:8]}.bin"' + }, + ) + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config/preview-restore", + summary="Preview the impact of restoring a config backup", + dependencies=[require_scope("config:read")], +) +@limiter.limit("20/minute") +async def preview_restore( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: RestoreRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Preview the impact of restoring a config backup before executing. + + Reads the target config from the git backup, fetches the current config + from the live device (falling back to the latest backup if unreachable), + and returns a diff with categories, risk levels, warnings, and validation. + """ + await _check_tenant_access(current_user, tenant_id, db) + + loop = asyncio.get_event_loop() + + # 1. Read target export from git + try: + target_bytes = await loop.run_in_executor( + None, + git_store.read_file, + str(tenant_id), + body.commit_sha, + str(device_id), + "export.rsc", + ) + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Backup export not found: {exc}", + ) from exc + + target_text = target_bytes.decode("utf-8", errors="replace") + + # 2. Get current export from device (live) or fallback to latest backup + current_text = "" + try: + result = await db.execute( + select(Device).where(Device.id == device_id) # type: ignore[arg-type] + ) + device = result.scalar_one_or_none() + if device and (device.encrypted_credentials_transit or device.encrypted_credentials): + key = settings.get_encryption_key_bytes() + creds_json = await decrypt_credentials_hybrid( + device.encrypted_credentials_transit, + device.encrypted_credentials, + str(tenant_id), + key, + ) + import json + creds = json.loads(creds_json) + current_text = await backup_service.capture_export( + device.ip_address, + username=creds.get("username", "admin"), + password=creds.get("password", ""), + ) + except Exception: + # Fallback to latest backup in git + logger.debug( + "Live export failed for device %s, falling back to latest backup", + device_id, + ) + latest = await db.execute( + select(ConfigBackupRun) + .where( + ConfigBackupRun.device_id == device_id, # type: ignore[arg-type] + ) + .order_by(ConfigBackupRun.created_at.desc()) + .limit(1) + ) + latest_run = latest.scalar_one_or_none() + if latest_run: + try: + current_bytes = await loop.run_in_executor( + None, + git_store.read_file, + str(tenant_id), + latest_run.commit_sha, + str(device_id), + "export.rsc", + ) + current_text = current_bytes.decode("utf-8", errors="replace") + except Exception: + current_text = "" + + # 3. Parse and analyze + current_parsed = parse_rsc(current_text) + target_parsed = parse_rsc(target_text) + validation = validate_rsc(target_text) + impact = compute_impact(current_parsed, target_parsed) + + return { + "diff": impact["diff"], + "categories": impact["categories"], + "warnings": impact["warnings"], + "validation": validation, + } + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config/restore", + summary="Restore a config version (two-phase push with panic-revert)", + dependencies=[require_scope("config:write")], +) +@limiter.limit("5/minute") +async def restore_config_endpoint( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: RestoreRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Restore a device config to a specific backup version. + + Implements two-phase push with panic-revert: + 1. Pre-backup is taken on device (mandatory before any push) + 2. RouterOS scheduler is installed as safety net (auto-reverts if unreachable) + 3. Config is pushed via /import + 4. Wait 60s for config to settle + 5. Reachability check — remove scheduler if device is reachable + 6. Return committed/reverted/failed status + + Returns: {"status": str, "message": str, "pre_backup_sha": str} + """ + await _check_tenant_access(current_user, tenant_id, db) + + try: + result = await restore_service.restore_config( + device_id=str(device_id), + tenant_id=str(tenant_id), + commit_sha=body.commit_sha, + db_session=db, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except Exception as exc: + logger.error( + "Restore failed for device %s tenant %s commit %s: %s", + device_id, + tenant_id, + body.commit_sha, + exc, + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Restore failed: {exc}", + ) from exc + + return result + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config/emergency-rollback", + summary="Emergency rollback to most recent pre-push backup", + dependencies=[require_scope("config:write")], +) +@limiter.limit("5/minute") +async def emergency_rollback( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Emergency rollback: restore the most recent pre-push backup. + + Used when a device goes offline after a config push. + Finds the latest 'pre-restore', 'checkpoint', or 'pre-template-push' + backup and restores it via the two-phase panic-revert process. + """ + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + select(ConfigBackupRun) + .where( + ConfigBackupRun.device_id == device_id, # type: ignore[arg-type] + ConfigBackupRun.tenant_id == tenant_id, # type: ignore[arg-type] + ConfigBackupRun.trigger_type.in_( + ["pre-restore", "checkpoint", "pre-template-push"] + ), + ) + .order_by(ConfigBackupRun.created_at.desc()) + .limit(1) + ) + backup = result.scalar_one_or_none() + if not backup: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No pre-push backup found for rollback", + ) + + try: + restore_result = await restore_service.restore_config( + device_id=str(device_id), + tenant_id=str(tenant_id), + commit_sha=backup.commit_sha, + db_session=db, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except Exception as exc: + logger.error( + "Emergency rollback failed for device %s tenant %s: %s", + device_id, + tenant_id, + exc, + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Emergency rollback failed: {exc}", + ) from exc + + return { + **restore_result, + "rolled_back_to": backup.commit_sha, + "rolled_back_to_date": backup.created_at.isoformat(), + } + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/config/schedules", + summary="Get effective backup schedule for a device", + dependencies=[require_scope("config:read")], +) +async def get_schedule( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Return the effective backup schedule for a device. + + Returns the device-specific override if it exists; falls back to the + tenant-level default. If no schedule is configured, returns a synthetic + default (2am UTC daily, enabled=True). + """ + await _check_tenant_access(current_user, tenant_id, db) + + # Check for device-specific override first + result = await db.execute( + select(ConfigBackupSchedule).where( + ConfigBackupSchedule.tenant_id == tenant_id, # type: ignore[arg-type] + ConfigBackupSchedule.device_id == device_id, # type: ignore[arg-type] + ) + ) + schedule = result.scalar_one_or_none() + + if schedule is None: + # Fall back to tenant-level default + result = await db.execute( + select(ConfigBackupSchedule).where( + ConfigBackupSchedule.tenant_id == tenant_id, # type: ignore[arg-type] + ConfigBackupSchedule.device_id.is_(None), # type: ignore[union-attr] + ) + ) + schedule = result.scalar_one_or_none() + + if schedule is None: + # No schedule configured — return synthetic default + return { + "id": None, + "tenant_id": str(tenant_id), + "device_id": str(device_id), + "cron_expression": "0 2 * * *", + "enabled": True, + "is_default": True, + } + + is_device_specific = schedule.device_id is not None + return { + "id": str(schedule.id), + "tenant_id": str(schedule.tenant_id), + "device_id": str(schedule.device_id) if schedule.device_id else None, + "cron_expression": schedule.cron_expression, + "enabled": schedule.enabled, + "is_default": not is_device_specific, + } + + +@router.put( + "/tenants/{tenant_id}/devices/{device_id}/config/schedules", + summary="Create or update the device-specific backup schedule", + dependencies=[require_scope("config:write")], +) +@limiter.limit("20/minute") +async def update_schedule( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: ScheduleUpdate, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Create or update the device-specific backup schedule override. + + If no device-specific schedule exists, creates one. If one exists, updates + its cron_expression and enabled fields. + + Returns the updated schedule. + """ + await _check_tenant_access(current_user, tenant_id, db) + + # Look for existing device-specific schedule + result = await db.execute( + select(ConfigBackupSchedule).where( + ConfigBackupSchedule.tenant_id == tenant_id, # type: ignore[arg-type] + ConfigBackupSchedule.device_id == device_id, # type: ignore[arg-type] + ) + ) + schedule = result.scalar_one_or_none() + + if schedule is None: + # Create new device-specific schedule + schedule = ConfigBackupSchedule( + tenant_id=tenant_id, + device_id=device_id, + cron_expression=body.cron_expression, + enabled=body.enabled, + ) + db.add(schedule) + else: + # Update existing schedule + schedule.cron_expression = body.cron_expression + schedule.enabled = body.enabled + + await db.flush() + + # Hot-reload the scheduler so changes take effect immediately + from app.services.backup_scheduler import on_schedule_change + await on_schedule_change(tenant_id, device_id) + + return { + "id": str(schedule.id), + "tenant_id": str(schedule.tenant_id), + "device_id": str(schedule.device_id), + "cron_expression": schedule.cron_expression, + "enabled": schedule.enabled, + "is_default": False, + } diff --git a/backend/app/routers/config_editor.py b/backend/app/routers/config_editor.py new file mode 100644 index 0000000..2e2833a --- /dev/null +++ b/backend/app/routers/config_editor.py @@ -0,0 +1,371 @@ +""" +Dynamic RouterOS config editor API endpoints. + +All routes are tenant-scoped under: + /api/tenants/{tenant_id}/devices/{device_id}/config-editor/ + +Proxies commands to the Go poller's CmdResponder via the RouterOS proxy service. + +Provides: + - GET /browse -- browse a RouterOS menu path + - POST /add -- add a new entry + - POST /set -- edit an existing entry + - POST /remove -- delete an entry + - POST /execute -- execute an arbitrary CLI command + +RLS is enforced via get_db() (app_user engine with tenant context). +RBAC: viewer = read-only (GET browse); operator and above = write (POST). +""" + +import uuid + +import structlog + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rate_limit import limiter +from app.middleware.rbac import require_min_role, require_scope +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.models.device import Device +from app.security.command_blocklist import check_command_safety, check_path_safety +from app.services import routeros_proxy +from app.services.audit_service import log_action + +logger = structlog.get_logger(__name__) +audit_logger = structlog.get_logger("audit") + +router = APIRouter(tags=["config-editor"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + from app.database import set_tenant_context + + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + # Set RLS context for regular users too + await set_tenant_context(db, str(tenant_id)) + + +async def _check_device_online( + db: AsyncSession, device_id: uuid.UUID +) -> Device: + """Verify the device exists and is online. Returns the Device object.""" + result = await db.execute( + select(Device).where(Device.id == device_id) # type: ignore[arg-type] + ) + device = result.scalar_one_or_none() + if device is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device {device_id} not found", + ) + if device.status != "online": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Device is offline \u2014 config editor requires a live connection.", + ) + return device + + +# --------------------------------------------------------------------------- +# Request schemas +# --------------------------------------------------------------------------- + + +class AddEntryRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + path: str + properties: dict[str, str] + + +class SetEntryRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + path: str + entry_id: str | None = None # Optional for singleton paths (e.g. /ip/dns) + properties: dict[str, str] + + +class RemoveEntryRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + path: str + entry_id: str + + +class ExecuteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + command: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/config-editor/browse", + summary="Browse a RouterOS menu path", + dependencies=[require_scope("config:read")], +) +async def browse_menu( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + path: str = Query("/interface", description="RouterOS menu path to browse"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Browse a RouterOS menu path and return all entries at that path.""" + await _check_tenant_access(current_user, tenant_id, db) + await _check_device_online(db, device_id) + check_path_safety(path) + + result = await routeros_proxy.browse_menu(str(device_id), path) + + if not result.get("success"): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=result.get("error", "Failed to browse menu path"), + ) + + audit_logger.info( + "routeros_config_browsed", + device_id=str(device_id), + tenant_id=str(tenant_id), + user_id=str(current_user.user_id), + path=path, + ) + + return { + "success": True, + "entries": result.get("data", []), + "error": None, + "path": path, + } + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config-editor/add", + summary="Add a new entry to a RouterOS menu path", + dependencies=[require_scope("config:write")], +) +@limiter.limit("20/minute") +async def add_entry( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: AddEntryRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Add a new entry to a RouterOS menu path with the given properties.""" + await _check_tenant_access(current_user, tenant_id, db) + await _check_device_online(db, device_id) + check_path_safety(body.path, write=True) + + result = await routeros_proxy.add_entry(str(device_id), body.path, body.properties) + + if not result.get("success"): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=result.get("error", "Failed to add entry"), + ) + + audit_logger.info( + "routeros_config_added", + device_id=str(device_id), + tenant_id=str(tenant_id), + user_id=str(current_user.user_id), + user_role=current_user.role, + path=body.path, + success=result.get("success", False), + ) + + try: + await log_action( + db, tenant_id, current_user.user_id, "config_add", + resource_type="config", resource_id=str(device_id), + device_id=device_id, + details={"path": body.path, "properties": body.properties}, + ) + except Exception: + pass + + return result + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config-editor/set", + summary="Edit an existing entry in a RouterOS menu path", + dependencies=[require_scope("config:write")], +) +@limiter.limit("20/minute") +async def set_entry( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: SetEntryRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Update an existing entry's properties on the device.""" + await _check_tenant_access(current_user, tenant_id, db) + await _check_device_online(db, device_id) + check_path_safety(body.path, write=True) + + result = await routeros_proxy.update_entry( + str(device_id), body.path, body.entry_id, body.properties + ) + + if not result.get("success"): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=result.get("error", "Failed to update entry"), + ) + + audit_logger.info( + "routeros_config_modified", + device_id=str(device_id), + tenant_id=str(tenant_id), + user_id=str(current_user.user_id), + user_role=current_user.role, + path=body.path, + entry_id=body.entry_id, + success=result.get("success", False), + ) + + try: + await log_action( + db, tenant_id, current_user.user_id, "config_set", + resource_type="config", resource_id=str(device_id), + device_id=device_id, + details={"path": body.path, "entry_id": body.entry_id, "properties": body.properties}, + ) + except Exception: + pass + + return result + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config-editor/remove", + summary="Delete an entry from a RouterOS menu path", + dependencies=[require_scope("config:write")], +) +@limiter.limit("5/minute") +async def remove_entry( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: RemoveEntryRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Remove an entry from a RouterOS menu path.""" + await _check_tenant_access(current_user, tenant_id, db) + await _check_device_online(db, device_id) + check_path_safety(body.path, write=True) + + result = await routeros_proxy.remove_entry( + str(device_id), body.path, body.entry_id + ) + + if not result.get("success"): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=result.get("error", "Failed to remove entry"), + ) + + audit_logger.info( + "routeros_config_removed", + device_id=str(device_id), + tenant_id=str(tenant_id), + user_id=str(current_user.user_id), + user_role=current_user.role, + path=body.path, + entry_id=body.entry_id, + success=result.get("success", False), + ) + + try: + await log_action( + db, tenant_id, current_user.user_id, "config_remove", + resource_type="config", resource_id=str(device_id), + device_id=device_id, + details={"path": body.path, "entry_id": body.entry_id}, + ) + except Exception: + pass + + return result + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/config-editor/execute", + summary="Execute an arbitrary RouterOS CLI command", + dependencies=[require_scope("config:write")], +) +@limiter.limit("20/minute") +async def execute_command( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: ExecuteRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Execute an arbitrary RouterOS CLI command on the device.""" + await _check_tenant_access(current_user, tenant_id, db) + await _check_device_online(db, device_id) + check_command_safety(body.command) + + result = await routeros_proxy.execute_cli(str(device_id), body.command) + + if not result.get("success"): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=result.get("error", "Failed to execute command"), + ) + + audit_logger.info( + "routeros_command_executed", + device_id=str(device_id), + tenant_id=str(tenant_id), + user_id=str(current_user.user_id), + user_role=current_user.role, + command=body.command, + success=result.get("success", False), + ) + + try: + await log_action( + db, tenant_id, current_user.user_id, "config_execute", + resource_type="config", resource_id=str(device_id), + device_id=device_id, + details={"command": body.command}, + ) + except Exception: + pass + + return result diff --git a/backend/app/routers/device_groups.py b/backend/app/routers/device_groups.py new file mode 100644 index 0000000..25e8665 --- /dev/null +++ b/backend/app/routers/device_groups.py @@ -0,0 +1,94 @@ +""" +Device group management API endpoints. + +Routes: /api/tenants/{tenant_id}/device-groups + +RBAC: +- viewer: GET (read-only) +- operator: POST, PUT (write) +- tenant_admin/admin: DELETE +""" + +import uuid + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rbac import require_operator_or_above, require_tenant_admin_or_above +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.routers.devices import _check_tenant_access +from app.schemas.device import DeviceGroupCreate, DeviceGroupResponse, DeviceGroupUpdate +from app.services import device as device_service + +router = APIRouter(tags=["device-groups"]) + + +@router.get( + "/tenants/{tenant_id}/device-groups", + response_model=list[DeviceGroupResponse], + summary="List device groups", +) +async def list_groups( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[DeviceGroupResponse]: + """List all device groups for a tenant. Viewer role and above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await device_service.get_groups(db=db, tenant_id=tenant_id) + + +@router.post( + "/tenants/{tenant_id}/device-groups", + response_model=DeviceGroupResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a device group", + dependencies=[Depends(require_operator_or_above)], +) +async def create_group( + tenant_id: uuid.UUID, + data: DeviceGroupCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceGroupResponse: + """Create a new device group. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await device_service.create_group(db=db, tenant_id=tenant_id, data=data) + + +@router.put( + "/tenants/{tenant_id}/device-groups/{group_id}", + response_model=DeviceGroupResponse, + summary="Update a device group", + dependencies=[Depends(require_operator_or_above)], +) +async def update_group( + tenant_id: uuid.UUID, + group_id: uuid.UUID, + data: DeviceGroupUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceGroupResponse: + """Update a device group. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await device_service.update_group( + db=db, tenant_id=tenant_id, group_id=group_id, data=data + ) + + +@router.delete( + "/tenants/{tenant_id}/device-groups/{group_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a device group", + dependencies=[Depends(require_tenant_admin_or_above)], +) +async def delete_group( + tenant_id: uuid.UUID, + group_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Delete a device group. Requires tenant_admin or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await device_service.delete_group(db=db, tenant_id=tenant_id, group_id=group_id) diff --git a/backend/app/routers/device_logs.py b/backend/app/routers/device_logs.py new file mode 100644 index 0000000..bdfe07c --- /dev/null +++ b/backend/app/routers/device_logs.py @@ -0,0 +1,150 @@ +""" +Device syslog fetch endpoint via NATS RouterOS proxy. + +Provides: + - GET /tenants/{tenant_id}/devices/{device_id}/logs -- fetch device log entries + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: viewer and above can read logs. +""" + +import uuid + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rbac import require_min_role +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.services import routeros_proxy + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["device-logs"]) + + +# --------------------------------------------------------------------------- +# Helpers (same pattern as config_editor.py) +# --------------------------------------------------------------------------- + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + from app.database import set_tenant_context + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + + +async def _check_device_exists( + db: AsyncSession, device_id: uuid.UUID +) -> None: + """Verify the device exists (does not require online status for logs).""" + from sqlalchemy import select + from app.models.device import Device + + result = await db.execute( + select(Device).where(Device.id == device_id) + ) + device = result.scalar_one_or_none() + if device is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device {device_id} not found", + ) + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + +class LogEntry(BaseModel): + time: str + topics: str + message: str + + +class LogsResponse(BaseModel): + logs: list[LogEntry] + device_id: str + count: int + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/logs", + response_model=LogsResponse, + summary="Fetch device syslog entries via RouterOS API", + dependencies=[Depends(require_min_role("viewer"))], +) +async def get_device_logs( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + limit: int = Query(default=100, ge=1, le=500), + topic: str | None = Query(default=None, description="Filter by log topic"), + search: str | None = Query(default=None, description="Search in message/topics"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> LogsResponse: + """Fetch device log entries via the RouterOS /log/print command.""" + await _check_tenant_access(current_user, tenant_id, db) + await _check_device_exists(db, device_id) + + # Build RouterOS command args + args = [f"=count={limit}"] + if topic: + args.append(f"?topics={topic}") + + result = await routeros_proxy.execute_command( + str(device_id), "/log/print", args=args, timeout=15.0 + ) + + if not result.get("success"): + error_msg = result.get("error", "Unknown error fetching logs") + logger.warning( + "failed to fetch device logs", + device_id=str(device_id), + error=error_msg, + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch device logs: {error_msg}", + ) + + # Parse log entries from RouterOS response + raw_entries = result.get("data", []) + logs: list[LogEntry] = [] + for entry in raw_entries: + log_entry = LogEntry( + time=entry.get("time", ""), + topics=entry.get("topics", ""), + message=entry.get("message", ""), + ) + + # Apply search filter (case-insensitive) if provided + if search: + search_lower = search.lower() + if ( + search_lower not in log_entry.message.lower() + and search_lower not in log_entry.topics.lower() + ): + continue + + logs.append(log_entry) + + return LogsResponse( + logs=logs, + device_id=str(device_id), + count=len(logs), + ) diff --git a/backend/app/routers/device_tags.py b/backend/app/routers/device_tags.py new file mode 100644 index 0000000..523cca1 --- /dev/null +++ b/backend/app/routers/device_tags.py @@ -0,0 +1,94 @@ +""" +Device tag management API endpoints. + +Routes: /api/tenants/{tenant_id}/device-tags + +RBAC: +- viewer: GET (read-only) +- operator: POST, PUT (write) +- tenant_admin/admin: DELETE +""" + +import uuid + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rbac import require_operator_or_above, require_tenant_admin_or_above +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.routers.devices import _check_tenant_access +from app.schemas.device import DeviceTagCreate, DeviceTagResponse, DeviceTagUpdate +from app.services import device as device_service + +router = APIRouter(tags=["device-tags"]) + + +@router.get( + "/tenants/{tenant_id}/device-tags", + response_model=list[DeviceTagResponse], + summary="List device tags", +) +async def list_tags( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[DeviceTagResponse]: + """List all device tags for a tenant. Viewer role and above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await device_service.get_tags(db=db, tenant_id=tenant_id) + + +@router.post( + "/tenants/{tenant_id}/device-tags", + response_model=DeviceTagResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a device tag", + dependencies=[Depends(require_operator_or_above)], +) +async def create_tag( + tenant_id: uuid.UUID, + data: DeviceTagCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceTagResponse: + """Create a new device tag. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await device_service.create_tag(db=db, tenant_id=tenant_id, data=data) + + +@router.put( + "/tenants/{tenant_id}/device-tags/{tag_id}", + response_model=DeviceTagResponse, + summary="Update a device tag", + dependencies=[Depends(require_operator_or_above)], +) +async def update_tag( + tenant_id: uuid.UUID, + tag_id: uuid.UUID, + data: DeviceTagUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceTagResponse: + """Update a device tag. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await device_service.update_tag( + db=db, tenant_id=tenant_id, tag_id=tag_id, data=data + ) + + +@router.delete( + "/tenants/{tenant_id}/device-tags/{tag_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a device tag", + dependencies=[Depends(require_tenant_admin_or_above)], +) +async def delete_tag( + tenant_id: uuid.UUID, + tag_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Delete a device tag. Requires tenant_admin or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await device_service.delete_tag(db=db, tenant_id=tenant_id, tag_id=tag_id) diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py new file mode 100644 index 0000000..c3ac89b --- /dev/null +++ b/backend/app/routers/devices.py @@ -0,0 +1,452 @@ +""" +Device management API endpoints. + +All routes are tenant-scoped under /api/tenants/{tenant_id}/devices. +RLS is enforced via PostgreSQL — the app_user engine automatically filters +cross-tenant data based on the SET LOCAL app.current_tenant context set by +get_current_user dependency. + +RBAC: +- viewer: GET (read-only) +- operator: POST, PUT (write) +- admin/tenant_admin: DELETE +""" + +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db +from app.middleware.rate_limit import limiter +from app.services.audit_service import log_action +from app.middleware.rbac import ( + require_min_role, + require_operator_or_above, + require_scope, + require_tenant_admin_or_above, +) +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.schemas.device import ( + BulkAddRequest, + BulkAddResult, + DeviceCreate, + DeviceListResponse, + DeviceResponse, + DeviceUpdate, + SubnetScanRequest, + SubnetScanResponse, +) +from app.services import device as device_service +from app.services.scanner import scan_subnet + +router = APIRouter(tags=["devices"]) + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """ + Verify the current user is allowed to access the given tenant. + + - super_admin can access any tenant — re-sets DB tenant context to target tenant. + - All other roles must match their own tenant_id. + """ + if current_user.is_super_admin: + # Re-set tenant context to the target tenant so RLS allows the operation + from app.database import set_tenant_context + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + + +# --------------------------------------------------------------------------- +# Device CRUD +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices", + response_model=DeviceListResponse, + summary="List devices with pagination and filtering", + dependencies=[require_scope("devices:read")], +) +async def list_devices( + tenant_id: uuid.UUID, + page: int = Query(1, ge=1, description="Page number (1-based)"), + page_size: int = Query(25, ge=1, le=100, description="Items per page (1-100)"), + status_filter: Optional[str] = Query(None, alias="status"), + search: Optional[str] = Query(None, description="Text search on hostname or IP"), + tag_id: Optional[uuid.UUID] = Query(None), + group_id: Optional[uuid.UUID] = Query(None), + sort_by: str = Query("created_at", description="Field to sort by"), + sort_order: str = Query("desc", description="asc or desc"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceListResponse: + """List devices for a tenant with optional pagination, filtering, and sorting.""" + await _check_tenant_access(current_user, tenant_id, db) + + items, total = await device_service.get_devices( + db=db, + tenant_id=tenant_id, + page=page, + page_size=page_size, + status=status_filter, + search=search, + tag_id=tag_id, + group_id=group_id, + sort_by=sort_by, + sort_order=sort_order, + ) + return DeviceListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.post( + "/tenants/{tenant_id}/devices", + response_model=DeviceResponse, + status_code=status.HTTP_201_CREATED, + summary="Add a device (validates TCP connectivity first)", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("20/minute") +async def create_device( + request: Request, + tenant_id: uuid.UUID, + data: DeviceCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceResponse: + """ + Create a new device. Requires operator role or above. + + The device IP/port is TCP-probed before the record is saved. + Credentials are encrypted with AES-256-GCM before storage and never returned. + """ + await _check_tenant_access(current_user, tenant_id, db) + result = await device_service.create_device( + db=db, + tenant_id=tenant_id, + data=data, + encryption_key=settings.get_encryption_key_bytes(), + ) + try: + await log_action( + db, tenant_id, current_user.user_id, "device_create", + resource_type="device", resource_id=str(result.id), + details={"hostname": data.hostname, "ip_address": data.ip_address}, + ip_address=request.client.host if request.client else None, + ) + except Exception: + pass + return result + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}", + response_model=DeviceResponse, + summary="Get a single device", + dependencies=[require_scope("devices:read")], +) +async def get_device( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceResponse: + """Get device details. Viewer role and above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await device_service.get_device(db=db, tenant_id=tenant_id, device_id=device_id) + + +@router.put( + "/tenants/{tenant_id}/devices/{device_id}", + response_model=DeviceResponse, + summary="Update a device", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("20/minute") +async def update_device( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + data: DeviceUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DeviceResponse: + """Update device fields. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + result = await device_service.update_device( + db=db, + tenant_id=tenant_id, + device_id=device_id, + data=data, + encryption_key=settings.get_encryption_key_bytes(), + ) + try: + await log_action( + db, tenant_id, current_user.user_id, "device_update", + resource_type="device", resource_id=str(device_id), + device_id=device_id, + details={"changes": data.model_dump(exclude_unset=True)}, + ip_address=request.client.host if request.client else None, + ) + except Exception: + pass + return result + + +@router.delete( + "/tenants/{tenant_id}/devices/{device_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a device", + dependencies=[Depends(require_tenant_admin_or_above), require_scope("devices:write")], +) +@limiter.limit("5/minute") +async def delete_device( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Hard-delete a device. Requires tenant_admin or above.""" + await _check_tenant_access(current_user, tenant_id, db) + try: + await log_action( + db, tenant_id, current_user.user_id, "device_delete", + resource_type="device", resource_id=str(device_id), + device_id=device_id, + ip_address=request.client.host if request.client else None, + ) + except Exception: + pass + await device_service.delete_device(db=db, tenant_id=tenant_id, device_id=device_id) + + +# --------------------------------------------------------------------------- +# Subnet scan and bulk add +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/devices/scan", + response_model=SubnetScanResponse, + summary="Scan a subnet for MikroTik devices", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("5/minute") +async def scan_devices( + request: Request, + tenant_id: uuid.UUID, + data: SubnetScanRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> SubnetScanResponse: + """ + Scan a CIDR subnet for hosts with open RouterOS API ports (8728/8729). + + Returns a list of discovered IPs for the user to review and selectively + import — does NOT automatically add devices. + + Requires operator role or above. + """ + if not current_user.is_super_admin and current_user.tenant_id != tenant_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + + discovered = await scan_subnet(data.cidr) + import ipaddress + network = ipaddress.ip_network(data.cidr, strict=False) + total_scanned = network.num_addresses - 2 if network.num_addresses > 2 else network.num_addresses + + # Audit log the scan (fire-and-forget — never breaks the response) + try: + await log_action( + db, tenant_id, current_user.user_id, "subnet_scan", + resource_type="network", resource_id=data.cidr, + details={ + "cidr": data.cidr, + "devices_found": len(discovered), + "ip": request.client.host if request.client else None, + }, + ip_address=request.client.host if request.client else None, + ) + except Exception: + pass + + return SubnetScanResponse( + cidr=data.cidr, + discovered=discovered, + total_scanned=total_scanned, + total_discovered=len(discovered), + ) + + +@router.post( + "/tenants/{tenant_id}/devices/bulk-add", + response_model=BulkAddResult, + status_code=status.HTTP_201_CREATED, + summary="Bulk-add devices from scan results", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("5/minute") +async def bulk_add_devices( + request: Request, + tenant_id: uuid.UUID, + data: BulkAddRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> BulkAddResult: + """ + Add multiple devices at once from scan results. + + Per-device credentials take precedence over shared credentials. + Devices that fail connectivity checks or validation are reported in `failed`. + Requires operator role or above. + """ + await _check_tenant_access(current_user, tenant_id, db) + + added = [] + failed = [] + encryption_key = settings.get_encryption_key_bytes() + + for dev_data in data.devices: + # Resolve credentials: per-device first, then shared + username = dev_data.username or data.shared_username + password = dev_data.password or data.shared_password + + if not username or not password: + failed.append({ + "ip_address": dev_data.ip_address, + "error": "No credentials provided (set per-device or shared credentials)", + }) + continue + + create_data = DeviceCreate( + hostname=dev_data.hostname or dev_data.ip_address, + ip_address=dev_data.ip_address, + api_port=dev_data.api_port, + api_ssl_port=dev_data.api_ssl_port, + username=username, + password=password, + ) + + try: + device = await device_service.create_device( + db=db, + tenant_id=tenant_id, + data=create_data, + encryption_key=encryption_key, + ) + added.append(device) + try: + await log_action( + db, tenant_id, current_user.user_id, "device_adopt", + resource_type="device", resource_id=str(device.id), + details={"hostname": create_data.hostname, "ip_address": create_data.ip_address}, + ip_address=request.client.host if request.client else None, + ) + except Exception: + pass + except HTTPException as exc: + failed.append({"ip_address": dev_data.ip_address, "error": exc.detail}) + except Exception as exc: + failed.append({"ip_address": dev_data.ip_address, "error": str(exc)}) + + return BulkAddResult(added=added, failed=failed) + + +# --------------------------------------------------------------------------- +# Group assignment +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/groups/{group_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Add device to a group", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("20/minute") +async def add_device_to_group( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + group_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Assign a device to a group. Requires operator or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await device_service.assign_device_to_group(db, tenant_id, device_id, group_id) + + +@router.delete( + "/tenants/{tenant_id}/devices/{device_id}/groups/{group_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove device from a group", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("5/minute") +async def remove_device_from_group( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + group_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Remove a device from a group. Requires operator or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await device_service.remove_device_from_group(db, tenant_id, device_id, group_id) + + +# --------------------------------------------------------------------------- +# Tag assignment +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/devices/{device_id}/tags/{tag_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Add tag to a device", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("20/minute") +async def add_tag_to_device( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + tag_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Assign a tag to a device. Requires operator or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await device_service.assign_tag_to_device(db, tenant_id, device_id, tag_id) + + +@router.delete( + "/tenants/{tenant_id}/devices/{device_id}/tags/{tag_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove tag from a device", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("5/minute") +async def remove_tag_from_device( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + tag_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Remove a tag from a device. Requires operator or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await device_service.remove_tag_from_device(db, tenant_id, device_id, tag_id) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py new file mode 100644 index 0000000..3ac9f19 --- /dev/null +++ b/backend/app/routers/events.py @@ -0,0 +1,164 @@ +"""Unified events timeline API endpoint. + +Provides a single GET endpoint that unions alert events, device status changes, +and config backup runs into a unified timeline for the dashboard. + +RLS enforced via get_db() (app_user engine with tenant context). +""" + +import logging +import uuid +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.tenant_context import CurrentUser, get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["events"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +# --------------------------------------------------------------------------- +# Unified events endpoint +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/events", + summary="List unified events (alerts, status changes, config backups)", +) +async def list_events( + tenant_id: uuid.UUID, + limit: int = Query(50, ge=1, le=200, description="Max events to return"), + event_type: Optional[str] = Query( + None, + description="Filter by event type: alert, status_change, config_backup", + ), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """Return a unified list of recent events across alerts, device status, and config backups. + + Events are ordered by timestamp descending, limited to `limit` (default 50). + RLS automatically filters to the tenant's data via the app_user session. + """ + await _check_tenant_access(current_user, tenant_id, db) + + if event_type and event_type not in ("alert", "status_change", "config_backup"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="event_type must be one of: alert, status_change, config_backup", + ) + + events: list[dict[str, Any]] = [] + + # 1. Alert events + if not event_type or event_type == "alert": + alert_result = await db.execute( + text(""" + SELECT ae.id, ae.status, ae.severity, ae.metric, ae.message, + ae.fired_at, ae.device_id, d.hostname + FROM alert_events ae + LEFT JOIN devices d ON d.id = ae.device_id + ORDER BY ae.fired_at DESC + LIMIT :limit + """), + {"limit": limit}, + ) + for row in alert_result.fetchall(): + alert_status = row[1] or "firing" + metric = row[3] or "unknown" + events.append({ + "id": str(row[0]), + "event_type": "alert", + "severity": row[2], + "title": f"{alert_status}: {metric}", + "description": row[4] or f"Alert {alert_status} for {metric}", + "device_hostname": row[7], + "device_id": str(row[6]) if row[6] else None, + "timestamp": row[5].isoformat() if row[5] else None, + }) + + # 2. Device status changes (inferred from current status + last_seen) + if not event_type or event_type == "status_change": + status_result = await db.execute( + text(""" + SELECT d.id, d.hostname, d.status, d.last_seen + FROM devices d + WHERE d.last_seen IS NOT NULL + ORDER BY d.last_seen DESC + LIMIT :limit + """), + {"limit": limit}, + ) + for row in status_result.fetchall(): + device_status = row[2] or "unknown" + hostname = row[1] or "Unknown device" + severity = "info" if device_status == "online" else "warning" + events.append({ + "id": f"status-{row[0]}", + "event_type": "status_change", + "severity": severity, + "title": f"Device {device_status}", + "description": f"{hostname} is now {device_status}", + "device_hostname": hostname, + "device_id": str(row[0]), + "timestamp": row[3].isoformat() if row[3] else None, + }) + + # 3. Config backup runs + if not event_type or event_type == "config_backup": + backup_result = await db.execute( + text(""" + SELECT cbr.id, cbr.trigger_type, cbr.created_at, + cbr.device_id, d.hostname + FROM config_backup_runs cbr + LEFT JOIN devices d ON d.id = cbr.device_id + ORDER BY cbr.created_at DESC + LIMIT :limit + """), + {"limit": limit}, + ) + for row in backup_result.fetchall(): + trigger_type = row[1] or "manual" + hostname = row[4] or "Unknown device" + events.append({ + "id": str(row[0]), + "event_type": "config_backup", + "severity": "info", + "title": "Config backup", + "description": f"{trigger_type} backup completed for {hostname}", + "device_hostname": hostname, + "device_id": str(row[3]) if row[3] else None, + "timestamp": row[2].isoformat() if row[2] else None, + }) + + # Sort all events by timestamp descending, then apply final limit + events.sort( + key=lambda e: e["timestamp"] or "", + reverse=True, + ) + + return events[:limit] diff --git a/backend/app/routers/firmware.py b/backend/app/routers/firmware.py new file mode 100644 index 0000000..278be84 --- /dev/null +++ b/backend/app/routers/firmware.py @@ -0,0 +1,712 @@ +"""Firmware API endpoints for version overview, cache management, preferred channel, +and firmware upgrade orchestration. + +Tenant-scoped routes under /api/tenants/{tenant_id}/firmware/*. +Global routes under /api/firmware/* for version listing and admin actions. +""" + +import asyncio +import uuid +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.rate_limit import limiter +from app.middleware.rbac import require_scope +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.services.audit_service import log_action + +router = APIRouter(tags=["firmware"]) + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +class PreferredChannelRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + preferred_channel: str # "stable", "long-term", "testing" + + +class FirmwareDownloadRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + architecture: str + channel: str + version: str + + +# ========================================================================= +# TENANT-SCOPED ENDPOINTS +# ========================================================================= + + +@router.get( + "/tenants/{tenant_id}/firmware/overview", + summary="Get firmware status for all devices in tenant", + dependencies=[require_scope("firmware:write")], +) +async def get_firmware_overview( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + from app.services.firmware_service import get_firmware_overview as _get_overview + return await _get_overview(str(tenant_id)) + + +@router.patch( + "/tenants/{tenant_id}/devices/{device_id}/preferred-channel", + summary="Set preferred firmware channel for a device", + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("20/minute") +async def set_device_preferred_channel( + request: Request, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + body: PreferredChannelRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + await _check_tenant_access(current_user, tenant_id, db) + + if body.preferred_channel not in ("stable", "long-term", "testing"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="preferred_channel must be one of: stable, long-term, testing", + ) + + result = await db.execute( + text(""" + UPDATE devices SET preferred_channel = :channel, updated_at = NOW() + WHERE id = :device_id + RETURNING id + """), + {"channel": body.preferred_channel, "device_id": str(device_id)}, + ) + if not result.fetchone(): + raise HTTPException(status_code=404, detail="Device not found") + await db.commit() + return {"status": "ok", "preferred_channel": body.preferred_channel} + + +@router.patch( + "/tenants/{tenant_id}/device-groups/{group_id}/preferred-channel", + summary="Set preferred firmware channel for a device group", + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("20/minute") +async def set_group_preferred_channel( + request: Request, + tenant_id: uuid.UUID, + group_id: uuid.UUID, + body: PreferredChannelRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + await _check_tenant_access(current_user, tenant_id, db) + + if body.preferred_channel not in ("stable", "long-term", "testing"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="preferred_channel must be one of: stable, long-term, testing", + ) + + result = await db.execute( + text(""" + UPDATE device_groups SET preferred_channel = :channel + WHERE id = :group_id + RETURNING id + """), + {"channel": body.preferred_channel, "group_id": str(group_id)}, + ) + if not result.fetchone(): + raise HTTPException(status_code=404, detail="Device group not found") + await db.commit() + return {"status": "ok", "preferred_channel": body.preferred_channel} + + +# ========================================================================= +# GLOBAL ENDPOINTS (firmware versions are not tenant-scoped) +# ========================================================================= + + +@router.get( + "/firmware/versions", + summary="List all known firmware versions from cache", +) +async def list_firmware_versions( + architecture: Optional[str] = Query(None), + channel: Optional[str] = Query(None), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + filters = [] + params: dict[str, Any] = {} + + if architecture: + filters.append("architecture = :arch") + params["arch"] = architecture + if channel: + filters.append("channel = :channel") + params["channel"] = channel + + where = f"WHERE {' AND '.join(filters)}" if filters else "" + + result = await db.execute( + text(f""" + SELECT id, architecture, channel, version, npk_url, + npk_local_path, npk_size_bytes, checked_at + FROM firmware_versions + {where} + ORDER BY architecture, channel, checked_at DESC + """), + params, + ) + + return [ + { + "id": str(row[0]), + "architecture": row[1], + "channel": row[2], + "version": row[3], + "npk_url": row[4], + "npk_local_path": row[5], + "npk_size_bytes": row[6], + "checked_at": row[7].isoformat() if row[7] else None, + } + for row in result.fetchall() + ] + + +@router.post( + "/firmware/check", + summary="Trigger immediate firmware version check (super admin only)", +) +async def trigger_firmware_check( + current_user: CurrentUser = Depends(get_current_user), +) -> dict[str, Any]: + if not current_user.is_super_admin: + raise HTTPException(status_code=403, detail="Super admin only") + + from app.services.firmware_service import check_latest_versions + results = await check_latest_versions() + return {"status": "ok", "versions_discovered": len(results), "versions": results} + + +@router.get( + "/firmware/cache", + summary="List locally cached NPK files (super admin only)", +) +async def list_firmware_cache( + current_user: CurrentUser = Depends(get_current_user), +) -> list[dict[str, Any]]: + if not current_user.is_super_admin: + raise HTTPException(status_code=403, detail="Super admin only") + + from app.services.firmware_service import get_cached_firmware + return await get_cached_firmware() + + +@router.post( + "/firmware/download", + summary="Download a specific NPK to local cache (super admin only)", +) +async def download_firmware( + body: FirmwareDownloadRequest, + current_user: CurrentUser = Depends(get_current_user), +) -> dict[str, str]: + if not current_user.is_super_admin: + raise HTTPException(status_code=403, detail="Super admin only") + + from app.services.firmware_service import download_firmware as _download + path = await _download(body.architecture, body.channel, body.version) + return {"status": "ok", "path": path} + + +# ========================================================================= +# UPGRADE ENDPOINTS +# ========================================================================= + + +class UpgradeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + device_id: str + target_version: str + architecture: str + channel: str = "stable" + confirmed_major_upgrade: bool = False + scheduled_at: Optional[str] = None # ISO datetime or None for immediate + + +class MassUpgradeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + device_ids: list[str] + target_version: str + channel: str = "stable" + confirmed_major_upgrade: bool = False + scheduled_at: Optional[str] = None + + +@router.post( + "/tenants/{tenant_id}/firmware/upgrade", + summary="Start or schedule a single device firmware upgrade", + status_code=status.HTTP_202_ACCEPTED, + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("20/minute") +async def start_firmware_upgrade( + request: Request, + tenant_id: uuid.UUID, + body: UpgradeRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + if current_user.role == "viewer": + raise HTTPException(403, "Viewers cannot initiate upgrades") + + # Look up device architecture if not provided + architecture = body.architecture + if not architecture: + dev_result = await db.execute( + text("SELECT architecture FROM devices WHERE id = CAST(:id AS uuid)"), + {"id": body.device_id}, + ) + dev_row = dev_result.fetchone() + if not dev_row or not dev_row[0]: + raise HTTPException(422, "Device architecture unknown — cannot upgrade") + architecture = dev_row[0] + + # Create upgrade job + job_id = str(uuid.uuid4()) + await db.execute( + text(""" + INSERT INTO firmware_upgrade_jobs + (id, tenant_id, device_id, target_version, architecture, channel, + status, confirmed_major_upgrade, scheduled_at) + VALUES + (CAST(:id AS uuid), CAST(:tenant_id AS uuid), CAST(:device_id AS uuid), + :target_version, :architecture, :channel, + :status, :confirmed, :scheduled_at) + """), + { + "id": job_id, + "tenant_id": str(tenant_id), + "device_id": body.device_id, + "target_version": body.target_version, + "architecture": architecture, + "channel": body.channel, + "status": "scheduled" if body.scheduled_at else "pending", + "confirmed": body.confirmed_major_upgrade, + "scheduled_at": body.scheduled_at, + }, + ) + await db.commit() + + # Schedule or start immediately + if body.scheduled_at: + from app.services.upgrade_service import schedule_upgrade + schedule_upgrade(job_id, datetime.fromisoformat(body.scheduled_at)) + else: + from app.services.upgrade_service import start_upgrade + asyncio.create_task(start_upgrade(job_id)) + + try: + await log_action( + db, tenant_id, current_user.user_id, "firmware_upgrade", + resource_type="firmware", resource_id=job_id, + device_id=uuid.UUID(body.device_id), + details={"target_version": body.target_version, "channel": body.channel}, + ) + except Exception: + pass + + return {"status": "accepted", "job_id": job_id} + + +@router.post( + "/tenants/{tenant_id}/firmware/mass-upgrade", + summary="Start or schedule a mass firmware upgrade for multiple devices", + status_code=status.HTTP_202_ACCEPTED, + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("5/minute") +async def start_mass_firmware_upgrade( + request: Request, + tenant_id: uuid.UUID, + body: MassUpgradeRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + if current_user.role == "viewer": + raise HTTPException(403, "Viewers cannot initiate upgrades") + + rollout_group_id = str(uuid.uuid4()) + jobs = [] + + for device_id in body.device_ids: + # Look up architecture per device + dev_result = await db.execute( + text("SELECT architecture FROM devices WHERE id = CAST(:id AS uuid)"), + {"id": device_id}, + ) + dev_row = dev_result.fetchone() + architecture = dev_row[0] if dev_row and dev_row[0] else "unknown" + + job_id = str(uuid.uuid4()) + await db.execute( + text(""" + INSERT INTO firmware_upgrade_jobs + (id, tenant_id, device_id, rollout_group_id, + target_version, architecture, channel, + status, confirmed_major_upgrade, scheduled_at) + VALUES + (CAST(:id AS uuid), CAST(:tenant_id AS uuid), + CAST(:device_id AS uuid), CAST(:group_id AS uuid), + :target_version, :architecture, :channel, + :status, :confirmed, :scheduled_at) + """), + { + "id": job_id, + "tenant_id": str(tenant_id), + "device_id": device_id, + "group_id": rollout_group_id, + "target_version": body.target_version, + "architecture": architecture, + "channel": body.channel, + "status": "scheduled" if body.scheduled_at else "pending", + "confirmed": body.confirmed_major_upgrade, + "scheduled_at": body.scheduled_at, + }, + ) + jobs.append({"job_id": job_id, "device_id": device_id, "architecture": architecture}) + + await db.commit() + + # Schedule or start immediately + if body.scheduled_at: + from app.services.upgrade_service import schedule_mass_upgrade + schedule_mass_upgrade(rollout_group_id, datetime.fromisoformat(body.scheduled_at)) + else: + from app.services.upgrade_service import start_mass_upgrade + asyncio.create_task(start_mass_upgrade(rollout_group_id)) + + return { + "status": "accepted", + "rollout_group_id": rollout_group_id, + "jobs": jobs, + } + + +@router.get( + "/tenants/{tenant_id}/firmware/upgrades", + summary="List firmware upgrade jobs for tenant", + dependencies=[require_scope("firmware:write")], +) +async def list_upgrade_jobs( + tenant_id: uuid.UUID, + upgrade_status: Optional[str] = Query(None, alias="status"), + device_id: Optional[str] = Query(None), + rollout_group_id: Optional[str] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + filters = ["1=1"] + params: dict[str, Any] = {} + + if upgrade_status: + filters.append("j.status = :status") + params["status"] = upgrade_status + if device_id: + filters.append("j.device_id = CAST(:device_id AS uuid)") + params["device_id"] = device_id + if rollout_group_id: + filters.append("j.rollout_group_id = CAST(:group_id AS uuid)") + params["group_id"] = rollout_group_id + + where = " AND ".join(filters) + offset = (page - 1) * per_page + + count_result = await db.execute( + text(f"SELECT COUNT(*) FROM firmware_upgrade_jobs j WHERE {where}"), + params, + ) + total = count_result.scalar() or 0 + + result = await db.execute( + text(f""" + SELECT j.id, j.device_id, j.rollout_group_id, + j.target_version, j.architecture, j.channel, + j.status, j.pre_upgrade_backup_sha, j.scheduled_at, + j.started_at, j.completed_at, j.error_message, + j.confirmed_major_upgrade, j.created_at, + d.hostname AS device_hostname + FROM firmware_upgrade_jobs j + LEFT JOIN devices d ON d.id = j.device_id + WHERE {where} + ORDER BY j.created_at DESC + LIMIT :limit OFFSET :offset + """), + {**params, "limit": per_page, "offset": offset}, + ) + + items = [ + { + "id": str(row[0]), + "device_id": str(row[1]), + "rollout_group_id": str(row[2]) if row[2] else None, + "target_version": row[3], + "architecture": row[4], + "channel": row[5], + "status": row[6], + "pre_upgrade_backup_sha": row[7], + "scheduled_at": row[8].isoformat() if row[8] else None, + "started_at": row[9].isoformat() if row[9] else None, + "completed_at": row[10].isoformat() if row[10] else None, + "error_message": row[11], + "confirmed_major_upgrade": row[12], + "created_at": row[13].isoformat() if row[13] else None, + "device_hostname": row[14], + } + for row in result.fetchall() + ] + + return {"items": items, "total": total, "page": page, "per_page": per_page} + + +@router.get( + "/tenants/{tenant_id}/firmware/upgrades/{job_id}", + summary="Get single upgrade job detail", + dependencies=[require_scope("firmware:write")], +) +async def get_upgrade_job( + tenant_id: uuid.UUID, + job_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT j.id, j.device_id, j.rollout_group_id, + j.target_version, j.architecture, j.channel, + j.status, j.pre_upgrade_backup_sha, j.scheduled_at, + j.started_at, j.completed_at, j.error_message, + j.confirmed_major_upgrade, j.created_at, + d.hostname AS device_hostname + FROM firmware_upgrade_jobs j + LEFT JOIN devices d ON d.id = j.device_id + WHERE j.id = CAST(:job_id AS uuid) + """), + {"job_id": str(job_id)}, + ) + row = result.fetchone() + if not row: + raise HTTPException(404, "Upgrade job not found") + + return { + "id": str(row[0]), + "device_id": str(row[1]), + "rollout_group_id": str(row[2]) if row[2] else None, + "target_version": row[3], + "architecture": row[4], + "channel": row[5], + "status": row[6], + "pre_upgrade_backup_sha": row[7], + "scheduled_at": row[8].isoformat() if row[8] else None, + "started_at": row[9].isoformat() if row[9] else None, + "completed_at": row[10].isoformat() if row[10] else None, + "error_message": row[11], + "confirmed_major_upgrade": row[12], + "created_at": row[13].isoformat() if row[13] else None, + "device_hostname": row[14], + } + + +@router.get( + "/tenants/{tenant_id}/firmware/rollouts/{rollout_group_id}", + summary="Get mass rollout status with all jobs", + dependencies=[require_scope("firmware:write")], +) +async def get_rollout_status( + tenant_id: uuid.UUID, + rollout_group_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT j.id, j.device_id, j.status, j.target_version, + j.architecture, j.error_message, j.started_at, + j.completed_at, d.hostname + FROM firmware_upgrade_jobs j + LEFT JOIN devices d ON d.id = j.device_id + WHERE j.rollout_group_id = CAST(:group_id AS uuid) + ORDER BY j.created_at ASC + """), + {"group_id": str(rollout_group_id)}, + ) + rows = result.fetchall() + + if not rows: + raise HTTPException(404, "Rollout group not found") + + # Compute summary + total = len(rows) + completed = sum(1 for r in rows if r[2] == "completed") + failed = sum(1 for r in rows if r[2] == "failed") + paused = sum(1 for r in rows if r[2] == "paused") + pending = sum(1 for r in rows if r[2] in ("pending", "scheduled")) + + # Find currently running device + active_statuses = {"downloading", "uploading", "rebooting", "verifying"} + current_device = None + for r in rows: + if r[2] in active_statuses: + current_device = r[8] or str(r[1]) + break + + jobs = [ + { + "id": str(r[0]), + "device_id": str(r[1]), + "status": r[2], + "target_version": r[3], + "architecture": r[4], + "error_message": r[5], + "started_at": r[6].isoformat() if r[6] else None, + "completed_at": r[7].isoformat() if r[7] else None, + "device_hostname": r[8], + } + for r in rows + ] + + return { + "rollout_group_id": str(rollout_group_id), + "total": total, + "completed": completed, + "failed": failed, + "paused": paused, + "pending": pending, + "current_device": current_device, + "jobs": jobs, + } + + +@router.post( + "/tenants/{tenant_id}/firmware/upgrades/{job_id}/cancel", + summary="Cancel a scheduled or pending upgrade", + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("20/minute") +async def cancel_upgrade_endpoint( + request: Request, + tenant_id: uuid.UUID, + job_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + await _check_tenant_access(current_user, tenant_id, db) + + if current_user.role == "viewer": + raise HTTPException(403, "Viewers cannot cancel upgrades") + + from app.services.upgrade_service import cancel_upgrade + await cancel_upgrade(str(job_id)) + return {"status": "ok", "message": "Upgrade cancelled"} + + +@router.post( + "/tenants/{tenant_id}/firmware/upgrades/{job_id}/retry", + summary="Retry a failed upgrade", + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("20/minute") +async def retry_upgrade_endpoint( + request: Request, + tenant_id: uuid.UUID, + job_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + await _check_tenant_access(current_user, tenant_id, db) + + if current_user.role == "viewer": + raise HTTPException(403, "Viewers cannot retry upgrades") + + from app.services.upgrade_service import retry_failed_upgrade + await retry_failed_upgrade(str(job_id)) + return {"status": "ok", "message": "Upgrade retry started"} + + +@router.post( + "/tenants/{tenant_id}/firmware/rollouts/{rollout_group_id}/resume", + summary="Resume a paused mass rollout", + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("20/minute") +async def resume_rollout_endpoint( + request: Request, + tenant_id: uuid.UUID, + rollout_group_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + await _check_tenant_access(current_user, tenant_id, db) + + if current_user.role == "viewer": + raise HTTPException(403, "Viewers cannot resume rollouts") + + from app.services.upgrade_service import resume_mass_upgrade + await resume_mass_upgrade(str(rollout_group_id)) + return {"status": "ok", "message": "Rollout resumed"} + + +@router.post( + "/tenants/{tenant_id}/firmware/rollouts/{rollout_group_id}/abort", + summary="Abort remaining devices in a paused rollout", + dependencies=[require_scope("firmware:write")], +) +@limiter.limit("5/minute") +async def abort_rollout_endpoint( + request: Request, + tenant_id: uuid.UUID, + rollout_group_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + + if current_user.role == "viewer": + raise HTTPException(403, "Viewers cannot abort rollouts") + + from app.services.upgrade_service import abort_mass_upgrade + aborted = await abort_mass_upgrade(str(rollout_group_id)) + return {"status": "ok", "aborted_count": aborted} diff --git a/backend/app/routers/maintenance_windows.py b/backend/app/routers/maintenance_windows.py new file mode 100644 index 0000000..61e5abf --- /dev/null +++ b/backend/app/routers/maintenance_windows.py @@ -0,0 +1,309 @@ +"""Maintenance windows API endpoints. + +Tenant-scoped routes under /api/tenants/{tenant_id}/ for: +- Maintenance window CRUD (list, create, update, delete) +- Filterable by status: upcoming, active, past + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: operator and above for all operations. +""" + +import json +import logging +import uuid +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.rate_limit import limiter +from app.middleware.tenant_context import CurrentUser, get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["maintenance-windows"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +def _require_operator(current_user: CurrentUser) -> None: + """Raise 403 if user does not have at least operator role.""" + if current_user.role == "viewer": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Requires at least operator role.", + ) + + +# --------------------------------------------------------------------------- +# Request/response schemas +# --------------------------------------------------------------------------- + + +class MaintenanceWindowCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + device_ids: list[str] = [] + start_at: datetime + end_at: datetime + suppress_alerts: bool = True + notes: Optional[str] = None + + +class MaintenanceWindowUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: Optional[str] = None + device_ids: Optional[list[str]] = None + start_at: Optional[datetime] = None + end_at: Optional[datetime] = None + suppress_alerts: Optional[bool] = None + notes: Optional[str] = None + + +class MaintenanceWindowResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + id: str + tenant_id: str + name: str + device_ids: list[str] + start_at: str + end_at: str + suppress_alerts: bool + notes: Optional[str] = None + created_by: Optional[str] = None + created_at: str + + +# --------------------------------------------------------------------------- +# CRUD endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/maintenance-windows", + summary="List maintenance windows for tenant", +) +async def list_maintenance_windows( + tenant_id: uuid.UUID, + window_status: Optional[str] = Query(None, alias="status"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + + filters = ["1=1"] + params: dict[str, Any] = {} + + if window_status == "active": + filters.append("mw.start_at <= NOW() AND mw.end_at >= NOW()") + elif window_status == "upcoming": + filters.append("mw.start_at > NOW()") + elif window_status == "past": + filters.append("mw.end_at < NOW()") + + where = " AND ".join(filters) + + result = await db.execute( + text(f""" + SELECT mw.id, mw.tenant_id, mw.name, mw.device_ids, + mw.start_at, mw.end_at, mw.suppress_alerts, + mw.notes, mw.created_by, mw.created_at + FROM maintenance_windows mw + WHERE {where} + ORDER BY mw.start_at DESC + """), + params, + ) + + return [ + { + "id": str(row[0]), + "tenant_id": str(row[1]), + "name": row[2], + "device_ids": row[3] if isinstance(row[3], list) else [], + "start_at": row[4].isoformat() if row[4] else None, + "end_at": row[5].isoformat() if row[5] else None, + "suppress_alerts": row[6], + "notes": row[7], + "created_by": str(row[8]) if row[8] else None, + "created_at": row[9].isoformat() if row[9] else None, + } + for row in result.fetchall() + ] + + +@router.post( + "/tenants/{tenant_id}/maintenance-windows", + summary="Create maintenance window", + status_code=status.HTTP_201_CREATED, +) +@limiter.limit("20/minute") +async def create_maintenance_window( + request: Request, + tenant_id: uuid.UUID, + body: MaintenanceWindowCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + + if body.end_at <= body.start_at: + raise HTTPException(422, "end_at must be after start_at") + + window_id = str(uuid.uuid4()) + + await db.execute( + text(""" + INSERT INTO maintenance_windows + (id, tenant_id, name, device_ids, start_at, end_at, + suppress_alerts, notes, created_by) + VALUES + (CAST(:id AS uuid), CAST(:tenant_id AS uuid), + :name, CAST(:device_ids AS jsonb), :start_at, :end_at, + :suppress_alerts, :notes, CAST(:created_by AS uuid)) + """), + { + "id": window_id, + "tenant_id": str(tenant_id), + "name": body.name, + "device_ids": json.dumps(body.device_ids), + "start_at": body.start_at, + "end_at": body.end_at, + "suppress_alerts": body.suppress_alerts, + "notes": body.notes, + "created_by": str(current_user.user_id), + }, + ) + await db.commit() + + return { + "id": window_id, + "tenant_id": str(tenant_id), + "name": body.name, + "device_ids": body.device_ids, + "start_at": body.start_at.isoformat(), + "end_at": body.end_at.isoformat(), + "suppress_alerts": body.suppress_alerts, + "notes": body.notes, + "created_by": str(current_user.user_id), + "created_at": datetime.utcnow().isoformat(), + } + + +@router.put( + "/tenants/{tenant_id}/maintenance-windows/{window_id}", + summary="Update maintenance window", +) +@limiter.limit("20/minute") +async def update_maintenance_window( + request: Request, + tenant_id: uuid.UUID, + window_id: uuid.UUID, + body: MaintenanceWindowUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + + # Build dynamic SET clause for partial updates + set_parts: list[str] = ["updated_at = NOW()"] + params: dict[str, Any] = {"window_id": str(window_id)} + + if body.name is not None: + set_parts.append("name = :name") + params["name"] = body.name + if body.device_ids is not None: + set_parts.append("device_ids = CAST(:device_ids AS jsonb)") + params["device_ids"] = json.dumps(body.device_ids) + if body.start_at is not None: + set_parts.append("start_at = :start_at") + params["start_at"] = body.start_at + if body.end_at is not None: + set_parts.append("end_at = :end_at") + params["end_at"] = body.end_at + if body.suppress_alerts is not None: + set_parts.append("suppress_alerts = :suppress_alerts") + params["suppress_alerts"] = body.suppress_alerts + if body.notes is not None: + set_parts.append("notes = :notes") + params["notes"] = body.notes + + set_clause = ", ".join(set_parts) + + result = await db.execute( + text(f""" + UPDATE maintenance_windows + SET {set_clause} + WHERE id = CAST(:window_id AS uuid) + RETURNING id, tenant_id, name, device_ids, start_at, end_at, + suppress_alerts, notes, created_by, created_at + """), + params, + ) + row = result.fetchone() + if not row: + raise HTTPException(404, "Maintenance window not found") + await db.commit() + + return { + "id": str(row[0]), + "tenant_id": str(row[1]), + "name": row[2], + "device_ids": row[3] if isinstance(row[3], list) else [], + "start_at": row[4].isoformat() if row[4] else None, + "end_at": row[5].isoformat() if row[5] else None, + "suppress_alerts": row[6], + "notes": row[7], + "created_by": str(row[8]) if row[8] else None, + "created_at": row[9].isoformat() if row[9] else None, + } + + +@router.delete( + "/tenants/{tenant_id}/maintenance-windows/{window_id}", + summary="Delete maintenance window", + status_code=status.HTTP_204_NO_CONTENT, +) +@limiter.limit("5/minute") +async def delete_maintenance_window( + request: Request, + tenant_id: uuid.UUID, + window_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + + result = await db.execute( + text( + "DELETE FROM maintenance_windows WHERE id = CAST(:id AS uuid) RETURNING id" + ), + {"id": str(window_id)}, + ) + if not result.fetchone(): + raise HTTPException(404, "Maintenance window not found") + await db.commit() diff --git a/backend/app/routers/metrics.py b/backend/app/routers/metrics.py new file mode 100644 index 0000000..92ae3ea --- /dev/null +++ b/backend/app/routers/metrics.py @@ -0,0 +1,414 @@ +""" +Metrics API endpoints for querying TimescaleDB hypertables. + +All device-scoped routes are tenant-scoped under +/api/tenants/{tenant_id}/devices/{device_id}/metrics/*. +Fleet summary endpoints are under /api/tenants/{tenant_id}/fleet/summary +and /api/fleet/summary (super_admin cross-tenant). + +RLS is enforced via get_db() — the app_user engine applies tenant filtering +automatically based on the SET LOCAL app.current_tenant context. + +All endpoints require authentication (get_current_user) and enforce +tenant access via _check_tenant_access. +""" + +import uuid +from datetime import datetime, timedelta +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.tenant_context import CurrentUser, get_current_user + +router = APIRouter(tags=["metrics"]) + + +def _bucket_for_range(start: datetime, end: datetime) -> timedelta: + """ + Select an appropriate time_bucket size based on the requested time range. + + Shorter ranges get finer granularity; longer ranges get coarser buckets + to keep result sets manageable. + + Returns a timedelta because asyncpg requires a Python timedelta (not a + string interval literal) when binding the first argument of time_bucket(). + """ + delta = end - start + hours = delta.total_seconds() / 3600 + if hours <= 1: + return timedelta(minutes=1) + elif hours <= 6: + return timedelta(minutes=5) + elif hours <= 24: + return timedelta(minutes=15) + elif hours <= 168: # 7 days + return timedelta(hours=1) + elif hours <= 720: # 30 days + return timedelta(hours=6) + else: + return timedelta(days=1) + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """ + Verify the current user is allowed to access the given tenant. + + - super_admin can access any tenant — re-sets DB tenant context to target tenant. + - All other roles must match their own tenant_id. + """ + if current_user.is_super_admin: + # Re-set tenant context to the target tenant so RLS allows the operation + from app.database import set_tenant_context + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + + +# --------------------------------------------------------------------------- +# Health metrics +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/health", + summary="Time-bucketed health metrics (CPU, memory, disk, temperature)", +) +async def device_health_metrics( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + start: datetime = Query(..., description="Start of time range (ISO format)"), + end: datetime = Query(..., description="End of time range (ISO format)"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """ + Return time-bucketed CPU, memory, disk, and temperature metrics for a device. + + Bucket size adapts automatically to the requested time range. + """ + await _check_tenant_access(current_user, tenant_id, db) + bucket = _bucket_for_range(start, end) + + result = await db.execute( + text(""" + SELECT + time_bucket(:bucket, time) AS bucket, + avg(cpu_load)::smallint AS avg_cpu, + max(cpu_load)::smallint AS max_cpu, + avg(CASE WHEN total_memory > 0 + THEN round((1 - free_memory::float / total_memory) * 100) + ELSE NULL END)::smallint AS avg_mem_pct, + avg(CASE WHEN total_disk > 0 + THEN round((1 - free_disk::float / total_disk) * 100) + ELSE NULL END)::smallint AS avg_disk_pct, + avg(temperature)::smallint AS avg_temp + FROM health_metrics + WHERE device_id = :device_id + AND time >= :start AND time < :end + GROUP BY bucket + ORDER BY bucket ASC + """), + {"bucket": bucket, "device_id": str(device_id), "start": start, "end": end}, + ) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +# --------------------------------------------------------------------------- +# Interface traffic metrics +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/interfaces", + summary="Time-bucketed interface bandwidth metrics (bps from cumulative byte deltas)", +) +async def device_interface_metrics( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + start: datetime = Query(..., description="Start of time range (ISO format)"), + end: datetime = Query(..., description="End of time range (ISO format)"), + interface: Optional[str] = Query(None, description="Filter to a specific interface name"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """ + Return time-bucketed interface traffic metrics for a device. + + Bandwidth (bps) is computed from raw cumulative byte counters using + SQL LAG() window functions — no poller-side state is required. + Counter wraps (rx_bytes < prev_rx) are treated as NULL to avoid + incorrect spikes. + """ + await _check_tenant_access(current_user, tenant_id, db) + bucket = _bucket_for_range(start, end) + + # Build interface filter clause conditionally. + # The interface name is passed as a bind parameter — never interpolated + # into the SQL string — so this is safe from SQL injection. + interface_filter = "AND interface = :interface" if interface else "" + + sql = f""" + WITH ordered AS ( + SELECT + time, + interface, + rx_bytes, + tx_bytes, + LAG(rx_bytes) OVER (PARTITION BY interface ORDER BY time) AS prev_rx, + LAG(tx_bytes) OVER (PARTITION BY interface ORDER BY time) AS prev_tx, + EXTRACT(EPOCH FROM time - LAG(time) OVER (PARTITION BY interface ORDER BY time)) AS dt + FROM interface_metrics + WHERE device_id = :device_id + AND time >= :start AND time < :end + {interface_filter} + ), + with_bps AS ( + SELECT + time, + interface, + rx_bytes, + tx_bytes, + CASE WHEN rx_bytes >= prev_rx AND dt > 0 + THEN ((rx_bytes - prev_rx) * 8 / dt)::bigint + ELSE NULL END AS rx_bps, + CASE WHEN tx_bytes >= prev_tx AND dt > 0 + THEN ((tx_bytes - prev_tx) * 8 / dt)::bigint + ELSE NULL END AS tx_bps + FROM ordered + WHERE prev_rx IS NOT NULL + ) + SELECT + time_bucket(:bucket, time) AS bucket, + interface, + avg(rx_bps)::bigint AS avg_rx_bps, + avg(tx_bps)::bigint AS avg_tx_bps, + max(rx_bps)::bigint AS max_rx_bps, + max(tx_bps)::bigint AS max_tx_bps + FROM with_bps + WHERE rx_bps IS NOT NULL + GROUP BY bucket, interface + ORDER BY interface, bucket ASC + """ + + params: dict[str, Any] = { + "bucket": bucket, + "device_id": str(device_id), + "start": start, + "end": end, + } + if interface: + params["interface"] = interface + + result = await db.execute(text(sql), params) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/interfaces/list", + summary="List distinct interface names for a device", +) +async def device_interface_list( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[str]: + """Return distinct interface names seen in interface_metrics for a device.""" + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT DISTINCT interface + FROM interface_metrics + WHERE device_id = :device_id + ORDER BY interface + """), + {"device_id": str(device_id)}, + ) + rows = result.scalars().all() + return list(rows) + + +# --------------------------------------------------------------------------- +# Wireless metrics +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/wireless", + summary="Time-bucketed wireless metrics (clients, signal, CCQ)", +) +async def device_wireless_metrics( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + start: datetime = Query(..., description="Start of time range (ISO format)"), + end: datetime = Query(..., description="End of time range (ISO format)"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """Return time-bucketed wireless metrics per interface for a device.""" + await _check_tenant_access(current_user, tenant_id, db) + bucket = _bucket_for_range(start, end) + + result = await db.execute( + text(""" + SELECT + time_bucket(:bucket, time) AS bucket, + interface, + avg(client_count)::smallint AS avg_clients, + max(client_count)::smallint AS max_clients, + avg(avg_signal)::smallint AS avg_signal, + avg(ccq)::smallint AS avg_ccq, + max(frequency) AS frequency + FROM wireless_metrics + WHERE device_id = :device_id + AND time >= :start AND time < :end + GROUP BY bucket, interface + ORDER BY interface, bucket ASC + """), + {"bucket": bucket, "device_id": str(device_id), "start": start, "end": end}, + ) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/wireless/latest", + summary="Latest wireless stats per interface (not time-bucketed)", +) +async def device_wireless_latest( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """Return the most recent wireless reading per interface for a device.""" + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT DISTINCT ON (interface) + interface, client_count, avg_signal, ccq, frequency, time + FROM wireless_metrics + WHERE device_id = :device_id + ORDER BY interface, time DESC + """), + {"device_id": str(device_id)}, + ) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +# --------------------------------------------------------------------------- +# Sparkline +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/sparkline", + summary="Last 12 health readings for sparkline display", +) +async def device_sparkline( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """ + Return the last 12 CPU readings (in chronological order) for sparkline + display in the fleet table. + """ + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT cpu_load, time + FROM ( + SELECT cpu_load, time + FROM health_metrics + WHERE device_id = :device_id + ORDER BY time DESC + LIMIT 12 + ) sub + ORDER BY time ASC + """), + {"device_id": str(device_id)}, + ) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +# --------------------------------------------------------------------------- +# Fleet summary +# --------------------------------------------------------------------------- + +_FLEET_SUMMARY_SQL = """ + SELECT + d.id, d.hostname, d.ip_address, d.status, d.model, d.last_seen, + d.uptime_seconds, d.last_cpu_load, d.last_memory_used_pct, + d.latitude, d.longitude, + d.tenant_id, t.name AS tenant_name + FROM devices d + JOIN tenants t ON d.tenant_id = t.id + ORDER BY t.name, d.hostname +""" + + +@router.get( + "/tenants/{tenant_id}/fleet/summary", + summary="Fleet summary for a tenant (latest metrics per device)", +) +async def fleet_summary( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """ + Return fleet summary for a single tenant. + + Queries the devices table (not hypertables) for speed. + RLS filters to only devices belonging to the tenant automatically. + """ + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute(text(_FLEET_SUMMARY_SQL)) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +@router.get( + "/fleet/summary", + summary="Cross-tenant fleet summary (super_admin only)", +) +async def fleet_summary_all( + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """ + Return fleet summary across ALL tenants. + + Requires super_admin role. The RLS policy for super_admin returns all + rows across all tenants, so the same SQL query works without modification. + This avoids the N+1 problem of fetching per-tenant summaries in a loop. + """ + if current_user.role != "super_admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Super admin required", + ) + + result = await db.execute(text(_FLEET_SUMMARY_SQL)) + rows = result.mappings().all() + return [dict(row) for row in rows] diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py new file mode 100644 index 0000000..e9bf72a --- /dev/null +++ b/backend/app/routers/reports.py @@ -0,0 +1,146 @@ +"""Report generation API endpoint. + +POST /api/tenants/{tenant_id}/reports/generate +Generates PDF or CSV reports for device inventory, metrics summary, +alert history, and change log. + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: require at least operator role. +""" + +import uuid +from datetime import datetime +from enum import Enum +from typing import Optional + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, ConfigDict +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.services.report_service import generate_report + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["reports"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +def _require_operator(current_user: CurrentUser) -> None: + """Raise 403 if user is a viewer (reports require operator+).""" + if current_user.role == "viewer": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Reports require at least operator role.", + ) + + +# --------------------------------------------------------------------------- +# Request schema +# --------------------------------------------------------------------------- + + +class ReportType(str, Enum): + device_inventory = "device_inventory" + metrics_summary = "metrics_summary" + alert_history = "alert_history" + change_log = "change_log" + + +class ReportFormat(str, Enum): + pdf = "pdf" + csv = "csv" + + +class ReportRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: ReportType + date_from: Optional[datetime] = None + date_to: Optional[datetime] = None + format: ReportFormat = ReportFormat.pdf + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/reports/generate", + summary="Generate a report (PDF or CSV)", + response_class=StreamingResponse, +) +async def generate_report_endpoint( + tenant_id: uuid.UUID, + body: ReportRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> StreamingResponse: + """Generate and download a report as PDF or CSV. + + - device_inventory: no date range required + - metrics_summary, alert_history, change_log: date_from and date_to required + """ + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + + # Validate date range for time-based reports + if body.type != ReportType.device_inventory: + if not body.date_from or not body.date_to: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"date_from and date_to are required for {body.type.value} reports.", + ) + if body.date_from > body.date_to: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="date_from must be before date_to.", + ) + + try: + file_bytes, content_type, filename = await generate_report( + db=db, + tenant_id=tenant_id, + report_type=body.type.value, + date_from=body.date_from, + date_to=body.date_to, + fmt=body.format.value, + ) + except Exception as exc: + logger.error("report_generation_failed", error=str(exc), report_type=body.type.value) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Report generation failed: {str(exc)}", + ) + + import io + + return StreamingResponse( + io.BytesIO(file_bytes), + media_type=content_type, + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Length": str(len(file_bytes)), + }, + ) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..0bb6185 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,155 @@ +"""System settings router — global SMTP configuration. + +Super-admin only. Stores SMTP settings in system_settings table with +Transit encryption for passwords. Falls back to .env values. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy import text + +from app.config import settings +from app.database import AdminAsyncSessionLocal +from app.middleware.rbac import require_role +from app.services.email_service import SMTPConfig, send_test_email, test_smtp_connection + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/settings", tags=["settings"]) + +SMTP_KEYS = [ + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_password", + "smtp_use_tls", + "smtp_from_address", + "smtp_provider", +] + + +class SMTPSettingsUpdate(BaseModel): + smtp_host: str + smtp_port: int = 587 + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None + smtp_use_tls: bool = False + smtp_from_address: str = "noreply@example.com" + smtp_provider: str = "custom" + + +class SMTPTestRequest(BaseModel): + to: str + smtp_host: Optional[str] = None + smtp_port: Optional[int] = None + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None + smtp_use_tls: Optional[bool] = None + smtp_from_address: Optional[str] = None + + +async def _get_system_settings(keys: list[str]) -> dict: + """Read settings from system_settings table.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text("SELECT key, value FROM system_settings WHERE key = ANY(:keys)"), + {"keys": keys}, + ) + return {row[0]: row[1] for row in result.fetchall()} + + +async def _set_system_settings(updates: dict, user_id: str) -> None: + """Upsert settings into system_settings table.""" + async with AdminAsyncSessionLocal() as session: + for key, value in updates.items(): + await session.execute( + text(""" + INSERT INTO system_settings (key, value, updated_by, updated_at) + VALUES (:key, :value, CAST(:user_id AS uuid), now()) + ON CONFLICT (key) DO UPDATE + SET value = :value, updated_by = CAST(:user_id AS uuid), updated_at = now() + """), + {"key": key, "value": str(value) if value is not None else None, "user_id": user_id}, + ) + await session.commit() + + +async def get_smtp_config() -> SMTPConfig: + """Get SMTP config from system_settings, falling back to .env.""" + db_settings = await _get_system_settings(SMTP_KEYS) + + return SMTPConfig( + host=db_settings.get("smtp_host") or settings.SMTP_HOST, + port=int(db_settings.get("smtp_port") or settings.SMTP_PORT), + user=db_settings.get("smtp_user") or settings.SMTP_USER, + password=db_settings.get("smtp_password") or settings.SMTP_PASSWORD, + use_tls=(db_settings.get("smtp_use_tls") or str(settings.SMTP_USE_TLS)).lower() == "true", + from_address=db_settings.get("smtp_from_address") or settings.SMTP_FROM_ADDRESS, + ) + + +@router.get("/smtp") +async def get_smtp_settings(user=Depends(require_role("super_admin"))): + """Get current global SMTP configuration. Password is redacted.""" + db_settings = await _get_system_settings(SMTP_KEYS) + + return { + "smtp_host": db_settings.get("smtp_host") or settings.SMTP_HOST, + "smtp_port": int(db_settings.get("smtp_port") or settings.SMTP_PORT), + "smtp_user": db_settings.get("smtp_user") or settings.SMTP_USER or "", + "smtp_use_tls": (db_settings.get("smtp_use_tls") or str(settings.SMTP_USE_TLS)).lower() == "true", + "smtp_from_address": db_settings.get("smtp_from_address") or settings.SMTP_FROM_ADDRESS, + "smtp_provider": db_settings.get("smtp_provider") or "custom", + "smtp_password_set": bool(db_settings.get("smtp_password") or settings.SMTP_PASSWORD), + "source": "database" if db_settings.get("smtp_host") else "environment", + } + + +@router.put("/smtp") +async def update_smtp_settings( + data: SMTPSettingsUpdate, + user=Depends(require_role("super_admin")), +): + """Update global SMTP configuration.""" + updates = { + "smtp_host": data.smtp_host, + "smtp_port": str(data.smtp_port), + "smtp_user": data.smtp_user, + "smtp_use_tls": str(data.smtp_use_tls).lower(), + "smtp_from_address": data.smtp_from_address, + "smtp_provider": data.smtp_provider, + } + if data.smtp_password is not None: + updates["smtp_password"] = data.smtp_password + + await _set_system_settings(updates, str(user.id)) + return {"status": "ok"} + + +@router.post("/smtp/test") +async def test_smtp( + data: SMTPTestRequest, + user=Depends(require_role("super_admin")), +): + """Test SMTP connection and optionally send a test email.""" + # Use provided values or fall back to saved config + saved = await get_smtp_config() + config = SMTPConfig( + host=data.smtp_host or saved.host, + port=data.smtp_port if data.smtp_port is not None else saved.port, + user=data.smtp_user if data.smtp_user is not None else saved.user, + password=data.smtp_password if data.smtp_password is not None else saved.password, + use_tls=data.smtp_use_tls if data.smtp_use_tls is not None else saved.use_tls, + from_address=data.smtp_from_address or saved.from_address, + ) + + conn_result = await test_smtp_connection(config) + if not conn_result["success"]: + return conn_result + + if data.to: + return await send_test_email(data.to, config) + + return conn_result diff --git a/backend/app/routers/sse.py b/backend/app/routers/sse.py new file mode 100644 index 0000000..8ea9ad6 --- /dev/null +++ b/backend/app/routers/sse.py @@ -0,0 +1,141 @@ +"""SSE streaming endpoint for real-time event delivery. + +Provides a Server-Sent Events endpoint per tenant that streams device status, +alert, config push, and firmware progress events in real time. Authentication +is via a short-lived, single-use exchange token (obtained from POST /auth/sse-token) +to avoid exposing the full JWT in query parameters. +""" + +import asyncio +import json +import uuid +from typing import AsyncGenerator, Optional + +import redis.asyncio as aioredis +import structlog +from fastapi import APIRouter, HTTPException, Query, Request, status +from sse_starlette.sse import EventSourceResponse, ServerSentEvent + +from app.services.sse_manager import SSEConnectionManager + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["sse"]) + +# ─── Redis for SSE token validation ─────────────────────────────────────────── + +_redis: aioredis.Redis | None = None + + +async def _get_sse_redis() -> aioredis.Redis: + """Lazily initialise and return the SSE Redis client.""" + global _redis + if _redis is None: + from app.config import settings + _redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True) + return _redis + + +async def _validate_sse_token(token: str) -> dict: + """Validate a short-lived SSE exchange token via Redis. + + The token is single-use: retrieved and deleted atomically with GETDEL. + If the token is not found (expired or already used), raises 401. + + Args: + token: SSE exchange token string (from query param). + + Returns: + Dict with user_id, tenant_id, and role. + + Raises: + HTTPException 401: If the token is invalid, expired, or already used. + """ + redis = await _get_sse_redis() + key = f"sse_token:{token}" + data = await redis.getdel(key) # Single-use: delete on retrieval + if not data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired SSE token", + ) + return json.loads(data) + + +@router.get( + "/tenants/{tenant_id}/events/stream", + summary="SSE event stream for real-time tenant events", + response_class=EventSourceResponse, +) +async def event_stream( + request: Request, + tenant_id: uuid.UUID, + token: str = Query(..., description="Short-lived SSE exchange token (from POST /auth/sse-token)"), +) -> EventSourceResponse: + """Stream real-time events for a tenant via Server-Sent Events. + + Event types: device_status, alert_fired, alert_resolved, config_push, + firmware_progress, metric_update. + + Supports Last-Event-ID header for reconnection replay. + Sends heartbeat comments every 15 seconds on idle connections. + """ + # Validate exchange token from query parameter (single-use, 30s TTL) + user_context = await _validate_sse_token(token) + user_role = user_context.get("role", "") + user_tenant_id = user_context.get("tenant_id") + user_id = user_context.get("user_id", "") + + # Authorization: user must belong to the requested tenant or be super_admin + if user_role != "super_admin" and (user_tenant_id is None or str(user_tenant_id) != str(tenant_id)): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized for this tenant", + ) + + # super_admin receives events from ALL tenants (tenant_id filter = None) + filter_tenant_id: Optional[str] = None if user_role == "super_admin" else str(tenant_id) + + # Generate unique connection ID + connection_id = f"sse-{uuid.uuid4().hex[:12]}" + + # Check for Last-Event-ID header (reconnection replay) + last_event_id = request.headers.get("Last-Event-ID") + + logger.info( + "sse.stream_requested", + connection_id=connection_id, + tenant_id=str(tenant_id), + user_id=user_id, + role=user_role, + last_event_id=last_event_id, + ) + + manager = SSEConnectionManager() + queue = await manager.connect( + connection_id=connection_id, + tenant_id=filter_tenant_id, + last_event_id=last_event_id, + ) + + async def event_generator() -> AsyncGenerator[ServerSentEvent, None]: + """Yield SSE events from the queue with 15s heartbeat on idle.""" + try: + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=15.0) + yield ServerSentEvent( + data=event["data"], + event=event["event"], + id=event["id"], + ) + except asyncio.TimeoutError: + # Send heartbeat comment to keep connection alive + yield ServerSentEvent(comment="heartbeat") + except asyncio.CancelledError: + break + finally: + await manager.disconnect() + logger.info("sse.stream_closed", connection_id=connection_id) + + return EventSourceResponse(event_generator()) diff --git a/backend/app/routers/templates.py b/backend/app/routers/templates.py new file mode 100644 index 0000000..eb56267 --- /dev/null +++ b/backend/app/routers/templates.py @@ -0,0 +1,613 @@ +""" +Config template CRUD, preview, and push API endpoints. + +All routes are tenant-scoped under: + /api/tenants/{tenant_id}/templates/ + +Provides: + - GET /templates -- list templates (optional tag filter) + - POST /templates -- create a template + - GET /templates/{id} -- get single template + - PUT /templates/{id} -- update a template + - DELETE /templates/{id} -- delete a template + - POST /templates/{id}/preview -- preview rendered template for a device + - POST /templates/{id}/push -- push template to devices (sequential rollout) + - GET /templates/push-status/{rollout_id} -- poll push progress + +RLS is enforced via get_db() (app_user engine with tenant context). +RBAC: viewer = read (GET/preview); operator and above = write (POST/PUT/DELETE/push). +""" + +import asyncio +import logging +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.middleware.rate_limit import limiter +from app.middleware.rbac import require_min_role, require_scope +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.models.config_template import ConfigTemplate, ConfigTemplateTag, TemplatePushJob +from app.models.device import Device +from app.services import template_service + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["templates"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + from app.database import set_tenant_context + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + + +def _serialize_template(template: ConfigTemplate, include_content: bool = False) -> dict: + """Serialize a ConfigTemplate to a response dict.""" + result: dict[str, Any] = { + "id": str(template.id), + "name": template.name, + "description": template.description, + "tags": [tag.name for tag in template.tags], + "variable_count": len(template.variables) if template.variables else 0, + "created_at": template.created_at.isoformat(), + "updated_at": template.updated_at.isoformat(), + } + if include_content: + result["content"] = template.content + result["variables"] = template.variables or [] + return result + + +# --------------------------------------------------------------------------- +# Request/Response schemas +# --------------------------------------------------------------------------- + + +class VariableDef(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + type: str = "string" # string | ip | integer | boolean | subnet + default: Optional[str] = None + description: Optional[str] = None + + +class TemplateCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + description: Optional[str] = None + content: str + variables: list[VariableDef] = [] + tags: list[str] = [] + + +class TemplateUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + description: Optional[str] = None + content: str + variables: list[VariableDef] = [] + tags: list[str] = [] + + +class PreviewRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + device_id: str + variables: dict[str, str] = {} + + +class PushRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + device_ids: list[str] + variables: dict[str, str] = {} + + +# --------------------------------------------------------------------------- +# CRUD endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/templates", + summary="List config templates", + dependencies=[require_scope("config:read")], +) +async def list_templates( + tenant_id: uuid.UUID, + tag: Optional[str] = Query(None, description="Filter by tag name"), + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> list[dict]: + """List all config templates for a tenant with optional tag filtering.""" + await _check_tenant_access(current_user, tenant_id, db) + + query = ( + select(ConfigTemplate) + .options(selectinload(ConfigTemplate.tags)) + .where(ConfigTemplate.tenant_id == tenant_id) # type: ignore[arg-type] + .order_by(ConfigTemplate.updated_at.desc()) + ) + + if tag: + query = query.where( + ConfigTemplate.id.in_( # type: ignore[attr-defined] + select(ConfigTemplateTag.template_id).where( + ConfigTemplateTag.name == tag, + ConfigTemplateTag.tenant_id == tenant_id, # type: ignore[arg-type] + ) + ) + ) + + result = await db.execute(query) + templates = result.scalars().all() + + return [_serialize_template(t) for t in templates] + + +@router.post( + "/tenants/{tenant_id}/templates", + summary="Create a config template", + status_code=status.HTTP_201_CREATED, + dependencies=[require_scope("config:write")], +) +@limiter.limit("20/minute") +async def create_template( + request: Request, + tenant_id: uuid.UUID, + body: TemplateCreateRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Create a new config template with Jinja2 content and variable definitions.""" + await _check_tenant_access(current_user, tenant_id, db) + + # Auto-extract variables from content for comparison + detected = template_service.extract_variables(body.content) + provided_names = {v.name for v in body.variables} + unmatched = set(detected) - provided_names + if unmatched: + logger.warning( + "Template '%s' has undeclared variables: %s (auto-adding as string type)", + body.name, unmatched, + ) + + # Create template + template = ConfigTemplate( + tenant_id=tenant_id, + name=body.name, + description=body.description, + content=body.content, + variables=[v.model_dump() for v in body.variables], + ) + db.add(template) + await db.flush() # Get the generated ID + + # Create tags + for tag_name in body.tags: + tag = ConfigTemplateTag( + tenant_id=tenant_id, + name=tag_name, + template_id=template.id, + ) + db.add(tag) + + await db.flush() + + # Re-query with tags loaded + result = await db.execute( + select(ConfigTemplate) + .options(selectinload(ConfigTemplate.tags)) + .where(ConfigTemplate.id == template.id) # type: ignore[arg-type] + ) + template = result.scalar_one() + + return _serialize_template(template, include_content=True) + + +@router.get( + "/tenants/{tenant_id}/templates/{template_id}", + summary="Get a single config template", + dependencies=[require_scope("config:read")], +) +async def get_template( + tenant_id: uuid.UUID, + template_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Get a config template with full content, variables, and tags.""" + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + select(ConfigTemplate) + .options(selectinload(ConfigTemplate.tags)) + .where( + ConfigTemplate.id == template_id, # type: ignore[arg-type] + ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] + ) + ) + template = result.scalar_one_or_none() + + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template {template_id} not found", + ) + + return _serialize_template(template, include_content=True) + + +@router.put( + "/tenants/{tenant_id}/templates/{template_id}", + summary="Update a config template", + dependencies=[require_scope("config:write")], +) +@limiter.limit("20/minute") +async def update_template( + request: Request, + tenant_id: uuid.UUID, + template_id: uuid.UUID, + body: TemplateUpdateRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Update an existing config template.""" + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + select(ConfigTemplate) + .options(selectinload(ConfigTemplate.tags)) + .where( + ConfigTemplate.id == template_id, # type: ignore[arg-type] + ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] + ) + ) + template = result.scalar_one_or_none() + + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template {template_id} not found", + ) + + # Update fields + template.name = body.name + template.description = body.description + template.content = body.content + template.variables = [v.model_dump() for v in body.variables] + + # Replace tags: delete old, create new + await db.execute( + delete(ConfigTemplateTag).where( + ConfigTemplateTag.template_id == template_id # type: ignore[arg-type] + ) + ) + for tag_name in body.tags: + tag = ConfigTemplateTag( + tenant_id=tenant_id, + name=tag_name, + template_id=template.id, + ) + db.add(tag) + + await db.flush() + + # Re-query with fresh tags + result = await db.execute( + select(ConfigTemplate) + .options(selectinload(ConfigTemplate.tags)) + .where(ConfigTemplate.id == template.id) # type: ignore[arg-type] + ) + template = result.scalar_one() + + return _serialize_template(template, include_content=True) + + +@router.delete( + "/tenants/{tenant_id}/templates/{template_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a config template", + dependencies=[require_scope("config:write")], +) +@limiter.limit("5/minute") +async def delete_template( + request: Request, + tenant_id: uuid.UUID, + template_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> None: + """Delete a config template. Tags are cascade-deleted. Push jobs are SET NULL.""" + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + select(ConfigTemplate).where( + ConfigTemplate.id == template_id, # type: ignore[arg-type] + ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] + ) + ) + template = result.scalar_one_or_none() + + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template {template_id} not found", + ) + + await db.delete(template) + + +# --------------------------------------------------------------------------- +# Preview & Push endpoints +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/templates/{template_id}/preview", + summary="Preview template rendered for a specific device", + dependencies=[require_scope("config:read")], +) +async def preview_template( + tenant_id: uuid.UUID, + template_id: uuid.UUID, + body: PreviewRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Render a template with device context and custom variables for preview.""" + await _check_tenant_access(current_user, tenant_id, db) + + # Load template + result = await db.execute( + select(ConfigTemplate).where( + ConfigTemplate.id == template_id, # type: ignore[arg-type] + ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] + ) + ) + template = result.scalar_one_or_none() + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template {template_id} not found", + ) + + # Load device + result = await db.execute( + select(Device).where(Device.id == body.device_id) # type: ignore[arg-type] + ) + device = result.scalar_one_or_none() + if device is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device {body.device_id} not found", + ) + + # Validate variables against type definitions + if template.variables: + for var_def in template.variables: + var_name = var_def.get("name", "") + var_type = var_def.get("type", "string") + value = body.variables.get(var_name) + if value is None: + # Use default if available + default = var_def.get("default") + if default is not None: + body.variables[var_name] = default + continue + error = template_service.validate_variable(var_name, value, var_type) + if error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=error, + ) + + # Render + try: + rendered = template_service.render_template( + template.content, + { + "hostname": device.hostname, + "ip_address": device.ip_address, + "model": device.model, + }, + body.variables, + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Template rendering failed: {exc}", + ) + + return { + "rendered": rendered, + "device_hostname": device.hostname, + } + + +@router.post( + "/tenants/{tenant_id}/templates/{template_id}/push", + summary="Push template to devices (sequential rollout with panic-revert)", + dependencies=[require_scope("config:write")], +) +@limiter.limit("5/minute") +async def push_template( + request: Request, + tenant_id: uuid.UUID, + template_id: uuid.UUID, + body: PushRequest, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("operator")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Start a template push to one or more devices. + + Creates push jobs for each device and starts a background sequential rollout. + Returns the rollout_id for status polling. + """ + await _check_tenant_access(current_user, tenant_id, db) + + # Load template + result = await db.execute( + select(ConfigTemplate).where( + ConfigTemplate.id == template_id, # type: ignore[arg-type] + ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] + ) + ) + template = result.scalar_one_or_none() + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Template {template_id} not found", + ) + + if not body.device_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one device_id is required", + ) + + # Validate variables + if template.variables: + for var_def in template.variables: + var_name = var_def.get("name", "") + var_type = var_def.get("type", "string") + value = body.variables.get(var_name) + if value is None: + default = var_def.get("default") + if default is not None: + body.variables[var_name] = default + continue + error = template_service.validate_variable(var_name, value, var_type) + if error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=error, + ) + + rollout_id = uuid.uuid4() + jobs_created = [] + + for device_id_str in body.device_ids: + # Load device to render template per-device + result = await db.execute( + select(Device).where(Device.id == device_id_str) # type: ignore[arg-type] + ) + device = result.scalar_one_or_none() + if device is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device {device_id_str} not found", + ) + + # Render template with this device's context + try: + rendered = template_service.render_template( + template.content, + { + "hostname": device.hostname, + "ip_address": device.ip_address, + "model": device.model, + }, + body.variables, + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Template rendering failed for device {device.hostname}: {exc}", + ) + + # Create push job + job = TemplatePushJob( + tenant_id=tenant_id, + template_id=template_id, + device_id=device.id, + rollout_id=rollout_id, + rendered_content=rendered, + status="pending", + ) + db.add(job) + jobs_created.append({ + "job_id": str(job.id), + "device_id": str(device.id), + "device_hostname": device.hostname, + }) + + await db.flush() + + # Start background push task + asyncio.create_task(template_service.push_to_devices(str(rollout_id))) + + return { + "rollout_id": str(rollout_id), + "jobs": jobs_created, + } + + +@router.get( + "/tenants/{tenant_id}/templates/push-status/{rollout_id}", + summary="Poll push progress for a rollout", + dependencies=[require_scope("config:read")], +) +async def push_status( + tenant_id: uuid.UUID, + rollout_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> dict: + """Return all push job statuses for a rollout with device hostnames.""" + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + select(TemplatePushJob, Device.hostname) + .join(Device, TemplatePushJob.device_id == Device.id) # type: ignore[arg-type] + .where( + TemplatePushJob.rollout_id == rollout_id, # type: ignore[arg-type] + TemplatePushJob.tenant_id == tenant_id, # type: ignore[arg-type] + ) + .order_by(TemplatePushJob.created_at.asc()) + ) + rows = result.all() + + jobs = [] + for job, hostname in rows: + jobs.append({ + "device_id": str(job.device_id), + "hostname": hostname, + "status": job.status, + "error_message": job.error_message, + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + }) + + return { + "rollout_id": str(rollout_id), + "jobs": jobs, + } diff --git a/backend/app/routers/tenants.py b/backend/app/routers/tenants.py new file mode 100644 index 0000000..f868779 --- /dev/null +++ b/backend/app/routers/tenants.py @@ -0,0 +1,367 @@ +""" +Tenant management endpoints. + +GET /api/tenants — list tenants (super_admin: all; tenant_admin: own only) +POST /api/tenants — create tenant (super_admin only) +GET /api/tenants/{id} — get tenant detail +PUT /api/tenants/{id} — update tenant (super_admin only) +DELETE /api/tenants/{id} — delete tenant (super_admin only) +""" + +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.middleware.rate_limit import limiter + +from app.database import get_admin_db, get_db +from app.middleware.rbac import require_super_admin, require_tenant_admin_or_above +from app.middleware.tenant_context import CurrentUser +from app.models.device import Device +from app.models.tenant import Tenant +from app.models.user import User +from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate + +router = APIRouter(prefix="/tenants", tags=["tenants"]) + + +async def _get_tenant_response( + tenant: Tenant, + db: AsyncSession, +) -> TenantResponse: + """Build a TenantResponse with user and device counts.""" + user_count_result = await db.execute( + select(func.count(User.id)).where(User.tenant_id == tenant.id) + ) + user_count = user_count_result.scalar_one() or 0 + + device_count_result = await db.execute( + select(func.count(Device.id)).where(Device.tenant_id == tenant.id) + ) + device_count = device_count_result.scalar_one() or 0 + + return TenantResponse( + id=tenant.id, + name=tenant.name, + description=tenant.description, + contact_email=tenant.contact_email, + user_count=user_count, + device_count=device_count, + created_at=tenant.created_at, + ) + + +@router.get("", response_model=list[TenantResponse], summary="List tenants") +async def list_tenants( + current_user: CurrentUser = Depends(require_tenant_admin_or_above), + db: AsyncSession = Depends(get_admin_db), +) -> list[TenantResponse]: + """ + List tenants. + - super_admin: sees all tenants + - tenant_admin: sees only their own tenant + """ + if current_user.is_super_admin: + result = await db.execute(select(Tenant).order_by(Tenant.name)) + tenants = result.scalars().all() + else: + if not current_user.tenant_id: + return [] + result = await db.execute( + select(Tenant).where(Tenant.id == current_user.tenant_id) + ) + tenants = result.scalars().all() + + return [await _get_tenant_response(tenant, db) for tenant in tenants] + + +@router.post("", response_model=TenantResponse, status_code=status.HTTP_201_CREATED, summary="Create a tenant") +@limiter.limit("20/minute") +async def create_tenant( + request: Request, + data: TenantCreate, + current_user: CurrentUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_admin_db), +) -> TenantResponse: + """Create a new tenant (super_admin only).""" + # Check for name uniqueness + existing = await db.execute(select(Tenant).where(Tenant.name == data.name)) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Tenant with name '{data.name}' already exists", + ) + + tenant = Tenant(name=data.name, description=data.description, contact_email=data.contact_email) + db.add(tenant) + await db.commit() + await db.refresh(tenant) + + # Seed default alert rules for new tenant + default_rules = [ + ("High CPU Usage", "cpu_load", "gt", 90, 5, "warning"), + ("High Memory Usage", "memory_used_pct", "gt", 90, 5, "warning"), + ("High Disk Usage", "disk_used_pct", "gt", 85, 3, "warning"), + ("Device Offline", "device_offline", "eq", 1, 1, "critical"), + ] + for name, metric, operator, threshold, duration, sev in default_rules: + await db.execute(text(""" + INSERT INTO alert_rules (id, tenant_id, name, metric, operator, threshold, duration_polls, severity, enabled, is_default) + VALUES (gen_random_uuid(), CAST(:tenant_id AS uuid), :name, :metric, :operator, :threshold, :duration, :severity, TRUE, TRUE) + """), { + "tenant_id": str(tenant.id), "name": name, "metric": metric, + "operator": operator, "threshold": threshold, "duration": duration, "severity": sev, + }) + await db.commit() + + # Seed starter config templates for new tenant + await _seed_starter_templates(db, tenant.id) + await db.commit() + + # Provision OpenBao Transit key for the new tenant (non-blocking) + try: + from app.config import settings + from app.services.key_service import provision_tenant_key + + if settings.OPENBAO_ADDR: + await provision_tenant_key(db, tenant.id) + await db.commit() + except Exception as exc: + import logging + logging.getLogger(__name__).warning( + "OpenBao key provisioning failed for tenant %s (will be provisioned on next startup): %s", + tenant.id, + exc, + ) + + return await _get_tenant_response(tenant, db) + + +@router.get("/{tenant_id}", response_model=TenantResponse, summary="Get tenant detail") +async def get_tenant( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(require_tenant_admin_or_above), + db: AsyncSession = Depends(get_admin_db), +) -> TenantResponse: + """Get tenant detail. Tenant admins can only view their own tenant.""" + # Enforce tenant_admin can only see their own tenant + if not current_user.is_super_admin and current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + + return await _get_tenant_response(tenant, db) + + +@router.put("/{tenant_id}", response_model=TenantResponse, summary="Update a tenant") +@limiter.limit("20/minute") +async def update_tenant( + request: Request, + tenant_id: uuid.UUID, + data: TenantUpdate, + current_user: CurrentUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_admin_db), +) -> TenantResponse: + """Update tenant (super_admin only).""" + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + + if data.name is not None: + # Check name uniqueness + name_check = await db.execute( + select(Tenant).where(Tenant.name == data.name, Tenant.id != tenant_id) + ) + if name_check.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Tenant with name '{data.name}' already exists", + ) + tenant.name = data.name + + if data.description is not None: + tenant.description = data.description + + if data.contact_email is not None: + tenant.contact_email = data.contact_email + + await db.commit() + await db.refresh(tenant) + + return await _get_tenant_response(tenant, db) + + +@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a tenant") +@limiter.limit("5/minute") +async def delete_tenant( + request: Request, + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_admin_db), +) -> None: + """Delete tenant (super_admin only). Cascades to all users and devices.""" + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + + await db.delete(tenant) + await db.commit() + + +# --------------------------------------------------------------------------- +# Starter template seeding +# --------------------------------------------------------------------------- + +_STARTER_TEMPLATES = [ + { + "name": "Basic Router", + "description": "Complete SOHO/branch router setup: WAN on ether1, LAN bridge, DHCP, DNS, NAT, basic firewall", + "content": """/interface bridge add name=bridge-lan comment="LAN bridge" +/interface bridge port add bridge=bridge-lan interface=ether2 +/interface bridge port add bridge=bridge-lan interface=ether3 +/interface bridge port add bridge=bridge-lan interface=ether4 +/interface bridge port add bridge=bridge-lan interface=ether5 + +# WAN — DHCP client on ether1 +/ip dhcp-client add interface={{ wan_interface }} disabled=no comment="WAN uplink" + +# LAN address +/ip address add address={{ lan_gateway }}/{{ lan_cidr }} interface=bridge-lan + +# DNS +/ip dns set servers={{ dns_servers }} allow-remote-requests=yes + +# DHCP server for LAN +/ip pool add name=lan-pool ranges={{ dhcp_start }}-{{ dhcp_end }} +/ip dhcp-server network add address={{ lan_network }}/{{ lan_cidr }} gateway={{ lan_gateway }} dns-server={{ lan_gateway }} +/ip dhcp-server add name=lan-dhcp interface=bridge-lan address-pool=lan-pool disabled=no + +# NAT masquerade +/ip firewall nat add chain=srcnat out-interface={{ wan_interface }} action=masquerade + +# Firewall — input chain +/ip firewall filter +add chain=input connection-state=established,related action=accept +add chain=input connection-state=invalid action=drop +add chain=input in-interface={{ wan_interface }} action=drop comment="Drop all other WAN input" + +# Firewall — forward chain +add chain=forward connection-state=established,related action=accept +add chain=forward connection-state=invalid action=drop +add chain=forward in-interface=bridge-lan out-interface={{ wan_interface }} action=accept comment="Allow LAN to WAN" +add chain=forward action=drop comment="Drop everything else" + +# NTP +/system ntp client set enabled=yes servers={{ ntp_server }} + +# Identity +/system identity set name={{ device.hostname }}""", + "variables": [ + {"name": "wan_interface", "type": "string", "default": "ether1", "description": "WAN-facing interface"}, + {"name": "lan_gateway", "type": "ip", "default": "192.168.88.1", "description": "LAN gateway IP"}, + {"name": "lan_cidr", "type": "integer", "default": "24", "description": "LAN subnet mask bits"}, + {"name": "lan_network", "type": "ip", "default": "192.168.88.0", "description": "LAN network address"}, + {"name": "dhcp_start", "type": "ip", "default": "192.168.88.100", "description": "DHCP pool start"}, + {"name": "dhcp_end", "type": "ip", "default": "192.168.88.254", "description": "DHCP pool end"}, + {"name": "dns_servers", "type": "string", "default": "8.8.8.8,8.8.4.4", "description": "Upstream DNS servers"}, + {"name": "ntp_server", "type": "string", "default": "pool.ntp.org", "description": "NTP server"}, + ], + }, + { + "name": "Basic Firewall", + "description": "Standard firewall ruleset with WAN protection and LAN forwarding", + "content": """/ip firewall filter +add chain=input connection-state=established,related action=accept +add chain=input connection-state=invalid action=drop +add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=8291 action=drop comment="Block Winbox from WAN" +add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=22 action=drop comment="Block SSH from WAN" +add chain=forward connection-state=established,related action=accept +add chain=forward connection-state=invalid action=drop +add chain=forward src-address={{ allowed_network }} action=accept +add chain=forward action=drop""", + "variables": [ + {"name": "wan_interface", "type": "string", "default": "ether1", "description": "WAN-facing interface"}, + {"name": "allowed_network", "type": "subnet", "default": "192.168.88.0/24", "description": "Allowed source network"}, + ], + }, + { + "name": "DHCP Server Setup", + "description": "Configure DHCP server with address pool, DNS, and gateway", + "content": """/ip pool add name=dhcp-pool ranges={{ pool_start }}-{{ pool_end }} +/ip dhcp-server network add address={{ gateway }}/24 gateway={{ gateway }} dns-server={{ dns_server }} +/ip dhcp-server add name=dhcp1 interface={{ interface }} address-pool=dhcp-pool disabled=no""", + "variables": [ + {"name": "pool_start", "type": "ip", "default": "192.168.88.100", "description": "DHCP pool start address"}, + {"name": "pool_end", "type": "ip", "default": "192.168.88.254", "description": "DHCP pool end address"}, + {"name": "gateway", "type": "ip", "default": "192.168.88.1", "description": "Default gateway"}, + {"name": "dns_server", "type": "ip", "default": "8.8.8.8", "description": "DNS server address"}, + {"name": "interface", "type": "string", "default": "bridge-lan", "description": "Interface to serve DHCP on"}, + ], + }, + { + "name": "Wireless AP Config", + "description": "Configure wireless access point with WPA2 security", + "content": """/interface wireless security-profiles add name=portal-wpa2 mode=dynamic-keys authentication-types=wpa2-psk wpa2-pre-shared-key={{ password }} +/interface wireless set wlan1 mode=ap-bridge ssid={{ ssid }} security-profile=portal-wpa2 frequency={{ frequency }} channel-width={{ channel_width }} disabled=no""", + "variables": [ + {"name": "ssid", "type": "string", "default": "MikroTik-AP", "description": "Wireless network name"}, + {"name": "password", "type": "string", "default": "", "description": "WPA2 pre-shared key (min 8 characters)"}, + {"name": "frequency", "type": "integer", "default": "2412", "description": "Wireless frequency in MHz"}, + {"name": "channel_width", "type": "string", "default": "20/40mhz-XX", "description": "Channel width setting"}, + ], + }, + { + "name": "Initial Device Setup", + "description": "Set device identity, NTP, DNS, and disable unused services", + "content": """/system identity set name={{ device.hostname }} +/system ntp client set enabled=yes servers={{ ntp_server }} +/ip dns set servers={{ dns_servers }} allow-remote-requests=no +/ip service disable telnet,ftp,www,api-ssl +/ip service set ssh port=22 +/ip service set winbox port=8291""", + "variables": [ + {"name": "ntp_server", "type": "ip", "default": "pool.ntp.org", "description": "NTP server address"}, + {"name": "dns_servers", "type": "string", "default": "8.8.8.8,8.8.4.4", "description": "Comma-separated DNS servers"}, + ], + }, +] + + +async def _seed_starter_templates(db, tenant_id) -> None: + """Insert starter config templates for a newly created tenant.""" + import json as _json + + for tmpl in _STARTER_TEMPLATES: + await db.execute(text(""" + INSERT INTO config_templates (id, tenant_id, name, description, content, variables) + VALUES (gen_random_uuid(), CAST(:tid AS uuid), :name, :desc, :content, CAST(:vars AS jsonb)) + """), { + "tid": str(tenant_id), + "name": tmpl["name"], + "desc": tmpl["description"], + "content": tmpl["content"], + "vars": _json.dumps(tmpl["variables"]), + }) diff --git a/backend/app/routers/topology.py b/backend/app/routers/topology.py new file mode 100644 index 0000000..ab928d1 --- /dev/null +++ b/backend/app/routers/topology.py @@ -0,0 +1,374 @@ +""" +Network topology inference endpoint. + +Endpoint: GET /api/tenants/{tenant_id}/topology + +Builds a topology graph of managed devices by: +1. Querying all devices for the tenant (via RLS) +2. Fetching /ip/neighbor tables from online devices via NATS +3. Matching neighbor addresses to known devices +4. Falling back to shared /24 subnet inference when neighbor data is unavailable +5. Caching results in Redis with 5-minute TTL +""" + +import asyncio +import ipaddress +import json +import logging +import uuid +from typing import Any + +import redis.asyncio as aioredis +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db, set_tenant_context +from app.middleware.rbac import require_min_role +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.models.device import Device +from app.models.vpn import VpnPeer +from app.services import routeros_proxy + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["topology"]) + +# --------------------------------------------------------------------------- +# Redis connection (lazy initialized, same pattern as routeros_proxy NATS) +# --------------------------------------------------------------------------- + +_redis: aioredis.Redis | None = None +TOPOLOGY_CACHE_TTL = 300 # 5 minutes + + +async def _get_redis() -> aioredis.Redis: + """Get or create a Redis connection for topology caching.""" + global _redis + if _redis is None: + _redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True) + logger.info("Topology Redis connection established") + return _redis + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + + +class TopologyNode(BaseModel): + id: str + hostname: str + ip: str + status: str + model: str | None + uptime: str | None + + +class TopologyEdge(BaseModel): + source: str + target: str + label: str + + +class TopologyResponse(BaseModel): + nodes: list[TopologyNode] + edges: list[TopologyEdge] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + return + if current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you do not belong to this tenant.", + ) + + +def _format_uptime(seconds: int | None) -> str | None: + """Convert uptime seconds to a human-readable string.""" + if seconds is None: + return None + days = seconds // 86400 + hours = (seconds % 86400) // 3600 + minutes = (seconds % 3600) // 60 + if days > 0: + return f"{days}d {hours}h {minutes}m" + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + +def _get_subnet_key(ip_str: str) -> str | None: + """Return the /24 network key for an IPv4 address, or None if invalid.""" + try: + addr = ipaddress.ip_address(ip_str) + if isinstance(addr, ipaddress.IPv4Address): + network = ipaddress.ip_network(f"{ip_str}/24", strict=False) + return str(network) + except ValueError: + pass + return None + + +def _build_edges_from_neighbors( + neighbor_data: dict[str, list[dict[str, Any]]], + ip_to_device: dict[str, str], +) -> list[TopologyEdge]: + """Build topology edges from neighbor discovery results. + + Args: + neighbor_data: Mapping of device_id -> list of neighbor entries. + ip_to_device: Mapping of IP address -> device_id for known devices. + + Returns: + De-duplicated list of topology edges. + """ + seen_edges: set[tuple[str, str]] = set() + edges: list[TopologyEdge] = [] + + for device_id, neighbors in neighbor_data.items(): + for neighbor in neighbors: + # RouterOS neighbor entry has 'address' (or 'address4') field + neighbor_ip = neighbor.get("address") or neighbor.get("address4", "") + if not neighbor_ip: + continue + + target_device_id = ip_to_device.get(neighbor_ip) + if target_device_id is None or target_device_id == device_id: + continue + + # De-duplicate bidirectional edges (A->B and B->A become one edge) + edge_key = tuple(sorted([device_id, target_device_id])) + if edge_key in seen_edges: + continue + seen_edges.add(edge_key) + + interface_name = neighbor.get("interface", "neighbor") + edges.append( + TopologyEdge( + source=device_id, + target=target_device_id, + label=interface_name, + ) + ) + + return edges + + +def _build_edges_from_subnets( + devices: list[Device], + existing_connected: set[tuple[str, str]], +) -> list[TopologyEdge]: + """Infer edges from shared /24 subnets for devices without neighbor data. + + Only adds subnet-based edges for device pairs that are NOT already connected + via neighbor discovery. + """ + # Group devices by /24 subnet + subnet_groups: dict[str, list[str]] = {} + for device in devices: + subnet_key = _get_subnet_key(device.ip_address) + if subnet_key: + subnet_groups.setdefault(subnet_key, []).append(str(device.id)) + + edges: list[TopologyEdge] = [] + for subnet, device_ids in subnet_groups.items(): + if len(device_ids) < 2: + continue + # Connect all pairs in the subnet + for i, src in enumerate(device_ids): + for tgt in device_ids[i + 1 :]: + edge_key = tuple(sorted([src, tgt])) + if edge_key in existing_connected: + continue + edges.append( + TopologyEdge( + source=src, + target=tgt, + label="shared subnet", + ) + ) + existing_connected.add(edge_key) + + return edges + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/topology", + response_model=TopologyResponse, + summary="Get network topology for a tenant", +) +async def get_topology( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + _role: CurrentUser = Depends(require_min_role("viewer")), + db: AsyncSession = Depends(get_db), +) -> TopologyResponse: + """Build and return a network topology graph for the given tenant. + + The topology is inferred from: + 1. LLDP/CDP/MNDP neighbor discovery on online devices + 2. Shared /24 subnet fallback for devices without neighbor data + + Results are cached in Redis with a 5-minute TTL. + """ + await _check_tenant_access(current_user, tenant_id, db) + + cache_key = f"topology:{tenant_id}" + + # Check Redis cache + try: + rd = await _get_redis() + cached = await rd.get(cache_key) + if cached: + data = json.loads(cached) + return TopologyResponse(**data) + except Exception as exc: + logger.warning("Redis cache read failed, computing topology fresh", error=str(exc)) + + # Fetch all devices for tenant (RLS enforced via get_db) + result = await db.execute( + select( + Device.id, + Device.hostname, + Device.ip_address, + Device.status, + Device.model, + Device.uptime_seconds, + ) + ) + rows = result.all() + + if not rows: + return TopologyResponse(nodes=[], edges=[]) + + # Build nodes + nodes: list[TopologyNode] = [] + ip_to_device: dict[str, str] = {} + online_device_ids: list[str] = [] + devices_by_id: dict[str, Any] = {} + + for row in rows: + device_id = str(row.id) + nodes.append( + TopologyNode( + id=device_id, + hostname=row.hostname, + ip=row.ip_address, + status=row.status, + model=row.model, + uptime=_format_uptime(row.uptime_seconds), + ) + ) + ip_to_device[row.ip_address] = device_id + if row.status == "online": + online_device_ids.append(device_id) + + # Fetch neighbor tables from online devices in parallel + neighbor_data: dict[str, list[dict[str, Any]]] = {} + + if online_device_ids: + tasks = [ + routeros_proxy.execute_command( + device_id, "/ip/neighbor/print", timeout=10.0 + ) + for device_id in online_device_ids + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + for device_id, res in zip(online_device_ids, results): + if isinstance(res, Exception): + logger.warning( + "Neighbor fetch failed", + device_id=device_id, + error=str(res), + ) + continue + if isinstance(res, dict) and res.get("success") and res.get("data"): + neighbor_data[device_id] = res["data"] + + # Build edges from neighbor discovery + neighbor_edges = _build_edges_from_neighbors(neighbor_data, ip_to_device) + + # Track connected pairs for subnet fallback + connected_pairs: set[tuple[str, str]] = set() + for edge in neighbor_edges: + connected_pairs.add(tuple(sorted([edge.source, edge.target]))) + + # VPN-based edges: query WireGuard peers to infer hub-spoke topology. + # VPN peers all connect to the same WireGuard server. The gateway device + # is the managed device NOT in the VPN peers list (it's the server, not a + # client). If found, create star edges from gateway to each VPN peer device. + vpn_edges: list[TopologyEdge] = [] + vpn_peer_device_ids: set[str] = set() + try: + peer_result = await db.execute( + select(VpnPeer.device_id).where(VpnPeer.is_enabled.is_(True)) + ) + vpn_peer_device_ids = {str(row[0]) for row in peer_result.all()} + + if vpn_peer_device_ids: + # Gateway = managed devices NOT in VPN peers (typically the Core router) + all_device_ids = {str(row.id) for row in rows} + gateway_ids = all_device_ids - vpn_peer_device_ids + # Pick the gateway that's online (prefer online devices) + gateway_id = None + for gid in gateway_ids: + if gid in online_device_ids: + gateway_id = gid + break + if not gateway_id and gateway_ids: + gateway_id = next(iter(gateway_ids)) + + if gateway_id: + for peer_device_id in vpn_peer_device_ids: + edge_key = tuple(sorted([gateway_id, peer_device_id])) + if edge_key not in connected_pairs: + vpn_edges.append( + TopologyEdge( + source=gateway_id, + target=peer_device_id, + label="vpn tunnel", + ) + ) + connected_pairs.add(edge_key) + except Exception as exc: + logger.warning("VPN edge detection failed", error=str(exc)) + + # Fallback: infer connections from shared /24 subnets + # Query full Device objects for subnet analysis + device_result = await db.execute(select(Device)) + all_devices = list(device_result.scalars().all()) + subnet_edges = _build_edges_from_subnets(all_devices, connected_pairs) + + all_edges = neighbor_edges + vpn_edges + subnet_edges + + topology = TopologyResponse(nodes=nodes, edges=all_edges) + + # Cache result in Redis + try: + rd = await _get_redis() + await rd.set(cache_key, topology.model_dump_json(), ex=TOPOLOGY_CACHE_TTL) + except Exception as exc: + logger.warning("Redis cache write failed", error=str(exc)) + + return topology diff --git a/backend/app/routers/transparency.py b/backend/app/routers/transparency.py new file mode 100644 index 0000000..06ad16c --- /dev/null +++ b/backend/app/routers/transparency.py @@ -0,0 +1,391 @@ +"""Transparency log API endpoints. + +Tenant-scoped routes under /api/tenants/{tenant_id}/ for: +- Paginated, filterable key access transparency log listing +- Transparency log statistics (total events, last 24h, unique devices, justification breakdown) +- CSV export of transparency logs + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: admin and above can view transparency logs (tenant_admin or super_admin). + +Phase 31: Data Access Transparency Dashboard - TRUST-01, TRUST-02 +Shows tenant admins every KMS credential access event for their tenant. +""" + +import csv +import io +import logging +import uuid +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy import and_, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.tenant_context import CurrentUser, get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["transparency"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + """Verify the current user is allowed to access the given tenant.""" + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + +def _require_admin(current_user: CurrentUser) -> None: + """Raise 403 if user does not have at least admin role. + + Transparency data is sensitive operational intelligence -- + only tenant_admin and super_admin can view it. + """ + allowed = {"super_admin", "admin", "tenant_admin"} + if current_user.role not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="At least admin role required to view transparency logs.", + ) + + +# --------------------------------------------------------------------------- +# Response models +# --------------------------------------------------------------------------- + + +class TransparencyLogItem(BaseModel): + id: str + action: str + device_name: Optional[str] = None + device_id: Optional[str] = None + justification: Optional[str] = None + operator_email: Optional[str] = None + correlation_id: Optional[str] = None + resource_type: Optional[str] = None + resource_id: Optional[str] = None + ip_address: Optional[str] = None + created_at: str + + +class TransparencyLogResponse(BaseModel): + items: list[TransparencyLogItem] + total: int + page: int + per_page: int + + +class TransparencyStats(BaseModel): + total_events: int + events_last_24h: int + unique_devices: int + justification_breakdown: dict[str, int] + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/transparency-logs", + response_model=TransparencyLogResponse, + summary="List KMS credential access events for tenant", +) +async def list_transparency_logs( + tenant_id: uuid.UUID, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=100), + device_id: Optional[uuid.UUID] = Query(default=None), + justification: Optional[str] = Query(default=None), + action: Optional[str] = Query(default=None), + date_from: Optional[datetime] = Query(default=None), + date_to: Optional[datetime] = Query(default=None), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + _require_admin(current_user) + await _check_tenant_access(current_user, tenant_id, db) + + # Build filter conditions using parameterized text fragments + conditions = [text("k.tenant_id = :tenant_id")] + params: dict[str, Any] = {"tenant_id": str(tenant_id)} + + if device_id: + conditions.append(text("k.device_id = :device_id")) + params["device_id"] = str(device_id) + + if justification: + conditions.append(text("k.justification = :justification")) + params["justification"] = justification + + if action: + conditions.append(text("k.action = :action")) + params["action"] = action + + if date_from: + conditions.append(text("k.created_at >= :date_from")) + params["date_from"] = date_from.isoformat() + + if date_to: + conditions.append(text("k.created_at <= :date_to")) + params["date_to"] = date_to.isoformat() + + where_clause = and_(*conditions) + + # Shared SELECT columns for data queries + _data_columns = text( + "k.id, k.action, d.hostname AS device_name, " + "k.device_id, k.justification, u.email AS operator_email, " + "k.correlation_id, k.resource_type, k.resource_id, " + "k.ip_address, k.created_at" + ) + _data_from = text( + "key_access_log k " + "LEFT JOIN users u ON k.user_id = u.id " + "LEFT JOIN devices d ON k.device_id = d.id" + ) + + # Count total + count_result = await db.execute( + select(func.count()) + .select_from(text("key_access_log k")) + .where(where_clause), + params, + ) + total = count_result.scalar() or 0 + + # Paginated query + offset = (page - 1) * per_page + params["limit"] = per_page + params["offset"] = offset + + result = await db.execute( + select(_data_columns) + .select_from(_data_from) + .where(where_clause) + .order_by(text("k.created_at DESC")) + .limit(per_page) + .offset(offset), + params, + ) + rows = result.mappings().all() + + items = [ + TransparencyLogItem( + id=str(row["id"]), + action=row["action"], + device_name=row["device_name"], + device_id=str(row["device_id"]) if row["device_id"] else None, + justification=row["justification"], + operator_email=row["operator_email"], + correlation_id=row["correlation_id"], + resource_type=row["resource_type"], + resource_id=row["resource_id"], + ip_address=row["ip_address"], + created_at=row["created_at"].isoformat() if row["created_at"] else "", + ) + for row in rows + ] + + return TransparencyLogResponse( + items=items, + total=total, + page=page, + per_page=per_page, + ) + + +@router.get( + "/tenants/{tenant_id}/transparency-logs/stats", + response_model=TransparencyStats, + summary="Get transparency log statistics", +) +async def get_transparency_stats( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> TransparencyStats: + _require_admin(current_user) + await _check_tenant_access(current_user, tenant_id, db) + + params: dict[str, Any] = {"tenant_id": str(tenant_id)} + + # Total events + total_result = await db.execute( + select(func.count()) + .select_from(text("key_access_log")) + .where(text("tenant_id = :tenant_id")), + params, + ) + total_events = total_result.scalar() or 0 + + # Events in last 24 hours + last_24h_result = await db.execute( + select(func.count()) + .select_from(text("key_access_log")) + .where( + and_( + text("tenant_id = :tenant_id"), + text("created_at >= NOW() - INTERVAL '24 hours'"), + ) + ), + params, + ) + events_last_24h = last_24h_result.scalar() or 0 + + # Unique devices + unique_devices_result = await db.execute( + select(func.count(text("DISTINCT device_id"))) + .select_from(text("key_access_log")) + .where( + and_( + text("tenant_id = :tenant_id"), + text("device_id IS NOT NULL"), + ) + ), + params, + ) + unique_devices = unique_devices_result.scalar() or 0 + + # Justification breakdown + breakdown_result = await db.execute( + select( + text("COALESCE(justification, 'system') AS justification_label"), + func.count().label("count"), + ) + .select_from(text("key_access_log")) + .where(text("tenant_id = :tenant_id")) + .group_by(text("justification_label")), + params, + ) + justification_breakdown: dict[str, int] = {} + for row in breakdown_result.mappings().all(): + justification_breakdown[row["justification_label"]] = row["count"] + + return TransparencyStats( + total_events=total_events, + events_last_24h=events_last_24h, + unique_devices=unique_devices, + justification_breakdown=justification_breakdown, + ) + + +@router.get( + "/tenants/{tenant_id}/transparency-logs/export", + summary="Export transparency logs as CSV", +) +async def export_transparency_logs( + tenant_id: uuid.UUID, + device_id: Optional[uuid.UUID] = Query(default=None), + justification: Optional[str] = Query(default=None), + action: Optional[str] = Query(default=None), + date_from: Optional[datetime] = Query(default=None), + date_to: Optional[datetime] = Query(default=None), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> StreamingResponse: + _require_admin(current_user) + await _check_tenant_access(current_user, tenant_id, db) + + # Build filter conditions + conditions = [text("k.tenant_id = :tenant_id")] + params: dict[str, Any] = {"tenant_id": str(tenant_id)} + + if device_id: + conditions.append(text("k.device_id = :device_id")) + params["device_id"] = str(device_id) + + if justification: + conditions.append(text("k.justification = :justification")) + params["justification"] = justification + + if action: + conditions.append(text("k.action = :action")) + params["action"] = action + + if date_from: + conditions.append(text("k.created_at >= :date_from")) + params["date_from"] = date_from.isoformat() + + if date_to: + conditions.append(text("k.created_at <= :date_to")) + params["date_to"] = date_to.isoformat() + + where_clause = and_(*conditions) + + _data_columns = text( + "k.id, k.action, d.hostname AS device_name, " + "k.device_id, k.justification, u.email AS operator_email, " + "k.correlation_id, k.resource_type, k.resource_id, " + "k.ip_address, k.created_at" + ) + _data_from = text( + "key_access_log k " + "LEFT JOIN users u ON k.user_id = u.id " + "LEFT JOIN devices d ON k.device_id = d.id" + ) + + result = await db.execute( + select(_data_columns) + .select_from(_data_from) + .where(where_clause) + .order_by(text("k.created_at DESC")), + params, + ) + all_rows = result.mappings().all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "ID", + "Action", + "Device Name", + "Device ID", + "Justification", + "Operator Email", + "Correlation ID", + "Resource Type", + "Resource ID", + "IP Address", + "Timestamp", + ]) + for row in all_rows: + writer.writerow([ + str(row["id"]), + row["action"], + row["device_name"] or "", + str(row["device_id"]) if row["device_id"] else "", + row["justification"] or "", + row["operator_email"] or "", + row["correlation_id"] or "", + row["resource_type"] or "", + row["resource_id"] or "", + row["ip_address"] or "", + str(row["created_at"]), + ]) + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={ + "Content-Disposition": "attachment; filename=transparency-logs.csv" + }, + ) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..0d85fe2 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,231 @@ +""" +User management endpoints (scoped to tenant). + +GET /api/tenants/{tenant_id}/users — list users in tenant +POST /api/tenants/{tenant_id}/users — create user in tenant +GET /api/tenants/{tenant_id}/users/{id} — get user detail +PUT /api/tenants/{tenant_id}/users/{id} — update user +DELETE /api/tenants/{tenant_id}/users/{id} — deactivate user +""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.middleware.rate_limit import limiter + +from app.database import get_admin_db +from app.middleware.rbac import require_tenant_admin_or_above +from app.middleware.tenant_context import CurrentUser +from app.models.tenant import Tenant +from app.models.user import User, UserRole +from app.schemas.user import UserCreate, UserResponse, UserUpdate +from app.services.auth import hash_password + +router = APIRouter(prefix="/tenants", tags=["users"]) + + +async def _check_tenant_access( + tenant_id: uuid.UUID, + current_user: CurrentUser, + db: AsyncSession, +) -> Tenant: + """ + Verify the tenant exists and the current user has access to it. + + super_admin can access any tenant. + tenant_admin can only access their own tenant. + """ + if not current_user.is_super_admin and current_user.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this tenant", + ) + + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + + return tenant + + +@router.get("/{tenant_id}/users", response_model=list[UserResponse], summary="List users in tenant") +async def list_users( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(require_tenant_admin_or_above), + db: AsyncSession = Depends(get_admin_db), +) -> list[UserResponse]: + """ + List users in a tenant. + - super_admin: can list users in any tenant + - tenant_admin: can only list users in their own tenant + """ + await _check_tenant_access(tenant_id, current_user, db) + + result = await db.execute( + select(User) + .where(User.tenant_id == tenant_id) + .order_by(User.name) + ) + users = result.scalars().all() + + return [UserResponse.model_validate(user) for user in users] + + +@router.post( + "/{tenant_id}/users", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a user in tenant", +) +@limiter.limit("20/minute") +async def create_user( + request: Request, + tenant_id: uuid.UUID, + data: UserCreate, + current_user: CurrentUser = Depends(require_tenant_admin_or_above), + db: AsyncSession = Depends(get_admin_db), +) -> UserResponse: + """ + Create a user within a tenant. + + - super_admin: can create users in any tenant + - tenant_admin: can only create users in their own tenant + - No email invitation flow — admin creates accounts with temporary passwords + """ + await _check_tenant_access(tenant_id, current_user, db) + + # Check email uniqueness (global, not per-tenant) + existing = await db.execute( + select(User).where(User.email == data.email.lower()) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A user with this email already exists", + ) + + user = User( + email=data.email.lower(), + hashed_password=hash_password(data.password), + name=data.name, + role=data.role.value, + tenant_id=tenant_id, + is_active=True, + must_upgrade_auth=True, + ) + db.add(user) + await db.commit() + await db.refresh(user) + + return UserResponse.model_validate(user) + + +@router.get("/{tenant_id}/users/{user_id}", response_model=UserResponse, summary="Get user detail") +async def get_user( + tenant_id: uuid.UUID, + user_id: uuid.UUID, + current_user: CurrentUser = Depends(require_tenant_admin_or_above), + db: AsyncSession = Depends(get_admin_db), +) -> UserResponse: + """Get user detail.""" + await _check_tenant_access(tenant_id, current_user, db) + + result = await db.execute( + select(User).where(User.id == user_id, User.tenant_id == tenant_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return UserResponse.model_validate(user) + + +@router.put("/{tenant_id}/users/{user_id}", response_model=UserResponse, summary="Update a user") +@limiter.limit("20/minute") +async def update_user( + request: Request, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + data: UserUpdate, + current_user: CurrentUser = Depends(require_tenant_admin_or_above), + db: AsyncSession = Depends(get_admin_db), +) -> UserResponse: + """ + Update user attributes (name, role, is_active). + Role assignment is editable by admins. + """ + await _check_tenant_access(tenant_id, current_user, db) + + result = await db.execute( + select(User).where(User.id == user_id, User.tenant_id == tenant_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + if data.name is not None: + user.name = data.name + + if data.role is not None: + user.role = data.role.value + + if data.is_active is not None: + user.is_active = data.is_active + + await db.commit() + await db.refresh(user) + + return UserResponse.model_validate(user) + + +@router.delete("/{tenant_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Deactivate a user") +@limiter.limit("5/minute") +async def deactivate_user( + request: Request, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + current_user: CurrentUser = Depends(require_tenant_admin_or_above), + db: AsyncSession = Depends(get_admin_db), +) -> None: + """ + Deactivate a user (soft delete — sets is_active=False). + This preserves audit trail while preventing login. + """ + await _check_tenant_access(tenant_id, current_user, db) + + result = await db.execute( + select(User).where(User.id == user_id, User.tenant_id == tenant_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Prevent self-deactivation + if user.id == current_user.user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot deactivate your own account", + ) + + user.is_active = False + await db.commit() diff --git a/backend/app/routers/vpn.py b/backend/app/routers/vpn.py new file mode 100644 index 0000000..131aa4f --- /dev/null +++ b/backend/app/routers/vpn.py @@ -0,0 +1,236 @@ +"""WireGuard VPN API endpoints. + +Tenant-scoped routes under /api/tenants/{tenant_id}/vpn/ for: +- VPN setup (enable WireGuard for tenant) +- VPN config management (update endpoint, enable/disable) +- Peer management (add device, remove, get config) + +RLS enforced via get_db() (app_user engine with tenant context). +RBAC: operator and above for all operations. +""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, set_tenant_context +from app.middleware.rate_limit import limiter +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.models.device import Device +from app.schemas.vpn import ( + VpnConfigResponse, + VpnConfigUpdate, + VpnOnboardRequest, + VpnOnboardResponse, + VpnPeerConfig, + VpnPeerCreate, + VpnPeerResponse, + VpnSetupRequest, +) +from app.services import vpn_service + +router = APIRouter(tags=["vpn"]) + + +async def _check_tenant_access( + current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession +) -> None: + if current_user.is_super_admin: + await set_tenant_context(db, str(tenant_id)) + elif current_user.tenant_id != tenant_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + + +def _require_operator(current_user: CurrentUser) -> None: + if current_user.role == "viewer": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Operator role required") + + +# ── VPN Config ── + + +@router.get("/tenants/{tenant_id}/vpn", response_model=VpnConfigResponse | None) +async def get_vpn_config( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get VPN configuration for this tenant.""" + await _check_tenant_access(current_user, tenant_id, db) + config = await vpn_service.get_vpn_config(db, tenant_id) + if not config: + return None + peers = await vpn_service.get_peers(db, tenant_id) + resp = VpnConfigResponse.model_validate(config) + resp.peer_count = len(peers) + return resp + + +@router.post("/tenants/{tenant_id}/vpn", response_model=VpnConfigResponse, status_code=status.HTTP_201_CREATED) +@limiter.limit("20/minute") +async def setup_vpn( + request: Request, + tenant_id: uuid.UUID, + body: VpnSetupRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Enable VPN for this tenant — generates server keys.""" + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + try: + config = await vpn_service.setup_vpn(db, tenant_id, endpoint=body.endpoint) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + return VpnConfigResponse.model_validate(config) + + +@router.patch("/tenants/{tenant_id}/vpn", response_model=VpnConfigResponse) +@limiter.limit("20/minute") +async def update_vpn_config( + request: Request, + tenant_id: uuid.UUID, + body: VpnConfigUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Update VPN settings (endpoint, enable/disable).""" + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + try: + config = await vpn_service.update_vpn_config( + db, tenant_id, endpoint=body.endpoint, is_enabled=body.is_enabled + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + peers = await vpn_service.get_peers(db, tenant_id) + resp = VpnConfigResponse.model_validate(config) + resp.peer_count = len(peers) + return resp + + +# ── VPN Peers ── + + +@router.get("/tenants/{tenant_id}/vpn/peers", response_model=list[VpnPeerResponse]) +async def list_peers( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List all VPN peers for this tenant.""" + await _check_tenant_access(current_user, tenant_id, db) + peers = await vpn_service.get_peers(db, tenant_id) + + # Enrich with device info + device_ids = [p.device_id for p in peers] + devices = {} + if device_ids: + result = await db.execute(select(Device).where(Device.id.in_(device_ids))) + devices = {d.id: d for d in result.scalars().all()} + + # Read live WireGuard status for handshake enrichment + wg_status = vpn_service.read_wg_status() + + responses = [] + for peer in peers: + resp = VpnPeerResponse.model_validate(peer) + device = devices.get(peer.device_id) + if device: + resp.device_hostname = device.hostname + resp.device_ip = device.ip_address + # Enrich with live handshake from WireGuard container + live_handshake = vpn_service.get_peer_handshake(wg_status, peer.peer_public_key) + if live_handshake: + resp.last_handshake = live_handshake + responses.append(resp) + return responses + + +@router.post("/tenants/{tenant_id}/vpn/peers", response_model=VpnPeerResponse, status_code=status.HTTP_201_CREATED) +@limiter.limit("20/minute") +async def add_peer( + request: Request, + tenant_id: uuid.UUID, + body: VpnPeerCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Add a device as a VPN peer.""" + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + try: + peer = await vpn_service.add_peer(db, tenant_id, body.device_id, additional_allowed_ips=body.additional_allowed_ips) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + + # Enrich with device info + result = await db.execute(select(Device).where(Device.id == peer.device_id)) + device = result.scalar_one_or_none() + + resp = VpnPeerResponse.model_validate(peer) + if device: + resp.device_hostname = device.hostname + resp.device_ip = device.ip_address + return resp + + +@router.post("/tenants/{tenant_id}/vpn/peers/onboard", response_model=VpnOnboardResponse, status_code=status.HTTP_201_CREATED) +@limiter.limit("10/minute") +async def onboard_device( + request: Request, + tenant_id: uuid.UUID, + body: VpnOnboardRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Create device + VPN peer in one step. Returns RouterOS commands for tunnel setup.""" + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + try: + result = await vpn_service.onboard_device( + db, tenant_id, + hostname=body.hostname, + username=body.username, + password=body.password, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + return VpnOnboardResponse(**result) + + +@router.delete("/tenants/{tenant_id}/vpn/peers/{peer_id}", status_code=status.HTTP_204_NO_CONTENT) +@limiter.limit("5/minute") +async def remove_peer( + request: Request, + tenant_id: uuid.UUID, + peer_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Remove a VPN peer.""" + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + try: + await vpn_service.remove_peer(db, tenant_id, peer_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.get("/tenants/{tenant_id}/vpn/peers/{peer_id}/config", response_model=VpnPeerConfig) +async def get_peer_device_config( + tenant_id: uuid.UUID, + peer_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get the full config for a peer — includes private key and RouterOS commands.""" + await _check_tenant_access(current_user, tenant_id, db) + _require_operator(current_user) + try: + config = await vpn_service.get_peer_config(db, tenant_id, peer_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + return VpnPeerConfig(**config) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..12673b3 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,18 @@ +"""Pydantic schemas for request/response validation.""" + +from app.schemas.auth import LoginRequest, TokenResponse, RefreshRequest, UserMeResponse +from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate +from app.schemas.user import UserCreate, UserResponse, UserUpdate + +__all__ = [ + "LoginRequest", + "TokenResponse", + "RefreshRequest", + "UserMeResponse", + "TenantCreate", + "TenantResponse", + "TenantUpdate", + "UserCreate", + "UserResponse", + "UserUpdate", +] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..9e7d9b2 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,123 @@ +"""Authentication request/response schemas.""" + +import uuid +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + auth_upgrade_required: bool = False # True when bcrypt user needs SRP registration + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class UserMeResponse(BaseModel): + id: uuid.UUID + email: str + name: str + role: str + tenant_id: Optional[uuid.UUID] = None + auth_version: int = 1 + + model_config = {"from_attributes": True} + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + # SRP users must provide re-derived credentials + new_srp_salt: Optional[str] = None + new_srp_verifier: Optional[str] = None + # Re-wrapped key bundle (SRP users re-encrypt with new AUK) + encrypted_private_key: Optional[str] = None + private_key_nonce: Optional[str] = None + encrypted_vault_key: Optional[str] = None + vault_key_nonce: Optional[str] = None + public_key: Optional[str] = None + pbkdf2_salt: Optional[str] = None + hkdf_salt: Optional[str] = None + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str + + +class MessageResponse(BaseModel): + message: str + + +# --- SRP Zero-Knowledge Authentication Schemas --- + + +class SRPInitRequest(BaseModel): + """Step 1 request: client sends email to begin SRP handshake.""" + email: EmailStr + + +class SRPInitResponse(BaseModel): + """Step 1 response: server returns ephemeral B and key derivation salts.""" + salt: str # hex-encoded SRP salt + server_public: str # hex-encoded server ephemeral B + session_id: str # Redis session key nonce + pbkdf2_salt: str # base64-encoded, from user_key_sets (needed for 2SKD before SRP verify) + hkdf_salt: str # base64-encoded, from user_key_sets (needed for 2SKD before SRP verify) + + +class SRPVerifyRequest(BaseModel): + """Step 2 request: client sends proof M1 to complete handshake.""" + email: EmailStr + session_id: str + client_public: str # hex-encoded client ephemeral A + client_proof: str # hex-encoded client proof M1 + + +class SRPVerifyResponse(BaseModel): + """Step 2 response: server returns tokens and proof M2.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + server_proof: str # hex-encoded server proof M2 + encrypted_key_set: Optional[dict] = None # Key bundle for client-side decryption + + +class SRPRegisterRequest(BaseModel): + """Used during registration to store SRP verifier and key set.""" + srp_salt: str # hex-encoded + srp_verifier: str # hex-encoded + encrypted_private_key: str # base64-encoded + private_key_nonce: str # base64-encoded + encrypted_vault_key: str # base64-encoded + vault_key_nonce: str # base64-encoded + public_key: str # base64-encoded + pbkdf2_salt: str # base64-encoded + hkdf_salt: str # base64-encoded + + +# --- Account Self-Service Schemas --- + + +class DeleteAccountRequest(BaseModel): + """Request body for account self-deletion. User must type 'DELETE' to confirm.""" + confirmation: str # Must be "DELETE" to confirm + + +class DeleteAccountResponse(BaseModel): + """Response after successful account deletion.""" + message: str + deleted: bool diff --git a/backend/app/schemas/certificate.py b/backend/app/schemas/certificate.py new file mode 100644 index 0000000..08aa9c1 --- /dev/null +++ b/backend/app/schemas/certificate.py @@ -0,0 +1,78 @@ +"""Pydantic request/response schemas for the Internal Certificate Authority.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +# --------------------------------------------------------------------------- +# Request schemas +# --------------------------------------------------------------------------- + +class CACreateRequest(BaseModel): + """Request to generate a new root CA for the tenant.""" + + common_name: str = "Portal Root CA" + validity_years: int = 10 # Default 10 years for CA + + +class CertSignRequest(BaseModel): + """Request to sign a per-device certificate using the tenant CA.""" + + device_id: UUID + validity_days: int = 730 # Default 2 years for device certs + + +class BulkCertDeployRequest(BaseModel): + """Request to deploy certificates to multiple devices.""" + + device_ids: list[UUID] + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class CAResponse(BaseModel): + """Public details of a tenant's Certificate Authority (no private key).""" + + id: UUID + tenant_id: UUID + common_name: str + fingerprint_sha256: str + serial_number: str + not_valid_before: datetime + not_valid_after: datetime + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class DeviceCertResponse(BaseModel): + """Public details of a device certificate (no private key).""" + + id: UUID + tenant_id: UUID + device_id: UUID + ca_id: UUID + common_name: str + fingerprint_sha256: str + serial_number: str + not_valid_before: datetime + not_valid_after: datetime + status: str + deployed_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CertDeployResponse(BaseModel): + """Result of a single device certificate deployment attempt.""" + + success: bool + device_id: UUID + cert_name_on_device: str | None = None + error: str | None = None diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py new file mode 100644 index 0000000..1cf46f7 --- /dev/null +++ b/backend/app/schemas/device.py @@ -0,0 +1,271 @@ +"""Pydantic schemas for Device, DeviceGroup, and DeviceTag endpoints.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, field_validator + + +# --------------------------------------------------------------------------- +# Device schemas +# --------------------------------------------------------------------------- + + +class DeviceCreate(BaseModel): + """Schema for creating a new device.""" + + hostname: str + ip_address: str + api_port: int = 8728 + api_ssl_port: int = 8729 + username: str + password: str + + +class DeviceUpdate(BaseModel): + """Schema for updating an existing device. All fields optional.""" + + hostname: Optional[str] = None + ip_address: Optional[str] = None + api_port: Optional[int] = None + api_ssl_port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + tls_mode: Optional[str] = None + + @field_validator("tls_mode") + @classmethod + def validate_tls_mode(cls, v: Optional[str]) -> Optional[str]: + """Validate tls_mode is one of the allowed values.""" + if v is None: + return v + allowed = {"auto", "insecure", "plain", "portal_ca"} + if v not in allowed: + raise ValueError(f"tls_mode must be one of: {', '.join(sorted(allowed))}") + return v + + +class DeviceTagRef(BaseModel): + """Minimal tag info embedded in device responses.""" + + id: uuid.UUID + name: str + color: Optional[str] = None + + model_config = {"from_attributes": True} + + +class DeviceGroupRef(BaseModel): + """Minimal group info embedded in device responses.""" + + id: uuid.UUID + name: str + + model_config = {"from_attributes": True} + + +class DeviceResponse(BaseModel): + """Device response schema. NEVER includes credential fields.""" + + id: uuid.UUID + hostname: str + ip_address: str + api_port: int + api_ssl_port: int + model: Optional[str] = None + serial_number: Optional[str] = None + firmware_version: Optional[str] = None + routeros_version: Optional[str] = None + routeros_major_version: Optional[int] = None + uptime_seconds: Optional[int] = None + last_seen: Optional[datetime] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + status: str + tls_mode: str = "auto" + tags: list[DeviceTagRef] = [] + groups: list[DeviceGroupRef] = [] + created_at: datetime + + model_config = {"from_attributes": True} + + +class DeviceListResponse(BaseModel): + """Paginated device list response.""" + + items: list[DeviceResponse] + total: int + page: int + page_size: int + + +# --------------------------------------------------------------------------- +# Subnet scan schemas +# --------------------------------------------------------------------------- + + +class SubnetScanRequest(BaseModel): + """Request body for a subnet scan.""" + + cidr: str + + @field_validator("cidr") + @classmethod + def validate_cidr(cls, v: str) -> str: + """Validate that the value is a valid CIDR notation and RFC 1918 private range.""" + import ipaddress + try: + network = ipaddress.ip_network(v, strict=False) + except ValueError as e: + raise ValueError(f"Invalid CIDR notation: {e}") from e + # Only allow private IP ranges (RFC 1918: 10/8, 172.16/12, 192.168/16) + if not network.is_private: + raise ValueError( + "Only private IP ranges can be scanned (RFC 1918: " + "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)" + ) + # Reject ranges larger than /20 (4096 IPs) to prevent abuse + if network.num_addresses > 4096: + raise ValueError( + f"CIDR range too large ({network.num_addresses} addresses). " + "Maximum allowed: /20 (4096 addresses)." + ) + return v + + +class SubnetScanResult(BaseModel): + """A single discovered host from a subnet scan.""" + + ip_address: str + hostname: Optional[str] = None + api_port_open: bool = False + api_ssl_port_open: bool = False + + +class SubnetScanResponse(BaseModel): + """Response for a subnet scan operation.""" + + cidr: str + discovered: list[SubnetScanResult] + total_scanned: int + total_discovered: int + + +# --------------------------------------------------------------------------- +# Bulk add from scan +# --------------------------------------------------------------------------- + + +class BulkDeviceAdd(BaseModel): + """One device entry within a bulk-add request.""" + + ip_address: str + hostname: Optional[str] = None + api_port: int = 8728 + api_ssl_port: int = 8729 + username: Optional[str] = None + password: Optional[str] = None + + +class BulkAddRequest(BaseModel): + """ + Bulk-add devices selected from a scan result. + + shared_username / shared_password are used for all devices that do not + provide their own credentials. + """ + + devices: list[BulkDeviceAdd] + shared_username: Optional[str] = None + shared_password: Optional[str] = None + + +class BulkAddResult(BaseModel): + """Summary result of a bulk-add operation.""" + + added: list[DeviceResponse] + failed: list[dict] # {ip_address, error} + + +# --------------------------------------------------------------------------- +# DeviceGroup schemas +# --------------------------------------------------------------------------- + + +class DeviceGroupCreate(BaseModel): + """Schema for creating a device group.""" + + name: str + description: Optional[str] = None + + +class DeviceGroupUpdate(BaseModel): + """Schema for updating a device group.""" + + name: Optional[str] = None + description: Optional[str] = None + + +class DeviceGroupResponse(BaseModel): + """Device group response schema.""" + + id: uuid.UUID + name: str + description: Optional[str] = None + device_count: int = 0 + created_at: datetime + + model_config = {"from_attributes": True} + + +# --------------------------------------------------------------------------- +# DeviceTag schemas +# --------------------------------------------------------------------------- + + +class DeviceTagCreate(BaseModel): + """Schema for creating a device tag.""" + + name: str + color: Optional[str] = None + + @field_validator("color") + @classmethod + def validate_color(cls, v: Optional[str]) -> Optional[str]: + """Validate hex color format if provided.""" + if v is None: + return v + import re + if not re.match(r"^#[0-9A-Fa-f]{6}$", v): + raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)") + return v + + +class DeviceTagUpdate(BaseModel): + """Schema for updating a device tag.""" + + name: Optional[str] = None + color: Optional[str] = None + + @field_validator("color") + @classmethod + def validate_color(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + import re + if not re.match(r"^#[0-9A-Fa-f]{6}$", v): + raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)") + return v + + +class DeviceTagResponse(BaseModel): + """Device tag response schema.""" + + id: uuid.UUID + name: str + color: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/tenant.py b/backend/app/schemas/tenant.py new file mode 100644 index 0000000..d14bd1f --- /dev/null +++ b/backend/app/schemas/tenant.py @@ -0,0 +1,31 @@ +"""Tenant request/response schemas.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class TenantCreate(BaseModel): + name: str + description: Optional[str] = None + contact_email: Optional[str] = None + + +class TenantUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + contact_email: Optional[str] = None + + +class TenantResponse(BaseModel): + id: uuid.UUID + name: str + description: Optional[str] = None + contact_email: Optional[str] = None + user_count: int = 0 + device_count: int = 0 + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..190ba88 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,53 @@ +"""User request/response schemas.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, field_validator + +from app.models.user import UserRole + + +class UserCreate(BaseModel): + name: str + email: EmailStr + password: str + role: UserRole = UserRole.VIEWER + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters") + return v + + @field_validator("role") + @classmethod + def validate_role(cls, v: UserRole) -> UserRole: + """Tenant admins can only create operator/viewer roles; super_admin via separate flow.""" + allowed_tenant_roles = {UserRole.TENANT_ADMIN, UserRole.OPERATOR, UserRole.VIEWER} + if v not in allowed_tenant_roles: + raise ValueError( + f"Role must be one of: {', '.join(r.value for r in allowed_tenant_roles)}" + ) + return v + + +class UserResponse(BaseModel): + id: uuid.UUID + name: str + email: str + role: str + tenant_id: Optional[uuid.UUID] = None + is_active: bool + last_login: Optional[datetime] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class UserUpdate(BaseModel): + name: Optional[str] = None + role: Optional[UserRole] = None + is_active: Optional[bool] = None diff --git a/backend/app/schemas/vpn.py b/backend/app/schemas/vpn.py new file mode 100644 index 0000000..d36d872 --- /dev/null +++ b/backend/app/schemas/vpn.py @@ -0,0 +1,91 @@ +"""Pydantic schemas for WireGuard VPN management.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +# ── VPN Config (server-side) ── + + +class VpnSetupRequest(BaseModel): + """Request to enable VPN for a tenant.""" + endpoint: Optional[str] = None # public hostname:port — if blank, devices must be configured manually + + +class VpnConfigResponse(BaseModel): + """VPN server configuration (never exposes private key).""" + model_config = {"from_attributes": True} + + id: uuid.UUID + tenant_id: uuid.UUID + server_public_key: str + subnet: str + server_port: int + server_address: str + endpoint: Optional[str] + is_enabled: bool + peer_count: int = 0 + created_at: datetime + + +class VpnConfigUpdate(BaseModel): + """Update VPN configuration.""" + endpoint: Optional[str] = None + is_enabled: Optional[bool] = None + + +# ── VPN Peers ── + + +class VpnPeerCreate(BaseModel): + """Add a device as a VPN peer.""" + device_id: uuid.UUID + additional_allowed_ips: Optional[str] = None # comma-separated subnets for site-to-site routing + + +class VpnPeerResponse(BaseModel): + """VPN peer info (never exposes private key).""" + model_config = {"from_attributes": True} + + id: uuid.UUID + device_id: uuid.UUID + device_hostname: str = "" + device_ip: str = "" + peer_public_key: str + assigned_ip: str + is_enabled: bool + last_handshake: Optional[datetime] + created_at: datetime + + +# ── VPN Onboarding (combined device + peer creation) ── + + +class VpnOnboardRequest(BaseModel): + """Combined device creation + VPN peer onboarding.""" + hostname: str + username: str + password: str + + +class VpnOnboardResponse(BaseModel): + """Response from onboarding — device, peer, and RouterOS commands.""" + device_id: uuid.UUID + peer_id: uuid.UUID + hostname: str + assigned_ip: str + routeros_commands: list[str] + + +class VpnPeerConfig(BaseModel): + """Full peer config for display/export — includes private key for device setup.""" + peer_private_key: str + peer_public_key: str + assigned_ip: str + server_public_key: str + server_endpoint: str + allowed_ips: str + routeros_commands: list[str] diff --git a/backend/app/security/__init__.py b/backend/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/security/command_blocklist.py b/backend/app/security/command_blocklist.py new file mode 100644 index 0000000..8f949ab --- /dev/null +++ b/backend/app/security/command_blocklist.py @@ -0,0 +1,95 @@ +"""Dangerous RouterOS command and path blocklist. + +Prevents destructive or sensitive operations from being executed through +the config editor. Commands and paths are checked via case-insensitive +prefix matching against known-dangerous entries. + +To extend: add strings to DANGEROUS_COMMANDS, BROWSE_BLOCKED_PATHS, +or WRITE_BLOCKED_PATHS. +""" + +from fastapi import HTTPException, status + +# CLI commands blocked from the execute endpoint. +# Matched as case-insensitive prefixes (e.g., "/user" blocks "/user/print" too). +DANGEROUS_COMMANDS: list[str] = [ + "/system/reset-configuration", + "/system/shutdown", + "/system/reboot", + "/system/backup", + "/system/license", + "/user", + "/password", + "/certificate", + "/radius", + "/export", + "/import", +] + +# Paths blocked from ALL operations including browse (truly dangerous to read). +BROWSE_BLOCKED_PATHS: list[str] = [ + "system/reset-configuration", + "system/shutdown", + "system/reboot", + "system/backup", + "system/license", + "password", +] + +# Paths blocked from write operations (add/set/remove) but readable via browse. +WRITE_BLOCKED_PATHS: list[str] = [ + "user", + "certificate", + "radius", +] + + +def check_command_safety(command: str) -> None: + """Reject dangerous CLI commands with HTTP 403. + + Normalizes the command (strip + lowercase) and checks against + DANGEROUS_COMMANDS using prefix matching. + + Raises: + HTTPException: 403 if the command matches a dangerous prefix. + """ + normalized = command.strip().lower() + for blocked in DANGEROUS_COMMANDS: + if normalized.startswith(blocked): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"Command blocked: '{command}' matches dangerous prefix '{blocked}'. " + f"This operation is not allowed through the config editor." + ), + ) + + +def check_path_safety(path: str, *, write: bool = False) -> None: + """Reject dangerous menu paths with HTTP 403. + + Normalizes the path (strip + lowercase + lstrip '/') and checks + against blocked path lists using prefix matching. + + Args: + path: The RouterOS menu path to check. + write: If True, also check WRITE_BLOCKED_PATHS (for add/set/remove). + If False, only check BROWSE_BLOCKED_PATHS (for read-only browse). + + Raises: + HTTPException: 403 if the path matches a blocked prefix. + """ + normalized = path.strip().lower().lstrip("/") + blocked_lists = [BROWSE_BLOCKED_PATHS] + if write: + blocked_lists.append(WRITE_BLOCKED_PATHS) + for blocklist in blocked_lists: + for blocked in blocklist: + if normalized.startswith(blocked): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"Path blocked: '{path}' matches dangerous prefix '{blocked}'. " + f"This operation is not allowed through the config editor." + ), + ) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..53a144d --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Backend services — auth, crypto, and business logic.""" diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py new file mode 100644 index 0000000..5339974 --- /dev/null +++ b/backend/app/services/account_service.py @@ -0,0 +1,240 @@ +"""Account self-service operations: deletion and data export. + +Provides GDPR/CCPA-compliant account deletion with full PII erasure +and data portability export (Article 20). + +All queries use raw SQL via text() with admin sessions (bypass RLS) +since these are cross-table operations on the authenticated user's data. +""" + +import hashlib +import uuid +from datetime import UTC, datetime +from typing import Any + +import structlog +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import AdminAsyncSessionLocal +from app.services.audit_service import log_action + +logger = structlog.get_logger("account_service") + + +async def delete_user_account( + db: AsyncSession, + user_id: uuid.UUID, + tenant_id: uuid.UUID | None, + user_email: str, +) -> dict[str, Any]: + """Hard-delete a user account with full PII erasure. + + Steps: + 1. Create a deletion receipt audit log (persisted via separate session) + 2. Anonymize PII in existing audit_logs for this user + 3. Hard-delete the user row (CASCADE handles related tables) + 4. Best-effort session invalidation via Redis + + Args: + db: Admin async session (bypasses RLS). + user_id: UUID of the user to delete. + tenant_id: Tenant UUID (None for super_admin). + user_email: User's email (needed for audit hash before deletion). + + Returns: + Dict with deleted=True and user_id on success. + """ + effective_tenant_id = tenant_id or uuid.UUID(int=0) + email_hash = hashlib.sha256(user_email.encode()).hexdigest() + + # ── 1. Pre-deletion audit receipt (separate session so it persists) ──── + try: + async with AdminAsyncSessionLocal() as audit_db: + await log_action( + audit_db, + tenant_id=effective_tenant_id, + user_id=user_id, + action="account_deleted", + resource_type="user", + resource_id=str(user_id), + details={ + "deleted_user_id": str(user_id), + "email_hash": email_hash, + "deletion_type": "self_service", + "deleted_at": datetime.now(UTC).isoformat(), + }, + ) + await audit_db.commit() + except Exception: + logger.warning( + "deletion_receipt_failed", + user_id=str(user_id), + exc_info=True, + ) + + # ── 2. Anonymize PII in audit_logs for this user ───────────────────── + # Strip PII keys from details JSONB (email, name, user_email, user_name) + await db.execute( + text( + "UPDATE audit_logs " + "SET details = details - 'email' - 'name' - 'user_email' - 'user_name' " + "WHERE user_id = :user_id" + ), + {"user_id": user_id}, + ) + + # Null out encrypted_details (may contain encrypted PII) + await db.execute( + text( + "UPDATE audit_logs " + "SET encrypted_details = NULL " + "WHERE user_id = :user_id" + ), + {"user_id": user_id}, + ) + + # ── 3. Hard delete user row ────────────────────────────────────────── + # CASCADE handles: user_key_sets, api_keys, password_reset_tokens + # SET NULL handles: audit_logs.user_id, key_access_log.user_id, + # maintenance_windows.created_by, alert_events.acknowledged_by + await db.execute( + text("DELETE FROM users WHERE id = :user_id"), + {"user_id": user_id}, + ) + + await db.commit() + + # ── 4. Best-effort Redis session invalidation ──────────────────────── + try: + import redis.asyncio as aioredis + from app.config import settings + from app.services.auth import revoke_user_tokens + + r = aioredis.from_url(settings.REDIS_URL, decode_responses=True) + await revoke_user_tokens(r, str(user_id)) + await r.aclose() + except Exception: + # JWT expires in 15 min anyway; not critical + logger.debug("redis_session_invalidation_skipped", user_id=str(user_id)) + + logger.info("account_deleted", user_id=str(user_id), email_hash=email_hash) + + return {"deleted": True, "user_id": str(user_id)} + + +async def export_user_data( + db: AsyncSession, + user_id: uuid.UUID, + tenant_id: uuid.UUID | None, +) -> dict[str, Any]: + """Assemble all user data for GDPR Art. 20 data portability export. + + Returns a structured dict with user profile, API keys, audit logs, + and key access log entries. + + Args: + db: Admin async session (bypasses RLS). + user_id: UUID of the user whose data to export. + tenant_id: Tenant UUID (None for super_admin). + + Returns: + Envelope dict with export_date, format_version, and all user data. + """ + + # ── User profile ───────────────────────────────────────────────────── + result = await db.execute( + text( + "SELECT id, email, name, role, tenant_id, " + "created_at, last_login, auth_version " + "FROM users WHERE id = :user_id" + ), + {"user_id": user_id}, + ) + user_row = result.mappings().first() + user_data: dict[str, Any] = {} + if user_row: + user_data = { + "id": str(user_row["id"]), + "email": user_row["email"], + "name": user_row["name"], + "role": user_row["role"], + "tenant_id": str(user_row["tenant_id"]) if user_row["tenant_id"] else None, + "created_at": user_row["created_at"].isoformat() if user_row["created_at"] else None, + "last_login": user_row["last_login"].isoformat() if user_row["last_login"] else None, + "auth_version": user_row["auth_version"], + } + + # ── API keys (exclude key_hash for security) ───────────────────────── + result = await db.execute( + text( + "SELECT id, name, key_prefix, scopes, created_at, " + "expires_at, revoked_at, last_used_at " + "FROM api_keys WHERE user_id = :user_id " + "ORDER BY created_at DESC" + ), + {"user_id": user_id}, + ) + api_keys = [] + for row in result.mappings().all(): + api_keys.append({ + "id": str(row["id"]), + "name": row["name"], + "key_prefix": row["key_prefix"], + "scopes": row["scopes"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "expires_at": row["expires_at"].isoformat() if row["expires_at"] else None, + "revoked_at": row["revoked_at"].isoformat() if row["revoked_at"] else None, + "last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None, + }) + + # ── Audit logs (limit 1000, most recent first) ─────────────────────── + result = await db.execute( + text( + "SELECT id, action, resource_type, resource_id, " + "details, ip_address, created_at " + "FROM audit_logs WHERE user_id = :user_id " + "ORDER BY created_at DESC LIMIT 1000" + ), + {"user_id": user_id}, + ) + audit_logs = [] + for row in result.mappings().all(): + details = row["details"] if row["details"] else {} + audit_logs.append({ + "id": str(row["id"]), + "action": row["action"], + "resource_type": row["resource_type"], + "resource_id": row["resource_id"], + "details": details, + "ip_address": row["ip_address"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + }) + + # ── Key access log (limit 1000, most recent first) ─────────────────── + result = await db.execute( + text( + "SELECT id, action, resource_type, ip_address, created_at " + "FROM key_access_log WHERE user_id = :user_id " + "ORDER BY created_at DESC LIMIT 1000" + ), + {"user_id": user_id}, + ) + key_access_entries = [] + for row in result.mappings().all(): + key_access_entries.append({ + "id": str(row["id"]), + "action": row["action"], + "resource_type": row["resource_type"], + "ip_address": row["ip_address"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + }) + + return { + "export_date": datetime.now(UTC).isoformat(), + "format_version": "1.0", + "user": user_data, + "api_keys": api_keys, + "audit_logs": audit_logs, + "key_access_log": key_access_entries, + } diff --git a/backend/app/services/alert_evaluator.py b/backend/app/services/alert_evaluator.py new file mode 100644 index 0000000..e6d474b --- /dev/null +++ b/backend/app/services/alert_evaluator.py @@ -0,0 +1,723 @@ +"""Alert rule evaluation engine with Redis breach counters and flap detection. + +Entry points: +- evaluate(device_id, tenant_id, metric_type, data): called from metrics_subscriber +- evaluate_offline(device_id, tenant_id): called from nats_subscriber on device offline +- evaluate_online(device_id, tenant_id): called from nats_subscriber on device online + +Uses Redis for: +- Consecutive breach counting (alert:breach:{device_id}:{rule_id}) +- Flap detection (alert:flap:{device_id}:{rule_id} sorted set) + +Uses AdminAsyncSessionLocal for all DB operations (runs cross-tenant in NATS handlers). +""" + +import asyncio +import logging +import time +from datetime import datetime, timezone +from typing import Any + +import redis.asyncio as aioredis +from sqlalchemy import text + +from app.config import settings +from app.database import AdminAsyncSessionLocal +from app.services.event_publisher import publish_event + +logger = logging.getLogger(__name__) + +# Module-level Redis client, lazily initialized +_redis_client: aioredis.Redis | None = None + +# Module-level rule cache: {tenant_id: (rules_list, fetched_at_timestamp)} +_rule_cache: dict[str, tuple[list[dict], float]] = {} +_CACHE_TTL_SECONDS = 60 + +# Module-level maintenance window cache: {tenant_id: (active_windows_list, fetched_at_timestamp)} +# Each window: {"device_ids": [...], "suppress_alerts": True} +_maintenance_cache: dict[str, tuple[list[dict], float]] = {} +_MAINTENANCE_CACHE_TTL = 30 # 30 seconds + + +async def _get_redis() -> aioredis.Redis: + """Get or create the Redis client.""" + global _redis_client + if _redis_client is None: + _redis_client = aioredis.from_url(settings.REDIS_URL, decode_responses=True) + return _redis_client + + +async def _get_active_maintenance_windows(tenant_id: str) -> list[dict]: + """Fetch active maintenance windows for a tenant, with 30s cache.""" + now = time.time() + cached = _maintenance_cache.get(tenant_id) + if cached and (now - cached[1]) < _MAINTENANCE_CACHE_TTL: + return cached[0] + + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT device_ids, suppress_alerts + FROM maintenance_windows + WHERE tenant_id = CAST(:tenant_id AS uuid) + AND suppress_alerts = true + AND start_at <= NOW() + AND end_at >= NOW() + """), + {"tenant_id": tenant_id}, + ) + rows = result.fetchall() + + windows = [ + { + "device_ids": row[0] if isinstance(row[0], list) else [], + "suppress_alerts": row[1], + } + for row in rows + ] + + _maintenance_cache[tenant_id] = (windows, now) + return windows + + +async def _is_device_in_maintenance(tenant_id: str, device_id: str) -> bool: + """Check if a device is currently under active maintenance with alert suppression. + + Returns True if there is at least one active maintenance window covering + this device (or all devices via empty device_ids array). + """ + windows = await _get_active_maintenance_windows(tenant_id) + for window in windows: + device_ids = window["device_ids"] + # Empty device_ids means "all devices in tenant" + if not device_ids or device_id in device_ids: + return True + return False + + +async def _get_rules_for_tenant(tenant_id: str) -> list[dict]: + """Fetch active alert rules for a tenant, with 60s cache.""" + now = time.time() + cached = _rule_cache.get(tenant_id) + if cached and (now - cached[1]) < _CACHE_TTL_SECONDS: + return cached[0] + + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT id, tenant_id, device_id, group_id, name, metric, + operator, threshold, duration_polls, severity + FROM alert_rules + WHERE tenant_id = CAST(:tenant_id AS uuid) AND enabled = TRUE + """), + {"tenant_id": tenant_id}, + ) + rows = result.fetchall() + + rules = [ + { + "id": str(row[0]), + "tenant_id": str(row[1]), + "device_id": str(row[2]) if row[2] else None, + "group_id": str(row[3]) if row[3] else None, + "name": row[4], + "metric": row[5], + "operator": row[6], + "threshold": float(row[7]), + "duration_polls": row[8], + "severity": row[9], + } + for row in rows + ] + + _rule_cache[tenant_id] = (rules, now) + return rules + + +def _check_threshold(value: float, operator: str, threshold: float) -> bool: + """Check if a metric value breaches a threshold.""" + if operator == "gt": + return value > threshold + elif operator == "lt": + return value < threshold + elif operator == "gte": + return value >= threshold + elif operator == "lte": + return value <= threshold + return False + + +def _extract_metrics(metric_type: str, data: dict) -> dict[str, float]: + """Extract metric name->value pairs from a NATS metrics event.""" + metrics: dict[str, float] = {} + + if metric_type == "health": + health = data.get("health", {}) + for key in ("cpu_load", "temperature"): + val = health.get(key) + if val is not None and val != "": + try: + metrics[key] = float(val) + except (ValueError, TypeError): + pass + # Compute memory_used_pct and disk_used_pct + free_mem = health.get("free_memory") + total_mem = health.get("total_memory") + if free_mem is not None and total_mem is not None: + try: + total = float(total_mem) + free = float(free_mem) + if total > 0: + metrics["memory_used_pct"] = round((1.0 - free / total) * 100, 1) + except (ValueError, TypeError): + pass + free_disk = health.get("free_disk") + total_disk = health.get("total_disk") + if free_disk is not None and total_disk is not None: + try: + total = float(total_disk) + free = float(free_disk) + if total > 0: + metrics["disk_used_pct"] = round((1.0 - free / total) * 100, 1) + except (ValueError, TypeError): + pass + + elif metric_type == "wireless": + wireless = data.get("wireless", []) + # Aggregate: use worst signal, lowest CCQ, sum client_count + for wif in wireless: + for key in ("signal_strength", "ccq", "client_count"): + val = wif.get(key) if key != "avg_signal" else wif.get("avg_signal") + if key == "signal_strength": + val = wif.get("avg_signal") + if val is not None and val != "": + try: + fval = float(val) + if key not in metrics: + metrics[key] = fval + elif key == "signal_strength": + metrics[key] = min(metrics[key], fval) # worst signal + elif key == "ccq": + metrics[key] = min(metrics[key], fval) # worst CCQ + elif key == "client_count": + metrics[key] = metrics.get(key, 0) + fval # sum + except (ValueError, TypeError): + pass + + # TODO: Interface bandwidth alerting (rx_bps/tx_bps) requires stateful delta + # computation between consecutive poll values. Deferred for now — the alert_rules + # table supports these metric types, but evaluation is skipped. + + return metrics + + +async def _increment_breach( + r: aioredis.Redis, device_id: str, rule_id: str, required_polls: int +) -> bool: + """Increment breach counter in Redis. Returns True when threshold duration reached.""" + key = f"alert:breach:{device_id}:{rule_id}" + count = await r.incr(key) + # Set TTL to (required_polls + 2) * 60 seconds so it expires if breaches stop + await r.expire(key, (required_polls + 2) * 60) + return count >= required_polls + + +async def _reset_breach(r: aioredis.Redis, device_id: str, rule_id: str) -> None: + """Reset breach counter when metric returns to normal.""" + key = f"alert:breach:{device_id}:{rule_id}" + await r.delete(key) + + +async def _check_flapping(r: aioredis.Redis, device_id: str, rule_id: str) -> bool: + """Check if alert is flapping (>= 5 state transitions in 10 minutes). + + Uses a Redis sorted set with timestamps as scores. + """ + key = f"alert:flap:{device_id}:{rule_id}" + now = time.time() + window_start = now - 600 # 10 minute window + + # Add this transition + await r.zadd(key, {str(now): now}) + # Remove entries outside the window + await r.zremrangebyscore(key, "-inf", window_start) + # Set TTL on the key + await r.expire(key, 1200) + # Count transitions in window + count = await r.zcard(key) + return count >= 5 + + +async def _get_device_groups(device_id: str) -> list[str]: + """Get group IDs for a device.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text("SELECT group_id FROM device_group_memberships WHERE device_id = CAST(:device_id AS uuid)"), + {"device_id": device_id}, + ) + return [str(row[0]) for row in result.fetchall()] + + +async def _has_open_alert(device_id: str, rule_id: str | None, metric: str | None = None) -> bool: + """Check if there's an open (firing, unresolved) alert for this device+rule.""" + async with AdminAsyncSessionLocal() as session: + if rule_id: + result = await session.execute( + text(""" + SELECT 1 FROM alert_events + WHERE device_id = CAST(:device_id AS uuid) AND rule_id = CAST(:rule_id AS uuid) + AND status = 'firing' AND resolved_at IS NULL + LIMIT 1 + """), + {"device_id": device_id, "rule_id": rule_id}, + ) + else: + result = await session.execute( + text(""" + SELECT 1 FROM alert_events + WHERE device_id = CAST(:device_id AS uuid) AND rule_id IS NULL + AND metric = :metric AND status = 'firing' AND resolved_at IS NULL + LIMIT 1 + """), + {"device_id": device_id, "metric": metric or "offline"}, + ) + return result.fetchone() is not None + + +async def _create_alert_event( + device_id: str, + tenant_id: str, + rule_id: str | None, + status: str, + severity: str, + metric: str | None, + value: float | None, + threshold: float | None, + message: str | None, + is_flapping: bool = False, +) -> dict: + """Create an alert event row and return its data.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + INSERT INTO alert_events + (id, device_id, tenant_id, rule_id, status, severity, metric, + value, threshold, message, is_flapping, fired_at, + resolved_at) + VALUES + (gen_random_uuid(), CAST(:device_id AS uuid), CAST(:tenant_id AS uuid), + :rule_id, :status, :severity, :metric, + :value, :threshold, :message, :is_flapping, NOW(), + CASE WHEN :status = 'resolved' THEN NOW() ELSE NULL END) + RETURNING id, fired_at + """), + { + "device_id": device_id, + "tenant_id": tenant_id, + "rule_id": rule_id, + "status": status, + "severity": severity, + "metric": metric, + "value": value, + "threshold": threshold, + "message": message, + "is_flapping": is_flapping, + }, + ) + row = result.fetchone() + await session.commit() + + alert_data = { + "id": str(row[0]) if row else None, + "device_id": device_id, + "tenant_id": tenant_id, + "rule_id": rule_id, + "status": status, + "severity": severity, + "metric": metric, + "value": value, + "threshold": threshold, + "message": message, + "is_flapping": is_flapping, + } + + # Publish real-time event to NATS for SSE pipeline (fire-and-forget) + if status in ("firing", "flapping"): + await publish_event(f"alert.fired.{tenant_id}", { + "event_type": "alert_fired", + "tenant_id": tenant_id, + "device_id": device_id, + "alert_event_id": alert_data["id"], + "severity": severity, + "metric": metric, + "current_value": value, + "threshold": threshold, + "message": message, + "is_flapping": is_flapping, + "fired_at": datetime.now(timezone.utc).isoformat(), + }) + elif status == "resolved": + await publish_event(f"alert.resolved.{tenant_id}", { + "event_type": "alert_resolved", + "tenant_id": tenant_id, + "device_id": device_id, + "alert_event_id": alert_data["id"], + "severity": severity, + "metric": metric, + "message": message, + "resolved_at": datetime.now(timezone.utc).isoformat(), + }) + + return alert_data + + +async def _resolve_alert(device_id: str, rule_id: str | None, metric: str | None = None) -> None: + """Resolve an open alert by setting resolved_at.""" + async with AdminAsyncSessionLocal() as session: + if rule_id: + await session.execute( + text(""" + UPDATE alert_events SET resolved_at = NOW(), status = 'resolved' + WHERE device_id = CAST(:device_id AS uuid) AND rule_id = CAST(:rule_id AS uuid) + AND status = 'firing' AND resolved_at IS NULL + """), + {"device_id": device_id, "rule_id": rule_id}, + ) + else: + await session.execute( + text(""" + UPDATE alert_events SET resolved_at = NOW(), status = 'resolved' + WHERE device_id = CAST(:device_id AS uuid) AND rule_id IS NULL + AND metric = :metric AND status = 'firing' AND resolved_at IS NULL + """), + {"device_id": device_id, "metric": metric or "offline"}, + ) + await session.commit() + + +async def _get_channels_for_tenant(tenant_id: str) -> list[dict]: + """Get all notification channels for a tenant.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT id, name, channel_type, smtp_host, smtp_port, smtp_user, + smtp_password, smtp_use_tls, from_address, to_address, + webhook_url, smtp_password_transit, slack_webhook_url, tenant_id + FROM notification_channels + WHERE tenant_id = CAST(:tenant_id AS uuid) + """), + {"tenant_id": tenant_id}, + ) + return [ + { + "id": str(row[0]), + "name": row[1], + "channel_type": row[2], + "smtp_host": row[3], + "smtp_port": row[4], + "smtp_user": row[5], + "smtp_password": row[6], + "smtp_use_tls": row[7], + "from_address": row[8], + "to_address": row[9], + "webhook_url": row[10], + "smtp_password_transit": row[11], + "slack_webhook_url": row[12], + "tenant_id": str(row[13]) if row[13] else None, + } + for row in result.fetchall() + ] + + +async def _get_channels_for_rule(rule_id: str) -> list[dict]: + """Get notification channels linked to a specific alert rule.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT nc.id, nc.name, nc.channel_type, nc.smtp_host, nc.smtp_port, + nc.smtp_user, nc.smtp_password, nc.smtp_use_tls, + nc.from_address, nc.to_address, nc.webhook_url, + nc.smtp_password_transit, nc.slack_webhook_url, nc.tenant_id + FROM notification_channels nc + JOIN alert_rule_channels arc ON arc.channel_id = nc.id + WHERE arc.rule_id = CAST(:rule_id AS uuid) + """), + {"rule_id": rule_id}, + ) + return [ + { + "id": str(row[0]), + "name": row[1], + "channel_type": row[2], + "smtp_host": row[3], + "smtp_port": row[4], + "smtp_user": row[5], + "smtp_password": row[6], + "smtp_use_tls": row[7], + "from_address": row[8], + "to_address": row[9], + "webhook_url": row[10], + "smtp_password_transit": row[11], + "slack_webhook_url": row[12], + "tenant_id": str(row[13]) if row[13] else None, + } + for row in result.fetchall() + ] + + +async def _dispatch_async(alert_event: dict, channels: list[dict], device_hostname: str) -> None: + """Fire-and-forget notification dispatch.""" + try: + from app.services.notification_service import dispatch_notifications + await dispatch_notifications(alert_event, channels, device_hostname) + except Exception as e: + logger.warning("Notification dispatch failed: %s", e) + + +async def _get_device_hostname(device_id: str) -> str: + """Get device hostname for notification messages.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text("SELECT hostname FROM devices WHERE id = CAST(:device_id AS uuid)"), + {"device_id": device_id}, + ) + row = result.fetchone() + return row[0] if row else device_id + + +async def evaluate( + device_id: str, + tenant_id: str, + metric_type: str, + data: dict[str, Any], +) -> None: + """Evaluate alert rules for incoming device metrics. + + Called from metrics_subscriber after metric DB write. + """ + # Check maintenance window suppression before evaluating rules + if await _is_device_in_maintenance(tenant_id, device_id): + logger.debug( + "Alert suppressed by maintenance window for device %s tenant %s", + device_id, tenant_id, + ) + return + + rules = await _get_rules_for_tenant(tenant_id) + if not rules: + return + + metrics = _extract_metrics(metric_type, data) + if not metrics: + return + + r = await _get_redis() + device_groups = await _get_device_groups(device_id) + + # Build a set of metrics that have device-specific rules + device_specific_metrics: set[str] = set() + for rule in rules: + if rule["device_id"] == device_id: + device_specific_metrics.add(rule["metric"]) + + for rule in rules: + rule_metric = rule["metric"] + if rule_metric not in metrics: + continue + + # Check if rule applies to this device + applies = False + if rule["device_id"] == device_id: + applies = True + elif rule["device_id"] is None and rule["group_id"] is None: + # Tenant-wide rule — skip if device-specific rule exists for same metric + if rule_metric in device_specific_metrics: + continue + applies = True + elif rule["group_id"] and rule["group_id"] in device_groups: + applies = True + + if not applies: + continue + + value = metrics[rule_metric] + breaching = _check_threshold(value, rule["operator"], rule["threshold"]) + + if breaching: + reached = await _increment_breach(r, device_id, rule["id"], rule["duration_polls"]) + if reached: + # Check if already firing + if await _has_open_alert(device_id, rule["id"]): + continue + + # Check flapping + is_flapping = await _check_flapping(r, device_id, rule["id"]) + + hostname = await _get_device_hostname(device_id) + message = f"{rule['name']}: {rule_metric} = {value} (threshold: {rule['operator']} {rule['threshold']})" + + alert_event = await _create_alert_event( + device_id=device_id, + tenant_id=tenant_id, + rule_id=rule["id"], + status="flapping" if is_flapping else "firing", + severity=rule["severity"], + metric=rule_metric, + value=value, + threshold=rule["threshold"], + message=message, + is_flapping=is_flapping, + ) + + if is_flapping: + logger.info( + "Alert %s for device %s is flapping — notifications suppressed", + rule["name"], device_id, + ) + else: + channels = await _get_channels_for_rule(rule["id"]) + if channels: + asyncio.create_task(_dispatch_async(alert_event, channels, hostname)) + else: + # Not breaching — reset counter and check for open alert to resolve + await _reset_breach(r, device_id, rule["id"]) + + if await _has_open_alert(device_id, rule["id"]): + # Check flapping before resolving + is_flapping = await _check_flapping(r, device_id, rule["id"]) + + await _resolve_alert(device_id, rule["id"]) + + hostname = await _get_device_hostname(device_id) + message = f"Resolved: {rule['name']}: {rule_metric} = {value}" + + resolved_event = await _create_alert_event( + device_id=device_id, + tenant_id=tenant_id, + rule_id=rule["id"], + status="resolved", + severity=rule["severity"], + metric=rule_metric, + value=value, + threshold=rule["threshold"], + message=message, + is_flapping=is_flapping, + ) + + if not is_flapping: + channels = await _get_channels_for_rule(rule["id"]) + if channels: + asyncio.create_task(_dispatch_async(resolved_event, channels, hostname)) + + +async def _get_offline_rule(tenant_id: str) -> dict | None: + """Look up the device_offline default rule for a tenant.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT id, enabled FROM alert_rules + WHERE tenant_id = CAST(:tenant_id AS uuid) + AND metric = 'device_offline' AND is_default = TRUE + LIMIT 1 + """), + {"tenant_id": tenant_id}, + ) + row = result.fetchone() + if row: + return {"id": str(row[0]), "enabled": row[1]} + return None + + +async def evaluate_offline(device_id: str, tenant_id: str) -> None: + """Create a critical alert when a device goes offline. + + Uses the tenant's device_offline default rule if it exists and is enabled. + Falls back to system-level alert (rule_id=NULL) for backward compatibility. + """ + if await _is_device_in_maintenance(tenant_id, device_id): + logger.debug( + "Offline alert suppressed by maintenance window for device %s", + device_id, + ) + return + + rule = await _get_offline_rule(tenant_id) + rule_id = rule["id"] if rule else None + + # If rule exists but is disabled, skip alert creation (user opted out) + if rule and not rule["enabled"]: + return + + if rule_id: + if await _has_open_alert(device_id, rule_id): + return + else: + if await _has_open_alert(device_id, None, "offline"): + return + + hostname = await _get_device_hostname(device_id) + message = f"Device {hostname} is offline" + + alert_event = await _create_alert_event( + device_id=device_id, + tenant_id=tenant_id, + rule_id=rule_id, + status="firing", + severity="critical", + metric="offline", + value=None, + threshold=None, + message=message, + ) + + # Use rule-linked channels if available, otherwise tenant-wide channels + if rule_id: + channels = await _get_channels_for_rule(rule_id) + if not channels: + channels = await _get_channels_for_tenant(tenant_id) + else: + channels = await _get_channels_for_tenant(tenant_id) + + if channels: + asyncio.create_task(_dispatch_async(alert_event, channels, hostname)) + + +async def evaluate_online(device_id: str, tenant_id: str) -> None: + """Resolve offline alert when device comes back online.""" + rule = await _get_offline_rule(tenant_id) + rule_id = rule["id"] if rule else None + + if rule_id: + if not await _has_open_alert(device_id, rule_id): + return + await _resolve_alert(device_id, rule_id) + else: + if not await _has_open_alert(device_id, None, "offline"): + return + await _resolve_alert(device_id, None, "offline") + + hostname = await _get_device_hostname(device_id) + message = f"Device {hostname} is back online" + + resolved_event = await _create_alert_event( + device_id=device_id, + tenant_id=tenant_id, + rule_id=rule_id, + status="resolved", + severity="critical", + metric="offline", + value=None, + threshold=None, + message=message, + ) + + if rule_id: + channels = await _get_channels_for_rule(rule_id) + if not channels: + channels = await _get_channels_for_tenant(tenant_id) + else: + channels = await _get_channels_for_tenant(tenant_id) + + if channels: + asyncio.create_task(_dispatch_async(resolved_event, channels, hostname)) diff --git a/backend/app/services/api_key_service.py b/backend/app/services/api_key_service.py new file mode 100644 index 0000000..b6fefd5 --- /dev/null +++ b/backend/app/services/api_key_service.py @@ -0,0 +1,190 @@ +"""API key generation, validation, and management service. + +Keys use the mktp_ prefix for easy identification in logs. +Storage uses SHA-256 hash -- the plaintext key is never persisted. +Validation uses AdminAsyncSessionLocal since it runs before tenant context is set. +""" + +import hashlib +import json +import secrets +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import text + +from app.database import AdminAsyncSessionLocal + +# Allowed scopes for API keys +ALLOWED_SCOPES: set[str] = { + "devices:read", + "devices:write", + "config:read", + "config:write", + "alerts:read", + "firmware:write", +} + + +def generate_raw_key() -> str: + """Generate a raw API key with mktp_ prefix + 32 URL-safe random chars.""" + random_part = secrets.token_urlsafe(32) + return f"mktp_{random_part}" + + +def hash_key(raw_key: str) -> str: + """SHA-256 hex digest of a raw API key.""" + return hashlib.sha256(raw_key.encode()).hexdigest() + + +async def create_api_key( + db, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + name: str, + scopes: list[str], + expires_at: Optional[datetime] = None, +) -> dict: + """Create a new API key. + + Returns dict with: + - key: the plaintext key (shown once, never again) + - id: the key UUID + - key_prefix: first 9 chars of the key (e.g. "mktp_abc1") + """ + raw_key = generate_raw_key() + key_hash_value = hash_key(raw_key) + key_prefix = raw_key[:9] # "mktp_" + first 4 random chars + + result = await db.execute( + text(""" + INSERT INTO api_keys (tenant_id, user_id, name, key_prefix, key_hash, scopes, expires_at) + VALUES (:tenant_id, :user_id, :name, :key_prefix, :key_hash, CAST(:scopes AS jsonb), :expires_at) + RETURNING id, created_at + """), + { + "tenant_id": str(tenant_id), + "user_id": str(user_id), + "name": name, + "key_prefix": key_prefix, + "key_hash": key_hash_value, + "scopes": json.dumps(scopes), + "expires_at": expires_at, + }, + ) + row = result.fetchone() + await db.commit() + + return { + "key": raw_key, + "id": row.id, + "key_prefix": key_prefix, + "name": name, + "scopes": scopes, + "expires_at": expires_at, + "created_at": row.created_at, + } + + +async def validate_api_key(raw_key: str) -> Optional[dict]: + """Validate an API key and return context if valid. + + Uses AdminAsyncSessionLocal since this runs before tenant context is set. + + Returns dict with tenant_id, user_id, scopes, key_id on success. + Returns None for invalid, expired, or revoked keys. + Updates last_used_at on successful validation. + """ + key_hash_value = hash_key(raw_key) + + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT id, tenant_id, user_id, scopes, expires_at, revoked_at + FROM api_keys + WHERE key_hash = :key_hash + """), + {"key_hash": key_hash_value}, + ) + row = result.fetchone() + + if not row: + return None + + # Check revoked + if row.revoked_at is not None: + return None + + # Check expired + if row.expires_at is not None and row.expires_at <= datetime.now(timezone.utc): + return None + + # Update last_used_at + await session.execute( + text(""" + UPDATE api_keys SET last_used_at = now() + WHERE id = :key_id + """), + {"key_id": str(row.id)}, + ) + await session.commit() + + return { + "tenant_id": row.tenant_id, + "user_id": row.user_id, + "scopes": row.scopes if row.scopes else [], + "key_id": row.id, + } + + +async def list_api_keys(db, tenant_id: uuid.UUID) -> list[dict]: + """List all API keys for a tenant (active and revoked). + + Returns keys with masked display (key_prefix + "..."). + """ + result = await db.execute( + text(""" + SELECT id, name, key_prefix, scopes, expires_at, last_used_at, + created_at, revoked_at, user_id + FROM api_keys + WHERE tenant_id = :tenant_id + ORDER BY created_at DESC + """), + {"tenant_id": str(tenant_id)}, + ) + rows = result.fetchall() + + return [ + { + "id": row.id, + "name": row.name, + "key_prefix": row.key_prefix, + "scopes": row.scopes if row.scopes else [], + "expires_at": row.expires_at.isoformat() if row.expires_at else None, + "last_used_at": row.last_used_at.isoformat() if row.last_used_at else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "revoked_at": row.revoked_at.isoformat() if row.revoked_at else None, + "user_id": str(row.user_id), + } + for row in rows + ] + + +async def revoke_api_key(db, tenant_id: uuid.UUID, key_id: uuid.UUID) -> bool: + """Revoke an API key by setting revoked_at = now(). + + Returns True if a key was actually revoked, False if not found or already revoked. + """ + result = await db.execute( + text(""" + UPDATE api_keys + SET revoked_at = now() + WHERE id = :key_id AND tenant_id = :tenant_id AND revoked_at IS NULL + RETURNING id + """), + {"key_id": str(key_id), "tenant_id": str(tenant_id)}, + ) + row = result.fetchone() + await db.commit() + return row is not None diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..ae6a65d --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,92 @@ +"""Centralized audit logging service. + +Provides a fire-and-forget ``log_action`` coroutine that inserts a row into +the ``audit_logs`` table. Uses raw SQL INSERT (not ORM) for minimal overhead. + +The function is wrapped in a try/except so that a logging failure **never** +breaks the parent operation. + +Phase 30: When details are non-empty, they are encrypted via OpenBao Transit +(per-tenant data key) and stored in encrypted_details. The plaintext details +column is set to '{}' for column compatibility. If Transit encryption fails +(e.g., OpenBao unavailable), details are stored in plaintext as a fallback. +""" + +import uuid +from typing import Any, Optional + +import structlog +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +logger = structlog.get_logger("audit") + + +async def log_action( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + action: str, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + device_id: Optional[uuid.UUID] = None, + details: Optional[dict[str, Any]] = None, + ip_address: Optional[str] = None, +) -> None: + """Insert a row into audit_logs. Swallows all exceptions on failure.""" + try: + import json as _json + + details_dict = details or {} + details_json = _json.dumps(details_dict) + encrypted_details: Optional[str] = None + + # Attempt Transit encryption for non-empty details + if details_dict: + try: + from app.services.crypto import encrypt_data_transit + + encrypted_details = await encrypt_data_transit( + details_json, str(tenant_id) + ) + # Encryption succeeded — clear plaintext details + details_json = _json.dumps({}) + except Exception: + # Transit unavailable — fall back to plaintext details + logger.warning( + "audit_transit_encryption_failed", + action=action, + tenant_id=str(tenant_id), + exc_info=True, + ) + # Keep details_json as-is (plaintext fallback) + encrypted_details = None + + await db.execute( + text( + "INSERT INTO audit_logs " + "(tenant_id, user_id, action, resource_type, resource_id, " + "device_id, details, encrypted_details, ip_address) " + "VALUES (:tenant_id, :user_id, :action, :resource_type, " + ":resource_id, :device_id, CAST(:details AS jsonb), " + ":encrypted_details, :ip_address)" + ), + { + "tenant_id": str(tenant_id), + "user_id": str(user_id), + "action": action, + "resource_type": resource_type, + "resource_id": resource_id, + "device_id": str(device_id) if device_id else None, + "details": details_json, + "encrypted_details": encrypted_details, + "ip_address": ip_address, + }, + ) + except Exception: + logger.warning( + "audit_log_insert_failed", + action=action, + tenant_id=str(tenant_id), + exc_info=True, + ) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..854a820 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,154 @@ +""" +JWT authentication service. + +Handles password hashing, JWT token creation, token verification, +and token revocation via Redis. +""" + +import time +import uuid +from datetime import UTC, datetime, timedelta +from typing import Optional + +import bcrypt +from fastapi import HTTPException, status +from jose import JWTError, jwt +from redis.asyncio import Redis + +from app.config import settings + +TOKEN_REVOCATION_PREFIX = "token_revoked:" + + +def hash_password(password: str) -> str: + """Hash a plaintext password using bcrypt. + + DEPRECATED: Used only by password reset (temporary bcrypt hash for + upgrade flow) and bootstrap_first_admin. Remove post-v6.0. + """ + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plaintext password against a bcrypt hash. + + DEPRECATED: Used only by the one-time SRP upgrade flow (login with + must_upgrade_auth=True) and anti-enumeration dummy calls. Remove post-v6.0. + """ + return bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) + + +def create_access_token( + user_id: uuid.UUID, + tenant_id: Optional[uuid.UUID], + role: str, +) -> str: + """ + Create a short-lived JWT access token. + + Claims: + sub: user UUID (subject) + tenant_id: tenant UUID or None for super_admin + role: user's role string + type: "access" (to distinguish from refresh tokens) + exp: expiry timestamp + """ + now = datetime.now(UTC) + expire = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + + payload = { + "sub": str(user_id), + "tenant_id": str(tenant_id) if tenant_id else None, + "role": role, + "type": "access", + "iat": now, + "exp": expire, + } + + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def create_refresh_token(user_id: uuid.UUID) -> str: + """ + Create a long-lived JWT refresh token. + + Claims: + sub: user UUID (subject) + type: "refresh" (to distinguish from access tokens) + exp: expiry timestamp (7 days) + """ + now = datetime.now(UTC) + expire = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) + + payload = { + "sub": str(user_id), + "type": "refresh", + "iat": now, + "exp": expire, + } + + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def verify_token(token: str, expected_type: str = "access") -> dict: + """ + Decode and validate a JWT token. + + Args: + token: JWT string to validate + expected_type: "access" or "refresh" + + Returns: + dict: Decoded payload (sub, tenant_id, role, type, exp, iat) + + Raises: + HTTPException 401: If token is invalid, expired, or wrong type + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + ) + except JWTError: + raise credentials_exception + + # Validate token type + token_type = payload.get("type") + if token_type != expected_type: + raise credentials_exception + + # Validate subject exists + sub = payload.get("sub") + if not sub: + raise credentials_exception + + return payload + + +async def revoke_user_tokens(redis: Redis, user_id: str) -> None: + """Mark all tokens for a user as revoked by storing current timestamp. + + Any refresh token issued before this timestamp will be rejected. + TTL matches maximum refresh token lifetime (7 days). + """ + key = f"{TOKEN_REVOCATION_PREFIX}{user_id}" + await redis.set(key, str(time.time()), ex=7 * 24 * 3600) + + +async def is_token_revoked(redis: Redis, user_id: str, issued_at: float) -> bool: + """Check if a token was issued before the user's revocation timestamp. + + Returns True if the token should be rejected. + """ + key = f"{TOKEN_REVOCATION_PREFIX}{user_id}" + revoked_at = await redis.get(key) + if revoked_at is None: + return False + return issued_at < float(revoked_at) diff --git a/backend/app/services/backup_scheduler.py b/backend/app/services/backup_scheduler.py new file mode 100644 index 0000000..cbaa5a1 --- /dev/null +++ b/backend/app/services/backup_scheduler.py @@ -0,0 +1,197 @@ +"""Dynamic backup scheduler — reads cron schedules from DB, manages APScheduler jobs.""" + +import logging +from typing import Optional + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.database import AdminAsyncSessionLocal +from app.models.config_backup import ConfigBackupSchedule +from app.models.device import Device +from app.services import backup_service + +from sqlalchemy import select + +logger = logging.getLogger(__name__) + +_scheduler: Optional[AsyncIOScheduler] = None + +# System default: 2am UTC daily +DEFAULT_CRON = "0 2 * * *" + + +def _cron_to_trigger(cron_expr: str) -> Optional[CronTrigger]: + """Parse a 5-field cron expression into an APScheduler CronTrigger. + + Returns None if the expression is invalid. + """ + try: + parts = cron_expr.strip().split() + if len(parts) != 5: + return None + minute, hour, day, month, day_of_week = parts + return CronTrigger( + minute=minute, hour=hour, day=day, month=month, + day_of_week=day_of_week, timezone="UTC", + ) + except Exception as e: + logger.warning("Invalid cron expression '%s': %s", cron_expr, e) + return None + + +def build_schedule_map(schedules: list) -> dict[str, list[dict]]: + """Group device schedules by cron expression. + + Returns: {cron_expression: [{device_id, tenant_id}, ...]} + """ + schedule_map: dict[str, list[dict]] = {} + for s in schedules: + if not s.enabled: + continue + cron = s.cron_expression or DEFAULT_CRON + if cron not in schedule_map: + schedule_map[cron] = [] + schedule_map[cron].append({ + "device_id": str(s.device_id), + "tenant_id": str(s.tenant_id), + }) + return schedule_map + + +async def _run_scheduled_backups(devices: list[dict]) -> None: + """Run backups for a list of devices. Each failure is isolated.""" + success_count = 0 + failure_count = 0 + + for dev_info in devices: + try: + async with AdminAsyncSessionLocal() as session: + await backup_service.run_backup( + device_id=dev_info["device_id"], + tenant_id=dev_info["tenant_id"], + trigger_type="scheduled", + db_session=session, + ) + await session.commit() + logger.info("Scheduled backup OK: device %s", dev_info["device_id"]) + success_count += 1 + except Exception as e: + logger.error( + "Scheduled backup FAILED: device %s: %s", + dev_info["device_id"], e, + ) + failure_count += 1 + + logger.info( + "Backup batch complete — %d succeeded, %d failed", + success_count, failure_count, + ) + + +async def _load_effective_schedules() -> list: + """Load all effective schedules from DB. + + For each device: use device-specific schedule if exists, else tenant default. + Returns flat list of (device_id, tenant_id, cron_expression, enabled) objects. + """ + from types import SimpleNamespace + + async with AdminAsyncSessionLocal() as session: + # Get all devices + dev_result = await session.execute(select(Device)) + devices = dev_result.scalars().all() + + # Get all schedules + sched_result = await session.execute(select(ConfigBackupSchedule)) + schedules = sched_result.scalars().all() + + # Index: device-specific and tenant defaults + device_schedules = {} # device_id -> schedule + tenant_defaults = {} # tenant_id -> schedule + + for s in schedules: + if s.device_id: + device_schedules[str(s.device_id)] = s + else: + tenant_defaults[str(s.tenant_id)] = s + + effective = [] + for dev in devices: + dev_id = str(dev.id) + tenant_id = str(dev.tenant_id) + + if dev_id in device_schedules: + sched = device_schedules[dev_id] + elif tenant_id in tenant_defaults: + sched = tenant_defaults[tenant_id] + else: + # No schedule configured — use system default + sched = None + + effective.append(SimpleNamespace( + device_id=dev_id, + tenant_id=tenant_id, + cron_expression=sched.cron_expression if sched else DEFAULT_CRON, + enabled=sched.enabled if sched else True, + )) + + return effective + + +async def sync_schedules() -> None: + """Reload all schedules from DB and reconfigure APScheduler jobs.""" + global _scheduler + if not _scheduler: + return + + # Remove all existing backup jobs (keep other jobs like firmware check) + for job in _scheduler.get_jobs(): + if job.id.startswith("backup_cron_"): + job.remove() + + schedules = await _load_effective_schedules() + schedule_map = build_schedule_map(schedules) + + for cron_expr, devices in schedule_map.items(): + trigger = _cron_to_trigger(cron_expr) + if not trigger: + logger.warning("Skipping invalid cron '%s', using default", cron_expr) + trigger = _cron_to_trigger(DEFAULT_CRON) + + job_id = f"backup_cron_{cron_expr.replace(' ', '_')}" + _scheduler.add_job( + _run_scheduled_backups, + trigger=trigger, + args=[devices], + id=job_id, + name=f"Backup: {cron_expr} ({len(devices)} devices)", + max_instances=1, + replace_existing=True, + ) + logger.info("Scheduled %d devices with cron '%s'", len(devices), cron_expr) + + +async def on_schedule_change(tenant_id: str, device_id: str) -> None: + """Called when a schedule is created/updated via API. Hot-reloads all schedules.""" + logger.info("Schedule changed for tenant=%s device=%s, resyncing", tenant_id, device_id) + await sync_schedules() + + +async def start_backup_scheduler() -> None: + """Start the APScheduler and load initial schedules from DB.""" + global _scheduler + _scheduler = AsyncIOScheduler(timezone="UTC") + _scheduler.start() + + await sync_schedules() + logger.info("Backup scheduler started with dynamic schedules") + + +async def stop_backup_scheduler() -> None: + """Gracefully shutdown the scheduler.""" + global _scheduler + if _scheduler: + _scheduler.shutdown(wait=False) + _scheduler = None + logger.info("Backup scheduler stopped") diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py new file mode 100644 index 0000000..e9a50fd --- /dev/null +++ b/backend/app/services/backup_service.py @@ -0,0 +1,378 @@ +"""SSH-based config capture service for RouterOS devices. + +This service handles: +1. capture_export() — SSH to device, run /export compact, return stdout text +2. capture_binary_backup() — SSH to device, trigger /system backup save, SFTP-download result +3. run_backup() — Orchestrate a full backup: capture + git commit + DB record + +All functions are async (asyncssh is asyncio-native). + +Security policy: + known_hosts=None is intentional — RouterOS devices use self-signed SSH host keys + that change on reset or key regeneration. This mirrors InsecureSkipVerify=true + used in the poller's TLS connection. The threat model accepts device impersonation + risk in exchange for operational simplicity (no pre-enrollment of host keys needed). + See Pitfall 2 in 04-RESEARCH.md. + +pygit2 calls are synchronous C bindings and MUST be wrapped in run_in_executor. +See Pitfall 3 in 04-RESEARCH.md. + +Phase 30: ALL backups (manual, scheduled, pre-restore) are encrypted via OpenBao +Transit (Tier 2) before git commit. The server retains decrypt capability for +on-demand viewing. Raw files in git are ciphertext; the API decrypts on GET. +""" + +import asyncio +import base64 +import io +import json +import logging +from datetime import datetime, timezone + +import asyncssh +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import AdminAsyncSessionLocal, set_tenant_context +from app.models.config_backup import ConfigBackupRun +from app.models.device import Device +from app.services import git_store +from app.services.crypto import decrypt_credentials_hybrid + +logger = logging.getLogger(__name__) + +# Fixed backup file name on device flash — overwrites on each run so files +# don't accumulate. See Pitfall 4 in 04-RESEARCH.md. +_BACKUP_NAME = "portal-backup" + + +async def capture_export( + ip: str, + port: int = 22, + username: str = "", + password: str = "", +) -> str: + """SSH to a RouterOS device and capture /export compact output. + + Args: + ip: Device IP address. + port: SSH port (default 22; RouterOS default is 22). + username: SSH login username. + password: SSH login password. + + Returns: + The raw RSC text from /export compact (may include RouterOS header line). + + Raises: + asyncssh.Error: On SSH connection or command execution failure. + """ + async with asyncssh.connect( + ip, + port=port, + username=username, + password=password, + known_hosts=None, # RouterOS self-signed host keys — see module docstring + connect_timeout=30, + ) as conn: + result = await conn.run("/export compact", check=True) + return result.stdout + + +async def capture_binary_backup( + ip: str, + port: int = 22, + username: str = "", + password: str = "", +) -> bytes: + """SSH to a RouterOS device, create a binary backup, SFTP-download it, then clean up. + + Uses a fixed backup name ({_BACKUP_NAME}.backup) so the file overwrites + on subsequent runs, preventing flash storage accumulation. + + The cleanup (removing the file from device flash) runs in a try/finally + block so cleanup failures don't mask the actual backup error but are + logged for observability. See Pitfall 4 in 04-RESEARCH.md. + + Args: + ip: Device IP address. + port: SSH port (default 22). + username: SSH login username. + password: SSH login password. + + Returns: + Raw bytes of the binary backup file. + + Raises: + asyncssh.Error: On SSH connection, command, or SFTP failure. + """ + async with asyncssh.connect( + ip, + port=port, + username=username, + password=password, + known_hosts=None, + connect_timeout=30, + ) as conn: + # Step 1: Trigger backup creation on device flash. + await conn.run( + f"/system backup save name={_BACKUP_NAME} dont-encrypt=yes", + check=True, + ) + + buf = io.BytesIO() + try: + # Step 2: SFTP-download the backup file. + async with conn.start_sftp_client() as sftp: + async with sftp.open(f"{_BACKUP_NAME}.backup", "rb") as f: + buf.write(await f.read()) + finally: + # Step 3: Remove backup file from device flash (best-effort cleanup). + try: + await conn.run(f"/file remove {_BACKUP_NAME}.backup", check=True) + except Exception as cleanup_err: + logger.warning( + "Failed to remove backup file from device %s: %s", + ip, + cleanup_err, + ) + + return buf.getvalue() + + +async def run_backup( + device_id: str, + tenant_id: str, + trigger_type: str, + db_session: AsyncSession | None = None, +) -> dict: + """Orchestrate a full config backup for a device. + + Steps: + 1. Load device from DB (ip_address, encrypted_credentials). + 2. Decrypt credentials using crypto.decrypt_credentials(). + 3. Capture /export compact and binary backup concurrently via asyncio.gather(). + 4. Compute line delta vs the most recent export.rsc in git (None for first backup). + 5. Commit both files to the tenant's bare git repo (run_in_executor for pygit2). + 6. Insert ConfigBackupRun record with commit SHA, trigger type, line deltas. + 7. Return summary dict. + + Args: + device_id: Device UUID as string. + tenant_id: Tenant UUID as string. + trigger_type: 'scheduled' | 'manual' | 'pre-restore' + db_session: Optional AsyncSession with RLS context already set. + If None, uses AdminAsyncSessionLocal (for scheduler context). + + Returns: + Dict: {"commit_sha": str, "trigger_type": str, "lines_added": int|None, "lines_removed": int|None} + + Raises: + ValueError: If device not found or missing credentials. + asyncssh.Error: On SSH/SFTP failure. + """ + loop = asyncio.get_event_loop() + ts = datetime.now(timezone.utc).isoformat() + + # ----------------------------------------------------------------------- + # Step 1: Load device from DB + # ----------------------------------------------------------------------- + if db_session is not None: + session = db_session + should_close = False + else: + # Scheduler context: use admin session (cross-tenant; RLS bypassed) + session = AdminAsyncSessionLocal() + should_close = True + + try: + from sqlalchemy import select + + if should_close: + # Admin session doesn't have RLS context — query directly. + result = await session.execute( + select(Device).where( + Device.id == device_id, # type: ignore[arg-type] + Device.tenant_id == tenant_id, # type: ignore[arg-type] + ) + ) + else: + result = await session.execute( + select(Device).where(Device.id == device_id) # type: ignore[arg-type] + ) + + device = result.scalar_one_or_none() + if device is None: + raise ValueError(f"Device {device_id!r} not found for tenant {tenant_id!r}") + + if not device.encrypted_credentials_transit and not device.encrypted_credentials: + raise ValueError( + f"Device {device_id!r} has no stored credentials — cannot perform backup" + ) + + # ----------------------------------------------------------------------- + # Step 2: Decrypt credentials (dual-read: Transit preferred, legacy fallback) + # ----------------------------------------------------------------------- + key = settings.get_encryption_key_bytes() + creds_json = await decrypt_credentials_hybrid( + device.encrypted_credentials_transit, + device.encrypted_credentials, + str(device.tenant_id), + key, + ) + creds = json.loads(creds_json) + ssh_username = creds.get("username", "") + ssh_password = creds.get("password", "") + ip = device.ip_address + + hostname = device.hostname or ip + + # ----------------------------------------------------------------------- + # Step 3: Capture export and binary backup concurrently + # ----------------------------------------------------------------------- + logger.info( + "Starting %s backup for device %s (%s) tenant %s", + trigger_type, + hostname, + ip, + tenant_id, + ) + + export_text, binary_backup = await asyncio.gather( + capture_export(ip, username=ssh_username, password=ssh_password), + capture_binary_backup(ip, username=ssh_username, password=ssh_password), + ) + + # ----------------------------------------------------------------------- + # Step 4: Compute line delta vs prior version + # ----------------------------------------------------------------------- + lines_added: int | None = None + lines_removed: int | None = None + + prior_commits = await loop.run_in_executor( + None, git_store.list_device_commits, tenant_id, device_id + ) + + if prior_commits: + try: + prior_export_bytes = await loop.run_in_executor( + None, git_store.read_file, tenant_id, prior_commits[0]["sha"], device_id, "export.rsc" + ) + prior_text = prior_export_bytes.decode("utf-8", errors="replace") + lines_added, lines_removed = await loop.run_in_executor( + None, git_store.compute_line_delta, prior_text, export_text + ) + except Exception as delta_err: + logger.warning( + "Failed to compute line delta for device %s: %s", + device_id, + delta_err, + ) + # Keep lines_added/lines_removed as None on error — non-fatal + else: + # First backup: all lines are "added", none removed + all_lines = len(export_text.splitlines()) + lines_added = all_lines + lines_removed = 0 + + # ----------------------------------------------------------------------- + # Step 5: Encrypt ALL backups via Transit (Tier 2: OpenBao Transit) + # ----------------------------------------------------------------------- + encryption_tier: int | None = None + git_export_content = export_text + git_binary_content = binary_backup + + try: + from app.services.crypto import encrypt_data_transit + + encrypted_export = await encrypt_data_transit( + export_text, tenant_id + ) + encrypted_binary = await encrypt_data_transit( + base64.b64encode(binary_backup).decode(), tenant_id + ) + # Transit ciphertext is text — store directly in git + git_export_content = encrypted_export + git_binary_content = encrypted_binary.encode("utf-8") + encryption_tier = 2 + logger.info( + "Tier 2 Transit encryption applied for %s backup of device %s", + trigger_type, + device_id, + ) + except Exception as enc_err: + # Transit unavailable — fall back to plaintext (non-fatal) + logger.warning( + "Transit encryption failed for %s backup of device %s, " + "storing plaintext: %s", + trigger_type, + device_id, + enc_err, + ) + # Keep encryption_tier = None (plaintext fallback) + + # ----------------------------------------------------------------------- + # Step 6: Commit to git (wrapped in run_in_executor — pygit2 is sync C bindings) + # ----------------------------------------------------------------------- + commit_message = ( + f"{trigger_type}: {hostname} ({ip}) at {ts}" + ) + + commit_sha = await loop.run_in_executor( + None, + git_store.commit_backup, + tenant_id, + device_id, + git_export_content, + git_binary_content, + commit_message, + ) + + logger.info( + "Committed backup for device %s to git SHA %s (tier=%s)", + device_id, + commit_sha[:8], + encryption_tier, + ) + + # ----------------------------------------------------------------------- + # Step 7: Insert ConfigBackupRun record + # ----------------------------------------------------------------------- + if not should_close: + # RLS-scoped session from API context — record directly + backup_run = ConfigBackupRun( + device_id=device.id, + tenant_id=device.tenant_id, + commit_sha=commit_sha, + trigger_type=trigger_type, + lines_added=lines_added, + lines_removed=lines_removed, + encryption_tier=encryption_tier, + ) + session.add(backup_run) + await session.flush() + else: + # Admin session — set tenant context before insert so RLS policy is satisfied + async with AdminAsyncSessionLocal() as admin_session: + await set_tenant_context(admin_session, str(device.tenant_id)) + backup_run = ConfigBackupRun( + device_id=device.id, + tenant_id=device.tenant_id, + commit_sha=commit_sha, + trigger_type=trigger_type, + lines_added=lines_added, + lines_removed=lines_removed, + encryption_tier=encryption_tier, + ) + admin_session.add(backup_run) + await admin_session.commit() + + return { + "commit_sha": commit_sha, + "trigger_type": trigger_type, + "lines_added": lines_added, + "lines_removed": lines_removed, + } + + finally: + if should_close: + await session.close() diff --git a/backend/app/services/ca_service.py b/backend/app/services/ca_service.py new file mode 100644 index 0000000..ba5c3cf --- /dev/null +++ b/backend/app/services/ca_service.py @@ -0,0 +1,462 @@ +"""Certificate Authority service — CA generation, device cert signing, lifecycle. + +This module provides the core PKI functionality for the Internal Certificate +Authority feature. All functions receive an ``AsyncSession`` and an +``encryption_key`` as parameters (no direct Settings access) for testability. + +Security notes: +- CA private keys are encrypted with AES-256-GCM before database storage. +- PEM key material is NEVER logged. +- Device keys are decrypted only when needed for NATS transmission. +""" + +from __future__ import annotations + +import datetime +import ipaddress +import logging +from uuid import UUID + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.certificate import CertificateAuthority, DeviceCertificate +from app.services.crypto import ( + decrypt_credentials_hybrid, + encrypt_credentials_transit, +) + +logger = logging.getLogger(__name__) + +# Valid status transitions for the device certificate lifecycle. +_VALID_TRANSITIONS: dict[str, set[str]] = { + "issued": {"deploying"}, + "deploying": {"deployed", "issued"}, # issued = rollback on deploy failure + "deployed": {"expiring", "revoked", "superseded"}, + "expiring": {"expired", "revoked", "superseded"}, + "expired": {"superseded"}, + "revoked": set(), + "superseded": set(), +} + + +# --------------------------------------------------------------------------- +# CA Generation +# --------------------------------------------------------------------------- + +async def generate_ca( + db: AsyncSession, + tenant_id: UUID, + common_name: str, + validity_years: int, + encryption_key: bytes, +) -> CertificateAuthority: + """Generate a self-signed root CA for a tenant. + + Args: + db: Async database session. + tenant_id: Tenant UUID — only one CA per tenant. + common_name: CN for the CA certificate (e.g., "Portal Root CA"). + validity_years: How many years the CA cert is valid. + encryption_key: 32-byte AES-256-GCM key for encrypting the CA private key. + + Returns: + The newly created ``CertificateAuthority`` model instance. + + Raises: + ValueError: If the tenant already has a CA. + """ + # Ensure one CA per tenant + existing = await get_ca_for_tenant(db, tenant_id) + if existing is not None: + raise ValueError( + f"Tenant {tenant_id} already has a CA (id={existing.id}). " + "Delete the existing CA before creating a new one." + ) + + # Generate RSA 2048 key pair + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + now = datetime.datetime.now(datetime.timezone.utc) + expiry = now + datetime.timedelta(days=365 * validity_years) + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "The Other Dude"), + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ]) + + ca_cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(expiry) + .add_extension( + x509.BasicConstraints(ca=True, path_length=0), critical=True + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + + # Serialize public cert to PEM + cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") + + # Serialize private key to PEM, then encrypt with OpenBao Transit + key_pem = ca_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode("utf-8") + encrypted_key_transit = await encrypt_credentials_transit(key_pem, str(tenant_id)) + + # Compute SHA-256 fingerprint (colon-separated hex) + fingerprint_bytes = ca_cert.fingerprint(hashes.SHA256()) + fingerprint = ":".join(f"{b:02X}" for b in fingerprint_bytes) + + # Serial number as hex string + serial_hex = format(ca_cert.serial_number, "X") + + model = CertificateAuthority( + tenant_id=tenant_id, + common_name=common_name, + cert_pem=cert_pem, + encrypted_private_key=b"", # Legacy column kept for schema compat + encrypted_private_key_transit=encrypted_key_transit, + serial_number=serial_hex, + fingerprint_sha256=fingerprint, + not_valid_before=now, + not_valid_after=expiry, + ) + db.add(model) + await db.flush() + + logger.info( + "Generated CA for tenant %s: cn=%s fingerprint=%s", + tenant_id, + common_name, + fingerprint, + ) + return model + + +# --------------------------------------------------------------------------- +# Device Certificate Signing +# --------------------------------------------------------------------------- + +async def sign_device_cert( + db: AsyncSession, + ca: CertificateAuthority, + device_id: UUID, + hostname: str, + ip_address: str, + validity_days: int, + encryption_key: bytes, +) -> DeviceCertificate: + """Sign a per-device TLS certificate using the tenant's CA. + + Args: + db: Async database session. + ca: The tenant's CertificateAuthority model instance. + device_id: UUID of the device receiving the cert. + hostname: Device hostname — used as CN and SAN DNSName. + ip_address: Device IP — used as SAN IPAddress. + validity_days: Certificate validity in days. + encryption_key: 32-byte AES-256-GCM key for encrypting the device private key. + + Returns: + The newly created ``DeviceCertificate`` model instance (status='issued'). + """ + # Decrypt CA private key (dual-read: Transit preferred, legacy fallback) + ca_key_pem = await decrypt_credentials_hybrid( + ca.encrypted_private_key_transit, + ca.encrypted_private_key, + str(ca.tenant_id), + encryption_key, + ) + ca_key = serialization.load_pem_private_key( + ca_key_pem.encode("utf-8"), password=None + ) + + # Load CA certificate for issuer info and AuthorityKeyIdentifier + ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8")) + + # Generate device RSA 2048 key + device_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + now = datetime.datetime.now(datetime.timezone.utc) + expiry = now + datetime.timedelta(days=validity_days) + + device_cert = ( + x509.CertificateBuilder() + .subject_name( + x509.Name([ + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "The Other Dude"), + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + ]) + ) + .issuer_name(ca_cert.subject) + .public_key(device_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(expiry) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=True + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=True, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .add_extension( + x509.SubjectAlternativeName([ + x509.IPAddress(ipaddress.ip_address(ip_address)), + x509.DNSName(hostname), + ]), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( + ca_cert.extensions.get_extension_for_class( + x509.SubjectKeyIdentifier + ).value + ), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + + # Serialize device cert and key to PEM + cert_pem = device_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") + key_pem = device_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode("utf-8") + + # Encrypt device private key via OpenBao Transit + encrypted_key_transit = await encrypt_credentials_transit(key_pem, str(ca.tenant_id)) + + # Compute fingerprint + fingerprint_bytes = device_cert.fingerprint(hashes.SHA256()) + fingerprint = ":".join(f"{b:02X}" for b in fingerprint_bytes) + + serial_hex = format(device_cert.serial_number, "X") + + model = DeviceCertificate( + tenant_id=ca.tenant_id, + device_id=device_id, + ca_id=ca.id, + common_name=hostname, + serial_number=serial_hex, + fingerprint_sha256=fingerprint, + cert_pem=cert_pem, + encrypted_private_key=b"", # Legacy column kept for schema compat + encrypted_private_key_transit=encrypted_key_transit, + not_valid_before=now, + not_valid_after=expiry, + status="issued", + ) + db.add(model) + await db.flush() + + logger.info( + "Signed device cert for device %s: cn=%s fingerprint=%s", + device_id, + hostname, + fingerprint, + ) + return model + + +# --------------------------------------------------------------------------- +# Queries +# --------------------------------------------------------------------------- + +async def get_ca_for_tenant( + db: AsyncSession, + tenant_id: UUID, +) -> CertificateAuthority | None: + """Return the tenant's CA, or None if not yet initialized.""" + result = await db.execute( + select(CertificateAuthority).where( + CertificateAuthority.tenant_id == tenant_id + ) + ) + return result.scalar_one_or_none() + + +async def get_device_certs( + db: AsyncSession, + tenant_id: UUID, + device_id: UUID | None = None, +) -> list[DeviceCertificate]: + """List device certificates for a tenant. + + Args: + db: Async database session. + tenant_id: Tenant UUID. + device_id: If provided, filter to certs for this device only. + + Returns: + List of DeviceCertificate models (excludes superseded by default). + """ + stmt = ( + select(DeviceCertificate) + .where(DeviceCertificate.tenant_id == tenant_id) + .where(DeviceCertificate.status != "superseded") + ) + if device_id is not None: + stmt = stmt.where(DeviceCertificate.device_id == device_id) + stmt = stmt.order_by(DeviceCertificate.created_at.desc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +# --------------------------------------------------------------------------- +# Status Management +# --------------------------------------------------------------------------- + +async def update_cert_status( + db: AsyncSession, + cert_id: UUID, + status: str, + deployed_at: datetime.datetime | None = None, +) -> DeviceCertificate: + """Update a device certificate's lifecycle status. + + Validates that the transition is allowed by the state machine: + issued -> deploying -> deployed -> expiring -> expired + \\-> revoked + \\-> superseded + + Args: + db: Async database session. + cert_id: Certificate UUID. + status: New status value. + deployed_at: Timestamp to set when transitioning to 'deployed'. + + Returns: + The updated DeviceCertificate model. + + Raises: + ValueError: If the certificate is not found or the transition is invalid. + """ + result = await db.execute( + select(DeviceCertificate).where(DeviceCertificate.id == cert_id) + ) + cert = result.scalar_one_or_none() + if cert is None: + raise ValueError(f"Device certificate {cert_id} not found") + + allowed = _VALID_TRANSITIONS.get(cert.status, set()) + if status not in allowed: + raise ValueError( + f"Invalid status transition: {cert.status} -> {status}. " + f"Allowed transitions from '{cert.status}': {allowed or 'none'}" + ) + + cert.status = status + cert.updated_at = datetime.datetime.now(datetime.timezone.utc) + + if status == "deployed" and deployed_at is not None: + cert.deployed_at = deployed_at + elif status == "deployed": + cert.deployed_at = cert.updated_at + + await db.flush() + + logger.info( + "Updated cert %s status to %s", + cert_id, + status, + ) + return cert + + +# --------------------------------------------------------------------------- +# Cert Data for Deployment +# --------------------------------------------------------------------------- + +async def get_cert_for_deploy( + db: AsyncSession, + cert_id: UUID, + encryption_key: bytes, +) -> tuple[str, str, str]: + """Retrieve and decrypt certificate data for NATS deployment. + + Returns the device cert PEM, decrypted device key PEM, and the CA cert + PEM — everything needed to push to a device via the Go poller. + + Args: + db: Async database session. + cert_id: Device certificate UUID. + encryption_key: 32-byte AES-256-GCM key to decrypt the device private key. + + Returns: + Tuple of (cert_pem, key_pem_decrypted, ca_cert_pem). + + Raises: + ValueError: If the certificate or its CA is not found. + """ + result = await db.execute( + select(DeviceCertificate).where(DeviceCertificate.id == cert_id) + ) + cert = result.scalar_one_or_none() + if cert is None: + raise ValueError(f"Device certificate {cert_id} not found") + + # Fetch the CA for the ca_cert_pem + ca_result = await db.execute( + select(CertificateAuthority).where( + CertificateAuthority.id == cert.ca_id + ) + ) + ca = ca_result.scalar_one_or_none() + if ca is None: + raise ValueError(f"CA {cert.ca_id} not found for certificate {cert_id}") + + # Decrypt device private key (dual-read: Transit preferred, legacy fallback) + key_pem = await decrypt_credentials_hybrid( + cert.encrypted_private_key_transit, + cert.encrypted_private_key, + str(cert.tenant_id), + encryption_key, + ) + + return cert.cert_pem, key_pem, ca.cert_pem diff --git a/backend/app/services/config_change_subscriber.py b/backend/app/services/config_change_subscriber.py new file mode 100644 index 0000000..fec1969 --- /dev/null +++ b/backend/app/services/config_change_subscriber.py @@ -0,0 +1,118 @@ +"""NATS subscriber for config change events from the Go poller. + +Triggers automatic backups when out-of-band config changes are detected, +with 5-minute deduplication to prevent rapid-fire backups. +""" + +import json +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from sqlalchemy import select + +from app.config import settings +from app.database import AdminAsyncSessionLocal +from app.models.config_backup import ConfigBackupRun +from app.services import backup_service + +logger = logging.getLogger(__name__) + +DEDUP_WINDOW_MINUTES = 5 + +_nc: Optional[Any] = None + + +async def _last_backup_within_dedup_window(device_id: str) -> bool: + """Check if a backup was created for this device in the last N minutes.""" + cutoff = datetime.now(timezone.utc) - timedelta(minutes=DEDUP_WINDOW_MINUTES) + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + select(ConfigBackupRun) + .where( + ConfigBackupRun.device_id == device_id, + ConfigBackupRun.created_at > cutoff, + ) + .limit(1) + ) + return result.scalar_one_or_none() is not None + + +async def handle_config_changed(event: dict) -> None: + """Handle a config change event. Trigger backup with dedup.""" + device_id = event.get("device_id") + tenant_id = event.get("tenant_id") + + if not device_id or not tenant_id: + logger.warning("Config change event missing device_id or tenant_id: %s", event) + return + + # Dedup check + if await _last_backup_within_dedup_window(device_id): + logger.info( + "Config change on device %s — skipping backup (within %dm dedup window)", + device_id, DEDUP_WINDOW_MINUTES, + ) + return + + logger.info( + "Config change detected on device %s (tenant %s): %s -> %s", + device_id, tenant_id, + event.get("old_timestamp", "?"), + event.get("new_timestamp", "?"), + ) + + try: + async with AdminAsyncSessionLocal() as session: + await backup_service.run_backup( + device_id=device_id, + tenant_id=tenant_id, + trigger_type="config-change", + db_session=session, + ) + await session.commit() + logger.info("Config-change backup completed for device %s", device_id) + except Exception as e: + logger.error("Config-change backup failed for device %s: %s", device_id, e) + + +async def _on_message(msg) -> None: + """NATS message handler for config.changed.> subjects.""" + try: + event = json.loads(msg.data.decode()) + await handle_config_changed(event) + await msg.ack() + except Exception as e: + logger.error("Error handling config change message: %s", e) + await msg.nak() + + +async def start_config_change_subscriber() -> Optional[Any]: + """Connect to NATS and subscribe to config.changed.> events.""" + import nats + + global _nc + try: + logger.info("NATS config-change: connecting to %s", settings.NATS_URL) + _nc = await nats.connect(settings.NATS_URL) + js = _nc.jetstream() + await js.subscribe( + "config.changed.>", + cb=_on_message, + durable="api-config-change-consumer", + stream="DEVICE_EVENTS", + manual_ack=True, + ) + logger.info("Config change subscriber started") + return _nc + except Exception as e: + logger.error("Failed to start config change subscriber: %s", e) + return None + + +async def stop_config_change_subscriber() -> None: + """Gracefully close the NATS connection.""" + global _nc + if _nc: + await _nc.drain() + _nc = None diff --git a/backend/app/services/crypto.py b/backend/app/services/crypto.py new file mode 100644 index 0000000..3aa5e01 --- /dev/null +++ b/backend/app/services/crypto.py @@ -0,0 +1,183 @@ +""" +Credential encryption/decryption with dual-read (OpenBao Transit + legacy AES-256-GCM). + +This module provides two encryption paths: +1. Legacy (sync): AES-256-GCM with static CREDENTIAL_ENCRYPTION_KEY — used for fallback reads. +2. Transit (async): OpenBao Transit per-tenant keys — used for all new writes. + +The dual-read pattern: +- New writes always use OpenBao Transit (encrypt_credentials_transit). +- Reads prefer Transit ciphertext, falling back to legacy (decrypt_credentials_hybrid). +- Legacy functions are preserved for backward compatibility during migration. + +Security properties: +- AES-256-GCM provides authenticated encryption (confidentiality + integrity) +- A unique 12-byte random nonce is generated per legacy encryption operation +- OpenBao Transit keys are AES-256-GCM96, managed entirely by OpenBao +- Ciphertext format: "vault:v1:..." for Transit, raw bytes for legacy +""" + +import os + + +def encrypt_credentials(plaintext: str, key: bytes) -> bytes: + """ + Encrypt a plaintext string using AES-256-GCM. + + Args: + plaintext: The credential string to encrypt (e.g., JSON with username/password) + key: 32-byte encryption key + + Returns: + bytes: nonce (12 bytes) + ciphertext + GCM tag (16 bytes) + + Raises: + ValueError: If key is not exactly 32 bytes + """ + if len(key) != 32: + raise ValueError(f"Key must be exactly 32 bytes, got {len(key)}") + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + aesgcm = AESGCM(key) + nonce = os.urandom(12) # 96-bit nonce, unique per encryption + ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) + + # Store as: nonce (12 bytes) + ciphertext + GCM tag (included in ciphertext by library) + return nonce + ciphertext + + +def decrypt_credentials(ciphertext: bytes, key: bytes) -> str: + """ + Decrypt AES-256-GCM encrypted credentials. + + Args: + ciphertext: bytes from encrypt_credentials (nonce + encrypted data + GCM tag) + key: 32-byte encryption key (must match the key used for encryption) + + Returns: + str: The original plaintext string + + Raises: + ValueError: If key is not exactly 32 bytes + cryptography.exceptions.InvalidTag: If authentication fails (tampered data or wrong key) + """ + if len(key) != 32: + raise ValueError(f"Key must be exactly 32 bytes, got {len(key)}") + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + nonce = ciphertext[:12] + encrypted_data = ciphertext[12:] + + aesgcm = AESGCM(key) + plaintext_bytes = aesgcm.decrypt(nonce, encrypted_data, None) + + return plaintext_bytes.decode("utf-8") + + +# --------------------------------------------------------------------------- +# OpenBao Transit functions (async, per-tenant keys) +# --------------------------------------------------------------------------- + + +async def encrypt_credentials_transit(plaintext: str, tenant_id: str) -> str: + """Encrypt via OpenBao Transit. Returns ciphertext string (vault:v1:...). + + Args: + plaintext: The credential string to encrypt. + tenant_id: Tenant UUID string for key lookup. + + Returns: + Transit ciphertext string (vault:v1:base64...). + """ + from app.services.openbao_service import get_openbao_service + + service = get_openbao_service() + return await service.encrypt(tenant_id, plaintext.encode("utf-8")) + + +async def decrypt_credentials_transit(ciphertext: str, tenant_id: str) -> str: + """Decrypt OpenBao Transit ciphertext. Returns plaintext string. + + Args: + ciphertext: Transit ciphertext (vault:v1:...). + tenant_id: Tenant UUID string for key lookup. + + Returns: + Decrypted plaintext string. + """ + from app.services.openbao_service import get_openbao_service + + service = get_openbao_service() + plaintext_bytes = await service.decrypt(tenant_id, ciphertext) + return plaintext_bytes.decode("utf-8") + + +# --------------------------------------------------------------------------- +# OpenBao Transit data encryption (async, per-tenant _data keys — Phase 30) +# --------------------------------------------------------------------------- + + +async def encrypt_data_transit(plaintext: str, tenant_id: str) -> str: + """Encrypt non-credential data via OpenBao Transit using per-tenant data key. + + Used for audit log details, config backups, and reports. Data keys are + separate from credential keys (tenant_{uuid}_data vs tenant_{uuid}). + + Args: + plaintext: The data string to encrypt. + tenant_id: Tenant UUID string for data key lookup. + + Returns: + Transit ciphertext string (vault:v1:base64...). + """ + from app.services.openbao_service import get_openbao_service + + service = get_openbao_service() + return await service.encrypt_data(tenant_id, plaintext.encode("utf-8")) + + +async def decrypt_data_transit(ciphertext: str, tenant_id: str) -> str: + """Decrypt OpenBao Transit data ciphertext. Returns plaintext string. + + Args: + ciphertext: Transit ciphertext (vault:v1:...). + tenant_id: Tenant UUID string for data key lookup. + + Returns: + Decrypted plaintext string. + """ + from app.services.openbao_service import get_openbao_service + + service = get_openbao_service() + plaintext_bytes = await service.decrypt_data(tenant_id, ciphertext) + return plaintext_bytes.decode("utf-8") + + +async def decrypt_credentials_hybrid( + transit_ciphertext: str | None, + legacy_ciphertext: bytes | None, + tenant_id: str, + legacy_key: bytes, +) -> str: + """Dual-read: prefer Transit ciphertext, fall back to legacy. + + Args: + transit_ciphertext: OpenBao Transit ciphertext (vault:v1:...) or None. + legacy_ciphertext: Legacy AES-256-GCM bytes (nonce+ciphertext+tag) or None. + tenant_id: Tenant UUID string for Transit key lookup. + legacy_key: 32-byte legacy encryption key for fallback. + + Returns: + Decrypted plaintext string. + + Raises: + ValueError: If neither ciphertext is available. + """ + if transit_ciphertext and transit_ciphertext.startswith("vault:v"): + return await decrypt_credentials_transit(transit_ciphertext, tenant_id) + elif legacy_ciphertext: + return decrypt_credentials(legacy_ciphertext, legacy_key) + else: + raise ValueError("No credentials available (both transit and legacy are empty)") diff --git a/backend/app/services/device.py b/backend/app/services/device.py new file mode 100644 index 0000000..627ff49 --- /dev/null +++ b/backend/app/services/device.py @@ -0,0 +1,670 @@ +""" +Device service — business logic for device CRUD, credential encryption, groups, and tags. + +All functions operate via the app_user engine (RLS enforced). +Tenant isolation is handled automatically by PostgreSQL RLS policies +(SET LOCAL app.current_tenant is set by the get_current_user dependency before +this layer is called). + +Credential policy: +- Credentials are always stored as AES-256-GCM encrypted JSON blobs. +- Credentials are NEVER returned in any public-facing response. +- Re-encryption happens only when a new password is explicitly provided in an update. +""" + +import asyncio +import json +import uuid +from typing import Optional + +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.device import ( + Device, + DeviceGroup, + DeviceGroupMembership, + DeviceTag, + DeviceTagAssignment, +) +from app.schemas.device import ( + BulkAddRequest, + BulkAddResult, + DeviceCreate, + DeviceGroupCreate, + DeviceGroupResponse, + DeviceGroupUpdate, + DeviceResponse, + DeviceTagCreate, + DeviceTagResponse, + DeviceTagUpdate, + DeviceUpdate, +) +from app.config import settings +from app.services.crypto import ( + decrypt_credentials, + decrypt_credentials_hybrid, + encrypt_credentials, + encrypt_credentials_transit, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _tcp_reachable(ip: str, port: int, timeout: float = 3.0) -> bool: + """Return True if a TCP connection to ip:port succeeds within timeout.""" + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port), timeout=timeout + ) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return True + except Exception: + return False + + +def _build_device_response(device: Device) -> DeviceResponse: + """ + Build a DeviceResponse from an ORM Device instance. + + Tags and groups are extracted from pre-loaded relationships. + Credentials are explicitly EXCLUDED. + """ + from app.schemas.device import DeviceGroupRef, DeviceTagRef + + tags = [ + DeviceTagRef( + id=a.tag.id, + name=a.tag.name, + color=a.tag.color, + ) + for a in device.tag_assignments + ] + + groups = [ + DeviceGroupRef( + id=m.group.id, + name=m.group.name, + ) + for m in device.group_memberships + ] + + return DeviceResponse( + id=device.id, + hostname=device.hostname, + ip_address=device.ip_address, + api_port=device.api_port, + api_ssl_port=device.api_ssl_port, + model=device.model, + serial_number=device.serial_number, + firmware_version=device.firmware_version, + routeros_version=device.routeros_version, + uptime_seconds=device.uptime_seconds, + last_seen=device.last_seen, + latitude=device.latitude, + longitude=device.longitude, + status=device.status, + tls_mode=device.tls_mode, + tags=tags, + groups=groups, + created_at=device.created_at, + ) + + +def _device_with_relations(): + """Return a select() for Device with tags and groups eagerly loaded.""" + return select(Device).options( + selectinload(Device.tag_assignments).selectinload(DeviceTagAssignment.tag), + selectinload(Device.group_memberships).selectinload(DeviceGroupMembership.group), + ) + + +# --------------------------------------------------------------------------- +# Device CRUD +# --------------------------------------------------------------------------- + + +async def create_device( + db: AsyncSession, + tenant_id: uuid.UUID, + data: DeviceCreate, + encryption_key: bytes, +) -> DeviceResponse: + """ + Create a new device. + + - Validates TCP connectivity (api_port or api_ssl_port must be reachable). + - Encrypts credentials before storage. + - Status set to "unknown" until the Go poller runs a full auth check (Phase 2). + """ + # Test connectivity before accepting the device + api_reachable = await _tcp_reachable(data.ip_address, data.api_port) + ssl_reachable = await _tcp_reachable(data.ip_address, data.api_ssl_port) + + if not api_reachable and not ssl_reachable: + from fastapi import HTTPException, status + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + f"Cannot reach {data.ip_address} on port {data.api_port} " + f"(RouterOS API) or {data.api_ssl_port} (RouterOS SSL API). " + "Verify the IP address and that the RouterOS API is enabled." + ), + ) + + # Encrypt credentials via OpenBao Transit (new writes go through Transit) + credentials_json = json.dumps({"username": data.username, "password": data.password}) + transit_ciphertext = await encrypt_credentials_transit( + credentials_json, str(tenant_id) + ) + + device = Device( + tenant_id=tenant_id, + hostname=data.hostname, + ip_address=data.ip_address, + api_port=data.api_port, + api_ssl_port=data.api_ssl_port, + encrypted_credentials_transit=transit_ciphertext, + status="unknown", + ) + db.add(device) + await db.flush() # Get the ID without committing + await db.refresh(device) + + # Re-query with relationships loaded + result = await db.execute( + _device_with_relations().where(Device.id == device.id) + ) + device = result.scalar_one() + return _build_device_response(device) + + +async def get_devices( + db: AsyncSession, + tenant_id: uuid.UUID, + page: int = 1, + page_size: int = 25, + status: Optional[str] = None, + search: Optional[str] = None, + tag_id: Optional[uuid.UUID] = None, + group_id: Optional[uuid.UUID] = None, + sort_by: str = "created_at", + sort_order: str = "desc", +) -> tuple[list[DeviceResponse], int]: + """ + Return a paginated list of devices with optional filtering and sorting. + + Returns (items, total_count). + RLS automatically scopes this to the caller's tenant. + """ + base_q = _device_with_relations() + + # Filtering + if status: + base_q = base_q.where(Device.status == status) + + if search: + pattern = f"%{search}%" + base_q = base_q.where( + or_( + Device.hostname.ilike(pattern), + Device.ip_address.ilike(pattern), + ) + ) + + if tag_id: + base_q = base_q.where( + Device.id.in_( + select(DeviceTagAssignment.device_id).where( + DeviceTagAssignment.tag_id == tag_id + ) + ) + ) + + if group_id: + base_q = base_q.where( + Device.id.in_( + select(DeviceGroupMembership.device_id).where( + DeviceGroupMembership.group_id == group_id + ) + ) + ) + + # Count total before pagination + count_q = select(func.count()).select_from(base_q.subquery()) + total_result = await db.execute(count_q) + total = total_result.scalar_one() + + # Sorting + allowed_sort_cols = { + "created_at": Device.created_at, + "hostname": Device.hostname, + "ip_address": Device.ip_address, + "status": Device.status, + "last_seen": Device.last_seen, + } + sort_col = allowed_sort_cols.get(sort_by, Device.created_at) + if sort_order.lower() == "asc": + base_q = base_q.order_by(sort_col.asc()) + else: + base_q = base_q.order_by(sort_col.desc()) + + # Pagination + offset = (page - 1) * page_size + base_q = base_q.offset(offset).limit(page_size) + + result = await db.execute(base_q) + devices = result.scalars().all() + return [_build_device_response(d) for d in devices], total + + +async def get_device( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, +) -> DeviceResponse: + """Get a single device by ID.""" + from fastapi import HTTPException, status + + result = await db.execute( + _device_with_relations().where(Device.id == device_id) + ) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") + return _build_device_response(device) + + +async def update_device( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + data: DeviceUpdate, + encryption_key: bytes, +) -> DeviceResponse: + """ + Update device fields. Re-encrypts credentials only if password is provided. + """ + from fastapi import HTTPException, status + + result = await db.execute( + _device_with_relations().where(Device.id == device_id) + ) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") + + # Update scalar fields + if data.hostname is not None: + device.hostname = data.hostname + if data.ip_address is not None: + device.ip_address = data.ip_address + if data.api_port is not None: + device.api_port = data.api_port + if data.api_ssl_port is not None: + device.api_ssl_port = data.api_ssl_port + if data.latitude is not None: + device.latitude = data.latitude + if data.longitude is not None: + device.longitude = data.longitude + if data.tls_mode is not None: + device.tls_mode = data.tls_mode + + # Re-encrypt credentials if new ones are provided + credentials_changed = False + if data.password is not None: + # Decrypt existing to get current username if no new username given + current_username: str = data.username or "" + if not current_username and (device.encrypted_credentials_transit or device.encrypted_credentials): + try: + existing_json = await decrypt_credentials_hybrid( + device.encrypted_credentials_transit, + device.encrypted_credentials, + str(device.tenant_id), + settings.get_encryption_key_bytes(), + ) + existing = json.loads(existing_json) + current_username = existing.get("username", "") + except Exception: + current_username = "" + + credentials_json = json.dumps({ + "username": data.username if data.username is not None else current_username, + "password": data.password, + }) + # New writes go through Transit + device.encrypted_credentials_transit = await encrypt_credentials_transit( + credentials_json, str(device.tenant_id) + ) + device.encrypted_credentials = None # Clear legacy (Transit is canonical) + credentials_changed = True + elif data.username is not None and (device.encrypted_credentials_transit or device.encrypted_credentials): + # Only username changed — update it without changing the password + try: + existing_json = await decrypt_credentials_hybrid( + device.encrypted_credentials_transit, + device.encrypted_credentials, + str(device.tenant_id), + settings.get_encryption_key_bytes(), + ) + existing = json.loads(existing_json) + existing["username"] = data.username + # Re-encrypt via Transit + device.encrypted_credentials_transit = await encrypt_credentials_transit( + json.dumps(existing), str(device.tenant_id) + ) + device.encrypted_credentials = None + credentials_changed = True + except Exception: + pass # Keep existing encrypted blob if decryption fails + + await db.flush() + await db.refresh(device) + + # Notify poller to invalidate cached credentials (fire-and-forget via NATS) + if credentials_changed: + try: + from app.services.event_publisher import publish_event + await publish_event( + f"device.credential_changed.{device_id}", + {"device_id": str(device_id), "tenant_id": str(tenant_id)}, + ) + except Exception: + pass # Never fail the update due to NATS issues + + result2 = await db.execute( + _device_with_relations().where(Device.id == device_id) + ) + device = result2.scalar_one() + return _build_device_response(device) + + +async def delete_device( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, +) -> None: + """Hard-delete a device (v1 — no soft delete for devices).""" + from fastapi import HTTPException, status + + result = await db.execute(select(Device).where(Device.id == device_id)) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") + await db.delete(device) + await db.flush() + + +# --------------------------------------------------------------------------- +# Group / Tag assignment +# --------------------------------------------------------------------------- + + +async def assign_device_to_group( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + group_id: uuid.UUID, +) -> None: + """Assign a device to a group (idempotent).""" + from fastapi import HTTPException, status + + # Verify device and group exist (RLS scopes both) + dev = await db.get(Device, device_id) + if not dev: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") + grp = await db.get(DeviceGroup, group_id) + if not grp: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + existing = await db.get(DeviceGroupMembership, (device_id, group_id)) + if not existing: + db.add(DeviceGroupMembership(device_id=device_id, group_id=group_id)) + await db.flush() + + +async def remove_device_from_group( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + group_id: uuid.UUID, +) -> None: + """Remove a device from a group.""" + from fastapi import HTTPException, status + + membership = await db.get(DeviceGroupMembership, (device_id, group_id)) + if not membership: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Device is not in this group", + ) + await db.delete(membership) + await db.flush() + + +async def assign_tag_to_device( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + tag_id: uuid.UUID, +) -> None: + """Assign a tag to a device (idempotent).""" + from fastapi import HTTPException, status + + dev = await db.get(Device, device_id) + if not dev: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") + tag = await db.get(DeviceTag, tag_id) + if not tag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found") + + existing = await db.get(DeviceTagAssignment, (device_id, tag_id)) + if not existing: + db.add(DeviceTagAssignment(device_id=device_id, tag_id=tag_id)) + await db.flush() + + +async def remove_tag_from_device( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, + tag_id: uuid.UUID, +) -> None: + """Remove a tag from a device.""" + from fastapi import HTTPException, status + + assignment = await db.get(DeviceTagAssignment, (device_id, tag_id)) + if not assignment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag is not assigned to this device", + ) + await db.delete(assignment) + await db.flush() + + +# --------------------------------------------------------------------------- +# DeviceGroup CRUD +# --------------------------------------------------------------------------- + + +async def create_group( + db: AsyncSession, + tenant_id: uuid.UUID, + data: DeviceGroupCreate, +) -> DeviceGroupResponse: + """Create a new device group.""" + group = DeviceGroup( + tenant_id=tenant_id, + name=data.name, + description=data.description, + ) + db.add(group) + await db.flush() + await db.refresh(group) + + # Count devices in the group (0 for new group) + return DeviceGroupResponse( + id=group.id, + name=group.name, + description=group.description, + device_count=0, + created_at=group.created_at, + ) + + +async def get_groups( + db: AsyncSession, + tenant_id: uuid.UUID, +) -> list[DeviceGroupResponse]: + """Return all device groups for the current tenant with device counts.""" + result = await db.execute( + select(DeviceGroup).options( + selectinload(DeviceGroup.memberships) + ) + ) + groups = result.scalars().all() + return [ + DeviceGroupResponse( + id=g.id, + name=g.name, + description=g.description, + device_count=len(g.memberships), + created_at=g.created_at, + ) + for g in groups + ] + + +async def update_group( + db: AsyncSession, + tenant_id: uuid.UUID, + group_id: uuid.UUID, + data: DeviceGroupUpdate, +) -> DeviceGroupResponse: + """Update a device group.""" + from fastapi import HTTPException, status + + result = await db.execute( + select(DeviceGroup).options( + selectinload(DeviceGroup.memberships) + ).where(DeviceGroup.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + if data.name is not None: + group.name = data.name + if data.description is not None: + group.description = data.description + + await db.flush() + await db.refresh(group) + + result2 = await db.execute( + select(DeviceGroup).options( + selectinload(DeviceGroup.memberships) + ).where(DeviceGroup.id == group_id) + ) + group = result2.scalar_one() + return DeviceGroupResponse( + id=group.id, + name=group.name, + description=group.description, + device_count=len(group.memberships), + created_at=group.created_at, + ) + + +async def delete_group( + db: AsyncSession, + tenant_id: uuid.UUID, + group_id: uuid.UUID, +) -> None: + """Delete a device group.""" + from fastapi import HTTPException, status + + group = await db.get(DeviceGroup, group_id) + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + await db.delete(group) + await db.flush() + + +# --------------------------------------------------------------------------- +# DeviceTag CRUD +# --------------------------------------------------------------------------- + + +async def create_tag( + db: AsyncSession, + tenant_id: uuid.UUID, + data: DeviceTagCreate, +) -> DeviceTagResponse: + """Create a new device tag.""" + tag = DeviceTag( + tenant_id=tenant_id, + name=data.name, + color=data.color, + ) + db.add(tag) + await db.flush() + await db.refresh(tag) + return DeviceTagResponse(id=tag.id, name=tag.name, color=tag.color) + + +async def get_tags( + db: AsyncSession, + tenant_id: uuid.UUID, +) -> list[DeviceTagResponse]: + """Return all device tags for the current tenant.""" + result = await db.execute(select(DeviceTag)) + tags = result.scalars().all() + return [DeviceTagResponse(id=t.id, name=t.name, color=t.color) for t in tags] + + +async def update_tag( + db: AsyncSession, + tenant_id: uuid.UUID, + tag_id: uuid.UUID, + data: DeviceTagUpdate, +) -> DeviceTagResponse: + """Update a device tag.""" + from fastapi import HTTPException, status + + tag = await db.get(DeviceTag, tag_id) + if not tag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found") + + if data.name is not None: + tag.name = data.name + if data.color is not None: + tag.color = data.color + + await db.flush() + await db.refresh(tag) + return DeviceTagResponse(id=tag.id, name=tag.name, color=tag.color) + + +async def delete_tag( + db: AsyncSession, + tenant_id: uuid.UUID, + tag_id: uuid.UUID, +) -> None: + """Delete a device tag.""" + from fastapi import HTTPException, status + + tag = await db.get(DeviceTag, tag_id) + if not tag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found") + await db.delete(tag) + await db.flush() diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..6e7cff5 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,124 @@ +"""Unified email sending service. + +All email sending (system emails, alert notifications) goes through this module. +Supports TLS, STARTTLS, and plain SMTP. Handles Transit + legacy Fernet password decryption. +""" + +import logging +from email.message import EmailMessage +from typing import Optional + +import aiosmtplib + +logger = logging.getLogger(__name__) + + +class SMTPConfig: + """SMTP connection configuration.""" + + def __init__( + self, + host: str, + port: int = 587, + user: Optional[str] = None, + password: Optional[str] = None, + use_tls: bool = False, + from_address: str = "noreply@example.com", + ): + self.host = host + self.port = port + self.user = user + self.password = password + self.use_tls = use_tls + self.from_address = from_address + + +async def send_email( + to: str, + subject: str, + html: str, + plain_text: str, + smtp_config: SMTPConfig, +) -> None: + """Send an email via SMTP. + + Args: + to: Recipient email address. + subject: Email subject line. + html: HTML body. + plain_text: Plain text fallback body. + smtp_config: SMTP connection settings. + + Raises: + aiosmtplib.SMTPException: On SMTP connection or send failure. + """ + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = smtp_config.from_address + msg["To"] = to + msg.set_content(plain_text) + msg.add_alternative(html, subtype="html") + + use_tls = smtp_config.use_tls + start_tls = not use_tls if smtp_config.port != 25 else False + + await aiosmtplib.send( + msg, + hostname=smtp_config.host, + port=smtp_config.port, + username=smtp_config.user or None, + password=smtp_config.password or None, + use_tls=use_tls, + start_tls=start_tls, + ) + + +async def test_smtp_connection(smtp_config: SMTPConfig) -> dict: + """Test SMTP connectivity without sending an email. + + Returns: + dict with "success" bool and "message" string. + """ + try: + smtp = aiosmtplib.SMTP( + hostname=smtp_config.host, + port=smtp_config.port, + use_tls=smtp_config.use_tls, + start_tls=not smtp_config.use_tls if smtp_config.port != 25 else False, + ) + await smtp.connect() + if smtp_config.user and smtp_config.password: + await smtp.login(smtp_config.user, smtp_config.password) + await smtp.quit() + return {"success": True, "message": "SMTP connection successful"} + except Exception as e: + return {"success": False, "message": str(e)} + + +async def send_test_email(to: str, smtp_config: SMTPConfig) -> dict: + """Send a test email to verify the full SMTP flow. + + Returns: + dict with "success" bool and "message" string. + """ + html = """ +
+
+

TOD — Email Test

+
+
+

This is a test email from The Other Dude.

+

If you're reading this, your SMTP configuration is working correctly.

+

+ Sent from TOD Fleet Management +

+
+
+ """ + plain = "TOD — Email Test\n\nThis is a test email from The Other Dude.\nIf you're reading this, your SMTP configuration is working correctly." + + try: + await send_email(to, "TOD — Test Email", html, plain, smtp_config) + return {"success": True, "message": f"Test email sent to {to}"} + except Exception as e: + return {"success": False, "message": str(e)} diff --git a/backend/app/services/emergency_kit_service.py b/backend/app/services/emergency_kit_service.py new file mode 100644 index 0000000..41171bf --- /dev/null +++ b/backend/app/services/emergency_kit_service.py @@ -0,0 +1,54 @@ +"""Emergency Kit PDF template generation. + +Generates an Emergency Kit PDF containing the user's email and sign-in URL +but NOT the Secret Key. The Secret Key placeholder is filled client-side +so that the server never sees it. + +Uses Jinja2 + WeasyPrint following the same pattern as the reports service. +""" + +import asyncio +from datetime import UTC, datetime +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + +from app.config import settings + +TEMPLATE_DIR = Path(__file__).parent.parent.parent / "templates" + + +async def generate_emergency_kit_template( + email: str, +) -> bytes: + """Generate Emergency Kit PDF template WITHOUT the Secret Key. + + The Secret Key placeholder will be filled client-side. + The server never sees the Secret Key. + + Args: + email: The user's email address to display in the PDF. + + Returns: + PDF bytes ready for streaming response. + """ + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + autoescape=True, + ) + template = env.get_template("emergency_kit.html") + + html_content = template.render( + email=email, + signin_url=settings.APP_BASE_URL, + date=datetime.now(UTC).strftime("%Y-%m-%d"), + secret_key_placeholder="[Download complete -- your Secret Key will be inserted by your browser]", + ) + + # Run weasyprint in thread to avoid blocking the event loop + from weasyprint import HTML + + pdf_bytes = await asyncio.to_thread( + lambda: HTML(string=html_content).write_pdf() + ) + return pdf_bytes diff --git a/backend/app/services/event_publisher.py b/backend/app/services/event_publisher.py new file mode 100644 index 0000000..60724dd --- /dev/null +++ b/backend/app/services/event_publisher.py @@ -0,0 +1,52 @@ +"""Fire-and-forget NATS JetStream event publisher for real-time SSE pipeline. + +Provides a shared lazy NATS connection and publish helper used by: +- alert_evaluator.py (alert.fired.{tenant_id}, alert.resolved.{tenant_id}) +- restore_service.py (config.push.{tenant_id}.{device_id}) +- upgrade_service.py (firmware.progress.{tenant_id}.{device_id}) + +All publishes are fire-and-forget: errors are logged but never propagate +to the caller. A NATS outage must never block alert evaluation, config +push, or firmware upgrade operations. +""" + +import json +import logging +from typing import Any + +import nats +import nats.aio.client + +from app.config import settings + +logger = logging.getLogger(__name__) + +# Module-level NATS connection (lazy initialized, reused across publishes) +_nc: nats.aio.client.Client | None = None + + +async def _get_nats() -> nats.aio.client.Client: + """Get or create a NATS connection for event publishing.""" + global _nc + if _nc is None or _nc.is_closed: + _nc = await nats.connect(settings.NATS_URL) + logger.info("Event publisher NATS connection established") + return _nc + + +async def publish_event(subject: str, payload: dict[str, Any]) -> None: + """Publish a JSON event to a NATS JetStream subject (fire-and-forget). + + Args: + subject: NATS subject, e.g. "alert.fired.{tenant_id}". + payload: Dict that will be JSON-serialized as the message body. + + Never raises -- all exceptions are caught and logged as warnings. + """ + try: + nc = await _get_nats() + js = nc.jetstream() + await js.publish(subject, json.dumps(payload).encode()) + logger.debug("Published event to %s", subject) + except Exception as exc: + logger.warning("Failed to publish event to %s: %s", subject, exc) diff --git a/backend/app/services/firmware_service.py b/backend/app/services/firmware_service.py new file mode 100644 index 0000000..58cd7c2 --- /dev/null +++ b/backend/app/services/firmware_service.py @@ -0,0 +1,303 @@ +"""Firmware version cache service and NPK downloader. + +Responsibilities: +- check_latest_versions(): fetch latest RouterOS versions from download.mikrotik.com +- download_firmware(): download NPK packages to local PVC cache +- get_firmware_overview(): return fleet firmware status for a tenant +- schedule_firmware_checks(): register daily firmware check job with APScheduler + +Version discovery comes from two sources: +1. Go poller runs /system/package/update per device (rate-limited to once/day) + and publishes via NATS -> firmware_subscriber processes these events +2. check_latest_versions() fetches LATEST.7 / LATEST.6 from download.mikrotik.com +""" + +import logging +import os +from pathlib import Path + +import httpx +from sqlalchemy import text + +from app.config import settings +from app.database import AdminAsyncSessionLocal + +logger = logging.getLogger(__name__) + +# Architectures supported by RouterOS v7 and v6 +_V7_ARCHITECTURES = ["arm", "arm64", "mipsbe", "mmips", "smips", "tile", "ppc", "x86"] +_V6_ARCHITECTURES = ["mipsbe", "mmips", "smips", "tile", "ppc", "x86"] + +# Version source files on download.mikrotik.com +_VERSION_SOURCES = [ + ("LATEST.7", "stable", 7), + ("LATEST.7long", "long-term", 7), + ("LATEST.6", "stable", 6), + ("LATEST.6long", "long-term", 6), +] + + +async def check_latest_versions() -> list[dict]: + """Fetch latest RouterOS versions from download.mikrotik.com. + + Checks LATEST.7, LATEST.7long, LATEST.6, and LATEST.6long files for + version strings, then upserts into firmware_versions table for each + architecture/channel combination. + + Returns list of discovered version dicts. + """ + results: list[dict] = [] + + async with httpx.AsyncClient(timeout=30.0) as client: + for channel_file, channel, major in _VERSION_SOURCES: + try: + resp = await client.get( + f"https://download.mikrotik.com/routeros/{channel_file}" + ) + if resp.status_code != 200: + logger.warning( + "MikroTik version check returned %d for %s", + resp.status_code, channel_file, + ) + continue + + version = resp.text.strip() + if not version or not version[0].isdigit(): + logger.warning("Invalid version string from %s: %r", channel_file, version) + continue + + architectures = _V7_ARCHITECTURES if major == 7 else _V6_ARCHITECTURES + for arch in architectures: + npk_url = ( + f"https://download.mikrotik.com/routeros/" + f"{version}/routeros-{version}-{arch}.npk" + ) + results.append({ + "architecture": arch, + "channel": channel, + "version": version, + "npk_url": npk_url, + }) + + except Exception as e: + logger.warning("Failed to check %s: %s", channel_file, e) + + # Upsert into firmware_versions table + if results: + async with AdminAsyncSessionLocal() as session: + for r in results: + await session.execute( + text(""" + INSERT INTO firmware_versions (id, architecture, channel, version, npk_url, checked_at) + VALUES (gen_random_uuid(), :arch, :channel, :version, :npk_url, NOW()) + ON CONFLICT (architecture, channel, version) DO UPDATE SET checked_at = NOW() + """), + { + "arch": r["architecture"], + "channel": r["channel"], + "version": r["version"], + "npk_url": r["npk_url"], + }, + ) + await session.commit() + + logger.info("Firmware version check complete — %d versions discovered", len(results)) + return results + + +async def download_firmware(architecture: str, channel: str, version: str) -> str: + """Download an NPK package to the local firmware cache. + + Returns the local file path. Skips download if file already exists + and size matches. + """ + cache_dir = Path(settings.FIRMWARE_CACHE_DIR) / version + cache_dir.mkdir(parents=True, exist_ok=True) + + filename = f"routeros-{version}-{architecture}.npk" + local_path = cache_dir / filename + npk_url = f"https://download.mikrotik.com/routeros/{version}/{filename}" + + # Check if already cached + if local_path.exists() and local_path.stat().st_size > 0: + logger.info("Firmware already cached: %s", local_path) + return str(local_path) + + logger.info("Downloading firmware: %s", npk_url) + + async with httpx.AsyncClient(timeout=300.0) as client: + async with client.stream("GET", npk_url) as response: + response.raise_for_status() + with open(local_path, "wb") as f: + async for chunk in response.aiter_bytes(chunk_size=65536): + f.write(chunk) + + file_size = local_path.stat().st_size + logger.info("Firmware downloaded: %s (%d bytes)", local_path, file_size) + + # Update firmware_versions table with local path and size + async with AdminAsyncSessionLocal() as session: + await session.execute( + text(""" + UPDATE firmware_versions + SET npk_local_path = :path, npk_size_bytes = :size + WHERE architecture = :arch AND channel = :channel AND version = :version + """), + { + "path": str(local_path), + "size": file_size, + "arch": architecture, + "channel": channel, + "version": version, + }, + ) + await session.commit() + + return str(local_path) + + +async def get_firmware_overview(tenant_id: str) -> dict: + """Return fleet firmware status for a tenant. + + Returns devices grouped by firmware version, annotated with up-to-date status + based on the latest known version for each device's architecture and preferred channel. + """ + async with AdminAsyncSessionLocal() as session: + # Get all devices for tenant + devices_result = await session.execute( + text(""" + SELECT id, hostname, ip_address, routeros_version, architecture, + preferred_channel, routeros_major_version, + serial_number, firmware_version, model + FROM devices + WHERE tenant_id = CAST(:tenant_id AS uuid) + ORDER BY hostname + """), + {"tenant_id": tenant_id}, + ) + devices = devices_result.fetchall() + + # Get latest firmware versions per architecture/channel + versions_result = await session.execute( + text(""" + SELECT DISTINCT ON (architecture, channel) + architecture, channel, version, npk_url + FROM firmware_versions + ORDER BY architecture, channel, checked_at DESC + """) + ) + latest_versions = { + (row[0], row[1]): {"version": row[2], "npk_url": row[3]} + for row in versions_result.fetchall() + } + + # Build per-device status + device_list = [] + version_groups: dict[str, list] = {} + summary = {"total": 0, "up_to_date": 0, "outdated": 0, "unknown": 0} + + for dev in devices: + dev_id = str(dev[0]) + hostname = dev[1] + current_version = dev[3] + arch = dev[4] + channel = dev[5] or "stable" + + latest = latest_versions.get((arch, channel)) if arch else None + latest_version = latest["version"] if latest else None + + is_up_to_date = False + if not current_version or not arch: + summary["unknown"] += 1 + elif latest_version and current_version == latest_version: + is_up_to_date = True + summary["up_to_date"] += 1 + else: + summary["outdated"] += 1 + + summary["total"] += 1 + + dev_info = { + "id": dev_id, + "hostname": hostname, + "ip_address": dev[2], + "routeros_version": current_version, + "architecture": arch, + "latest_version": latest_version, + "channel": channel, + "is_up_to_date": is_up_to_date, + "serial_number": dev[7], + "firmware_version": dev[8], + "model": dev[9], + } + device_list.append(dev_info) + + # Group by version + ver_key = current_version or "unknown" + if ver_key not in version_groups: + version_groups[ver_key] = [] + version_groups[ver_key].append(dev_info) + + # Build version groups with is_latest flag + groups = [] + for ver, devs in sorted(version_groups.items()): + # A version is "latest" if it matches the latest for any arch/channel combo + is_latest = any( + v["version"] == ver for v in latest_versions.values() + ) + groups.append({ + "version": ver, + "count": len(devs), + "is_latest": is_latest, + "devices": devs, + }) + + return { + "devices": device_list, + "version_groups": groups, + "summary": summary, + } + + +async def get_cached_firmware() -> list[dict]: + """List all locally cached NPK files with their sizes.""" + cache_dir = Path(settings.FIRMWARE_CACHE_DIR) + cached = [] + + if not cache_dir.exists(): + return cached + + for version_dir in sorted(cache_dir.iterdir()): + if not version_dir.is_dir(): + continue + for npk_file in sorted(version_dir.iterdir()): + if npk_file.suffix == ".npk": + cached.append({ + "path": str(npk_file), + "version": version_dir.name, + "filename": npk_file.name, + "size_bytes": npk_file.stat().st_size, + }) + + return cached + + +def schedule_firmware_checks() -> None: + """Register daily firmware version check with APScheduler. + + Called from FastAPI lifespan startup to schedule check_latest_versions() + at 3am UTC daily. + """ + from apscheduler.triggers.cron import CronTrigger + from app.services.backup_scheduler import backup_scheduler + + backup_scheduler.add_job( + check_latest_versions, + trigger=CronTrigger(hour=3, minute=0, timezone="UTC"), + id="firmware_version_check", + name="Check for new RouterOS firmware versions", + max_instances=1, + replace_existing=True, + ) + + logger.info("Firmware version check scheduled — daily at 3am UTC") diff --git a/backend/app/services/firmware_subscriber.py b/backend/app/services/firmware_subscriber.py new file mode 100644 index 0000000..36ed39c --- /dev/null +++ b/backend/app/services/firmware_subscriber.py @@ -0,0 +1,206 @@ +"""NATS JetStream subscriber for device firmware events from the Go poller. + +Subscribes to device.firmware.> and: +1. Updates devices.routeros_version and devices.architecture from poller data +2. Upserts firmware_versions table with latest version per architecture/channel + +Uses AdminAsyncSessionLocal (superuser bypass RLS) so firmware data from any +tenant can be written without setting app.current_tenant. +""" + +import asyncio +import json +import logging +from typing import Optional + +import nats +from nats.js import JetStreamContext +from nats.aio.client import Client as NATSClient +from sqlalchemy import text + +from app.config import settings +from app.database import AdminAsyncSessionLocal + +logger = logging.getLogger(__name__) + +_firmware_client: Optional[NATSClient] = None + + +async def on_device_firmware(msg) -> None: + """Handle a device.firmware event published by the Go poller. + + Payload (JSON): + device_id (str) -- UUID of the device + tenant_id (str) -- UUID of the owning tenant + installed_version (str) -- currently installed RouterOS version + latest_version (str) -- latest available version (may be empty) + channel (str) -- firmware channel ("stable", "long-term") + status (str) -- "New version is available", etc. + architecture (str) -- CPU architecture (arm, arm64, mipsbe, etc.) + """ + try: + data = json.loads(msg.data) + device_id = data.get("device_id") + tenant_id = data.get("tenant_id") + architecture = data.get("architecture") + installed_version = data.get("installed_version") + latest_version = data.get("latest_version") + channel = data.get("channel", "stable") + + if not device_id: + logger.warning("device.firmware event missing device_id — skipping") + await msg.ack() + return + + async with AdminAsyncSessionLocal() as session: + # Update device routeros_version and architecture from poller data + if architecture or installed_version: + await session.execute( + text(""" + UPDATE devices + SET routeros_version = COALESCE(:installed_ver, routeros_version), + architecture = COALESCE(:architecture, architecture), + updated_at = NOW() + WHERE id = CAST(:device_id AS uuid) + """), + { + "installed_ver": installed_version, + "architecture": architecture, + "device_id": device_id, + }, + ) + + # Upsert firmware_versions if we got latest version info + if latest_version and architecture: + npk_url = ( + f"https://download.mikrotik.com/routeros/" + f"{latest_version}/routeros-{latest_version}-{architecture}.npk" + ) + await session.execute( + text(""" + INSERT INTO firmware_versions (id, architecture, channel, version, npk_url, checked_at) + VALUES (gen_random_uuid(), :arch, :channel, :version, :url, NOW()) + ON CONFLICT (architecture, channel, version) DO UPDATE SET checked_at = NOW() + """), + { + "arch": architecture, + "channel": channel, + "version": latest_version, + "url": npk_url, + }, + ) + + await session.commit() + + logger.debug( + "device.firmware processed", + extra={ + "device_id": device_id, + "architecture": architecture, + "installed": installed_version, + "latest": latest_version, + }, + ) + await msg.ack() + + except Exception as exc: + logger.error( + "Failed to process device.firmware event: %s", + exc, + exc_info=True, + ) + try: + await msg.nak() + except Exception: + pass + + +async def _subscribe_with_retry(js: JetStreamContext) -> None: + """Subscribe to device.firmware.> with durable consumer, retrying if stream not ready.""" + max_attempts = 6 # ~30 seconds at 5s intervals + for attempt in range(1, max_attempts + 1): + try: + await js.subscribe( + "device.firmware.>", + cb=on_device_firmware, + durable="api-firmware-consumer", + stream="DEVICE_EVENTS", + ) + logger.info( + "NATS: subscribed to device.firmware.> (durable: api-firmware-consumer)" + ) + return + except Exception as exc: + if attempt < max_attempts: + logger.warning( + "NATS: stream DEVICE_EVENTS not ready for firmware (attempt %d/%d): %s — retrying in 5s", + attempt, + max_attempts, + exc, + ) + await asyncio.sleep(5) + else: + logger.warning( + "NATS: giving up on device.firmware.> after %d attempts: %s — API will run without firmware updates", + max_attempts, + exc, + ) + return + + +async def start_firmware_subscriber() -> Optional[NATSClient]: + """Connect to NATS and start the device.firmware.> subscription. + + Uses a separate NATS connection from the status and metrics subscribers. + + Returns the NATS connection (must be passed to stop_firmware_subscriber on shutdown). + Raises on fatal connection errors after retry exhaustion. + """ + global _firmware_client + + logger.info("NATS firmware: connecting to %s", settings.NATS_URL) + + nc = await nats.connect( + settings.NATS_URL, + max_reconnect_attempts=-1, + reconnect_time_wait=2, + error_cb=_on_error, + reconnected_cb=_on_reconnected, + disconnected_cb=_on_disconnected, + ) + + logger.info("NATS firmware: connected to %s", settings.NATS_URL) + + js = nc.jetstream() + await _subscribe_with_retry(js) + + _firmware_client = nc + return nc + + +async def stop_firmware_subscriber(nc: Optional[NATSClient]) -> None: + """Drain and close the firmware NATS connection gracefully.""" + if nc is None: + return + try: + logger.info("NATS firmware: draining connection...") + await nc.drain() + logger.info("NATS firmware: connection closed") + except Exception as exc: + logger.warning("NATS firmware: error during drain: %s", exc) + try: + await nc.close() + except Exception: + pass + + +async def _on_error(exc: Exception) -> None: + logger.error("NATS firmware error: %s", exc) + + +async def _on_reconnected() -> None: + logger.info("NATS firmware: reconnected") + + +async def _on_disconnected() -> None: + logger.warning("NATS firmware: disconnected") diff --git a/backend/app/services/git_store.py b/backend/app/services/git_store.py new file mode 100644 index 0000000..cc52e48 --- /dev/null +++ b/backend/app/services/git_store.py @@ -0,0 +1,296 @@ +"""pygit2-based git store for versioned config backup storage. + +All functions in this module are synchronous (pygit2 is C bindings over libgit2). +Callers running in an async context MUST wrap calls in: + loop.run_in_executor(None, func, *args) +or: + asyncio.get_event_loop().run_in_executor(None, func, *args) + +See Pitfall 3 in 04-RESEARCH.md — blocking pygit2 in async context stalls +the event loop and causes timeouts for other concurrent requests. + +Git layout: + {GIT_STORE_PATH}/{tenant_id}.git/ <- bare repo per tenant + objects/ refs/ HEAD <- standard bare git structure + {device_id}/ <- device subtree + export.rsc <- text export (/export compact) + backup.bin <- binary system backup +""" + +import difflib +import threading +from pathlib import Path +from typing import Optional + +import pygit2 + +from app.config import settings + +# ========================================================================= +# Per-tenant mutex to prevent TreeBuilder race condition (Pitfall 5 in RESEARCH.md). +# Two simultaneous backups for different devices in the same tenant repo would +# each read HEAD, build their own device subtrees, and write conflicting root +# trees. The second commit would lose the first's device subtree. +# Lock scope is the entire tenant repo — not just the device. +# ========================================================================= +_tenant_locks: dict[str, threading.Lock] = {} +_tenant_locks_guard = threading.Lock() + + +def _get_tenant_lock(tenant_id: str) -> threading.Lock: + """Return (creating if needed) the per-tenant commit lock.""" + with _tenant_locks_guard: + if tenant_id not in _tenant_locks: + _tenant_locks[tenant_id] = threading.Lock() + return _tenant_locks[tenant_id] + + +# ========================================================================= +# PUBLIC API +# ========================================================================= + + +def get_or_create_repo(tenant_id: str) -> pygit2.Repository: + """Open the tenant's bare git repo, creating it on first use. + + The repo lives at {GIT_STORE_PATH}/{tenant_id}.git. The parent directory + is created if it does not exist. + + Args: + tenant_id: Tenant UUID as string. + + Returns: + An open pygit2.Repository instance (bare). + """ + git_store_root = Path(settings.GIT_STORE_PATH) + git_store_root.mkdir(parents=True, exist_ok=True) + + repo_path = git_store_root / f"{tenant_id}.git" + if repo_path.exists(): + return pygit2.Repository(str(repo_path)) + + return pygit2.init_repository(str(repo_path), bare=True) + + +def commit_backup( + tenant_id: str, + device_id: str, + export_text: str, + binary_backup: bytes, + message: str, +) -> str: + """Write a backup pair (export.rsc + backup.bin) as a git commit. + + Creates or updates the device subdirectory in the tenant's bare repo. + Preserves other devices' subdirectories by merging the device subtree + into the existing root tree. + + Per-tenant locking (threading.Lock) prevents the TreeBuilder race + condition when two devices in the same tenant back up concurrently. + + Args: + tenant_id: Tenant UUID as string. + device_id: Device UUID as string (becomes a subdirectory in the repo). + export_text: Text output of /export compact. + binary_backup: Raw bytes from /system backup save. + message: Commit message (format: "{trigger}: {hostname} ({ip}) at {ts}"). + + Returns: + The hex commit SHA string (40 characters). + """ + lock = _get_tenant_lock(tenant_id) + + with lock: + repo = get_or_create_repo(tenant_id) + + # Create blobs from content + export_oid = repo.create_blob(export_text.encode("utf-8")) + binary_oid = repo.create_blob(binary_backup) + + # Build device subtree: {device_id}/export.rsc and {device_id}/backup.bin + device_builder = repo.TreeBuilder() + device_builder.insert("export.rsc", export_oid, pygit2.GIT_FILEMODE_BLOB) + device_builder.insert("backup.bin", binary_oid, pygit2.GIT_FILEMODE_BLOB) + device_tree_oid = device_builder.write() + + # Merge device subtree into root tree, preserving all other device subtrees. + # If the repo has no commits yet, start with an empty root tree. + root_ref = repo.references.get("refs/heads/main") + parent_commit: Optional[pygit2.Commit] = None + + if root_ref is not None: + try: + parent_commit = repo.get(root_ref.target) + root_builder = repo.TreeBuilder(parent_commit.tree) + except Exception: + root_builder = repo.TreeBuilder() + else: + root_builder = repo.TreeBuilder() + + root_builder.insert(device_id, device_tree_oid, pygit2.GIT_FILEMODE_TREE) + root_tree_oid = root_builder.write() + + # Author signature — no real identity, portal service account + author = pygit2.Signature("The Other Dude", "backup@tod.local") + + parents = [root_ref.target] if root_ref is not None else [] + + commit_oid = repo.create_commit( + "refs/heads/main", + author, + author, + message, + root_tree_oid, + parents, + ) + + return str(commit_oid) + + +def read_file( + tenant_id: str, + commit_sha: str, + device_id: str, + filename: str, +) -> bytes: + """Read a file blob from a specific backup commit. + + Navigates the tree: root -> device_id subtree -> filename. + + Args: + tenant_id: Tenant UUID as string. + commit_sha: Full or abbreviated git commit SHA. + device_id: Device UUID as string (subdirectory name in the repo). + filename: File to read: "export.rsc" or "backup.bin". + + Returns: + Raw bytes of the file content. + + Raises: + KeyError: If device_id subtree or filename does not exist in commit. + pygit2.GitError: If commit_sha is not found. + """ + repo = get_or_create_repo(tenant_id) + + commit_obj = repo.get(commit_sha) + if commit_obj is None: + raise KeyError(f"Commit {commit_sha!r} not found in tenant {tenant_id!r} repo") + + # Navigate: root tree -> device subtree -> file blob + device_entry = commit_obj.tree[device_id] + device_tree = repo.get(device_entry.id) + file_entry = device_tree[filename] + file_blob = repo.get(file_entry.id) + + return file_blob.data + + +def list_device_commits( + tenant_id: str, + device_id: str, +) -> list[dict]: + """Walk commit history and return commits that include the device subtree. + + Walks commits newest-first. Returns only commits where the device_id + subtree is present in the root tree (the device had a backup in that commit). + + Args: + tenant_id: Tenant UUID as string. + device_id: Device UUID as string. + + Returns: + List of dicts (newest first): + [{"sha": str, "message": str, "timestamp": int}, ...] + Empty list if no commits or device has never been backed up. + """ + repo = get_or_create_repo(tenant_id) + + # If there are no commits, return empty list immediately. + # Use refs/heads/main explicitly rather than repo.head (which defaults to + # refs/heads/master — wrong when the repo uses 'main' as the default branch). + main_ref = repo.references.get("refs/heads/main") + if main_ref is None: + return [] + head_target = main_ref.target + + results = [] + walker = repo.walk(head_target, pygit2.GIT_SORT_TIME) + + for commit in walker: + # Check if device_id subtree exists in this commit's root tree. + try: + device_entry = commit.tree[device_id] + except KeyError: + # Device not present in this commit at all — skip. + continue + + # Only include this commit if it actually changed the device's subtree + # vs its parent. This prevents every subsequent backup (for any device + # in the same tenant) from appearing in all devices' histories. + if commit.parents: + parent = commit.parents[0] + try: + parent_device_entry = parent.tree[device_id] + if parent_device_entry.id == device_entry.id: + # Device subtree unchanged in this commit — skip. + continue + except KeyError: + # Device wasn't in parent but is in this commit — it's the first entry. + pass + + results.append({ + "sha": str(commit.id), + "message": commit.message.strip(), + "timestamp": commit.commit_time, + }) + + return results + + +def compute_line_delta(old_text: str, new_text: str) -> tuple[int, int]: + """Compute (lines_added, lines_removed) between two text versions. + + Uses difflib.SequenceMatcher to efficiently compute the line-count delta + without generating a full unified diff. This is faster than + difflib.unified_diff for large config files. + + For the first backup (no prior version), pass old_text="" to get + (total_lines, 0) as the delta. + + Args: + old_text: Previous export.rsc content (empty string for first backup). + new_text: New export.rsc content. + + Returns: + Tuple of (lines_added, lines_removed). + """ + old_lines = old_text.splitlines() if old_text else [] + new_lines = new_text.splitlines() if new_text else [] + + if not old_lines and not new_lines: + return (0, 0) + + # For first backup (empty old), all lines are "added". + if not old_lines: + return (len(new_lines), 0) + + # For deletion of all content, all lines are "removed". + if not new_lines: + return (0, len(old_lines)) + + matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False) + + lines_added = 0 + lines_removed = 0 + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "replace": + lines_removed += i2 - i1 + lines_added += j2 - j1 + elif tag == "delete": + lines_removed += i2 - i1 + elif tag == "insert": + lines_added += j2 - j1 + # "equal" — no change + + return (lines_added, lines_removed) diff --git a/backend/app/services/key_service.py b/backend/app/services/key_service.py new file mode 100644 index 0000000..8a7b278 --- /dev/null +++ b/backend/app/services/key_service.py @@ -0,0 +1,324 @@ +"""Key hierarchy management service for zero-knowledge architecture. + +Provides CRUD operations for encrypted key bundles (UserKeySet), +append-only audit logging (KeyAccessLog), and OpenBao Transit +tenant key provisioning with credential migration. +""" + +import logging +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.key_set import KeyAccessLog, UserKeySet + +logger = logging.getLogger(__name__) + + +async def store_user_key_set( + db: AsyncSession, + user_id: UUID, + tenant_id: UUID | None, + encrypted_private_key: bytes, + private_key_nonce: bytes, + encrypted_vault_key: bytes, + vault_key_nonce: bytes, + public_key: bytes, + pbkdf2_salt: bytes, + hkdf_salt: bytes, + pbkdf2_iterations: int = 650000, +) -> UserKeySet: + """Store encrypted key bundle during registration. + + Creates a new UserKeySet for the user. Each user has exactly one + key set (UNIQUE constraint on user_id). + + Args: + db: Async database session. + user_id: The user's UUID. + tenant_id: The user's tenant UUID (None for super_admin). + encrypted_private_key: RSA private key wrapped by AUK (AES-GCM). + private_key_nonce: 12-byte AES-GCM nonce for private key. + encrypted_vault_key: Tenant vault key wrapped by user's public key. + vault_key_nonce: 12-byte AES-GCM nonce for vault key. + public_key: RSA-2048 public key in SPKI format. + pbkdf2_salt: 32-byte salt for PBKDF2 key derivation. + hkdf_salt: 32-byte salt for HKDF Secret Key derivation. + pbkdf2_iterations: PBKDF2 iteration count (default 650000). + + Returns: + The created UserKeySet instance. + """ + # Remove any existing key set (e.g. from a failed prior upgrade attempt) + from sqlalchemy import delete + await db.execute(delete(UserKeySet).where(UserKeySet.user_id == user_id)) + + key_set = UserKeySet( + user_id=user_id, + tenant_id=tenant_id, + encrypted_private_key=encrypted_private_key, + private_key_nonce=private_key_nonce, + encrypted_vault_key=encrypted_vault_key, + vault_key_nonce=vault_key_nonce, + public_key=public_key, + pbkdf2_salt=pbkdf2_salt, + hkdf_salt=hkdf_salt, + pbkdf2_iterations=pbkdf2_iterations, + ) + db.add(key_set) + await db.flush() + return key_set + + +async def get_user_key_set( + db: AsyncSession, user_id: UUID +) -> UserKeySet | None: + """Retrieve encrypted key bundle for login response. + + Args: + db: Async database session. + user_id: The user's UUID. + + Returns: + The UserKeySet if found, None otherwise. + """ + result = await db.execute( + select(UserKeySet).where(UserKeySet.user_id == user_id) + ) + return result.scalar_one_or_none() + + +async def log_key_access( + db: AsyncSession, + tenant_id: UUID, + user_id: UUID | None, + action: str, + resource_type: str | None = None, + resource_id: str | None = None, + key_version: int | None = None, + ip_address: str | None = None, + device_id: UUID | None = None, + justification: str | None = None, + correlation_id: str | None = None, +) -> None: + """Append to immutable key_access_log. + + This table is append-only (INSERT+SELECT only via RLS policy). + No UPDATE or DELETE is permitted. + + Args: + db: Async database session. + tenant_id: The tenant UUID for RLS isolation. + user_id: The user who performed the action (None for system ops). + action: Action description (e.g., 'create_key_set', 'decrypt_vault_key'). + resource_type: Optional resource type being accessed. + resource_id: Optional resource identifier. + key_version: Optional key version involved. + ip_address: Optional client IP address. + device_id: Optional device UUID for credential access tracking. + justification: Optional justification for the access (e.g., 'api_backup'). + correlation_id: Optional correlation ID for request tracing. + """ + log_entry = KeyAccessLog( + tenant_id=tenant_id, + user_id=user_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + key_version=key_version, + ip_address=ip_address, + device_id=device_id, + justification=justification, + correlation_id=correlation_id, + ) + db.add(log_entry) + await db.flush() + + +# --------------------------------------------------------------------------- +# OpenBao Transit tenant key provisioning and credential migration +# --------------------------------------------------------------------------- + + +async def provision_tenant_key(db: AsyncSession, tenant_id: UUID) -> str: + """Provision an OpenBao Transit key for a tenant and update the tenant record. + + Idempotent: if the key already exists in OpenBao, it's a no-op on the + OpenBao side. The tenant record is always updated with the key name. + + Args: + db: Async database session (admin engine, no RLS). + tenant_id: Tenant UUID. + + Returns: + The key name (tenant_{uuid}). + """ + from app.models.tenant import Tenant + from app.services.openbao_service import get_openbao_service + + openbao = get_openbao_service() + key_name = f"tenant_{tenant_id}" + + await openbao.create_tenant_key(str(tenant_id)) + + # Update tenant record with key name + result = await db.execute( + select(Tenant).where(Tenant.id == tenant_id) + ) + tenant = result.scalar_one_or_none() + if tenant: + tenant.openbao_key_name = key_name + await db.flush() + + logger.info( + "Provisioned OpenBao Transit key for tenant %s (key=%s)", + tenant_id, + key_name, + ) + return key_name + + +async def migrate_tenant_credentials(db: AsyncSession, tenant_id: UUID) -> dict: + """Re-encrypt all legacy credentials for a tenant from AES-256-GCM to Transit. + + Migrates device credentials, CA private keys, device cert private keys, + and notification channel secrets. Already-migrated items are skipped. + + Args: + db: Async database session (admin engine, no RLS). + tenant_id: Tenant UUID. + + Returns: + Dict with counts: {"devices": N, "cas": N, "certs": N, "channels": N, "errors": N} + """ + from app.config import settings + from app.models.alert import NotificationChannel + from app.models.certificate import CertificateAuthority, DeviceCertificate + from app.models.device import Device + from app.services.crypto import decrypt_credentials + from app.services.openbao_service import get_openbao_service + + openbao = get_openbao_service() + legacy_key = settings.get_encryption_key_bytes() + tid = str(tenant_id) + + counts = {"devices": 0, "cas": 0, "certs": 0, "channels": 0, "errors": 0} + + # --- Migrate device credentials --- + result = await db.execute( + select(Device).where( + Device.tenant_id == tenant_id, + Device.encrypted_credentials.isnot(None), + (Device.encrypted_credentials_transit.is_(None) | (Device.encrypted_credentials_transit == "")), + ) + ) + for device in result.scalars().all(): + try: + plaintext = decrypt_credentials(device.encrypted_credentials, legacy_key) + device.encrypted_credentials_transit = await openbao.encrypt(tid, plaintext.encode("utf-8")) + counts["devices"] += 1 + except Exception as e: + logger.error("Failed to migrate device %s credentials: %s", device.id, e) + counts["errors"] += 1 + + # --- Migrate CA private keys --- + result = await db.execute( + select(CertificateAuthority).where( + CertificateAuthority.tenant_id == tenant_id, + CertificateAuthority.encrypted_private_key.isnot(None), + (CertificateAuthority.encrypted_private_key_transit.is_(None) | (CertificateAuthority.encrypted_private_key_transit == "")), + ) + ) + for ca in result.scalars().all(): + try: + plaintext = decrypt_credentials(ca.encrypted_private_key, legacy_key) + ca.encrypted_private_key_transit = await openbao.encrypt(tid, plaintext.encode("utf-8")) + counts["cas"] += 1 + except Exception as e: + logger.error("Failed to migrate CA %s private key: %s", ca.id, e) + counts["errors"] += 1 + + # --- Migrate device cert private keys --- + result = await db.execute( + select(DeviceCertificate).where( + DeviceCertificate.tenant_id == tenant_id, + DeviceCertificate.encrypted_private_key.isnot(None), + (DeviceCertificate.encrypted_private_key_transit.is_(None) | (DeviceCertificate.encrypted_private_key_transit == "")), + ) + ) + for cert in result.scalars().all(): + try: + plaintext = decrypt_credentials(cert.encrypted_private_key, legacy_key) + cert.encrypted_private_key_transit = await openbao.encrypt(tid, plaintext.encode("utf-8")) + counts["certs"] += 1 + except Exception as e: + logger.error("Failed to migrate cert %s private key: %s", cert.id, e) + counts["errors"] += 1 + + # --- Migrate notification channel secrets --- + result = await db.execute( + select(NotificationChannel).where( + NotificationChannel.tenant_id == tenant_id, + ) + ) + for ch in result.scalars().all(): + migrated_any = False + try: + # SMTP password + if ch.smtp_password and not ch.smtp_password_transit: + plaintext = decrypt_credentials(ch.smtp_password, legacy_key) + ch.smtp_password_transit = await openbao.encrypt(tid, plaintext.encode("utf-8")) + migrated_any = True + if migrated_any: + counts["channels"] += 1 + except Exception as e: + logger.error("Failed to migrate channel %s secrets: %s", ch.id, e) + counts["errors"] += 1 + + await db.flush() + + logger.info( + "Tenant %s credential migration complete: %s", + tenant_id, + counts, + ) + return counts + + +async def provision_existing_tenants(db: AsyncSession) -> dict: + """Provision OpenBao Transit keys for all existing tenants and migrate credentials. + + Called on app startup to ensure all tenants have Transit keys. + Idempotent -- running multiple times is safe (already-migrated items are skipped). + + Args: + db: Async database session (admin engine, no RLS). + + Returns: + Summary dict with total counts across all tenants. + """ + from app.models.tenant import Tenant + + result = await db.execute(select(Tenant)) + tenants = result.scalars().all() + + total = {"tenants": len(tenants), "devices": 0, "cas": 0, "certs": 0, "channels": 0, "errors": 0} + + for tenant in tenants: + try: + await provision_tenant_key(db, tenant.id) + counts = await migrate_tenant_credentials(db, tenant.id) + total["devices"] += counts["devices"] + total["cas"] += counts["cas"] + total["certs"] += counts["certs"] + total["channels"] += counts["channels"] + total["errors"] += counts["errors"] + except Exception as e: + logger.error("Failed to provision/migrate tenant %s: %s", tenant.id, e) + total["errors"] += 1 + + await db.commit() + + logger.info("Existing tenant provisioning complete: %s", total) + return total diff --git a/backend/app/services/metrics_subscriber.py b/backend/app/services/metrics_subscriber.py new file mode 100644 index 0000000..f637c31 --- /dev/null +++ b/backend/app/services/metrics_subscriber.py @@ -0,0 +1,346 @@ +"""NATS JetStream subscriber for device metrics events. + +Subscribes to device.metrics.> and inserts into TimescaleDB hypertables: + - interface_metrics — per-interface rx/tx byte counters + - health_metrics — CPU, memory, disk, temperature per device + - wireless_metrics — per-wireless-interface aggregated client stats + +Also maintains denormalized last_cpu_load and last_memory_used_pct columns +on the devices table for efficient fleet table display. + +Uses AdminAsyncSessionLocal (superuser bypass RLS) so metrics from any tenant +can be written without setting app.current_tenant. +""" + +import asyncio +import json +import logging +from datetime import datetime, timezone +from typing import Optional + +import nats +from nats.js import JetStreamContext +from nats.aio.client import Client as NATSClient +from sqlalchemy import text + +from app.config import settings +from app.database import AdminAsyncSessionLocal + +logger = logging.getLogger(__name__) + +_metrics_client: Optional[NATSClient] = None + + +# ============================================================================= +# INSERT HANDLERS +# ============================================================================= + + +def _parse_timestamp(val: str | None) -> datetime: + """Parse an ISO 8601 / RFC 3339 timestamp string into a datetime object.""" + if not val: + return datetime.now(timezone.utc) + try: + return datetime.fromisoformat(val.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return datetime.now(timezone.utc) + + +async def _insert_health_metrics(session, data: dict) -> None: + """Insert a health metrics event into health_metrics and update devices.""" + health = data.get("health") + if not health: + logger.warning("health metrics event missing 'health' field — skipping") + return + + device_id = data.get("device_id") + tenant_id = data.get("tenant_id") + collected_at = _parse_timestamp(data.get("collected_at")) + + # Parse numeric values; treat empty strings as NULL. + def parse_int(val: str | None) -> int | None: + if not val: + return None + try: + return int(val) + except (ValueError, TypeError): + return None + + cpu_load = parse_int(health.get("cpu_load")) + free_memory = parse_int(health.get("free_memory")) + total_memory = parse_int(health.get("total_memory")) + free_disk = parse_int(health.get("free_disk")) + total_disk = parse_int(health.get("total_disk")) + temperature = parse_int(health.get("temperature")) + + await session.execute( + text(""" + INSERT INTO health_metrics + (time, device_id, tenant_id, cpu_load, free_memory, total_memory, + free_disk, total_disk, temperature) + VALUES + (:time, :device_id, :tenant_id, :cpu_load, :free_memory, :total_memory, + :free_disk, :total_disk, :temperature) + """), + { + "time": collected_at, + "device_id": device_id, + "tenant_id": tenant_id, + "cpu_load": cpu_load, + "free_memory": free_memory, + "total_memory": total_memory, + "free_disk": free_disk, + "total_disk": total_disk, + "temperature": temperature, + }, + ) + + # Update denormalized columns on devices for fleet table display. + # Compute memory percentage in Python to avoid asyncpg type ambiguity. + mem_pct = None + if total_memory and total_memory > 0 and free_memory is not None: + mem_pct = round((1.0 - free_memory / total_memory) * 100) + + await session.execute( + text(""" + UPDATE devices SET + last_cpu_load = COALESCE(:cpu_load, last_cpu_load), + last_memory_used_pct = COALESCE(:mem_pct, last_memory_used_pct), + updated_at = NOW() + WHERE id = CAST(:device_id AS uuid) + """), + { + "cpu_load": cpu_load, + "mem_pct": mem_pct, + "device_id": device_id, + }, + ) + + +async def _insert_interface_metrics(session, data: dict) -> None: + """Insert per-interface traffic counters into interface_metrics.""" + interfaces = data.get("interfaces") + if not interfaces: + return # Device may have no interfaces (unlikely but safe to skip) + + device_id = data.get("device_id") + tenant_id = data.get("tenant_id") + collected_at = _parse_timestamp(data.get("collected_at")) + + for iface in interfaces: + await session.execute( + text(""" + INSERT INTO interface_metrics + (time, device_id, tenant_id, interface, rx_bytes, tx_bytes, rx_bps, tx_bps) + VALUES + (:time, :device_id, :tenant_id, :interface, :rx_bytes, :tx_bytes, NULL, NULL) + """), + { + "time": collected_at, + "device_id": device_id, + "tenant_id": tenant_id, + "interface": iface.get("name"), + "rx_bytes": iface.get("rx_bytes"), + "tx_bytes": iface.get("tx_bytes"), + }, + ) + + +async def _insert_wireless_metrics(session, data: dict) -> None: + """Insert per-wireless-interface aggregated client stats into wireless_metrics.""" + wireless = data.get("wireless") + if not wireless: + return # Device may have no wireless interfaces + + device_id = data.get("device_id") + tenant_id = data.get("tenant_id") + collected_at = _parse_timestamp(data.get("collected_at")) + + for wif in wireless: + await session.execute( + text(""" + INSERT INTO wireless_metrics + (time, device_id, tenant_id, interface, client_count, avg_signal, ccq, frequency) + VALUES + (:time, :device_id, :tenant_id, :interface, + :client_count, :avg_signal, :ccq, :frequency) + """), + { + "time": collected_at, + "device_id": device_id, + "tenant_id": tenant_id, + "interface": wif.get("interface"), + "client_count": wif.get("client_count"), + "avg_signal": wif.get("avg_signal"), + "ccq": wif.get("ccq"), + "frequency": wif.get("frequency"), + }, + ) + + +# ============================================================================= +# MAIN MESSAGE HANDLER +# ============================================================================= + + +async def on_device_metrics(msg) -> None: + """Handle a device.metrics event published by the Go poller. + + Dispatches to the appropriate insert handler based on the 'type' field: + - "health" → _insert_health_metrics + update devices + - "interfaces" → _insert_interface_metrics + - "wireless" → _insert_wireless_metrics + + On success, acknowledges the message. On error, NAKs so NATS can redeliver. + """ + try: + data = json.loads(msg.data) + metric_type = data.get("type") + device_id = data.get("device_id") + + if not metric_type or not device_id: + logger.warning( + "device.metrics event missing 'type' or 'device_id' — skipping" + ) + await msg.ack() + return + + async with AdminAsyncSessionLocal() as session: + if metric_type == "health": + await _insert_health_metrics(session, data) + elif metric_type == "interfaces": + await _insert_interface_metrics(session, data) + elif metric_type == "wireless": + await _insert_wireless_metrics(session, data) + else: + logger.warning("Unknown metric type '%s' — skipping", metric_type) + await msg.ack() + return + + await session.commit() + + # Alert evaluation — non-fatal; metric write is the primary operation + try: + from app.services import alert_evaluator + await alert_evaluator.evaluate( + device_id=device_id, + tenant_id=data.get("tenant_id", ""), + metric_type=metric_type, + data=data, + ) + except Exception as eval_err: + logger.warning("Alert evaluation failed for device %s: %s", device_id, eval_err) + + logger.debug( + "device.metrics processed", + extra={"device_id": device_id, "type": metric_type}, + ) + await msg.ack() + + except Exception as exc: + logger.error( + "Failed to process device.metrics event: %s", + exc, + exc_info=True, + ) + try: + await msg.nak() + except Exception: + pass # If NAK also fails, NATS will redeliver after ack_wait + + +# ============================================================================= +# SUBSCRIPTION SETUP +# ============================================================================= + + +async def _subscribe_with_retry(js: JetStreamContext) -> None: + """Subscribe to device.metrics.> with durable consumer, retrying if stream not ready.""" + max_attempts = 6 # ~30 seconds at 5s intervals + for attempt in range(1, max_attempts + 1): + try: + await js.subscribe( + "device.metrics.>", + cb=on_device_metrics, + durable="api-metrics-consumer", + stream="DEVICE_EVENTS", + ) + logger.info( + "NATS: subscribed to device.metrics.> (durable: api-metrics-consumer)" + ) + return + except Exception as exc: + if attempt < max_attempts: + logger.warning( + "NATS: stream DEVICE_EVENTS not ready for metrics (attempt %d/%d): %s — retrying in 5s", + attempt, + max_attempts, + exc, + ) + await asyncio.sleep(5) + else: + logger.warning( + "NATS: giving up on device.metrics.> after %d attempts: %s — API will run without metrics ingestion", + max_attempts, + exc, + ) + return + + +async def start_metrics_subscriber() -> Optional[NATSClient]: + """Connect to NATS and start the device.metrics.> subscription. + + Uses a separate NATS connection from the status subscriber — simpler and + NATS handles multiple connections per client efficiently. + + Returns the NATS connection (must be passed to stop_metrics_subscriber on shutdown). + Raises on fatal connection errors after retry exhaustion. + """ + global _metrics_client + + logger.info("NATS metrics: connecting to %s", settings.NATS_URL) + + nc = await nats.connect( + settings.NATS_URL, + max_reconnect_attempts=-1, + reconnect_time_wait=2, + error_cb=_on_error, + reconnected_cb=_on_reconnected, + disconnected_cb=_on_disconnected, + ) + + logger.info("NATS metrics: connected to %s", settings.NATS_URL) + + js = nc.jetstream() + await _subscribe_with_retry(js) + + _metrics_client = nc + return nc + + +async def stop_metrics_subscriber(nc: Optional[NATSClient]) -> None: + """Drain and close the metrics NATS connection gracefully.""" + if nc is None: + return + try: + logger.info("NATS metrics: draining connection...") + await nc.drain() + logger.info("NATS metrics: connection closed") + except Exception as exc: + logger.warning("NATS metrics: error during drain: %s", exc) + try: + await nc.close() + except Exception: + pass + + +async def _on_error(exc: Exception) -> None: + logger.error("NATS metrics error: %s", exc) + + +async def _on_reconnected() -> None: + logger.info("NATS metrics: reconnected") + + +async def _on_disconnected() -> None: + logger.warning("NATS metrics: disconnected") diff --git a/backend/app/services/nats_subscriber.py b/backend/app/services/nats_subscriber.py new file mode 100644 index 0000000..123127e --- /dev/null +++ b/backend/app/services/nats_subscriber.py @@ -0,0 +1,231 @@ +"""NATS JetStream subscriber for device status events from the Go poller. + +Subscribes to device.status.> and updates device records in PostgreSQL. +This is a system-level process that needs to update devices across all tenants, +so it uses the admin engine (bypasses RLS). +""" + +import asyncio +import json +import logging +import re +from datetime import datetime, timezone +from typing import Optional + +import nats +from nats.js import JetStreamContext +from nats.aio.client import Client as NATSClient +from sqlalchemy import text + +from app.config import settings +from app.database import AdminAsyncSessionLocal + +logger = logging.getLogger(__name__) + +_nats_client: Optional[NATSClient] = None + +# Regex for RouterOS uptime strings like "42d14h23m15s", "14h23m15s", "23m15s", "3w2d" +_UPTIME_RE = re.compile(r"(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?") + + +def _parse_uptime(raw: str) -> int | None: + """Parse a RouterOS uptime string into total seconds.""" + if not raw: + return None + m = _UPTIME_RE.fullmatch(raw) + if not m: + return None + weeks = int(m.group(1) or 0) + days = int(m.group(2) or 0) + hours = int(m.group(3) or 0) + minutes = int(m.group(4) or 0) + seconds = int(m.group(5) or 0) + total = weeks * 604800 + days * 86400 + hours * 3600 + minutes * 60 + seconds + return total if total > 0 else None + + +async def on_device_status(msg) -> None: + """Handle a device.status event published by the Go poller. + + Payload (JSON): + device_id (str) — UUID of the device + tenant_id (str) — UUID of the owning tenant + status (str) — "online" or "offline" + routeros_version (str | None) — e.g. "7.16.2" + major_version (int | None) — e.g. 7 + board_name (str | None) — e.g. "RB4011iGS+5HacQ2HnD" + last_seen (str | None) — ISO-8601 timestamp + """ + try: + data = json.loads(msg.data) + device_id = data.get("device_id") + status = data.get("status") + routeros_version = data.get("routeros_version") + major_version = data.get("major_version") + board_name = data.get("board_name") + last_seen_raw = data.get("last_seen") + serial_number = data.get("serial_number") or None + firmware_version = data.get("firmware_version") or None + uptime_seconds = _parse_uptime(data.get("uptime", "")) + + if not device_id or not status: + logger.warning("Received device.status event with missing device_id or status — skipping") + await msg.ack() + return + + # Parse timestamp in Python — asyncpg needs datetime objects, not strings + last_seen_dt = None + if last_seen_raw: + try: + last_seen_dt = datetime.fromisoformat(last_seen_raw.replace("Z", "+00:00")) + except (ValueError, AttributeError): + last_seen_dt = datetime.now(timezone.utc) + + async with AdminAsyncSessionLocal() as session: + await session.execute( + text( + """ + UPDATE devices SET + status = :status, + routeros_version = COALESCE(:routeros_version, routeros_version), + routeros_major_version = COALESCE(:major_version, routeros_major_version), + model = COALESCE(:board_name, model), + serial_number = COALESCE(:serial_number, serial_number), + firmware_version = COALESCE(:firmware_version, firmware_version), + uptime_seconds = COALESCE(:uptime_seconds, uptime_seconds), + last_seen = COALESCE(:last_seen, last_seen), + updated_at = NOW() + WHERE id = CAST(:device_id AS uuid) + """ + ), + { + "status": status, + "routeros_version": routeros_version, + "major_version": major_version, + "board_name": board_name, + "serial_number": serial_number, + "firmware_version": firmware_version, + "uptime_seconds": uptime_seconds, + "last_seen": last_seen_dt, + "device_id": device_id, + }, + ) + await session.commit() + + # Alert evaluation for offline/online status changes — non-fatal + try: + from app.services import alert_evaluator + if status == "offline": + await alert_evaluator.evaluate_offline(device_id, data.get("tenant_id", "")) + elif status == "online": + await alert_evaluator.evaluate_online(device_id, data.get("tenant_id", "")) + except Exception as e: + logger.warning("Alert evaluation failed for device %s status=%s: %s", device_id, status, e) + + logger.info( + "Device status updated", + extra={ + "device_id": device_id, + "status": status, + "routeros_version": routeros_version, + }, + ) + await msg.ack() + + except Exception as exc: + logger.error( + "Failed to process device.status event: %s", + exc, + exc_info=True, + ) + try: + await msg.nak() + except Exception: + pass # If NAK also fails, NATS will redeliver after ack_wait + + +async def _subscribe_with_retry(js: JetStreamContext) -> None: + """Subscribe to device.status.> with durable consumer, retrying if stream not ready.""" + max_attempts = 6 # ~30 seconds at 5s intervals + for attempt in range(1, max_attempts + 1): + try: + await js.subscribe( + "device.status.>", + cb=on_device_status, + durable="api-status-consumer", + stream="DEVICE_EVENTS", + ) + logger.info("NATS: subscribed to device.status.> (durable: api-status-consumer)") + return + except Exception as exc: + if attempt < max_attempts: + logger.warning( + "NATS: stream DEVICE_EVENTS not ready (attempt %d/%d): %s — retrying in 5s", + attempt, + max_attempts, + exc, + ) + await asyncio.sleep(5) + else: + logger.warning( + "NATS: giving up on device.status.> after %d attempts: %s — API will run without real-time status updates", + max_attempts, + exc, + ) + return + + +async def start_nats_subscriber() -> Optional[NATSClient]: + """Connect to NATS and start the device.status.> subscription. + + Returns the NATS connection (must be passed to stop_nats_subscriber on shutdown). + Raises on fatal connection errors after retry exhaustion. + """ + global _nats_client + + logger.info("NATS: connecting to %s", settings.NATS_URL) + + nc = await nats.connect( + settings.NATS_URL, + max_reconnect_attempts=-1, # reconnect forever (pod-to-pod transient failures) + reconnect_time_wait=2, + error_cb=_on_error, + reconnected_cb=_on_reconnected, + disconnected_cb=_on_disconnected, + ) + + logger.info("NATS: connected to %s", settings.NATS_URL) + + js = nc.jetstream() + await _subscribe_with_retry(js) + + _nats_client = nc + return nc + + +async def stop_nats_subscriber(nc: Optional[NATSClient]) -> None: + """Drain and close the NATS connection gracefully.""" + if nc is None: + return + try: + logger.info("NATS: draining connection...") + await nc.drain() + logger.info("NATS: connection closed") + except Exception as exc: + logger.warning("NATS: error during drain: %s", exc) + try: + await nc.close() + except Exception: + pass + + +async def _on_error(exc: Exception) -> None: + logger.error("NATS error: %s", exc) + + +async def _on_reconnected() -> None: + logger.info("NATS: reconnected") + + +async def _on_disconnected() -> None: + logger.warning("NATS: disconnected") diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..0f1f31b --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,256 @@ +"""Email and webhook notification delivery for alert events. + +Best-effort delivery: failures are logged but never raised. +Each dispatch is wrapped in try/except so one failing channel +doesn't prevent delivery to other channels. +""" + +import logging +from typing import Any + +import httpx + + +logger = logging.getLogger(__name__) + + +async def dispatch_notifications( + alert_event: dict[str, Any], + channels: list[dict[str, Any]], + device_hostname: str, +) -> None: + """Send notifications for an alert event to all provided channels. + + Args: + alert_event: Dict with alert event fields (status, severity, metric, etc.) + channels: List of notification channel dicts + device_hostname: Human-readable device name for messages + """ + for channel in channels: + try: + if channel["channel_type"] == "email": + await _send_email(channel, alert_event, device_hostname) + elif channel["channel_type"] == "webhook": + await _send_webhook(channel, alert_event, device_hostname) + elif channel["channel_type"] == "slack": + await _send_slack(channel, alert_event, device_hostname) + else: + logger.warning("Unknown channel type: %s", channel["channel_type"]) + except Exception as e: + logger.warning( + "Notification delivery failed for channel %s (%s): %s", + channel.get("name"), channel.get("channel_type"), e, + ) + + +async def _send_email(channel: dict, alert_event: dict, device_hostname: str) -> None: + """Send alert notification email using per-channel SMTP config.""" + from app.services.email_service import SMTPConfig, send_email + + severity = alert_event.get("severity", "warning") + status = alert_event.get("status", "firing") + rule_name = alert_event.get("rule_name") or alert_event.get("message", "Unknown Rule") + metric = alert_event.get("metric_name") or alert_event.get("metric", "") + value = alert_event.get("current_value") or alert_event.get("value", "") + threshold = alert_event.get("threshold", "") + + severity_colors = { + "critical": "#ef4444", + "warning": "#f59e0b", + "info": "#38bdf8", + } + color = severity_colors.get(severity, "#38bdf8") + status_label = "RESOLVED" if status == "resolved" else "FIRING" + + html = f""" +
+
+

[{status_label}] {rule_name}

+
+
+ + + + + + +
Device{device_hostname}
Severity{severity.upper()}
Metric{metric}
Value{value}
Threshold{threshold}
+

+ TOD — Fleet Management for MikroTik RouterOS +

+
+
+ """ + + plain = ( + f"[{status_label}] {rule_name}\n\n" + f"Device: {device_hostname}\n" + f"Severity: {severity}\n" + f"Metric: {metric}\n" + f"Value: {value}\n" + f"Threshold: {threshold}\n" + ) + + # Decrypt SMTP password (Transit first, then legacy Fernet) + smtp_password = None + transit_cipher = channel.get("smtp_password_transit") + legacy_cipher = channel.get("smtp_password") + tenant_id = channel.get("tenant_id") + + if transit_cipher and tenant_id: + try: + from app.services.kms_service import decrypt_transit + smtp_password = await decrypt_transit(transit_cipher, tenant_id) + except Exception: + logger.warning("Transit decryption failed for channel %s, trying legacy", channel.get("id")) + + if not smtp_password and legacy_cipher: + try: + from app.config import settings as app_settings + from cryptography.fernet import Fernet + raw = bytes(legacy_cipher) if isinstance(legacy_cipher, memoryview) else legacy_cipher + f = Fernet(app_settings.CREDENTIAL_ENCRYPTION_KEY.encode()) + smtp_password = f.decrypt(raw).decode() + except Exception: + logger.warning("Legacy decryption failed for channel %s", channel.get("id")) + + config = SMTPConfig( + host=channel.get("smtp_host", "localhost"), + port=channel.get("smtp_port", 587), + user=channel.get("smtp_user"), + password=smtp_password, + use_tls=channel.get("smtp_use_tls", False), + from_address=channel.get("from_address") or "alerts@mikrotik-portal.local", + ) + + to = channel.get("to_address") + subject = f"[TOD {status_label}] {rule_name} — {device_hostname}" + await send_email(to, subject, html, plain, config) + + +async def _send_webhook( + channel: dict[str, Any], + alert_event: dict[str, Any], + device_hostname: str, +) -> None: + """Send alert notification to a webhook URL (Slack-compatible JSON).""" + severity = alert_event.get("severity", "info") + status = alert_event.get("status", "firing") + metric = alert_event.get("metric") + value = alert_event.get("value") + threshold = alert_event.get("threshold") + message_text = alert_event.get("message", "") + + payload = { + "alert_name": message_text, + "severity": severity, + "status": status, + "device": device_hostname, + "device_id": alert_event.get("device_id"), + "metric": metric, + "value": value, + "threshold": threshold, + "timestamp": str(alert_event.get("fired_at", "")), + "text": f"[{severity.upper()}] {device_hostname}: {message_text}", + } + + webhook_url = channel.get("webhook_url", "") + if not webhook_url: + logger.warning("Webhook channel %s has no URL configured", channel.get("name")) + return + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(webhook_url, json=payload) + logger.info( + "Webhook notification sent to %s — status %d", + webhook_url, response.status_code, + ) + + +async def _send_slack( + channel: dict[str, Any], + alert_event: dict[str, Any], + device_hostname: str, +) -> None: + """Send alert notification to Slack via incoming webhook with Block Kit formatting.""" + severity = alert_event.get("severity", "info").upper() + status = alert_event.get("status", "firing") + metric = alert_event.get("metric", "unknown") + message_text = alert_event.get("message", "") + value = alert_event.get("value") + threshold = alert_event.get("threshold") + + color = {"CRITICAL": "#dc2626", "WARNING": "#f59e0b", "INFO": "#3b82f6"}.get(severity, "#6b7280") + status_label = "RESOLVED" if status == "resolved" else status + + blocks = [ + { + "type": "header", + "text": {"type": "plain_text", "text": f"{'✅' if status == 'resolved' else '🚨'} [{severity}] {status_label.upper()}"}, + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": f"*Device:*\n{device_hostname}"}, + {"type": "mrkdwn", "text": f"*Metric:*\n{metric}"}, + ], + }, + ] + if value is not None or threshold is not None: + fields = [] + if value is not None: + fields.append({"type": "mrkdwn", "text": f"*Value:*\n{value}"}) + if threshold is not None: + fields.append({"type": "mrkdwn", "text": f"*Threshold:*\n{threshold}"}) + blocks.append({"type": "section", "fields": fields}) + + if message_text: + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"*Message:*\n{message_text}"}}) + + blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": "TOD Alert System"}]}) + + slack_url = channel.get("slack_webhook_url", "") + if not slack_url: + logger.warning("Slack channel %s has no webhook URL configured", channel.get("name")) + return + + payload = {"attachments": [{"color": color, "blocks": blocks}]} + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(slack_url, json=payload) + logger.info("Slack notification sent — status %d", response.status_code) + + +async def send_test_notification(channel: dict[str, Any]) -> bool: + """Send a test notification through a channel to verify configuration. + + Args: + channel: Notification channel dict with all config fields + + Returns: + True on success + + Raises: + Exception on delivery failure (caller handles) + """ + test_event = { + "status": "test", + "severity": "info", + "metric": "test", + "value": None, + "threshold": None, + "message": "Test notification from TOD", + "device_id": "00000000-0000-0000-0000-000000000000", + "fired_at": "", + } + + if channel["channel_type"] == "email": + await _send_email(channel, test_event, "Test Device") + elif channel["channel_type"] == "webhook": + await _send_webhook(channel, test_event, "Test Device") + elif channel["channel_type"] == "slack": + await _send_slack(channel, test_event, "Test Device") + else: + raise ValueError(f"Unknown channel type: {channel['channel_type']}") + + return True diff --git a/backend/app/services/openbao_service.py b/backend/app/services/openbao_service.py new file mode 100644 index 0000000..a7d6f83 --- /dev/null +++ b/backend/app/services/openbao_service.py @@ -0,0 +1,174 @@ +""" +OpenBao Transit secrets engine client for per-tenant envelope encryption. + +Provides encrypt/decrypt operations via OpenBao's HTTP API. Each tenant gets +a dedicated Transit key (tenant_{uuid}) for AES-256-GCM encryption. The key +material never leaves OpenBao -- the application only sees ciphertext. + +Ciphertext format: "vault:v1:base64..." (compatible with Vault Transit format) +""" +import base64 +import logging +from typing import Optional + +import httpx + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class OpenBaoTransitService: + """Async client for OpenBao Transit secrets engine.""" + + def __init__(self, addr: str | None = None, token: str | None = None): + self.addr = addr or settings.OPENBAO_ADDR + self.token = token or settings.OPENBAO_TOKEN + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.addr, + headers={"X-Vault-Token": self.token}, + timeout=5.0, + ) + return self._client + + async def close(self) -> None: + if self._client and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def create_tenant_key(self, tenant_id: str) -> None: + """Create Transit encryption keys for a tenant (credential + data). Idempotent.""" + client = await self._get_client() + + # Credential key: tenant_{uuid} + key_name = f"tenant_{tenant_id}" + resp = await client.post( + f"/v1/transit/keys/{key_name}", + json={"type": "aes256-gcm96"}, + ) + if resp.status_code not in (200, 204): + resp.raise_for_status() + logger.info("OpenBao Transit key ensured", extra={"key_name": key_name}) + + # Data key: tenant_{uuid}_data (Phase 30) + await self.create_tenant_data_key(tenant_id) + + async def encrypt(self, tenant_id: str, plaintext: bytes) -> str: + """Encrypt plaintext via Transit engine. Returns ciphertext string.""" + client = await self._get_client() + key_name = f"tenant_{tenant_id}" + resp = await client.post( + f"/v1/transit/encrypt/{key_name}", + json={"plaintext": base64.b64encode(plaintext).decode()}, + ) + resp.raise_for_status() + ciphertext = resp.json()["data"]["ciphertext"] + return ciphertext # "vault:v1:..." + + async def decrypt(self, tenant_id: str, ciphertext: str) -> bytes: + """Decrypt Transit ciphertext. Returns plaintext bytes.""" + client = await self._get_client() + key_name = f"tenant_{tenant_id}" + resp = await client.post( + f"/v1/transit/decrypt/{key_name}", + json={"ciphertext": ciphertext}, + ) + resp.raise_for_status() + plaintext_b64 = resp.json()["data"]["plaintext"] + return base64.b64decode(plaintext_b64) + + async def key_exists(self, tenant_id: str) -> bool: + """Check if a Transit key exists for a tenant.""" + client = await self._get_client() + key_name = f"tenant_{tenant_id}" + resp = await client.get(f"/v1/transit/keys/{key_name}") + return resp.status_code == 200 + + # ------------------------------------------------------------------ + # Data encryption keys (tenant_{uuid}_data) — Phase 30 + # ------------------------------------------------------------------ + + async def create_tenant_data_key(self, tenant_id: str) -> None: + """Create a Transit data encryption key for a tenant. Idempotent. + + Data keys use the suffix '_data' to separate them from credential keys. + Key naming: tenant_{uuid}_data (vs tenant_{uuid} for credentials). + """ + client = await self._get_client() + key_name = f"tenant_{tenant_id}_data" + resp = await client.post( + f"/v1/transit/keys/{key_name}", + json={"type": "aes256-gcm96"}, + ) + if resp.status_code not in (200, 204): + resp.raise_for_status() + logger.info("OpenBao Transit data key ensured", extra={"key_name": key_name}) + + async def ensure_tenant_data_key(self, tenant_id: str) -> None: + """Ensure a data encryption key exists for a tenant. Idempotent. + + Checks existence first and creates if missing. Safe to call on every + encrypt operation (fast path: single GET to check existence). + """ + client = await self._get_client() + key_name = f"tenant_{tenant_id}_data" + resp = await client.get(f"/v1/transit/keys/{key_name}") + if resp.status_code != 200: + await self.create_tenant_data_key(tenant_id) + + async def encrypt_data(self, tenant_id: str, plaintext: bytes) -> str: + """Encrypt data via Transit using per-tenant data key. + + Uses the tenant_{uuid}_data key (separate from credential key). + + Args: + tenant_id: Tenant UUID string. + plaintext: Raw bytes to encrypt. + + Returns: + Transit ciphertext string (vault:v1:...). + """ + client = await self._get_client() + key_name = f"tenant_{tenant_id}_data" + resp = await client.post( + f"/v1/transit/encrypt/{key_name}", + json={"plaintext": base64.b64encode(plaintext).decode()}, + ) + resp.raise_for_status() + return resp.json()["data"]["ciphertext"] + + async def decrypt_data(self, tenant_id: str, ciphertext: str) -> bytes: + """Decrypt Transit data ciphertext using per-tenant data key. + + Args: + tenant_id: Tenant UUID string. + ciphertext: Transit ciphertext (vault:v1:...). + + Returns: + Decrypted plaintext bytes. + """ + client = await self._get_client() + key_name = f"tenant_{tenant_id}_data" + resp = await client.post( + f"/v1/transit/decrypt/{key_name}", + json={"ciphertext": ciphertext}, + ) + resp.raise_for_status() + plaintext_b64 = resp.json()["data"]["plaintext"] + return base64.b64decode(plaintext_b64) + + +# Module-level singleton +_openbao_service: Optional[OpenBaoTransitService] = None + + +def get_openbao_service() -> OpenBaoTransitService: + """Return module-level OpenBao Transit service singleton.""" + global _openbao_service + if _openbao_service is None: + _openbao_service = OpenBaoTransitService() + return _openbao_service diff --git a/backend/app/services/push_rollback_subscriber.py b/backend/app/services/push_rollback_subscriber.py new file mode 100644 index 0000000..51766b9 --- /dev/null +++ b/backend/app/services/push_rollback_subscriber.py @@ -0,0 +1,141 @@ +"""NATS subscribers for push rollback (auto) and push alert (manual). + +- config.push.rollback.> -> auto-restore for template pushes +- config.push.alert.> -> create alert for editor pushes +""" + +import json +import logging +from typing import Any, Optional + +from app.config import settings +from app.database import AdminAsyncSessionLocal +from app.models.alert import AlertEvent +from app.services import restore_service + +logger = logging.getLogger(__name__) + +_nc: Optional[Any] = None + + +async def _create_push_alert(device_id: str, tenant_id: str, push_type: str) -> None: + """Create a high-priority alert for device offline after config push.""" + async with AdminAsyncSessionLocal() as session: + alert = AlertEvent( + device_id=device_id, + tenant_id=tenant_id, + status="firing", + severity="critical", + message=f"Device went offline after config {push_type} — rollback available", + ) + session.add(alert) + await session.commit() + logger.info("Created push alert for device %s (type=%s)", device_id, push_type) + + +async def handle_push_rollback(event: dict) -> None: + """Auto-rollback: restore device to pre-push config.""" + device_id = event.get("device_id") + tenant_id = event.get("tenant_id") + commit_sha = event.get("pre_push_commit_sha") + + if not all([device_id, tenant_id, commit_sha]): + logger.warning("Push rollback event missing fields: %s", event) + return + + logger.warning( + "AUTO-ROLLBACK: Device %s offline after template push, restoring to %s", + device_id, + commit_sha, + ) + + try: + async with AdminAsyncSessionLocal() as session: + result = await restore_service.restore_config( + device_id=device_id, + tenant_id=tenant_id, + commit_sha=commit_sha, + db_session=session, + ) + await session.commit() + logger.info( + "Auto-rollback result for device %s: %s", + device_id, + result.get("status"), + ) + except Exception as e: + logger.error("Auto-rollback failed for device %s: %s", device_id, e) + await _create_push_alert(device_id, tenant_id, "template (auto-rollback failed)") + + +async def handle_push_alert(event: dict) -> None: + """Alert: create notification for device offline after editor push.""" + device_id = event.get("device_id") + tenant_id = event.get("tenant_id") + push_type = event.get("push_type", "editor") + + if not device_id or not tenant_id: + logger.warning("Push alert event missing fields: %s", event) + return + + await _create_push_alert(device_id, tenant_id, push_type) + + +async def _on_rollback_message(msg) -> None: + """NATS message handler for config.push.rollback.> subjects.""" + try: + event = json.loads(msg.data.decode()) + await handle_push_rollback(event) + await msg.ack() + except Exception as e: + logger.error("Error handling rollback message: %s", e) + await msg.nak() + + +async def _on_alert_message(msg) -> None: + """NATS message handler for config.push.alert.> subjects.""" + try: + event = json.loads(msg.data.decode()) + await handle_push_alert(event) + await msg.ack() + except Exception as e: + logger.error("Error handling push alert message: %s", e) + await msg.nak() + + +async def start_push_rollback_subscriber() -> Optional[Any]: + """Connect to NATS and subscribe to push rollback/alert events.""" + import nats + + global _nc + try: + logger.info("NATS push-rollback: connecting to %s", settings.NATS_URL) + _nc = await nats.connect(settings.NATS_URL) + js = _nc.jetstream() + await js.subscribe( + "config.push.rollback.>", + cb=_on_rollback_message, + durable="api-push-rollback-consumer", + stream="DEVICE_EVENTS", + manual_ack=True, + ) + await js.subscribe( + "config.push.alert.>", + cb=_on_alert_message, + durable="api-push-alert-consumer", + stream="DEVICE_EVENTS", + manual_ack=True, + ) + logger.info("Push rollback/alert subscriber started") + return _nc + except Exception as e: + logger.error("Failed to start push rollback subscriber: %s", e) + return None + + +async def stop_push_rollback_subscriber() -> None: + """Gracefully close the NATS connection.""" + global _nc + if _nc: + await _nc.drain() + _nc = None diff --git a/backend/app/services/push_tracker.py b/backend/app/services/push_tracker.py new file mode 100644 index 0000000..41d209d --- /dev/null +++ b/backend/app/services/push_tracker.py @@ -0,0 +1,70 @@ +"""Track recent config pushes in Redis for poller-aware rollback. + +When a device goes offline shortly after a push, the poller checks these +keys and triggers rollback (template/restore) or alert (editor). + +Redis key format: push:recent:{device_id} +TTL: 300 seconds (5 minutes) +""" + +import json +import logging +from typing import Optional + +import redis.asyncio as redis + +from app.config import settings + +logger = logging.getLogger(__name__) + +PUSH_TTL_SECONDS = 300 # 5 minutes + +_redis: Optional[redis.Redis] = None + + +async def _get_redis() -> redis.Redis: + global _redis + if _redis is None: + _redis = redis.from_url(settings.REDIS_URL) + return _redis + + +async def record_push( + device_id: str, + tenant_id: str, + push_type: str, + push_operation_id: str = "", + pre_push_commit_sha: str = "", +) -> None: + """Record a recent config push in Redis. + + Args: + device_id: UUID of the device. + tenant_id: UUID of the tenant. + push_type: 'template' (auto-rollback) or 'editor' (alert only) or 'restore'. + push_operation_id: ID of the ConfigPushOperation row. + pre_push_commit_sha: Git SHA of the pre-push backup (for rollback). + """ + r = await _get_redis() + key = f"push:recent:{device_id}" + value = json.dumps({ + "device_id": device_id, + "tenant_id": tenant_id, + "push_type": push_type, + "push_operation_id": push_operation_id, + "pre_push_commit_sha": pre_push_commit_sha, + }) + await r.set(key, value, ex=PUSH_TTL_SECONDS) + logger.debug( + "Recorded push for device %s (type=%s, TTL=%ds)", + device_id, + push_type, + PUSH_TTL_SECONDS, + ) + + +async def clear_push(device_id: str) -> None: + """Clear the push tracking key (e.g., after successful commit).""" + r = await _get_redis() + await r.delete(f"push:recent:{device_id}") + logger.debug("Cleared push tracking for device %s", device_id) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py new file mode 100644 index 0000000..db9177a --- /dev/null +++ b/backend/app/services/report_service.py @@ -0,0 +1,572 @@ +"""Report generation service. + +Generates PDF (via Jinja2 + weasyprint) and CSV reports for: +- Device inventory +- Metrics summary +- Alert history +- Change log (audit_logs if available, else config_backups fallback) + +Phase 30 NOTE: Reports are currently ephemeral (generated on-demand per request, +never stored at rest). DATAENC-03 requires "report content is encrypted before +storage." Since no report storage exists yet, encryption will be applied when +report caching/storage is added. The generation pipeline is Transit-ready -- +wrap the file_bytes with encrypt_data_transit() before any future INSERT. +""" + +import csv +import io +import os +import time +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +import structlog +from jinja2 import Environment, FileSystemLoader +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +logger = structlog.get_logger(__name__) + +# Jinja2 environment pointing at the templates directory +_TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates") +_jinja_env = Environment( + loader=FileSystemLoader(_TEMPLATE_DIR), + autoescape=True, +) + + +async def generate_report( + db: AsyncSession, + tenant_id: UUID, + report_type: str, + date_from: Optional[datetime], + date_to: Optional[datetime], + fmt: str = "pdf", +) -> tuple[bytes, str, str]: + """Generate a report and return (file_bytes, content_type, filename). + + Args: + db: RLS-enforced async session (tenant context already set). + tenant_id: Tenant UUID for scoping. + report_type: One of device_inventory, metrics_summary, alert_history, change_log. + date_from: Start date for time-ranged reports. + date_to: End date for time-ranged reports. + fmt: Output format -- "pdf" or "csv". + + Returns: + Tuple of (file_bytes, content_type, filename). + """ + start = time.monotonic() + + # Fetch tenant name for the header + tenant_name = await _get_tenant_name(db, tenant_id) + + # Dispatch to the appropriate handler + handlers = { + "device_inventory": _device_inventory, + "metrics_summary": _metrics_summary, + "alert_history": _alert_history, + "change_log": _change_log, + } + handler = handlers[report_type] + template_data = await handler(db, tenant_id, date_from, date_to) + + # Common template context + generated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + base_context = { + "tenant_name": tenant_name, + "generated_at": generated_at, + } + + timestamp_str = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + + if fmt == "csv": + file_bytes = _render_csv(report_type, template_data) + content_type = "text/csv; charset=utf-8" + filename = f"{report_type}_{timestamp_str}.csv" + else: + file_bytes = _render_pdf(report_type, {**base_context, **template_data}) + content_type = "application/pdf" + filename = f"{report_type}_{timestamp_str}.pdf" + + elapsed = time.monotonic() - start + logger.info( + "report_generated", + report_type=report_type, + format=fmt, + tenant_id=str(tenant_id), + size_bytes=len(file_bytes), + elapsed_seconds=round(elapsed, 2), + ) + + return file_bytes, content_type, filename + + +# --------------------------------------------------------------------------- +# Tenant name helper +# --------------------------------------------------------------------------- + + +async def _get_tenant_name(db: AsyncSession, tenant_id: UUID) -> str: + """Fetch the tenant name by ID.""" + result = await db.execute( + text("SELECT name FROM tenants WHERE id = CAST(:tid AS uuid)"), + {"tid": str(tenant_id)}, + ) + row = result.fetchone() + return row[0] if row else "Unknown Tenant" + + +# --------------------------------------------------------------------------- +# Report type handlers +# --------------------------------------------------------------------------- + + +async def _device_inventory( + db: AsyncSession, + tenant_id: UUID, + date_from: Optional[datetime], + date_to: Optional[datetime], +) -> dict[str, Any]: + """Gather device inventory data.""" + result = await db.execute( + text(""" + SELECT d.hostname, d.ip_address, d.model, d.routeros_version, + d.status, d.last_seen, d.uptime_seconds, + COALESCE( + (SELECT string_agg(dg.name, ', ') + FROM device_group_memberships dgm + JOIN device_groups dg ON dg.id = dgm.group_id + WHERE dgm.device_id = d.id), + '' + ) AS groups + FROM devices d + ORDER BY d.hostname ASC + """) + ) + rows = result.fetchall() + + devices = [] + online_count = 0 + offline_count = 0 + unknown_count = 0 + + for row in rows: + status = row[4] + if status == "online": + online_count += 1 + elif status == "offline": + offline_count += 1 + else: + unknown_count += 1 + + uptime_str = _format_uptime(row[6]) if row[6] else None + last_seen_str = row[5].strftime("%Y-%m-%d %H:%M") if row[5] else None + + devices.append({ + "hostname": row[0], + "ip_address": row[1], + "model": row[2], + "routeros_version": row[3], + "status": status, + "last_seen": last_seen_str, + "uptime": uptime_str, + "groups": row[7] if row[7] else None, + }) + + return { + "report_title": "Device Inventory", + "devices": devices, + "total_devices": len(devices), + "online_count": online_count, + "offline_count": offline_count, + "unknown_count": unknown_count, + } + + +async def _metrics_summary( + db: AsyncSession, + tenant_id: UUID, + date_from: Optional[datetime], + date_to: Optional[datetime], +) -> dict[str, Any]: + """Gather metrics summary data grouped by device.""" + result = await db.execute( + text(""" + SELECT d.hostname, + AVG(hm.cpu_load) AS avg_cpu, + MAX(hm.cpu_load) AS peak_cpu, + AVG(CASE WHEN hm.total_memory > 0 + THEN 100.0 * (hm.total_memory - hm.free_memory) / hm.total_memory + END) AS avg_mem, + MAX(CASE WHEN hm.total_memory > 0 + THEN 100.0 * (hm.total_memory - hm.free_memory) / hm.total_memory + END) AS peak_mem, + AVG(CASE WHEN hm.total_disk > 0 + THEN 100.0 * (hm.total_disk - hm.free_disk) / hm.total_disk + END) AS avg_disk, + AVG(hm.temperature) AS avg_temp, + COUNT(*) AS data_points + FROM health_metrics hm + JOIN devices d ON d.id = hm.device_id + WHERE hm.time >= :date_from + AND hm.time <= :date_to + GROUP BY d.id, d.hostname + ORDER BY avg_cpu DESC NULLS LAST + """), + { + "date_from": date_from, + "date_to": date_to, + }, + ) + rows = result.fetchall() + + devices = [] + for row in rows: + devices.append({ + "hostname": row[0], + "avg_cpu": float(row[1]) if row[1] is not None else None, + "peak_cpu": float(row[2]) if row[2] is not None else None, + "avg_mem": float(row[3]) if row[3] is not None else None, + "peak_mem": float(row[4]) if row[4] is not None else None, + "avg_disk": float(row[5]) if row[5] is not None else None, + "avg_temp": float(row[6]) if row[6] is not None else None, + "data_points": row[7], + }) + + return { + "report_title": "Metrics Summary", + "devices": devices, + "date_from": date_from.strftime("%Y-%m-%d") if date_from else "", + "date_to": date_to.strftime("%Y-%m-%d") if date_to else "", + } + + +async def _alert_history( + db: AsyncSession, + tenant_id: UUID, + date_from: Optional[datetime], + date_to: Optional[datetime], +) -> dict[str, Any]: + """Gather alert history data.""" + result = await db.execute( + text(""" + SELECT ae.fired_at, ae.resolved_at, ae.severity, ae.status, + ae.message, d.hostname, + EXTRACT(EPOCH FROM (ae.resolved_at - ae.fired_at)) AS duration_secs + FROM alert_events ae + LEFT JOIN devices d ON d.id = ae.device_id + WHERE ae.fired_at >= :date_from + AND ae.fired_at <= :date_to + ORDER BY ae.fired_at DESC + """), + { + "date_from": date_from, + "date_to": date_to, + }, + ) + rows = result.fetchall() + + alerts = [] + critical_count = 0 + warning_count = 0 + info_count = 0 + resolved_durations: list[float] = [] + + for row in rows: + severity = row[2] + if severity == "critical": + critical_count += 1 + elif severity == "warning": + warning_count += 1 + else: + info_count += 1 + + duration_secs = float(row[6]) if row[6] is not None else None + if duration_secs is not None: + resolved_durations.append(duration_secs) + + alerts.append({ + "fired_at": row[0].strftime("%Y-%m-%d %H:%M") if row[0] else "-", + "hostname": row[5], + "severity": severity, + "status": row[3], + "message": row[4], + "duration": _format_duration(duration_secs) if duration_secs is not None else None, + }) + + mttr_minutes = None + mttr_display = None + if resolved_durations: + avg_secs = sum(resolved_durations) / len(resolved_durations) + mttr_minutes = round(avg_secs / 60, 1) + mttr_display = _format_duration(avg_secs) + + return { + "report_title": "Alert History", + "alerts": alerts, + "total_alerts": len(alerts), + "critical_count": critical_count, + "warning_count": warning_count, + "info_count": info_count, + "mttr_minutes": mttr_minutes, + "mttr_display": mttr_display, + "date_from": date_from.strftime("%Y-%m-%d") if date_from else "", + "date_to": date_to.strftime("%Y-%m-%d") if date_to else "", + } + + +async def _change_log( + db: AsyncSession, + tenant_id: UUID, + date_from: Optional[datetime], + date_to: Optional[datetime], +) -> dict[str, Any]: + """Gather change log data -- try audit_logs table first, fall back to config_backups.""" + # Check if audit_logs table exists (17-01 may not have run yet) + has_audit_logs = await _table_exists(db, "audit_logs") + + if has_audit_logs: + return await _change_log_from_audit(db, date_from, date_to) + else: + return await _change_log_from_backups(db, date_from, date_to) + + +async def _table_exists(db: AsyncSession, table_name: str) -> bool: + """Check if a table exists in the database.""" + result = await db.execute( + text(""" + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = :table_name + ) + """), + {"table_name": table_name}, + ) + return bool(result.scalar()) + + +async def _change_log_from_audit( + db: AsyncSession, + date_from: Optional[datetime], + date_to: Optional[datetime], +) -> dict[str, Any]: + """Build change log from audit_logs table.""" + result = await db.execute( + text(""" + SELECT al.created_at, u.name AS user_name, al.action, + d.hostname, al.resource_type, + al.details + FROM audit_logs al + LEFT JOIN users u ON u.id = al.user_id + LEFT JOIN devices d ON d.id = al.device_id + WHERE al.created_at >= :date_from + AND al.created_at <= :date_to + ORDER BY al.created_at DESC + """), + { + "date_from": date_from, + "date_to": date_to, + }, + ) + rows = result.fetchall() + + entries = [] + for row in rows: + entries.append({ + "timestamp": row[0].strftime("%Y-%m-%d %H:%M") if row[0] else "-", + "user": row[1], + "action": row[2], + "device": row[3], + "details": row[4] or row[5] or "", + }) + + return { + "report_title": "Change Log", + "entries": entries, + "total_entries": len(entries), + "data_source": "Audit Logs", + "date_from": date_from.strftime("%Y-%m-%d") if date_from else "", + "date_to": date_to.strftime("%Y-%m-%d") if date_to else "", + } + + +async def _change_log_from_backups( + db: AsyncSession, + date_from: Optional[datetime], + date_to: Optional[datetime], +) -> dict[str, Any]: + """Build change log from config_backups + alert_events as fallback.""" + # Config backups as change events + backup_result = await db.execute( + text(""" + SELECT cb.created_at, 'system' AS user_name, 'config_backup' AS action, + d.hostname, cb.trigger_type AS details + FROM config_backups cb + JOIN devices d ON d.id = cb.device_id + WHERE cb.created_at >= :date_from + AND cb.created_at <= :date_to + """), + { + "date_from": date_from, + "date_to": date_to, + }, + ) + backup_rows = backup_result.fetchall() + + # Alert events as change events + alert_result = await db.execute( + text(""" + SELECT ae.fired_at, 'system' AS user_name, + ae.severity || '_alert' AS action, + d.hostname, ae.message AS details + FROM alert_events ae + LEFT JOIN devices d ON d.id = ae.device_id + WHERE ae.fired_at >= :date_from + AND ae.fired_at <= :date_to + """), + { + "date_from": date_from, + "date_to": date_to, + }, + ) + alert_rows = alert_result.fetchall() + + # Merge and sort by timestamp descending + entries = [] + for row in backup_rows: + entries.append({ + "timestamp": row[0].strftime("%Y-%m-%d %H:%M") if row[0] else "-", + "user": row[1], + "action": row[2], + "device": row[3], + "details": row[4] or "", + }) + for row in alert_rows: + entries.append({ + "timestamp": row[0].strftime("%Y-%m-%d %H:%M") if row[0] else "-", + "user": row[1], + "action": row[2], + "device": row[3], + "details": row[4] or "", + }) + + # Sort by timestamp string descending + entries.sort(key=lambda e: e["timestamp"], reverse=True) + + return { + "report_title": "Change Log", + "entries": entries, + "total_entries": len(entries), + "data_source": "Backups + Alerts", + "date_from": date_from.strftime("%Y-%m-%d") if date_from else "", + "date_to": date_to.strftime("%Y-%m-%d") if date_to else "", + } + + +# --------------------------------------------------------------------------- +# Rendering helpers +# --------------------------------------------------------------------------- + + +def _render_pdf(report_type: str, context: dict[str, Any]) -> bytes: + """Render HTML template and convert to PDF via weasyprint.""" + import weasyprint + + template = _jinja_env.get_template(f"reports/{report_type}.html") + html_str = template.render(**context) + pdf_bytes = weasyprint.HTML(string=html_str).write_pdf() + return pdf_bytes + + +def _render_csv(report_type: str, data: dict[str, Any]) -> bytes: + """Render report data as CSV bytes.""" + output = io.StringIO() + writer = csv.writer(output) + + if report_type == "device_inventory": + writer.writerow([ + "Hostname", "IP Address", "Model", "RouterOS Version", + "Status", "Last Seen", "Uptime", "Groups", + ]) + for d in data.get("devices", []): + writer.writerow([ + d["hostname"], d["ip_address"], d["model"] or "", + d["routeros_version"] or "", d["status"], + d["last_seen"] or "", d["uptime"] or "", + d["groups"] or "", + ]) + + elif report_type == "metrics_summary": + writer.writerow([ + "Hostname", "Avg CPU %", "Peak CPU %", "Avg Memory %", + "Peak Memory %", "Avg Disk %", "Avg Temp", "Data Points", + ]) + for d in data.get("devices", []): + writer.writerow([ + d["hostname"], + f"{d['avg_cpu']:.1f}" if d["avg_cpu"] is not None else "", + f"{d['peak_cpu']:.1f}" if d["peak_cpu"] is not None else "", + f"{d['avg_mem']:.1f}" if d["avg_mem"] is not None else "", + f"{d['peak_mem']:.1f}" if d["peak_mem"] is not None else "", + f"{d['avg_disk']:.1f}" if d["avg_disk"] is not None else "", + f"{d['avg_temp']:.1f}" if d["avg_temp"] is not None else "", + d["data_points"], + ]) + + elif report_type == "alert_history": + writer.writerow([ + "Timestamp", "Device", "Severity", "Message", "Status", "Duration", + ]) + for a in data.get("alerts", []): + writer.writerow([ + a["fired_at"], a["hostname"] or "", a["severity"], + a["message"] or "", a["status"], a["duration"] or "", + ]) + + elif report_type == "change_log": + writer.writerow([ + "Timestamp", "User", "Action", "Device", "Details", + ]) + for e in data.get("entries", []): + writer.writerow([ + e["timestamp"], e["user"] or "", e["action"], + e["device"] or "", e["details"] or "", + ]) + + return output.getvalue().encode("utf-8") + + +# --------------------------------------------------------------------------- +# Formatting utilities +# --------------------------------------------------------------------------- + + +def _format_uptime(seconds: int) -> str: + """Format uptime seconds as human-readable string.""" + days = seconds // 86400 + hours = (seconds % 86400) // 3600 + minutes = (seconds % 3600) // 60 + if days > 0: + return f"{days}d {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + + +def _format_duration(seconds: float) -> str: + """Format a duration in seconds as a human-readable string.""" + if seconds < 60: + return f"{int(seconds)}s" + elif seconds < 3600: + return f"{int(seconds // 60)}m {int(seconds % 60)}s" + elif seconds < 86400: + hours = int(seconds // 3600) + mins = int((seconds % 3600) // 60) + return f"{hours}h {mins}m" + else: + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + return f"{days}d {hours}h" diff --git a/backend/app/services/restore_service.py b/backend/app/services/restore_service.py new file mode 100644 index 0000000..f21b934 --- /dev/null +++ b/backend/app/services/restore_service.py @@ -0,0 +1,599 @@ +"""Two-phase config push with panic-revert safety for RouterOS devices. + +This module implements the critical safety mechanism for config restoration: + +Phase 1 — Push: + 1. Pre-backup (mandatory) — snapshot current config before any changes + 2. Install panic-revert RouterOS scheduler — auto-reverts if device becomes + unreachable (the scheduler fires after 90s and loads the pre-push backup) + 3. Push the target config via SSH /import + +Phase 2 — Verification (60s settle window): + 4. Wait 60s for config to settle (scheduled processes restart, etc.) + 5. Reachability check via asyncssh + 6a. Reachable — remove panic-revert scheduler; mark operation committed + 6b. Unreachable — RouterOS is auto-reverting; mark operation reverted + +Pitfall 6 handling: + If the API pod restarts during the 60s window, the config_push_operations + row with status='pending_verification' serves as the recovery signal. + On startup, recover_stale_push_operations() resolves any stale rows. + +Security policy: + known_hosts=None — RouterOS self-signed host keys; mirrors InsecureSkipVerify + used in the poller's TLS connection. See Pitfall 2 in 04-RESEARCH.md. +""" + +import asyncio +import json +import logging +from datetime import datetime, timedelta, timezone + +import asyncssh +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import set_tenant_context, AdminAsyncSessionLocal +from app.models.config_backup import ConfigPushOperation +from app.models.device import Device +from app.services import backup_service, git_store +from app.services.event_publisher import publish_event +from app.services.push_tracker import record_push, clear_push + +logger = logging.getLogger(__name__) + +# Name of the panic-revert scheduler installed on the RouterOS device +_PANIC_REVERT_SCHEDULER = "mikrotik-portal-panic-revert" +# Name of the pre-push binary backup saved on device flash +_PRE_PUSH_BACKUP = "portal-pre-push" +# Name of the RSC file used for /import on device +_RESTORE_RSC = "portal-restore.rsc" + + +async def _publish_push_progress( + tenant_id: str, + device_id: str, + stage: str, + message: str, + push_op_id: str | None = None, + error: str | None = None, +) -> None: + """Publish config push progress event to NATS (fire-and-forget).""" + payload = { + "event_type": "config_push", + "tenant_id": tenant_id, + "device_id": device_id, + "stage": stage, + "message": message, + "timestamp": datetime.now(timezone.utc).isoformat(), + "push_operation_id": push_op_id, + } + if error: + payload["error"] = error + await publish_event(f"config.push.{tenant_id}.{device_id}", payload) + + +async def restore_config( + device_id: str, + tenant_id: str, + commit_sha: str, + db_session: AsyncSession, +) -> dict: + """Restore a device config to a specific backup version via two-phase push. + + Args: + device_id: Device UUID as string. + tenant_id: Tenant UUID as string. + commit_sha: Git commit SHA of the backup version to restore. + db_session: AsyncSession with RLS context already set (from API endpoint). + + Returns: + { + "status": "committed" | "reverted" | "failed", + "message": str, + "pre_backup_sha": str, + } + + Raises: + ValueError: If device not found or missing credentials. + Exception: On SSH failure during push phase (reverted status logged). + """ + loop = asyncio.get_event_loop() + + # ------------------------------------------------------------------ + # Step 1: Load device from DB and decrypt credentials + # ------------------------------------------------------------------ + from sqlalchemy import select + + result = await db_session.execute( + select(Device).where(Device.id == device_id) # type: ignore[arg-type] + ) + device = result.scalar_one_or_none() + if device is None: + raise ValueError(f"Device {device_id!r} not found") + + if not device.encrypted_credentials_transit and not device.encrypted_credentials: + raise ValueError( + f"Device {device_id!r} has no stored credentials — cannot perform restore" + ) + + key = settings.get_encryption_key_bytes() + from app.services.crypto import decrypt_credentials_hybrid + creds_json = await decrypt_credentials_hybrid( + device.encrypted_credentials_transit, + device.encrypted_credentials, + str(device.tenant_id), + key, + ) + creds = json.loads(creds_json) + ssh_username = creds.get("username", "") + ssh_password = creds.get("password", "") + ip = device.ip_address + + hostname = device.hostname or ip + + # Publish "started" progress event + await _publish_push_progress(tenant_id, device_id, "started", f"Config restore started for {hostname}") + + # ------------------------------------------------------------------ + # Step 2: Read the target export.rsc from the backup commit + # ------------------------------------------------------------------ + try: + export_bytes = await loop.run_in_executor( + None, + git_store.read_file, + tenant_id, + commit_sha, + device_id, + "export.rsc", + ) + except (KeyError, Exception) as exc: + raise ValueError( + f"Backup version {commit_sha!r} not found for device {device_id!r}: {exc}" + ) from exc + + export_text = export_bytes.decode("utf-8", errors="replace") + + # ------------------------------------------------------------------ + # Step 3: Mandatory pre-backup before push + # ------------------------------------------------------------------ + await _publish_push_progress(tenant_id, device_id, "backing_up", f"Creating pre-restore backup for {hostname}") + + logger.info( + "Starting pre-restore backup for device %s (%s) before pushing commit %s", + hostname, + ip, + commit_sha[:8], + ) + pre_backup_result = await backup_service.run_backup( + device_id=device_id, + tenant_id=tenant_id, + trigger_type="pre-restore", + db_session=db_session, + ) + pre_backup_sha = pre_backup_result["commit_sha"] + logger.info("Pre-restore backup complete: %s", pre_backup_sha[:8]) + + # ------------------------------------------------------------------ + # Step 4: Record push operation (pending_verification for recovery) + # ------------------------------------------------------------------ + push_op = ConfigPushOperation( + device_id=device.id, + tenant_id=device.tenant_id, + pre_push_commit_sha=pre_backup_sha, + scheduler_name=_PANIC_REVERT_SCHEDULER, + status="pending_verification", + ) + db_session.add(push_op) + await db_session.flush() + push_op_id = push_op.id + + logger.info( + "Push op %s in pending_verification — if API restarts, " + "recover_stale_push_operations() will resolve on next startup", + push_op.id, + ) + + # ------------------------------------------------------------------ + # Step 5: SSH to device — install panic-revert, push config + # ------------------------------------------------------------------ + push_op_id_str = str(push_op_id) + await _publish_push_progress(tenant_id, device_id, "pushing", f"Pushing config to {hostname}", push_op_id=push_op_id_str) + + logger.info( + "Pushing config to device %s (%s): installing panic-revert scheduler and uploading config", + hostname, + ip, + ) + + try: + async with asyncssh.connect( + ip, + port=22, + username=ssh_username, + password=ssh_password, + known_hosts=None, # RouterOS self-signed host keys — see module docstring + connect_timeout=30, + ) as conn: + # 5a: Create binary backup on device as revert point + await conn.run( + f"/system backup save name={_PRE_PUSH_BACKUP} dont-encrypt=yes", + check=True, + ) + logger.debug("Pre-push binary backup saved on device as %s.backup", _PRE_PUSH_BACKUP) + + # 5b: Install panic-revert RouterOS scheduler + # The scheduler fires after 90s on startup and loads the pre-push backup. + # This is the safety net: if the device becomes unreachable after push, + # RouterOS will auto-revert to the known-good config on the next reboot + # or after 90s of uptime. + await conn.run( + f"/system scheduler add " + f'name="{_PANIC_REVERT_SCHEDULER}" ' + f"interval=90s " + f'on-event=":delay 0; /system backup load name={_PRE_PUSH_BACKUP}" ' + f"start-time=startup", + check=True, + ) + logger.debug("Panic-revert scheduler installed on device") + + # 5c: Upload export.rsc and /import it + # Write the RSC content to the device filesystem via SSH exec, + # then use /import to apply it. The file is cleaned up after import. + # We use a here-doc approach: write content line-by-line via /file set. + # RouterOS supports writing files via /tool fetch or direct file commands. + # Simplest approach for large configs: use asyncssh's write_into to + # write file content, then /import. + # + # RouterOS doesn't support direct SFTP uploads via SSH open_sftp() easily + # for config files. Use the script approach instead: + # /system script add + run + remove (avoids flash write concerns). + # + # Actually the simplest method: write the export.rsc line by line via + # /file print / set commands is RouterOS 6 only and unreliable. + # Best approach for RouterOS 7: use SFTP to upload the file. + async with conn.start_sftp_client() as sftp: + async with sftp.open(_RESTORE_RSC, "wb") as f: + await f.write(export_text.encode("utf-8")) + logger.debug("Uploaded %s to device flash", _RESTORE_RSC) + + # /import the config file + import_result = await conn.run( + f"/import file={_RESTORE_RSC}", + check=False, # Don't raise on non-zero exit — import may succeed with warnings + ) + logger.info( + "Config import result for device %s: exit_status=%s stdout=%r", + hostname, + import_result.exit_status, + (import_result.stdout or "")[:200], + ) + + # Clean up the uploaded RSC file (best-effort) + try: + await conn.run(f"/file remove {_RESTORE_RSC}", check=True) + except Exception as cleanup_err: + logger.warning( + "Failed to clean up %s from device %s: %s", + _RESTORE_RSC, + ip, + cleanup_err, + ) + + except Exception as push_err: + logger.error( + "SSH push phase failed for device %s (%s): %s", + hostname, + ip, + push_err, + ) + # Update push operation to failed + await _update_push_op_status(push_op_id, "failed", db_session) + await _publish_push_progress( + tenant_id, device_id, "failed", + f"Config push failed for {hostname}: {push_err}", + push_op_id=push_op_id_str, error=str(push_err), + ) + return { + "status": "failed", + "message": f"Config push failed during SSH phase: {push_err}", + "pre_backup_sha": pre_backup_sha, + } + + # Record push in Redis so the poller can detect post-push offline events + await record_push( + device_id=device_id, + tenant_id=tenant_id, + push_type="restore", + push_operation_id=push_op_id_str, + pre_push_commit_sha=pre_backup_sha, + ) + + # ------------------------------------------------------------------ + # Step 6: Wait 60s for config to settle + # ------------------------------------------------------------------ + await _publish_push_progress(tenant_id, device_id, "settling", f"Config pushed to {hostname} — waiting 60s for settle", push_op_id=push_op_id_str) + + logger.info( + "Config pushed to device %s — waiting 60s for config to settle", + hostname, + ) + await asyncio.sleep(60) + + # ------------------------------------------------------------------ + # Step 7: Reachability check + # ------------------------------------------------------------------ + await _publish_push_progress(tenant_id, device_id, "verifying", f"Verifying device {hostname} reachability", push_op_id=push_op_id_str) + + reachable = await _check_reachability(ip, ssh_username, ssh_password) + + if reachable: + # ------------------------------------------------------------------ + # Step 8a: Device is reachable — remove panic-revert scheduler + cleanup + # ------------------------------------------------------------------ + logger.info("Device %s (%s) is reachable after push — committing", hostname, ip) + try: + async with asyncssh.connect( + ip, + port=22, + username=ssh_username, + password=ssh_password, + known_hosts=None, + connect_timeout=30, + ) as conn: + # Remove the panic-revert scheduler + await conn.run( + f'/system scheduler remove "{_PANIC_REVERT_SCHEDULER}"', + check=False, # Non-fatal if already removed + ) + # Clean up the pre-push binary backup from device flash + await conn.run( + f"/file remove {_PRE_PUSH_BACKUP}.backup", + check=False, # Non-fatal if already removed + ) + except Exception as cleanup_err: + # Cleanup failure is non-fatal — scheduler will eventually fire but + # the backup is now the correct config, so it's acceptable. + logger.warning( + "Failed to clean up panic-revert scheduler/backup on device %s: %s", + hostname, + cleanup_err, + ) + + await _update_push_op_status(push_op_id, "committed", db_session) + await clear_push(device_id) + await _publish_push_progress(tenant_id, device_id, "committed", f"Config restored successfully on {hostname}", push_op_id=push_op_id_str) + + return { + "status": "committed", + "message": "Config restored successfully", + "pre_backup_sha": pre_backup_sha, + } + + else: + # ------------------------------------------------------------------ + # Step 8b: Device unreachable — RouterOS is auto-reverting via scheduler + # ------------------------------------------------------------------ + logger.warning( + "Device %s (%s) is unreachable after push — RouterOS panic-revert scheduler " + "will auto-revert to %s.backup", + hostname, + ip, + _PRE_PUSH_BACKUP, + ) + + await _update_push_op_status(push_op_id, "reverted", db_session) + await _publish_push_progress( + tenant_id, device_id, "reverted", + f"Device {hostname} unreachable — auto-reverting via panic-revert scheduler", + push_op_id=push_op_id_str, + ) + + return { + "status": "reverted", + "message": ( + "Device unreachable after push; RouterOS is auto-reverting " + "via panic-revert scheduler" + ), + "pre_backup_sha": pre_backup_sha, + } + + +async def _check_reachability(ip: str, username: str, password: str) -> bool: + """Check if a RouterOS device is reachable via SSH. + + Attempts to connect and run a simple command (/system identity print). + Returns True if successful, False if the connection fails or times out. + + Uses asyncssh (not the poller's binary API) to avoid a circular import. + A 30-second timeout is used — if the device doesn't respond within that + window, it's considered unreachable (panic-revert will handle it). + + Args: + ip: Device IP address. + username: SSH username. + password: SSH password. + + Returns: + True if reachable, False if unreachable. + """ + try: + async with asyncssh.connect( + ip, + port=22, + username=username, + password=password, + known_hosts=None, + connect_timeout=30, + ) as conn: + result = await conn.run("/system identity print", check=True) + logger.debug("Reachability check OK for %s: %r", ip, result.stdout[:50]) + return True + except Exception as exc: + logger.info("Device %s unreachable after push: %s", ip, exc) + return False + + +async def _update_push_op_status( + push_op_id, + new_status: str, + db_session: AsyncSession, +) -> None: + """Update the status and completed_at of a ConfigPushOperation row. + + Args: + push_op_id: UUID of the ConfigPushOperation row. + new_status: New status value ('committed' | 'reverted' | 'failed'). + db_session: Database session (must already have tenant context set). + """ + from sqlalchemy import select, update + + await db_session.execute( + update(ConfigPushOperation) + .where(ConfigPushOperation.id == push_op_id) # type: ignore[arg-type] + .values( + status=new_status, + completed_at=datetime.now(timezone.utc), + ) + ) + # Don't commit here — the caller (endpoint) owns the transaction + + +async def _remove_panic_scheduler( + ip: str, username: str, password: str, scheduler_name: str +) -> bool: + """SSH to device and remove the panic-revert scheduler. Returns True if removed.""" + try: + async with asyncssh.connect( + ip, + username=username, + password=password, + known_hosts=None, + connect_timeout=30, + ) as conn: + # Check if scheduler exists + result = await conn.run( + f'/system scheduler print where name="{scheduler_name}"', + check=False, + ) + if scheduler_name in result.stdout: + await conn.run( + f'/system scheduler remove [find name="{scheduler_name}"]', + check=False, + ) + # Also clean up pre-push backup file + await conn.run( + f'/file remove [find name="{_PRE_PUSH_BACKUP}.backup"]', + check=False, + ) + return True + return False # Scheduler already gone (device reverted itself) + except Exception as e: + logger.error("Failed to remove panic scheduler from %s: %s", ip, e) + return False + + +async def recover_stale_push_operations(db_session: AsyncSession) -> None: + """Recover stale pending_verification push operations on API startup. + + Scans for operations older than 5 minutes that are still pending. + For each, checks device reachability and resolves the operation. + """ + from sqlalchemy import select + + from app.models.config_backup import ConfigPushOperation + from app.models.device import Device + from app.services.crypto import decrypt_credentials_hybrid + + cutoff = datetime.now(timezone.utc) - timedelta(minutes=5) + + result = await db_session.execute( + select(ConfigPushOperation).where( + ConfigPushOperation.status == "pending_verification", + ConfigPushOperation.started_at < cutoff, + ) + ) + stale_ops = result.scalars().all() + + if not stale_ops: + logger.info("No stale push operations to recover") + return + + logger.warning("Found %d stale push operations to recover", len(stale_ops)) + + key = settings.get_encryption_key_bytes() + + for op in stale_ops: + try: + # Load device + dev_result = await db_session.execute( + select(Device).where(Device.id == op.device_id) + ) + device = dev_result.scalar_one_or_none() + if not device: + logger.error("Device %s not found for stale op %s", op.device_id, op.id) + await _update_push_op_status(op.id, "failed", db_session) + continue + + # Decrypt credentials + creds_json = await decrypt_credentials_hybrid( + device.encrypted_credentials_transit, + device.encrypted_credentials, + str(op.tenant_id), + key, + ) + creds = json.loads(creds_json) + ssh_username = creds.get("username", "admin") + ssh_password = creds.get("password", "") + + # Check reachability + reachable = await _check_reachability( + device.ip_address, ssh_username, ssh_password + ) + + if reachable: + # Try to remove scheduler (if still there, push was good) + removed = await _remove_panic_scheduler( + device.ip_address, + ssh_username, + ssh_password, + op.scheduler_name, + ) + if removed: + logger.info("Recovery: committed op %s (scheduler removed)", op.id) + else: + # Scheduler already gone — device may have reverted + logger.warning( + "Recovery: op %s — scheduler gone, device may have reverted. " + "Marking committed (device is reachable).", + op.id, + ) + await _update_push_op_status(op.id, "committed", db_session) + + await _publish_push_progress( + str(op.tenant_id), + str(op.device_id), + "committed", + "Recovered after API restart", + push_op_id=str(op.id), + ) + else: + logger.warning( + "Recovery: device %s unreachable, marking op %s failed", + op.device_id, + op.id, + ) + await _update_push_op_status(op.id, "failed", db_session) + await _publish_push_progress( + str(op.tenant_id), + str(op.device_id), + "failed", + "Device unreachable during recovery after API restart", + push_op_id=str(op.id), + ) + + except Exception as e: + logger.error("Recovery failed for op %s: %s", op.id, e) + await _update_push_op_status(op.id, "failed", db_session) + + await db_session.commit() diff --git a/backend/app/services/routeros_proxy.py b/backend/app/services/routeros_proxy.py new file mode 100644 index 0000000..5b92066 --- /dev/null +++ b/backend/app/services/routeros_proxy.py @@ -0,0 +1,165 @@ +"""RouterOS command proxy via NATS request-reply. + +Sends command requests to the Go poller's CmdResponder subscription +(device.cmd.{device_id}) and returns structured RouterOS API response data. + +Used by: +- Config editor API (browse menu paths, add/edit/delete entries) +- Template push service (execute rendered template commands) +""" + +import json +import logging +from typing import Any + +import nats +import nats.aio.client + +from app.config import settings + +logger = logging.getLogger(__name__) + +# Module-level NATS connection (lazy initialized) +_nc: nats.aio.client.Client | None = None + + +async def _get_nats() -> nats.aio.client.Client: + """Get or create a NATS connection for command proxy requests.""" + global _nc + if _nc is None or _nc.is_closed: + _nc = await nats.connect(settings.NATS_URL) + logger.info("RouterOS proxy NATS connection established") + return _nc + + +async def execute_command( + device_id: str, + command: str, + args: list[str] | None = None, + timeout: float = 15.0, +) -> dict[str, Any]: + """Execute a RouterOS API command on a device via the Go poller. + + Args: + device_id: UUID string of the target device. + command: Full RouterOS API path, e.g. "/ip/address/print". + args: Optional list of RouterOS API args, e.g. ["=.proplist=.id,address"]. + timeout: NATS request timeout in seconds (default 15s). + + Returns: + {"success": bool, "data": list[dict], "error": str|None} + """ + nc = await _get_nats() + request = { + "device_id": device_id, + "command": command, + "args": args or [], + } + + try: + reply = await nc.request( + f"device.cmd.{device_id}", + json.dumps(request).encode(), + timeout=timeout, + ) + return json.loads(reply.data) + except nats.errors.TimeoutError: + return { + "success": False, + "data": [], + "error": "Device command timed out — device may be offline or unreachable", + } + except Exception as exc: + logger.error("NATS request failed for device %s: %s", device_id, exc) + return {"success": False, "data": [], "error": str(exc)} + + +async def browse_menu(device_id: str, path: str) -> dict[str, Any]: + """Browse a RouterOS menu path and return all entries. + + Args: + device_id: Device UUID string. + path: RouterOS menu path, e.g. "/ip/address" or "/interface". + + Returns: + {"success": bool, "data": list[dict], "error": str|None} + """ + command = f"{path}/print" + return await execute_command(device_id, command) + + +async def add_entry( + device_id: str, path: str, properties: dict[str, str] +) -> dict[str, Any]: + """Add a new entry to a RouterOS menu path. + + Args: + device_id: Device UUID. + path: Menu path, e.g. "/ip/address". + properties: Key-value pairs for the new entry. + + Returns: + Command response dict. + """ + args = [f"={k}={v}" for k, v in properties.items()] + return await execute_command(device_id, f"{path}/add", args) + + +async def update_entry( + device_id: str, path: str, entry_id: str | None, properties: dict[str, str] +) -> dict[str, Any]: + """Update an existing entry in a RouterOS menu path. + + Args: + device_id: Device UUID. + path: Menu path. + entry_id: RouterOS .id value (e.g. "*1"). None for singleton paths. + properties: Key-value pairs to update. + + Returns: + Command response dict. + """ + id_args = [f"=.id={entry_id}"] if entry_id else [] + args = id_args + [f"={k}={v}" for k, v in properties.items()] + return await execute_command(device_id, f"{path}/set", args) + + +async def remove_entry( + device_id: str, path: str, entry_id: str +) -> dict[str, Any]: + """Remove an entry from a RouterOS menu path. + + Args: + device_id: Device UUID. + path: Menu path. + entry_id: RouterOS .id value. + + Returns: + Command response dict. + """ + return await execute_command(device_id, f"{path}/remove", [f"=.id={entry_id}"]) + + +async def execute_cli(device_id: str, cli_command: str) -> dict[str, Any]: + """Execute an arbitrary RouterOS CLI command. + + For commands that don't follow the standard /path/action pattern. + The command is sent as-is to the RouterOS API. + + Args: + device_id: Device UUID. + cli_command: Full CLI command string. + + Returns: + Command response dict. + """ + return await execute_command(device_id, cli_command) + + +async def close() -> None: + """Close the NATS connection. Called on application shutdown.""" + global _nc + if _nc and not _nc.is_closed: + await _nc.drain() + _nc = None + logger.info("RouterOS proxy NATS connection closed") diff --git a/backend/app/services/rsc_parser.py b/backend/app/services/rsc_parser.py new file mode 100644 index 0000000..1448b65 --- /dev/null +++ b/backend/app/services/rsc_parser.py @@ -0,0 +1,220 @@ +"""RouterOS RSC export parser — extracts categories, validates syntax, computes impact.""" + +import re +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +HIGH_RISK_PATHS = { + "/ip address", "/ip route", "/ip firewall filter", "/ip firewall nat", + "/interface", "/interface bridge", "/interface vlan", + "/system identity", "/ip service", "/ip ssh", "/user", +} + +MANAGEMENT_PATTERNS = [ + (re.compile(r"chain=input.*dst-port=(22|8291|8728|8729|443|80)", re.I), + "Modifies firewall rules for management ports (SSH/WinBox/API/Web)"), + (re.compile(r"chain=input.*action=drop", re.I), + "Adds drop rule on input chain — may block management access"), + (re.compile(r"/ip service", re.I), + "Modifies IP services — may disable API/SSH/WinBox access"), + (re.compile(r"/user.*set.*password", re.I), + "Changes user password — may affect automated access"), +] + + +def _join_continuation_lines(text: str) -> list[str]: + """Join lines ending with \\ into single logical lines.""" + lines = text.split("\n") + joined: list[str] = [] + buf = "" + for line in lines: + stripped = line.rstrip() + if stripped.endswith("\\"): + buf += stripped[:-1].rstrip() + " " + else: + if buf: + buf += stripped + joined.append(buf) + buf = "" + else: + joined.append(stripped) + if buf: + joined.append(buf + " <>") + return joined + + +def parse_rsc(text: str) -> dict[str, Any]: + """Parse a RouterOS /export compact output. + + Returns a dict with a "categories" list, each containing: + - path: the RouterOS command path (e.g. "/ip address") + - adds: count of "add" commands + - sets: count of "set" commands + - removes: count of "remove" commands + - commands: list of command strings under this path + """ + lines = _join_continuation_lines(text) + categories: dict[str, dict] = {} + current_path: str | None = None + + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + + if line.startswith("/"): + # Could be just a path header, or a path followed by a command + parts = line.split(None, 1) + if len(parts) == 1: + # Pure path header like "/interface bridge" + current_path = parts[0] + else: + # Check if second part starts with a known command verb + cmd_check = parts[1].strip().split(None, 1) + if cmd_check and cmd_check[0] in ("add", "set", "remove", "print", "enable", "disable"): + current_path = parts[0] + line = parts[1].strip() + else: + # The whole line is a path (e.g. "/ip firewall filter") + current_path = line + continue + + if current_path and current_path not in categories: + categories[current_path] = { + "path": current_path, + "adds": 0, + "sets": 0, + "removes": 0, + "commands": [], + } + + if len(parts) == 1: + continue + + if current_path is None: + continue + + if current_path not in categories: + categories[current_path] = { + "path": current_path, + "adds": 0, + "sets": 0, + "removes": 0, + "commands": [], + } + + cat = categories[current_path] + cat["commands"].append(line) + + if line.startswith("add ") or line.startswith("add\t"): + cat["adds"] += 1 + elif line.startswith("set "): + cat["sets"] += 1 + elif line.startswith("remove "): + cat["removes"] += 1 + + return {"categories": list(categories.values())} + + +def validate_rsc(text: str) -> dict[str, Any]: + """Validate RSC export syntax. + + Checks for: + - Unbalanced quotes (indicates truncation or corruption) + - Trailing continuation lines (indicates truncated export) + + Returns dict with "valid" (bool) and "errors" (list of strings). + """ + errors: list[str] = [] + + # Check for unbalanced quotes across the entire file + in_quote = False + for line in text.split("\n"): + stripped = line.rstrip() + if stripped.endswith("\\"): + stripped = stripped[:-1] + # Count unescaped quotes + count = stripped.count('"') - stripped.count('\\"') + if count % 2 != 0: + in_quote = not in_quote + + if in_quote: + errors.append("Unbalanced quote detected — file may be truncated") + + # Check if file ends with a continuation backslash + lines = text.rstrip().split("\n") + if lines and lines[-1].rstrip().endswith("\\"): + errors.append("File ends with continuation line (\\) — truncated export") + + return {"valid": len(errors) == 0, "errors": errors} + + +def compute_impact( + current_parsed: dict[str, Any], + target_parsed: dict[str, Any], +) -> dict[str, Any]: + """Compare current vs target parsed RSC and compute impact analysis. + + Returns dict with: + - categories: list of per-path diffs with risk levels + - warnings: list of human-readable warning strings + - diff: summary counts (added, removed, modified) + """ + current_map = {c["path"]: c for c in current_parsed["categories"]} + target_map = {c["path"]: c for c in target_parsed["categories"]} + all_paths = sorted(set(list(current_map.keys()) + list(target_map.keys()))) + + result_categories = [] + warnings: list[str] = [] + total_added = total_removed = total_modified = 0 + + for path in all_paths: + curr = current_map.get(path, {"adds": 0, "sets": 0, "removes": 0, "commands": []}) + tgt = target_map.get(path, {"adds": 0, "sets": 0, "removes": 0, "commands": []}) + curr_cmds = set(curr.get("commands", [])) + tgt_cmds = set(tgt.get("commands", [])) + added = len(tgt_cmds - curr_cmds) + removed = len(curr_cmds - tgt_cmds) + total_added += added + total_removed += removed + + has_changes = added > 0 or removed > 0 + risk = "none" + if has_changes: + risk = "high" if path in HIGH_RISK_PATHS else "low" + result_categories.append({ + "path": path, + "adds": added, + "removes": removed, + "risk": risk, + }) + + # Check target commands against management patterns + target_text = "\n".join( + cmd for cat in target_parsed["categories"] for cmd in cat.get("commands", []) + ) + for pattern, message in MANAGEMENT_PATTERNS: + if pattern.search(target_text): + warnings.append(message) + + # Warn about removed IP addresses + if "/ip address" in current_map and "/ip address" in target_map: + curr_addrs = current_map["/ip address"].get("commands", []) + tgt_addrs = target_map["/ip address"].get("commands", []) + removed_addrs = set(curr_addrs) - set(tgt_addrs) + if removed_addrs: + warnings.append( + f"Removes {len(removed_addrs)} IP address(es) — verify none are management interfaces" + ) + + return { + "categories": result_categories, + "warnings": warnings, + "diff": { + "added": total_added, + "removed": total_removed, + "modified": total_modified, + }, + } diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py new file mode 100644 index 0000000..ad0be3a --- /dev/null +++ b/backend/app/services/scanner.py @@ -0,0 +1,124 @@ +""" +Subnet scanner for MikroTik device discovery. + +Scans a CIDR range by attempting TCP connections to RouterOS API ports +(8728 and 8729) with configurable concurrency limits and timeouts. + +Security constraints: +- CIDR range limited to /20 or smaller (4096 IPs maximum) +- Maximum 50 concurrent connections to prevent network flooding +- 2-second timeout per connection attempt +""" + +import asyncio +import ipaddress +import socket +from typing import Optional + +from app.schemas.device import SubnetScanResult + +# Maximum concurrency for TCP probes +_MAX_CONCURRENT = 50 +# Timeout (seconds) per TCP connection attempt +_TCP_TIMEOUT = 2.0 +# RouterOS API port +_API_PORT = 8728 +# RouterOS SSL API port +_SSL_PORT = 8729 + + +async def _probe_host( + semaphore: asyncio.Semaphore, + ip_str: str, +) -> Optional[SubnetScanResult]: + """ + Probe a single IP for RouterOS API ports. + + Returns a SubnetScanResult if either port is open, None otherwise. + """ + async with semaphore: + api_open, ssl_open = await asyncio.gather( + _tcp_connect(ip_str, _API_PORT), + _tcp_connect(ip_str, _SSL_PORT), + return_exceptions=False, + ) + + if not api_open and not ssl_open: + return None + + # Attempt reverse DNS (best-effort; won't fail the scan) + hostname = await _reverse_dns(ip_str) + + return SubnetScanResult( + ip_address=ip_str, + hostname=hostname, + api_port_open=api_open, + api_ssl_port_open=ssl_open, + ) + + +async def _tcp_connect(ip: str, port: int) -> bool: + """Return True if a TCP connection to ip:port succeeds within _TCP_TIMEOUT.""" + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port), + timeout=_TCP_TIMEOUT, + ) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return True + except Exception: + return False + + +async def _reverse_dns(ip: str) -> Optional[str]: + """Attempt a reverse DNS lookup. Returns None on failure.""" + try: + loop = asyncio.get_running_loop() + hostname, _, _ = await asyncio.wait_for( + loop.run_in_executor(None, socket.gethostbyaddr, ip), + timeout=1.5, + ) + return hostname + except Exception: + return None + + +async def scan_subnet(cidr: str) -> list[SubnetScanResult]: + """ + Scan a CIDR range for hosts with open RouterOS API ports. + + Args: + cidr: CIDR notation string, e.g. "192.168.1.0/24". + Must be /20 or smaller (validated by SubnetScanRequest). + + Returns: + List of SubnetScanResult for each host with at least one open API port. + + Raises: + ValueError: If CIDR is malformed or too large. + """ + try: + network = ipaddress.ip_network(cidr, strict=False) + except ValueError as e: + raise ValueError(f"Invalid CIDR: {e}") from e + + if network.num_addresses > 4096: + raise ValueError( + f"CIDR range too large ({network.num_addresses} addresses). " + "Maximum allowed is /20 (4096 addresses)." + ) + + # Skip network address and broadcast address for IPv4 + hosts = list(network.hosts()) if network.num_addresses > 2 else list(network) + + semaphore = asyncio.Semaphore(_MAX_CONCURRENT) + tasks = [_probe_host(semaphore, str(ip)) for ip in hosts] + + results = await asyncio.gather(*tasks, return_exceptions=False) + + # Filter out None (hosts with no open ports) + return [r for r in results if r is not None] diff --git a/backend/app/services/srp_service.py b/backend/app/services/srp_service.py new file mode 100644 index 0000000..b2efa53 --- /dev/null +++ b/backend/app/services/srp_service.py @@ -0,0 +1,113 @@ +"""SRP-6a server-side authentication service. + +Wraps the srptools library for the two-step SRP handshake. +All functions are async, using asyncio.to_thread() because +srptools operations are CPU-bound and synchronous. +""" + +import asyncio +import hashlib + +from srptools import SRPContext, SRPServerSession +from srptools.constants import PRIME_2048, PRIME_2048_GEN + +# Client uses Web Crypto SHA-256 — server must match. +# srptools defaults to SHA-1 which would cause proof mismatch. +_SRP_HASH = hashlib.sha256 + + +async def create_srp_verifier( + salt_hex: str, verifier_hex: str +) -> tuple[bytes, bytes]: + """Convert client-provided hex salt and verifier to bytes for storage. + + The client computes v = g^x mod N using 2SKD-derived SRP-x. + The server stores the verifier directly and never computes x + from the password. + + Returns: + Tuple of (salt_bytes, verifier_bytes) ready for database storage. + """ + return bytes.fromhex(salt_hex), bytes.fromhex(verifier_hex) + + +async def srp_init( + email: str, srp_verifier_hex: str +) -> tuple[str, str]: + """SRP Step 1: Generate server ephemeral (B) and private key (b). + + Args: + email: User email (SRP identity I). + srp_verifier_hex: Hex-encoded SRP verifier from database. + + Returns: + Tuple of (server_public_hex, server_private_hex). + Caller stores server_private in Redis with 60s TTL. + + Raises: + ValueError: If SRP initialization fails for any reason. + """ + def _init() -> tuple[str, str]: + context = SRPContext( + email, prime=PRIME_2048, generator=PRIME_2048_GEN, + hash_func=_SRP_HASH, + ) + server_session = SRPServerSession( + context, srp_verifier_hex + ) + return server_session.public, server_session.private + + try: + return await asyncio.to_thread(_init) + except Exception as e: + raise ValueError(f"SRP initialization failed: {e}") from e + + +async def srp_verify( + email: str, + srp_verifier_hex: str, + server_private: str, + client_public: str, + client_proof: str, + srp_salt_hex: str, +) -> tuple[bool, str | None]: + """SRP Step 2: Verify client proof M1, return server proof M2. + + Args: + email: User email (SRP identity I). + srp_verifier_hex: Hex-encoded SRP verifier from database. + server_private: Server private ephemeral from Redis session. + client_public: Hex-encoded client public ephemeral A. + client_proof: Hex-encoded client proof M1. + srp_salt_hex: Hex-encoded SRP salt. + + Returns: + Tuple of (is_valid, server_proof_hex_or_none). + If valid, server_proof is M2 for the client to verify. + """ + def _verify() -> tuple[bool, str | None]: + import logging + log = logging.getLogger("srp_debug") + context = SRPContext( + email, prime=PRIME_2048, generator=PRIME_2048_GEN, + hash_func=_SRP_HASH, + ) + server_session = SRPServerSession( + context, srp_verifier_hex, private=server_private + ) + _key, _key_proof, _key_proof_hash = server_session.process(client_public, srp_salt_hex) + # srptools verify_proof has a Python 3 bug: hexlify() returns bytes + # but client_proof is str, so bytes == str is always False. + # Compare manually with consistent types. + server_m1 = _key_proof if isinstance(_key_proof, str) else _key_proof.decode('ascii') + is_valid = client_proof.lower() == server_m1.lower() + if not is_valid: + return False, None + # Return M2 (key_proof_hash), also fixing the bytes/str issue + m2 = _key_proof_hash if isinstance(_key_proof_hash, str) else _key_proof_hash.decode('ascii') + return True, m2 + + try: + return await asyncio.to_thread(_verify) + except Exception as e: + raise ValueError(f"SRP verification failed: {e}") from e diff --git a/backend/app/services/sse_manager.py b/backend/app/services/sse_manager.py new file mode 100644 index 0000000..db241b5 --- /dev/null +++ b/backend/app/services/sse_manager.py @@ -0,0 +1,311 @@ +"""SSE Connection Manager -- bridges NATS JetStream to per-client asyncio queues. + +Each SSE client gets its own NATS connection with ephemeral consumers. +Events are tenant-filtered and placed onto an asyncio.Queue that the +SSE router drains via EventSourceResponse. +""" + +import asyncio +import json +from typing import Optional + +import nats +import structlog +from nats.js.api import ConsumerConfig, DeliverPolicy, StreamConfig + +from app.config import settings + +logger = structlog.get_logger(__name__) + +# Subjects per stream for SSE subscriptions +# Note: config.push.* subjects live in DEVICE_EVENTS (created by Go poller) +_DEVICE_EVENT_SUBJECTS = [ + "device.status.>", + "device.metrics.>", + "config.push.rollback.>", + "config.push.alert.>", +] +_ALERT_EVENT_SUBJECTS = ["alert.fired.>", "alert.resolved.>"] +_OPERATION_EVENT_SUBJECTS = ["firmware.progress.>"] + + +def _map_subject_to_event_type(subject: str) -> str: + """Map a NATS subject prefix to an SSE event type string.""" + if subject.startswith("device.status."): + return "device_status" + if subject.startswith("device.metrics."): + return "metric_update" + if subject.startswith("alert.fired."): + return "alert_fired" + if subject.startswith("alert.resolved."): + return "alert_resolved" + if subject.startswith("config.push."): + return "config_push" + if subject.startswith("firmware.progress."): + return "firmware_progress" + return "unknown" + + +async def ensure_sse_streams() -> None: + """Create ALERT_EVENTS and OPERATION_EVENTS NATS streams if they don't exist. + + Called once during app startup so the streams are ready before any + SSE connection or event publisher needs them. Idempotent -- uses + add_stream which acts as create-or-update. + """ + nc = None + try: + nc = await nats.connect(settings.NATS_URL) + js = nc.jetstream() + + await js.add_stream( + StreamConfig( + name="ALERT_EVENTS", + subjects=["alert.fired.>", "alert.resolved.>"], + max_age=3600, # 1 hour retention + ) + ) + logger.info("nats.stream.ensured", stream="ALERT_EVENTS") + + await js.add_stream( + StreamConfig( + name="OPERATION_EVENTS", + subjects=["firmware.progress.>"], + max_age=3600, # 1 hour retention + ) + ) + logger.info("nats.stream.ensured", stream="OPERATION_EVENTS") + + except Exception as exc: + logger.warning("sse.streams.ensure_failed", error=str(exc)) + raise + finally: + if nc: + try: + await nc.close() + except Exception: + pass + + +class SSEConnectionManager: + """Manages a single SSE client's lifecycle: NATS connection, subscriptions, and event queue.""" + + def __init__(self) -> None: + self._nc: Optional[nats.aio.client.Client] = None + self._subscriptions: list = [] + self._queue: Optional[asyncio.Queue] = None + self._tenant_id: Optional[str] = None + self._connection_id: Optional[str] = None + + async def connect( + self, + connection_id: str, + tenant_id: Optional[str], + last_event_id: Optional[str] = None, + ) -> asyncio.Queue: + """Set up NATS subscriptions and return an asyncio.Queue for SSE events. + + Args: + connection_id: Unique identifier for this SSE connection. + tenant_id: Tenant UUID string to filter events. None for super_admin + (receives events from all tenants). + last_event_id: NATS stream sequence number from the Last-Event-ID header. + If provided, replay starts from sequence + 1. + + Returns: + asyncio.Queue that the SSE generator should drain. + """ + self._connection_id = connection_id + self._tenant_id = tenant_id + self._queue = asyncio.Queue(maxsize=256) + + self._nc = await nats.connect( + settings.NATS_URL, + max_reconnect_attempts=5, + reconnect_time_wait=2, + ) + js = self._nc.jetstream() + + logger.info( + "sse.connecting", + connection_id=connection_id, + tenant_id=tenant_id, + last_event_id=last_event_id, + ) + + # Build consumer config for replay support + if last_event_id is not None: + try: + start_seq = int(last_event_id) + 1 + consumer_cfg = ConsumerConfig(deliver_policy=DeliverPolicy.BY_START_SEQUENCE, opt_start_seq=start_seq) + except (ValueError, TypeError): + consumer_cfg = ConsumerConfig(deliver_policy=DeliverPolicy.NEW) + else: + consumer_cfg = ConsumerConfig(deliver_policy=DeliverPolicy.NEW) + + # Subscribe to device events (DEVICE_EVENTS stream -- created by Go poller) + for subject in _DEVICE_EVENT_SUBJECTS: + try: + sub = await js.subscribe( + subject, + stream="DEVICE_EVENTS", + config=consumer_cfg, + ) + self._subscriptions.append(sub) + except Exception as exc: + logger.warning( + "sse.subscribe_failed", + subject=subject, + stream="DEVICE_EVENTS", + error=str(exc), + ) + + # Subscribe to alert events (ALERT_EVENTS stream) + # Lazily create the stream if it doesn't exist yet (startup race) + for subject in _ALERT_EVENT_SUBJECTS: + try: + sub = await js.subscribe( + subject, + stream="ALERT_EVENTS", + config=consumer_cfg, + ) + self._subscriptions.append(sub) + except Exception as exc: + if "stream not found" in str(exc): + try: + await js.add_stream(StreamConfig( + name="ALERT_EVENTS", + subjects=_ALERT_EVENT_SUBJECTS, + max_age=3600, + )) + sub = await js.subscribe(subject, stream="ALERT_EVENTS", config=consumer_cfg) + self._subscriptions.append(sub) + logger.info("sse.stream_created_lazily", stream="ALERT_EVENTS") + except Exception as retry_exc: + logger.warning("sse.subscribe_failed", subject=subject, stream="ALERT_EVENTS", error=str(retry_exc)) + else: + logger.warning("sse.subscribe_failed", subject=subject, stream="ALERT_EVENTS", error=str(exc)) + + # Subscribe to operation events (OPERATION_EVENTS stream) + for subject in _OPERATION_EVENT_SUBJECTS: + try: + sub = await js.subscribe( + subject, + stream="OPERATION_EVENTS", + config=consumer_cfg, + ) + self._subscriptions.append(sub) + except Exception as exc: + if "stream not found" in str(exc): + try: + await js.add_stream(StreamConfig( + name="OPERATION_EVENTS", + subjects=_OPERATION_EVENT_SUBJECTS, + max_age=3600, + )) + sub = await js.subscribe(subject, stream="OPERATION_EVENTS", config=consumer_cfg) + self._subscriptions.append(sub) + logger.info("sse.stream_created_lazily", stream="OPERATION_EVENTS") + except Exception as retry_exc: + logger.warning("sse.subscribe_failed", subject=subject, stream="OPERATION_EVENTS", error=str(retry_exc)) + else: + logger.warning("sse.subscribe_failed", subject=subject, stream="OPERATION_EVENTS", error=str(exc)) + + # Start background task to pull messages from subscriptions into the queue + asyncio.create_task(self._pump_messages()) + + logger.info( + "sse.connected", + connection_id=connection_id, + subscription_count=len(self._subscriptions), + ) + + return self._queue + + async def _pump_messages(self) -> None: + """Read messages from all NATS push subscriptions and push them onto the asyncio queue. + + Uses next_msg with a short timeout so we can interleave across + subscriptions without blocking. Runs until the NATS connection is closed + or drained. + """ + while self._nc and self._nc.is_connected: + for sub in self._subscriptions: + try: + msg = await sub.next_msg(timeout=0.5) + await self._handle_message(msg) + except nats.errors.TimeoutError: + # No messages available on this subscription -- move on + continue + except Exception as exc: + if self._nc and self._nc.is_connected: + logger.warning( + "sse.pump_error", + connection_id=self._connection_id, + error=str(exc), + ) + break + # Brief yield to avoid tight-looping + await asyncio.sleep(0.1) + + async def _handle_message(self, msg) -> None: + """Parse a NATS message, apply tenant filter, and enqueue as SSE event.""" + try: + data = json.loads(msg.data) + except (json.JSONDecodeError, UnicodeDecodeError): + await msg.ack() + return + + # Tenant filtering: skip messages not matching this connection's tenant + if self._tenant_id is not None: + msg_tenant = data.get("tenant_id", "") + if str(msg_tenant) != self._tenant_id: + await msg.ack() + return + + event_type = _map_subject_to_event_type(msg.subject) + + # Extract NATS stream sequence for Last-Event-ID support + seq_id = "0" + if msg.metadata and msg.metadata.sequence: + seq_id = str(msg.metadata.sequence.stream) + + sse_event = { + "event": event_type, + "data": json.dumps(data), + "id": seq_id, + } + + try: + self._queue.put_nowait(sse_event) + except asyncio.QueueFull: + logger.warning( + "sse.queue_full", + connection_id=self._connection_id, + dropped_event=event_type, + ) + + await msg.ack() + + async def disconnect(self) -> None: + """Unsubscribe from all NATS subscriptions and close the connection.""" + logger.info("sse.disconnecting", connection_id=self._connection_id) + + for sub in self._subscriptions: + try: + await sub.unsubscribe() + except Exception: + pass + self._subscriptions.clear() + + if self._nc: + try: + await self._nc.drain() + except Exception: + try: + await self._nc.close() + except Exception: + pass + self._nc = None + + logger.info("sse.disconnected", connection_id=self._connection_id) diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py new file mode 100644 index 0000000..8032f69 --- /dev/null +++ b/backend/app/services/template_service.py @@ -0,0 +1,480 @@ +"""Config template service: Jinja2 rendering, variable extraction, and multi-device push. + +Provides: +- extract_variables: Parse template content to find all undeclared Jinja2 variables +- render_template: Render a template with device context and custom variables +- validate_variable: Type-check a variable value against its declared type +- push_to_devices: Sequential multi-device push with pause-on-failure +- push_single_device: Two-phase panic-revert push for a single device + +The push logic follows the same two-phase pattern as restore_service but uses +separate scheduler and file names to avoid conflicts with restore operations. +""" + +import asyncio +import io +import ipaddress +import json +import logging +import uuid +from datetime import datetime, timezone + +import asyncssh +from jinja2 import meta +from jinja2.sandbox import SandboxedEnvironment +from sqlalchemy import select, text + +from app.config import settings +from app.database import AdminAsyncSessionLocal +from app.models.config_template import TemplatePushJob +from app.models.device import Device + +logger = logging.getLogger(__name__) + +# Sandboxed Jinja2 environment prevents template injection +_env = SandboxedEnvironment() + +# Names used on the RouterOS device during template push +_PANIC_REVERT_SCHEDULER = "mikrotik-portal-template-revert" +_PRE_PUSH_BACKUP = "portal-template-pre-push" +_TEMPLATE_RSC = "portal-template.rsc" + + +# --------------------------------------------------------------------------- +# Variable extraction & rendering +# --------------------------------------------------------------------------- + + +def extract_variables(template_content: str) -> list[str]: + """Extract all undeclared variables from a Jinja2 template. + + Returns a sorted list of variable names, excluding the built-in 'device' + variable which is auto-populated at render time. + """ + ast = _env.parse(template_content) + all_vars = meta.find_undeclared_variables(ast) + # 'device' is a built-in variable, not user-provided + return sorted(v for v in all_vars if v != "device") + + +def render_template( + template_content: str, + device: dict, + custom_variables: dict[str, str], +) -> str: + """Render a Jinja2 template with device context and custom variables. + + The 'device' variable is auto-populated from the device dict. + Custom variables are user-provided at push time. + + Uses SandboxedEnvironment to prevent template injection. + + Args: + template_content: Jinja2 template string. + device: Device info dict with keys: hostname, ip_address, model. + custom_variables: User-supplied variable values. + + Returns: + Rendered template string. + + Raises: + jinja2.TemplateSyntaxError: If template has syntax errors. + jinja2.UndefinedError: If required variables are missing. + """ + context = { + "device": { + "hostname": device.get("hostname", ""), + "ip": device.get("ip_address", ""), + "model": device.get("model", ""), + }, + **custom_variables, + } + tpl = _env.from_string(template_content) + return tpl.render(context) + + +def validate_variable(name: str, value: str, var_type: str) -> str | None: + """Validate a variable value against its declared type. + + Returns None on success, or an error message string on failure. + """ + if var_type == "string": + return None # any string is valid + elif var_type == "ip": + try: + ipaddress.ip_address(value) + return None + except ValueError: + return f"'{name}' must be a valid IP address" + elif var_type == "subnet": + try: + ipaddress.ip_network(value, strict=False) + return None + except ValueError: + return f"'{name}' must be a valid subnet (e.g., 192.168.1.0/24)" + elif var_type == "integer": + try: + int(value) + return None + except ValueError: + return f"'{name}' must be an integer" + elif var_type == "boolean": + if value.lower() in ("true", "false", "yes", "no", "1", "0"): + return None + return f"'{name}' must be a boolean (true/false)" + return None # unknown type, allow + + +# --------------------------------------------------------------------------- +# Multi-device push orchestration +# --------------------------------------------------------------------------- + + +async def push_to_devices(rollout_id: str) -> dict: + """Execute sequential template push for all jobs in a rollout. + + Processes devices one at a time. If any device fails or reverts, + remaining jobs stay pending (paused). Follows the same pattern as + firmware upgrade_service.start_mass_upgrade. + + This runs as a background task (asyncio.create_task) after the + API creates the push jobs and returns the rollout_id. + """ + try: + return await _run_push_rollout(rollout_id) + except Exception as exc: + logger.error( + "Uncaught exception in template push rollout %s: %s", + rollout_id, exc, exc_info=True, + ) + return {"completed": 0, "failed": 1, "pending": 0} + + +async def _run_push_rollout(rollout_id: str) -> dict: + """Internal rollout implementation.""" + # Load all jobs for this rollout + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT j.id::text, j.status, d.hostname + FROM template_push_jobs j + JOIN devices d ON d.id = j.device_id + WHERE j.rollout_id = CAST(:rollout_id AS uuid) + ORDER BY j.created_at ASC + """), + {"rollout_id": rollout_id}, + ) + jobs = result.fetchall() + + if not jobs: + logger.warning("No jobs found for template push rollout %s", rollout_id) + return {"completed": 0, "failed": 0, "pending": 0} + + completed = 0 + failed = False + + for job_id, current_status, hostname in jobs: + if current_status != "pending": + if current_status == "committed": + completed += 1 + continue + + logger.info( + "Template push rollout %s: pushing to device %s (job %s)", + rollout_id, hostname, job_id, + ) + + await push_single_device(job_id) + + # Check resulting status + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text("SELECT status FROM template_push_jobs WHERE id = CAST(:id AS uuid)"), + {"id": job_id}, + ) + row = result.fetchone() + + if row and row[0] == "committed": + completed += 1 + elif row and row[0] in ("failed", "reverted"): + failed = True + logger.error( + "Template push rollout %s paused: device %s %s", + rollout_id, hostname, row[0], + ) + break + + # Count remaining pending jobs + remaining = sum(1 for _, s, _ in jobs if s == "pending") - completed - (1 if failed else 0) + + return { + "completed": completed, + "failed": 1 if failed else 0, + "pending": max(0, remaining), + } + + +async def push_single_device(job_id: str) -> None: + """Push rendered template content to a single device. + + Implements the two-phase panic-revert pattern: + 1. Pre-backup (mandatory) + 2. Install panic-revert scheduler on device + 3. Write template content as RSC file via SFTP + 4. /import the RSC file + 5. Wait 60s for config to settle + 6. Reachability check -> committed or reverted + + All errors are caught and recorded in the job row. + """ + try: + await _run_single_push(job_id) + except Exception as exc: + logger.error( + "Uncaught exception in template push job %s: %s", + job_id, exc, exc_info=True, + ) + await _update_job(job_id, status="failed", error_message=f"Unexpected error: {exc}") + + +async def _run_single_push(job_id: str) -> None: + """Internal single-device push implementation.""" + + # Step 1: Load job and device info + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT j.id, j.device_id, j.tenant_id, j.rendered_content, + d.ip_address, d.hostname, d.encrypted_credentials, + d.encrypted_credentials_transit + FROM template_push_jobs j + JOIN devices d ON d.id = j.device_id + WHERE j.id = CAST(:job_id AS uuid) + """), + {"job_id": job_id}, + ) + row = result.fetchone() + + if not row: + logger.error("Template push job %s not found", job_id) + return + + ( + _, device_id, tenant_id, rendered_content, + ip_address, hostname, encrypted_credentials, + encrypted_credentials_transit, + ) = row + + device_id = str(device_id) + tenant_id = str(tenant_id) + hostname = hostname or ip_address + + # Step 2: Update status to pushing + await _update_job(job_id, status="pushing", started_at=datetime.now(timezone.utc)) + + # Step 3: Decrypt credentials (dual-read: Transit preferred, legacy fallback) + if not encrypted_credentials_transit and not encrypted_credentials: + await _update_job(job_id, status="failed", error_message="Device has no stored credentials") + return + + try: + from app.services.crypto import decrypt_credentials_hybrid + key = settings.get_encryption_key_bytes() + creds_json = await decrypt_credentials_hybrid( + encrypted_credentials_transit, encrypted_credentials, tenant_id, key, + ) + creds = json.loads(creds_json) + ssh_username = creds.get("username", "") + ssh_password = creds.get("password", "") + except Exception as cred_err: + await _update_job( + job_id, status="failed", + error_message=f"Failed to decrypt credentials: {cred_err}", + ) + return + + # Step 4: Mandatory pre-push backup + logger.info("Running mandatory pre-push backup for device %s (%s)", hostname, ip_address) + try: + from app.services import backup_service + backup_result = await backup_service.run_backup( + device_id=device_id, + tenant_id=tenant_id, + trigger_type="pre-template-push", + ) + backup_sha = backup_result["commit_sha"] + await _update_job(job_id, pre_push_backup_sha=backup_sha) + logger.info("Pre-push backup complete: %s", backup_sha[:8]) + except Exception as backup_err: + logger.error("Pre-push backup failed for %s: %s", hostname, backup_err) + await _update_job( + job_id, status="failed", + error_message=f"Pre-push backup failed: {backup_err}", + ) + return + + # Step 5: SSH to device - install panic-revert, push config + logger.info( + "Pushing template to device %s (%s): installing panic-revert and uploading config", + hostname, ip_address, + ) + + try: + async with asyncssh.connect( + ip_address, + port=22, + username=ssh_username, + password=ssh_password, + known_hosts=None, + connect_timeout=30, + ) as conn: + # 5a: Create binary backup on device as revert point + await conn.run( + f"/system backup save name={_PRE_PUSH_BACKUP} dont-encrypt=yes", + check=True, + ) + logger.debug("Pre-push binary backup saved on device as %s.backup", _PRE_PUSH_BACKUP) + + # 5b: Install panic-revert RouterOS scheduler + await conn.run( + f"/system scheduler add " + f'name="{_PANIC_REVERT_SCHEDULER}" ' + f"interval=90s " + f'on-event=":delay 0; /system backup load name={_PRE_PUSH_BACKUP}" ' + f"start-time=startup", + check=True, + ) + logger.debug("Panic-revert scheduler installed on device") + + # 5c: Upload rendered template as RSC file via SFTP + async with conn.start_sftp_client() as sftp: + async with sftp.open(_TEMPLATE_RSC, "wb") as f: + await f.write(rendered_content.encode("utf-8")) + logger.debug("Uploaded %s to device flash", _TEMPLATE_RSC) + + # 5d: /import the config file + import_result = await conn.run( + f"/import file={_TEMPLATE_RSC}", + check=False, + ) + logger.info( + "Template import result for device %s: exit_status=%s stdout=%r", + hostname, import_result.exit_status, + (import_result.stdout or "")[:200], + ) + + # 5e: Clean up the uploaded RSC file (best-effort) + try: + await conn.run(f"/file remove {_TEMPLATE_RSC}", check=True) + except Exception as cleanup_err: + logger.warning( + "Failed to clean up %s from device %s: %s", + _TEMPLATE_RSC, ip_address, cleanup_err, + ) + + except Exception as push_err: + logger.error( + "SSH push phase failed for device %s (%s): %s", + hostname, ip_address, push_err, + ) + await _update_job( + job_id, status="failed", + error_message=f"Config push failed during SSH phase: {push_err}", + ) + return + + # Step 6: Wait 60s for config to settle + logger.info("Template pushed to device %s - waiting 60s for config to settle", hostname) + await asyncio.sleep(60) + + # Step 7: Reachability check + reachable = await _check_reachability(ip_address, ssh_username, ssh_password) + + if reachable: + # Step 8a: Device is reachable - remove panic-revert scheduler + cleanup + logger.info("Device %s (%s) is reachable after push - committing", hostname, ip_address) + try: + async with asyncssh.connect( + ip_address, port=22, + username=ssh_username, password=ssh_password, + known_hosts=None, connect_timeout=30, + ) as conn: + await conn.run( + f'/system scheduler remove "{_PANIC_REVERT_SCHEDULER}"', + check=False, + ) + await conn.run( + f"/file remove {_PRE_PUSH_BACKUP}.backup", + check=False, + ) + except Exception as cleanup_err: + logger.warning( + "Failed to clean up panic-revert scheduler/backup on device %s: %s", + hostname, cleanup_err, + ) + + await _update_job( + job_id, status="committed", + completed_at=datetime.now(timezone.utc), + ) + else: + # Step 8b: Device unreachable - RouterOS is auto-reverting + logger.warning( + "Device %s (%s) is unreachable after push - panic-revert scheduler " + "will auto-revert to %s.backup", + hostname, ip_address, _PRE_PUSH_BACKUP, + ) + await _update_job( + job_id, status="reverted", + error_message="Device unreachable after push; auto-reverted via panic-revert scheduler", + completed_at=datetime.now(timezone.utc), + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +async def _check_reachability(ip: str, username: str, password: str) -> bool: + """Check if a RouterOS device is reachable via SSH.""" + try: + async with asyncssh.connect( + ip, port=22, + username=username, password=password, + known_hosts=None, connect_timeout=30, + ) as conn: + result = await conn.run("/system identity print", check=True) + logger.debug("Reachability check OK for %s: %r", ip, result.stdout[:50]) + return True + except Exception as exc: + logger.info("Device %s unreachable after push: %s", ip, exc) + return False + + +async def _update_job(job_id: str, **kwargs) -> None: + """Update TemplatePushJob fields via raw SQL (background task, no RLS).""" + sets = [] + params: dict = {"job_id": job_id} + + for key, value in kwargs.items(): + param_name = f"v_{key}" + if value is None and key in ("error_message", "started_at", "completed_at", "pre_push_backup_sha"): + sets.append(f"{key} = NULL") + else: + sets.append(f"{key} = :{param_name}") + params[param_name] = value + + if not sets: + return + + async with AdminAsyncSessionLocal() as session: + await session.execute( + text(f""" + UPDATE template_push_jobs + SET {', '.join(sets)} + WHERE id = CAST(:job_id AS uuid) + """), + params, + ) + await session.commit() diff --git a/backend/app/services/upgrade_service.py b/backend/app/services/upgrade_service.py new file mode 100644 index 0000000..ead083b --- /dev/null +++ b/backend/app/services/upgrade_service.py @@ -0,0 +1,564 @@ +"""Firmware upgrade orchestration service. + +Handles single-device and mass firmware upgrades with: +- Mandatory pre-upgrade config backup +- NPK download and SFTP upload to device +- Reboot trigger and reconnect polling +- Post-upgrade version verification +- Sequential mass rollout with pause-on-failure +- Scheduled upgrades via APScheduler DateTrigger + +All DB operations use AdminAsyncSessionLocal to bypass RLS since upgrade +jobs may span multiple tenants and run in background asyncio tasks. +""" + +import asyncio +import io +import json +import logging +from datetime import datetime, timezone +from pathlib import Path + +import asyncssh +from sqlalchemy import text + +from app.config import settings +from app.database import AdminAsyncSessionLocal +from app.services.event_publisher import publish_event + +logger = logging.getLogger(__name__) + +# Maximum time to wait for a device to reconnect after reboot (seconds) +_RECONNECT_TIMEOUT = 300 # 5 minutes +_RECONNECT_POLL_INTERVAL = 15 # seconds +_INITIAL_WAIT = 60 # Wait before first reconnect attempt (boot cycle) + + +async def start_upgrade(job_id: str) -> None: + """Execute a single device firmware upgrade. + + Lifecycle: pending -> downloading -> uploading -> rebooting -> verifying -> completed/failed + + This function is designed to run as a background asyncio.create_task or + APScheduler job. It never raises — all errors are caught and recorded + in the FirmwareUpgradeJob row. + """ + try: + await _run_upgrade(job_id) + except Exception as exc: + logger.error("Uncaught exception in firmware upgrade %s: %s", job_id, exc, exc_info=True) + await _update_job(job_id, status="failed", error_message=f"Unexpected error: {exc}") + + +async def _publish_upgrade_progress( + tenant_id: str, + device_id: str, + job_id: str, + stage: str, + target_version: str, + message: str, + error: str | None = None, +) -> None: + """Publish firmware upgrade progress event to NATS (fire-and-forget).""" + payload = { + "event_type": "firmware_progress", + "tenant_id": tenant_id, + "device_id": device_id, + "job_id": job_id, + "stage": stage, + "target_version": target_version, + "message": message, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + if error: + payload["error"] = error + await publish_event(f"firmware.progress.{tenant_id}.{device_id}", payload) + + +async def _run_upgrade(job_id: str) -> None: + """Internal upgrade implementation.""" + + # Step 1: Load job + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT j.id, j.device_id, j.tenant_id, j.target_version, + j.architecture, j.channel, j.status, j.confirmed_major_upgrade, + d.ip_address, d.hostname, d.encrypted_credentials, + d.routeros_version, d.encrypted_credentials_transit + FROM firmware_upgrade_jobs j + JOIN devices d ON d.id = j.device_id + WHERE j.id = CAST(:job_id AS uuid) + """), + {"job_id": job_id}, + ) + row = result.fetchone() + + if not row: + logger.error("Upgrade job %s not found", job_id) + return + + ( + _, device_id, tenant_id, target_version, + architecture, channel, status, confirmed_major, + ip_address, hostname, encrypted_credentials, + current_version, encrypted_credentials_transit, + ) = row + + device_id = str(device_id) + tenant_id = str(tenant_id) + hostname = hostname or ip_address + + # Skip if already running or completed + if status not in ("pending", "scheduled"): + logger.info("Upgrade job %s already in status %s — skipping", job_id, status) + return + + logger.info( + "Starting firmware upgrade for %s (%s): %s -> %s", + hostname, ip_address, current_version, target_version, + ) + + # Step 2: Update status to downloading + await _update_job(job_id, status="downloading", started_at=datetime.now(timezone.utc)) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "downloading", target_version, f"Downloading firmware {target_version} for {hostname}") + + # Step 3: Check major version upgrade confirmation + if current_version and target_version: + current_major = current_version.split(".")[0] if current_version else "" + target_major = target_version.split(".")[0] + if current_major != target_major and not confirmed_major: + await _update_job( + job_id, + status="failed", + error_message="Major version upgrade requires explicit confirmation", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"Major version upgrade requires explicit confirmation for {hostname}", error="Major version upgrade requires explicit confirmation") + return + + # Step 4: Mandatory config backup + logger.info("Running mandatory pre-upgrade backup for %s", hostname) + try: + from app.services import backup_service + backup_result = await backup_service.run_backup( + device_id=device_id, + tenant_id=tenant_id, + trigger_type="pre-upgrade", + ) + backup_sha = backup_result["commit_sha"] + await _update_job(job_id, pre_upgrade_backup_sha=backup_sha) + logger.info("Pre-upgrade backup complete: %s", backup_sha[:8]) + except Exception as backup_err: + logger.error("Pre-upgrade backup failed for %s: %s", hostname, backup_err) + await _update_job( + job_id, + status="failed", + error_message=f"Pre-upgrade backup failed: {backup_err}", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"Pre-upgrade backup failed for {hostname}", error=str(backup_err)) + return + + # Step 5: Download NPK + logger.info("Downloading firmware %s for %s/%s", target_version, architecture, channel) + try: + from app.services.firmware_service import download_firmware + npk_path = await download_firmware(architecture, channel, target_version) + logger.info("Firmware cached at %s", npk_path) + except Exception as dl_err: + logger.error("Firmware download failed: %s", dl_err) + await _update_job( + job_id, + status="failed", + error_message=f"Firmware download failed: {dl_err}", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"Firmware download failed for {hostname}", error=str(dl_err)) + return + + # Step 6: Upload NPK to device via SFTP + await _update_job(job_id, status="uploading") + await _publish_upgrade_progress(tenant_id, device_id, job_id, "uploading", target_version, f"Uploading firmware to {hostname}") + + # Decrypt device credentials (dual-read: Transit preferred, legacy fallback) + if not encrypted_credentials_transit and not encrypted_credentials: + await _update_job(job_id, status="failed", error_message="Device has no stored credentials") + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"No stored credentials for {hostname}", error="Device has no stored credentials") + return + + try: + from app.services.crypto import decrypt_credentials_hybrid + key = settings.get_encryption_key_bytes() + creds_json = await decrypt_credentials_hybrid( + encrypted_credentials_transit, encrypted_credentials, tenant_id, key, + ) + creds = json.loads(creds_json) + ssh_username = creds.get("username", "") + ssh_password = creds.get("password", "") + except Exception as cred_err: + await _update_job( + job_id, + status="failed", + error_message=f"Failed to decrypt credentials: {cred_err}", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"Failed to decrypt credentials for {hostname}", error=str(cred_err)) + return + + try: + npk_data = Path(npk_path).read_bytes() + npk_filename = Path(npk_path).name + + async with asyncssh.connect( + ip_address, + port=22, + username=ssh_username, + password=ssh_password, + known_hosts=None, + connect_timeout=30, + ) as conn: + async with conn.start_sftp_client() as sftp: + async with sftp.open(f"/{npk_filename}", "wb") as f: + await f.write(npk_data) + logger.info("Uploaded %s to %s", npk_filename, hostname) + except Exception as upload_err: + logger.error("NPK upload failed for %s: %s", hostname, upload_err) + await _update_job( + job_id, + status="failed", + error_message=f"NPK upload failed: {upload_err}", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"NPK upload failed for {hostname}", error=str(upload_err)) + return + + # Step 7: Trigger reboot + await _update_job(job_id, status="rebooting") + await _publish_upgrade_progress(tenant_id, device_id, job_id, "rebooting", target_version, f"Rebooting {hostname} for firmware install") + try: + async with asyncssh.connect( + ip_address, + port=22, + username=ssh_username, + password=ssh_password, + known_hosts=None, + connect_timeout=30, + ) as conn: + # RouterOS will install NPK on boot + await conn.run("/system reboot", check=False) + logger.info("Reboot command sent to %s", hostname) + except Exception as reboot_err: + # Device may drop connection during reboot — this is expected + logger.info("Device %s dropped connection after reboot command (expected): %s", hostname, reboot_err) + + # Step 8: Wait for reconnect + logger.info("Waiting %ds before polling %s for reconnect", _INITIAL_WAIT, hostname) + await asyncio.sleep(_INITIAL_WAIT) + + reconnected = False + elapsed = 0 + while elapsed < _RECONNECT_TIMEOUT: + if await _check_ssh_reachable(ip_address, ssh_username, ssh_password): + reconnected = True + break + await asyncio.sleep(_RECONNECT_POLL_INTERVAL) + elapsed += _RECONNECT_POLL_INTERVAL + + if not reconnected: + logger.error("Device %s did not reconnect within %ds", hostname, _RECONNECT_TIMEOUT) + await _update_job( + job_id, + status="failed", + error_message=f"Device did not reconnect within {_RECONNECT_TIMEOUT // 60} minutes after reboot", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"Device {hostname} did not reconnect within {_RECONNECT_TIMEOUT // 60} minutes", error="Reconnect timeout") + return + + # Step 9: Verify upgrade + await _update_job(job_id, status="verifying") + await _publish_upgrade_progress(tenant_id, device_id, job_id, "verifying", target_version, f"Verifying firmware version on {hostname}") + try: + actual_version = await _get_device_version(ip_address, ssh_username, ssh_password) + if actual_version and target_version in actual_version: + logger.info( + "Firmware upgrade verified for %s: %s", + hostname, actual_version, + ) + await _update_job( + job_id, + status="completed", + completed_at=datetime.now(timezone.utc), + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "completed", target_version, f"Firmware upgrade to {target_version} completed on {hostname}") + else: + logger.error( + "Version mismatch for %s: expected %s, got %s", + hostname, target_version, actual_version, + ) + await _update_job( + job_id, + status="failed", + error_message=f"Expected {target_version} but got {actual_version}", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"Version mismatch on {hostname}: expected {target_version}, got {actual_version}", error=f"Expected {target_version} but got {actual_version}") + except Exception as verify_err: + logger.error("Post-upgrade verification failed for %s: %s", hostname, verify_err) + await _update_job( + job_id, + status="failed", + error_message=f"Post-upgrade verification failed: {verify_err}", + ) + await _publish_upgrade_progress(tenant_id, device_id, job_id, "failed", target_version, f"Post-upgrade verification failed for {hostname}", error=str(verify_err)) + + +async def start_mass_upgrade(rollout_group_id: str) -> dict: + """Execute a sequential mass firmware upgrade. + + Processes upgrade jobs one at a time. If any device fails, + all remaining jobs in the group are paused. + + Returns summary dict with completed/failed/paused counts. + """ + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + SELECT j.id, j.status, d.hostname + FROM firmware_upgrade_jobs j + JOIN devices d ON d.id = j.device_id + WHERE j.rollout_group_id = CAST(:group_id AS uuid) + ORDER BY j.created_at ASC + """), + {"group_id": rollout_group_id}, + ) + jobs = result.fetchall() + + if not jobs: + logger.warning("No jobs found for rollout group %s", rollout_group_id) + return {"completed": 0, "failed": 0, "paused": 0} + + completed = 0 + failed_device = None + + for job_id, current_status, hostname in jobs: + job_id_str = str(job_id) + + # Only process pending/scheduled jobs + if current_status not in ("pending", "scheduled"): + if current_status == "completed": + completed += 1 + continue + + logger.info("Mass rollout: upgrading device %s (job %s)", hostname, job_id_str) + await start_upgrade(job_id_str) + + # Check if it completed or failed + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text("SELECT status FROM firmware_upgrade_jobs WHERE id = CAST(:id AS uuid)"), + {"id": job_id_str}, + ) + row = result.fetchone() + + if row and row[0] == "completed": + completed += 1 + elif row and row[0] == "failed": + failed_device = hostname + logger.error("Mass rollout paused: %s failed", hostname) + break + + # Pause remaining jobs if one failed + paused = 0 + if failed_device: + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + UPDATE firmware_upgrade_jobs + SET status = 'paused' + WHERE rollout_group_id = CAST(:group_id AS uuid) + AND status IN ('pending', 'scheduled') + RETURNING id + """), + {"group_id": rollout_group_id}, + ) + paused = len(result.fetchall()) + await session.commit() + + return { + "completed": completed, + "failed": 1 if failed_device else 0, + "failed_device": failed_device, + "paused": paused, + } + + +def schedule_upgrade(job_id: str, scheduled_at: datetime) -> None: + """Schedule a firmware upgrade for future execution via APScheduler.""" + from app.services.backup_scheduler import backup_scheduler + + backup_scheduler.add_job( + start_upgrade, + trigger="date", + run_date=scheduled_at, + args=[job_id], + id=f"fw_upgrade_{job_id}", + name=f"Firmware upgrade {job_id}", + max_instances=1, + replace_existing=True, + ) + logger.info("Scheduled firmware upgrade %s for %s", job_id, scheduled_at) + + +def schedule_mass_upgrade(rollout_group_id: str, scheduled_at: datetime) -> None: + """Schedule a mass firmware upgrade for future execution.""" + from app.services.backup_scheduler import backup_scheduler + + backup_scheduler.add_job( + start_mass_upgrade, + trigger="date", + run_date=scheduled_at, + args=[rollout_group_id], + id=f"fw_mass_upgrade_{rollout_group_id}", + name=f"Mass firmware upgrade {rollout_group_id}", + max_instances=1, + replace_existing=True, + ) + logger.info("Scheduled mass firmware upgrade %s for %s", rollout_group_id, scheduled_at) + + +async def cancel_upgrade(job_id: str) -> None: + """Cancel a scheduled or pending upgrade.""" + from app.services.backup_scheduler import backup_scheduler + + # Remove APScheduler job if it exists + try: + backup_scheduler.remove_job(f"fw_upgrade_{job_id}") + except Exception: + pass # Job might not be scheduled + + await _update_job( + job_id, + status="failed", + error_message="Cancelled by operator", + completed_at=datetime.now(timezone.utc), + ) + logger.info("Upgrade job %s cancelled", job_id) + + +async def retry_failed_upgrade(job_id: str) -> None: + """Reset a failed upgrade job to pending and re-execute.""" + await _update_job( + job_id, + status="pending", + error_message=None, + started_at=None, + completed_at=None, + ) + asyncio.create_task(start_upgrade(job_id)) + logger.info("Retrying upgrade job %s", job_id) + + +async def resume_mass_upgrade(rollout_group_id: str) -> None: + """Resume a paused mass rollout from where it left off.""" + # Reset first paused job to pending, then restart sequential processing + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + UPDATE firmware_upgrade_jobs + SET status = 'pending' + WHERE rollout_group_id = CAST(:group_id AS uuid) + AND status = 'paused' + """), + {"group_id": rollout_group_id}, + ) + await session.commit() + + asyncio.create_task(start_mass_upgrade(rollout_group_id)) + logger.info("Resuming mass rollout %s", rollout_group_id) + + +async def abort_mass_upgrade(rollout_group_id: str) -> int: + """Abort all remaining jobs in a paused mass rollout.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute( + text(""" + UPDATE firmware_upgrade_jobs + SET status = 'failed', + error_message = 'Aborted by operator', + completed_at = NOW() + WHERE rollout_group_id = CAST(:group_id AS uuid) + AND status IN ('pending', 'scheduled', 'paused') + RETURNING id + """), + {"group_id": rollout_group_id}, + ) + aborted = len(result.fetchall()) + await session.commit() + + logger.info("Aborted %d remaining jobs in rollout %s", aborted, rollout_group_id) + return aborted + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +async def _update_job(job_id: str, **kwargs) -> None: + """Update FirmwareUpgradeJob fields.""" + sets = [] + params: dict = {"job_id": job_id} + + for key, value in kwargs.items(): + param_name = f"v_{key}" + if value is None and key in ("error_message", "started_at", "completed_at"): + sets.append(f"{key} = NULL") + else: + sets.append(f"{key} = :{param_name}") + params[param_name] = value + + if not sets: + return + + async with AdminAsyncSessionLocal() as session: + await session.execute( + text(f""" + UPDATE firmware_upgrade_jobs + SET {', '.join(sets)} + WHERE id = CAST(:job_id AS uuid) + """), + params, + ) + await session.commit() + + +async def _check_ssh_reachable(ip: str, username: str, password: str) -> bool: + """Check if a device is reachable via SSH.""" + try: + async with asyncssh.connect( + ip, + port=22, + username=username, + password=password, + known_hosts=None, + connect_timeout=15, + ) as conn: + await conn.run("/system identity print", check=True) + return True + except Exception: + return False + + +async def _get_device_version(ip: str, username: str, password: str) -> str: + """Get the current RouterOS version from a device via SSH.""" + async with asyncssh.connect( + ip, + port=22, + username=username, + password=password, + known_hosts=None, + connect_timeout=30, + ) as conn: + result = await conn.run("/system resource print", check=True) + # Parse version from output: "version: 7.17 (stable)" + for line in result.stdout.splitlines(): + if "version" in line.lower(): + parts = line.split(":", 1) + if len(parts) == 2: + return parts[1].strip() + return "" diff --git a/backend/app/services/vpn_service.py b/backend/app/services/vpn_service.py new file mode 100644 index 0000000..947715c --- /dev/null +++ b/backend/app/services/vpn_service.py @@ -0,0 +1,392 @@ +"""WireGuard VPN management service. + +Handles key generation, peer management, config file sync, and RouterOS command generation. +""" + +import base64 +import ipaddress +import json +import os +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import structlog +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.device import Device +from app.models.vpn import VpnConfig, VpnPeer +from app.services.crypto import decrypt_credentials, encrypt_credentials, encrypt_credentials_transit + +logger = structlog.get_logger(__name__) + + +# ── Key Generation ── + + +def generate_wireguard_keypair() -> tuple[str, str]: + """Generate a WireGuard X25519 keypair. Returns (private_key_b64, public_key_b64).""" + private_key = X25519PrivateKey.generate() + priv_bytes = private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) + pub_bytes = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return base64.b64encode(priv_bytes).decode(), base64.b64encode(pub_bytes).decode() + + +def generate_preshared_key() -> str: + """Generate a WireGuard preshared key (32 random bytes, base64).""" + return base64.b64encode(os.urandom(32)).decode() + + +# ── Config File Management ── + + +def _get_wg_config_path() -> Path: + """Return the path to the shared WireGuard config directory.""" + return Path(os.getenv("WIREGUARD_CONFIG_PATH", "/data/wireguard")) + + +async def sync_wireguard_config(db: AsyncSession, tenant_id: uuid.UUID) -> None: + """Regenerate wg0.conf from database state and write to shared volume.""" + config = await get_vpn_config(db, tenant_id) + if not config or not config.is_enabled: + return + + key_bytes = settings.get_encryption_key_bytes() + server_private_key = decrypt_credentials(config.server_private_key, key_bytes) + + result = await db.execute( + select(VpnPeer).where(VpnPeer.tenant_id == tenant_id, VpnPeer.is_enabled.is_(True)) + ) + peers = result.scalars().all() + + # Build wg0.conf + lines = [ + "[Interface]", + f"Address = {config.server_address}", + f"ListenPort = {config.server_port}", + f"PrivateKey = {server_private_key}", + "", + ] + + for peer in peers: + peer_ip = peer.assigned_ip.split("/")[0] # strip CIDR for AllowedIPs + allowed_ips = [f"{peer_ip}/32"] + if peer.additional_allowed_ips: + # Comma-separated additional subnets (e.g. site-to-site routing) + extra = [s.strip() for s in peer.additional_allowed_ips.split(",") if s.strip()] + allowed_ips.extend(extra) + lines.append("[Peer]") + lines.append(f"PublicKey = {peer.peer_public_key}") + if peer.preshared_key: + psk = decrypt_credentials(peer.preshared_key, key_bytes) + lines.append(f"PresharedKey = {psk}") + lines.append(f"AllowedIPs = {', '.join(allowed_ips)}") + lines.append("") + + config_dir = _get_wg_config_path() + wg_confs_dir = config_dir / "wg_confs" + wg_confs_dir.mkdir(parents=True, exist_ok=True) + + conf_path = wg_confs_dir / "wg0.conf" + conf_path.write_text("\n".join(lines)) + + # Signal WireGuard container to reload config + reload_flag = wg_confs_dir / ".reload" + reload_flag.write_text("1") + + logger.info("wireguard config synced", tenant_id=str(tenant_id), peers=len(peers)) + + +# ── Live Status ── + + +def read_wg_status() -> dict[str, dict]: + """Read live WireGuard peer status from the shared volume. + + The WireGuard container writes wg_status.json every 15 seconds + with output from `wg show wg0 dump`. Returns a dict keyed by + peer public key with handshake timestamp and transfer stats. + """ + status_path = _get_wg_config_path() / "wg_status.json" + if not status_path.exists(): + return {} + try: + data = json.loads(status_path.read_text()) + return {entry["public_key"]: entry for entry in data} + except (json.JSONDecodeError, KeyError, OSError): + return {} + + +def get_peer_handshake(wg_status: dict[str, dict], public_key: str) -> Optional[datetime]: + """Get last_handshake datetime for a peer from live WireGuard status.""" + entry = wg_status.get(public_key) + if not entry: + return None + ts = entry.get("last_handshake", 0) + if ts and ts > 0: + return datetime.fromtimestamp(ts, tz=timezone.utc) + return None + + +# ── CRUD Operations ── + + +async def get_vpn_config(db: AsyncSession, tenant_id: uuid.UUID) -> Optional[VpnConfig]: + """Get the VPN config for a tenant.""" + result = await db.execute(select(VpnConfig).where(VpnConfig.tenant_id == tenant_id)) + return result.scalar_one_or_none() + + +async def setup_vpn( + db: AsyncSession, tenant_id: uuid.UUID, endpoint: Optional[str] = None +) -> VpnConfig: + """Initialize VPN for a tenant — generates server keys and creates config.""" + existing = await get_vpn_config(db, tenant_id) + if existing: + raise ValueError("VPN already configured for this tenant") + + private_key_b64, public_key_b64 = generate_wireguard_keypair() + + key_bytes = settings.get_encryption_key_bytes() + encrypted_private = encrypt_credentials(private_key_b64, key_bytes) + + config = VpnConfig( + tenant_id=tenant_id, + server_private_key=encrypted_private, + server_public_key=public_key_b64, + endpoint=endpoint, + is_enabled=True, + ) + db.add(config) + await db.flush() + + await sync_wireguard_config(db, tenant_id) + return config + + +async def update_vpn_config( + db: AsyncSession, tenant_id: uuid.UUID, endpoint: Optional[str] = None, is_enabled: Optional[bool] = None +) -> VpnConfig: + """Update VPN config settings.""" + config = await get_vpn_config(db, tenant_id) + if not config: + raise ValueError("VPN not configured for this tenant") + + if endpoint is not None: + config.endpoint = endpoint + if is_enabled is not None: + config.is_enabled = is_enabled + + await db.flush() + await sync_wireguard_config(db, tenant_id) + return config + + +async def get_peers(db: AsyncSession, tenant_id: uuid.UUID) -> list[VpnPeer]: + """List all VPN peers for a tenant.""" + result = await db.execute( + select(VpnPeer).where(VpnPeer.tenant_id == tenant_id).order_by(VpnPeer.created_at) + ) + return list(result.scalars().all()) + + +async def _next_available_ip(db: AsyncSession, tenant_id: uuid.UUID, config: VpnConfig) -> str: + """Allocate the next available IP in the VPN subnet.""" + # Parse subnet: e.g. "10.10.0.0/24" → start from .2 (server is .1) + network = ipaddress.ip_network(config.subnet, strict=False) + hosts = list(network.hosts()) + + # Get already assigned IPs + result = await db.execute(select(VpnPeer.assigned_ip).where(VpnPeer.tenant_id == tenant_id)) + used_ips = {row[0].split("/")[0] for row in result.all()} + used_ips.add(config.server_address.split("/")[0]) # exclude server IP + + for host in hosts[1:]: # skip .1 (server) + if str(host) not in used_ips: + return f"{host}/24" + + raise ValueError("No available IPs in VPN subnet") + + +async def add_peer(db: AsyncSession, tenant_id: uuid.UUID, device_id: uuid.UUID, additional_allowed_ips: Optional[str] = None) -> VpnPeer: + """Add a device as a VPN peer.""" + config = await get_vpn_config(db, tenant_id) + if not config: + raise ValueError("VPN not configured — enable VPN first") + + # Check device exists + device = await db.execute(select(Device).where(Device.id == device_id, Device.tenant_id == tenant_id)) + if not device.scalar_one_or_none(): + raise ValueError("Device not found") + + # Check if already a peer + existing = await db.execute(select(VpnPeer).where(VpnPeer.device_id == device_id)) + if existing.scalar_one_or_none(): + raise ValueError("Device is already a VPN peer") + + private_key_b64, public_key_b64 = generate_wireguard_keypair() + psk = generate_preshared_key() + + key_bytes = settings.get_encryption_key_bytes() + encrypted_private = encrypt_credentials(private_key_b64, key_bytes) + encrypted_psk = encrypt_credentials(psk, key_bytes) + + assigned_ip = await _next_available_ip(db, tenant_id, config) + + peer = VpnPeer( + tenant_id=tenant_id, + device_id=device_id, + peer_private_key=encrypted_private, + peer_public_key=public_key_b64, + preshared_key=encrypted_psk, + assigned_ip=assigned_ip, + additional_allowed_ips=additional_allowed_ips, + ) + db.add(peer) + await db.flush() + + await sync_wireguard_config(db, tenant_id) + return peer + + +async def remove_peer(db: AsyncSession, tenant_id: uuid.UUID, peer_id: uuid.UUID) -> None: + """Remove a VPN peer.""" + result = await db.execute( + select(VpnPeer).where(VpnPeer.id == peer_id, VpnPeer.tenant_id == tenant_id) + ) + peer = result.scalar_one_or_none() + if not peer: + raise ValueError("Peer not found") + + await db.delete(peer) + await db.flush() + await sync_wireguard_config(db, tenant_id) + + +async def get_peer_config(db: AsyncSession, tenant_id: uuid.UUID, peer_id: uuid.UUID) -> dict: + """Get the full config for a peer — includes private key for device setup.""" + config = await get_vpn_config(db, tenant_id) + if not config: + raise ValueError("VPN not configured") + + result = await db.execute( + select(VpnPeer).where(VpnPeer.id == peer_id, VpnPeer.tenant_id == tenant_id) + ) + peer = result.scalar_one_or_none() + if not peer: + raise ValueError("Peer not found") + + key_bytes = settings.get_encryption_key_bytes() + private_key = decrypt_credentials(peer.peer_private_key, key_bytes) + psk = decrypt_credentials(peer.preshared_key, key_bytes) if peer.preshared_key else None + + endpoint = config.endpoint or "YOUR_SERVER_IP:51820" + peer_ip_no_cidr = peer.assigned_ip.split("/")[0] + + routeros_commands = [ + f'/interface wireguard add name=wg-portal listen-port=13231 private-key="{private_key}"', + f'/interface wireguard peers add interface=wg-portal public-key="{config.server_public_key}" ' + f'endpoint-address={endpoint.split(":")[0]} endpoint-port={endpoint.split(":")[-1]} ' + f'allowed-address={config.subnet} persistent-keepalive=25' + + (f' preshared-key="{psk}"' if psk else ""), + f"/ip address add address={peer.assigned_ip} interface=wg-portal", + ] + + return { + "peer_private_key": private_key, + "peer_public_key": peer.peer_public_key, + "assigned_ip": peer.assigned_ip, + "server_public_key": config.server_public_key, + "server_endpoint": endpoint, + "allowed_ips": config.subnet, + "routeros_commands": routeros_commands, + } + + +async def onboard_device( + db: AsyncSession, + tenant_id: uuid.UUID, + hostname: str, + username: str, + password: str, +) -> dict: + """Create device + VPN peer in one transaction. Returns device, peer, and RouterOS commands. + + Unlike regular device creation, this skips TCP connectivity checks because + the VPN tunnel isn't up yet. The device IP is set to the VPN-assigned address. + """ + config = await get_vpn_config(db, tenant_id) + if not config: + raise ValueError("VPN not configured — enable VPN first") + + # Allocate VPN IP before creating device + assigned_ip = await _next_available_ip(db, tenant_id, config) + vpn_ip_no_cidr = assigned_ip.split("/")[0] + + # Create device with VPN IP (skip TCP check — tunnel not up yet) + credentials_json = json.dumps({"username": username, "password": password}) + transit_ciphertext = await encrypt_credentials_transit(credentials_json, str(tenant_id)) + + device = Device( + tenant_id=tenant_id, + hostname=hostname, + ip_address=vpn_ip_no_cidr, + api_port=8728, + api_ssl_port=8729, + encrypted_credentials_transit=transit_ciphertext, + status="unknown", + ) + db.add(device) + await db.flush() + + # Create VPN peer linked to this device + private_key_b64, public_key_b64 = generate_wireguard_keypair() + psk = generate_preshared_key() + + key_bytes = settings.get_encryption_key_bytes() + encrypted_private = encrypt_credentials(private_key_b64, key_bytes) + encrypted_psk = encrypt_credentials(psk, key_bytes) + + peer = VpnPeer( + tenant_id=tenant_id, + device_id=device.id, + peer_private_key=encrypted_private, + peer_public_key=public_key_b64, + preshared_key=encrypted_psk, + assigned_ip=assigned_ip, + ) + db.add(peer) + await db.flush() + + await sync_wireguard_config(db, tenant_id) + + # Generate RouterOS commands + endpoint = config.endpoint or "YOUR_SERVER_IP:51820" + psk_decrypted = decrypt_credentials(encrypted_psk, key_bytes) + + routeros_commands = [ + f'/interface wireguard add name=wg-portal listen-port=13231 private-key="{private_key_b64}"', + f'/interface wireguard peers add interface=wg-portal public-key="{config.server_public_key}" ' + f'endpoint-address={endpoint.split(":")[0]} endpoint-port={endpoint.split(":")[-1]} ' + f'allowed-address={config.subnet} persistent-keepalive=25' + f' preshared-key="{psk_decrypted}"', + f"/ip address add address={assigned_ip} interface=wg-portal", + ] + + return { + "device_id": device.id, + "peer_id": peer.id, + "hostname": hostname, + "assigned_ip": assigned_ip, + "routeros_commands": routeros_commands, + } diff --git a/backend/app/templates/reports/alert_history.html b/backend/app/templates/reports/alert_history.html new file mode 100644 index 0000000..3382240 --- /dev/null +++ b/backend/app/templates/reports/alert_history.html @@ -0,0 +1,66 @@ +{% extends "reports/base.html" %} + +{% block content %} +
+ Report period: {{ date_from }} to {{ date_to }} +
+ +
+
+
{{ total_alerts }}
+
Total Alerts
+
+
+
{{ critical_count }}
+
Critical
+
+
+
{{ warning_count }}
+
Warning
+
+
+
{{ info_count }}
+
Info
+
+ {% if mttr_minutes is not none %} +
+
{{ mttr_display }}
+
Avg MTTR
+
+ {% endif %} +
+ +{% if alerts %} +

Alert Events

+ + + + + + + + + + + + + {% for alert in alerts %} + + + + + + + + + {% endfor %} + +
TimestampDeviceSeverityMessageStatusDuration
{{ alert.fired_at }}{{ alert.hostname or '-' }} + {{ alert.severity | upper }} + {{ alert.message or '-' }} + {{ alert.status | upper }} + {{ alert.duration or '-' }}
+{% else %} +
No alerts found for the selected date range.
+{% endif %} +{% endblock %} diff --git a/backend/app/templates/reports/base.html b/backend/app/templates/reports/base.html new file mode 100644 index 0000000..32c0e26 --- /dev/null +++ b/backend/app/templates/reports/base.html @@ -0,0 +1,208 @@ + + + + + {{ report_title }} - TOD + + + +
+
+ +
TOD - The Other Dude
+
+
+
{{ report_title }}
+
{{ tenant_name }} • Generated {{ generated_at }}
+
+
+ +
+ {% block content %}{% endblock %} +
+ + diff --git a/backend/app/templates/reports/change_log.html b/backend/app/templates/reports/change_log.html new file mode 100644 index 0000000..b43b90d --- /dev/null +++ b/backend/app/templates/reports/change_log.html @@ -0,0 +1,46 @@ +{% extends "reports/base.html" %} + +{% block content %} +
+ Report period: {{ date_from }} to {{ date_to }} +
+ +
+
+
{{ total_entries }}
+
Total Changes
+
+
+
{{ data_source }}
+
Data Source
+
+
+ +{% if entries %} +

Change Log

+ + + + + + + + + + + + {% for entry in entries %} + + + + + + + + {% endfor %} + +
TimestampUserActionDeviceDetails
{{ entry.timestamp }}{{ entry.user or '-' }}{{ entry.action }}{{ entry.device or '-' }}{{ entry.details or '-' }}
+{% else %} +
No changes found for the selected date range.
+{% endif %} +{% endblock %} diff --git a/backend/app/templates/reports/device_inventory.html b/backend/app/templates/reports/device_inventory.html new file mode 100644 index 0000000..168d265 --- /dev/null +++ b/backend/app/templates/reports/device_inventory.html @@ -0,0 +1,59 @@ +{% extends "reports/base.html" %} + +{% block page_size %}A4 landscape{% endblock %} + +{% block content %} +
+
+
{{ total_devices }}
+
Total Devices
+
+
+
{{ online_count }}
+
Online
+
+
+
{{ offline_count }}
+
Offline
+
+
+
{{ unknown_count }}
+
Unknown
+
+
+ +{% if devices %} + + + + + + + + + + + + + + + {% for device in devices %} + + + + + + + + + + + {% endfor %} + +
HostnameIP AddressModelRouterOSStatusLast SeenUptimeGroups
{{ device.hostname }}{{ device.ip_address }}{{ device.model or '-' }}{{ device.routeros_version or '-' }} + {{ device.status | upper }} + {{ device.last_seen or '-' }}{{ device.uptime or '-' }}{{ device.groups or '-' }}
+{% else %} +
No devices found for this tenant.
+{% endif %} +{% endblock %} diff --git a/backend/app/templates/reports/metrics_summary.html b/backend/app/templates/reports/metrics_summary.html new file mode 100644 index 0000000..6511456 --- /dev/null +++ b/backend/app/templates/reports/metrics_summary.html @@ -0,0 +1,45 @@ +{% extends "reports/base.html" %} + +{% block content %} +
+ Report period: {{ date_from }} to {{ date_to }} +
+ +{% if devices %} +

Resource Usage by Device

+ + + + + + + + + + + + + + + {% for device in devices %} + + + 80 %} style="color: #991B1B; font-weight: 600;"{% elif device.avg_cpu and device.avg_cpu > 50 %} style="color: #92400E;"{% endif %}> + {{ '%.1f' | format(device.avg_cpu) if device.avg_cpu is not none else '-' }} + + + 80 %} style="color: #991B1B; font-weight: 600;"{% elif device.avg_mem and device.avg_mem > 50 %} style="color: #92400E;"{% endif %}> + {{ '%.1f' | format(device.avg_mem) if device.avg_mem is not none else '-' }} + + + + + + + {% endfor %} + +
HostnameAvg CPU %Peak CPU %Avg Memory %Peak Memory %Avg Disk %Avg TempData Points
{{ device.hostname }}{{ '%.1f' | format(device.peak_cpu) if device.peak_cpu is not none else '-' }}{{ '%.1f' | format(device.peak_mem) if device.peak_mem is not none else '-' }}{{ '%.1f' | format(device.avg_disk) if device.avg_disk is not none else '-' }}{{ '%.1f' | format(device.avg_temp) if device.avg_temp is not none else '-' }}{{ device.data_points }}
+{% else %} +
No metrics data found for the selected date range.
+{% endif %} +{% endblock %} diff --git a/backend/gunicorn.conf.py b/backend/gunicorn.conf.py new file mode 100644 index 0000000..510d6df --- /dev/null +++ b/backend/gunicorn.conf.py @@ -0,0 +1,30 @@ +"""Gunicorn configuration for production deployment. + +Uses UvicornWorker for async support under gunicorn's process management. +Worker count and timeouts are configurable via environment variables. +""" + +import os + +# Server socket +bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8000") + +# Worker processes +workers = int(os.getenv("GUNICORN_WORKERS", "2")) +worker_class = "uvicorn.workers.UvicornWorker" + +# Timeouts +graceful_timeout = int(os.getenv("GUNICORN_GRACEFUL_TIMEOUT", "30")) +timeout = int(os.getenv("GUNICORN_TIMEOUT", "120")) +keepalive = int(os.getenv("GUNICORN_KEEPALIVE", "5")) + +# Logging -- use stdout/stderr for Docker log collection +accesslog = "-" +errorlog = "-" +loglevel = os.getenv("LOG_LEVEL", "info") + +# Process naming +proc_name = "mikrotik-api" + +# Preload application for faster worker spawning (shared memory for code) +preload_app = True diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..5742475 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mikrotik-portal-backend" +version = "9.0.1" +description = "MikroTik Fleet Management Portal - Backend API" +requires-python = ">=3.12" +dependencies = [ + "fastapi[standard]>=0.115.0", + "sqlalchemy[asyncio]>=2.0.0", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "python-jose[cryptography]>=3.3.0", + "bcrypt>=4.0.0,<5.0.0", + "redis>=5.0.0", + "nats-py>=2.7.0", + "cryptography>=42.0.0", + "python-multipart>=0.0.9", + "httpx>=0.27.0", + "asyncssh>=2.20.0", + "pygit2>=1.14.0", + "apscheduler>=3.10.0,<4.0", + "aiosmtplib>=3.0.0", + "structlog>=25.1.0", + "slowapi>=0.1.9", + "jinja2>=3.1.6", + "prometheus-fastapi-instrumentator>=7.0.0", + "gunicorn>=23.0.0", + "sse-starlette>=2.0.0", + "weasyprint>=62.0", + "srptools==1.0.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.25,<1.0", + "pytest-mock>=3.14", + "httpx>=0.27.0", + "pytest-cov>=5.0.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "integration: marks tests as integration tests requiring PostgreSQL (deselect with '-m \"not integration\"')", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" diff --git a/backend/templates/emergency_kit.html b/backend/templates/emergency_kit.html new file mode 100644 index 0000000..06fb715 --- /dev/null +++ b/backend/templates/emergency_kit.html @@ -0,0 +1,297 @@ + + + + + TOD - Emergency Kit + + + +
+ +
+ +
+

Emergency Kit

+

TOD Zero-Knowledge Recovery

+
+
+ + +
+ +
+ Keep this document safe + This Emergency Kit is your only way to recover access if you lose your Secret Key. + Store it in a secure location such as a home safe or safety deposit box. +
+ + +
+
Email Address
+
{{ email }}
+
+ + +
+
Sign-in URL
+
{{ signin_url }}
+
+ + +
+
Secret Key
+
{{ secret_key_placeholder }}
+
+ + +
+
Master Password (write by hand)
+
+
+ +
+ + +
+

Instructions

+
    +
  • This Emergency Kit contains your Secret Key needed to log in on new devices.
  • +
  • Store this document in a safe place — a home safe, safety deposit box, or other secure location.
  • +
  • Do NOT store this document digitally alongside your password.
  • +
  • Consider writing your Master Password on this sheet and storing it securely.
  • +
  • If you lose both your Emergency Kit and forget your Secret Key, your encrypted data cannot be recovered. There is no reset mechanism.
  • +
+
+
+ + + +
+ + diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0f94577 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,16 @@ +"""Shared test fixtures for the backend test suite. + +Phase 7: Minimal fixtures for unit tests (no database, no async). +Phase 10: Integration test fixtures added in tests/integration/conftest.py. + +Pytest marker registration and shared configuration lives here. +""" + +import pytest + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line( + "markers", "integration: marks tests as integration tests requiring PostgreSQL" + ) diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..19c382a --- /dev/null +++ b/backend/tests/integration/__init__.py @@ -0,0 +1,2 @@ +# Integration tests for TOD backend. +# Run against real PostgreSQL+TimescaleDB via docker-compose. diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..e7b7126 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,439 @@ +""" +Integration test fixtures for the TOD backend. + +Provides: +- Database engines (admin + app_user) pointing at real PostgreSQL+TimescaleDB +- Per-test session fixtures with transaction rollback for isolation +- app_session_factory for RLS multi-tenant tests (creates sessions with tenant context) +- FastAPI test client with dependency overrides +- Entity factory fixtures (tenants, users, devices) +- Auth helper for getting login tokens + +All fixtures use the existing docker-compose PostgreSQL instance. +Set TEST_DATABASE_URL / TEST_APP_USER_DATABASE_URL env vars to override defaults. + +Event loop strategy: All async fixtures are function-scoped to avoid the +pytest-asyncio 0.26 session/function loop mismatch. Engine creation and DB +setup use synchronous subprocess calls (Alembic) and module-level singletons. +""" + +import os +import subprocess +import sys +import uuid +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +# --------------------------------------------------------------------------- +# Environment configuration +# --------------------------------------------------------------------------- + +TEST_DATABASE_URL = os.environ.get( + "TEST_DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/mikrotik_test", +) +TEST_APP_USER_DATABASE_URL = os.environ.get( + "TEST_APP_USER_DATABASE_URL", + "postgresql+asyncpg://app_user:app_password@localhost:5432/mikrotik_test", +) + + +# --------------------------------------------------------------------------- +# One-time database setup (runs once per session via autouse sync fixture) +# --------------------------------------------------------------------------- + +_DB_SETUP_DONE = False + + +def _ensure_database_setup(): + """Synchronous one-time DB setup: create test DB if needed, run migrations.""" + global _DB_SETUP_DONE + if _DB_SETUP_DONE: + return + _DB_SETUP_DONE = True + + backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + env = os.environ.copy() + env["DATABASE_URL"] = TEST_DATABASE_URL + + # Run Alembic migrations via subprocess (handles DB creation and schema) + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + capture_output=True, + text=True, + cwd=backend_dir, + env=env, + ) + if result.returncode != 0: + raise RuntimeError(f"Alembic migration failed:\n{result.stderr}") + + +@pytest.fixture(scope="session", autouse=True) +def setup_database(): + """Session-scoped sync fixture: ensures DB schema is ready.""" + _ensure_database_setup() + yield + + +# --------------------------------------------------------------------------- +# Engine fixtures (function-scoped to stay on same event loop as tests) +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def admin_engine(): + """Admin engine (superuser) -- bypasses RLS. + + Created fresh per-test to avoid event loop issues. + pool_size=2 since each test only needs a few connections. + """ + engine = create_async_engine( + TEST_DATABASE_URL, echo=False, pool_pre_ping=True, pool_size=2, max_overflow=3 + ) + yield engine + await engine.dispose() + + +@pytest_asyncio.fixture +async def app_engine(): + """App-user engine -- RLS enforced. + + Created fresh per-test to avoid event loop issues. + """ + engine = create_async_engine( + TEST_APP_USER_DATABASE_URL, echo=False, pool_pre_ping=True, pool_size=2, max_overflow=3 + ) + yield engine + await engine.dispose() + + +# --------------------------------------------------------------------------- +# Function-scoped session fixtures (fresh per test) +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def admin_session(admin_engine) -> AsyncGenerator[AsyncSession, None]: + """Per-test admin session with transaction rollback. + + Each test gets a clean transaction that is rolled back after the test, + ensuring no state leakage between tests. + """ + async with admin_engine.connect() as conn: + trans = await conn.begin() + session = AsyncSession(bind=conn, expire_on_commit=False) + try: + yield session + finally: + await trans.rollback() + await session.close() + + +@pytest_asyncio.fixture +async def app_session(app_engine) -> AsyncGenerator[AsyncSession, None]: + """Per-test app_user session with transaction rollback (RLS enforced). + + Caller must call set_tenant_context() before querying. + """ + async with app_engine.connect() as conn: + trans = await conn.begin() + session = AsyncSession(bind=conn, expire_on_commit=False) + # Reset tenant context + await session.execute(text("RESET app.current_tenant")) + try: + yield session + finally: + await trans.rollback() + await session.close() + + +@pytest.fixture +def app_session_factory(app_engine): + """Factory that returns an async context manager for app_user sessions. + + Each session gets its own connection and transaction (rolled back on exit). + Caller can pass tenant_id to auto-set RLS context. + + Usage: + async with app_session_factory(tenant_id=str(tenant.id)) as session: + result = await session.execute(select(Device)) + """ + from app.database import set_tenant_context + + @asynccontextmanager + async def _create(tenant_id: str | None = None): + async with app_engine.connect() as conn: + trans = await conn.begin() + session = AsyncSession(bind=conn, expire_on_commit=False) + # Reset tenant context to prevent leakage + await session.execute(text("RESET app.current_tenant")) + if tenant_id: + await set_tenant_context(session, tenant_id) + try: + yield session + finally: + await trans.rollback() + await session.close() + + return _create + + +# --------------------------------------------------------------------------- +# FastAPI test app and HTTP client +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def test_app(admin_engine, app_engine): + """Create a FastAPI app instance with test database dependency overrides. + + - get_db uses app_engine (non-superuser, RLS enforced) so tenant + isolation is tested correctly at the API level. + - get_admin_db uses admin_engine (superuser) for auth/bootstrap routes. + - Disables lifespan to skip migrations, NATS, and scheduler startup. + """ + from fastapi import FastAPI + + from app.database import get_admin_db, get_db + + # Create a minimal app without lifespan + app = FastAPI(lifespan=None) + + # Import and mount all routers (same as main app) + from app.routers.alerts import router as alerts_router + from app.routers.auth import router as auth_router + from app.routers.config_backups import router as config_router + from app.routers.config_editor import router as config_editor_router + from app.routers.device_groups import router as device_groups_router + from app.routers.device_tags import router as device_tags_router + from app.routers.devices import router as devices_router + from app.routers.firmware import router as firmware_router + from app.routers.metrics import router as metrics_router + from app.routers.templates import router as templates_router + from app.routers.tenants import router as tenants_router + from app.routers.users import router as users_router + + app.include_router(auth_router, prefix="/api") + app.include_router(tenants_router, prefix="/api") + app.include_router(users_router, prefix="/api") + app.include_router(devices_router, prefix="/api") + app.include_router(device_groups_router, prefix="/api") + app.include_router(device_tags_router, prefix="/api") + app.include_router(metrics_router, prefix="/api") + app.include_router(config_router, prefix="/api") + app.include_router(firmware_router, prefix="/api") + app.include_router(alerts_router, prefix="/api") + app.include_router(config_editor_router, prefix="/api") + app.include_router(templates_router, prefix="/api") + + # Register rate limiter (auth endpoints use @limiter.limit) + from app.middleware.rate_limit import setup_rate_limiting + setup_rate_limiting(app) + + # Create test session factories + test_admin_session_factory = async_sessionmaker( + admin_engine, class_=AsyncSession, expire_on_commit=False + ) + test_app_session_factory = async_sessionmaker( + app_engine, class_=AsyncSession, expire_on_commit=False + ) + + # get_db uses app_engine (RLS enforced) -- tenant context is set + # by get_current_user dependency via set_tenant_context() + async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + async with test_app_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + # get_admin_db uses admin engine (superuser) for auth/bootstrap + async def override_get_admin_db() -> AsyncGenerator[AsyncSession, None]: + async with test_admin_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_admin_db] = override_get_admin_db + + yield app + + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def client(test_app) -> AsyncGenerator[AsyncClient, None]: + """HTTP client using ASGI transport (no network, real app). + + Flushes Redis DB 1 (rate limit storage) before each test to prevent + cross-test 429 errors from slowapi. + """ + import redis + + try: + # Rate limiter uses Redis DB 1 (see app/middleware/rate_limit.py) + r = redis.Redis(host="localhost", port=6379, db=1) + r.flushdb() + r.close() + except Exception: + pass # Redis not available -- skip clearing + + transport = ASGITransport(app=test_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +# --------------------------------------------------------------------------- +# Entity factory fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def create_test_tenant(): + """Factory to create a test tenant via admin session.""" + + async def _create( + session: AsyncSession, + name: str | None = None, + ): + from app.models.tenant import Tenant + + tenant_name = name or f"test-tenant-{uuid.uuid4().hex[:8]}" + tenant = Tenant(name=tenant_name) + session.add(tenant) + await session.flush() + return tenant + + return _create + + +@pytest.fixture +def create_test_user(): + """Factory to create a test user via admin session.""" + + async def _create( + session: AsyncSession, + tenant_id: uuid.UUID | None, + email: str | None = None, + password: str = "TestPass123!", + role: str = "tenant_admin", + name: str = "Test User", + ): + from app.models.user import User + from app.services.auth import hash_password + + user_email = email or f"test-{uuid.uuid4().hex[:8]}@example.com" + user = User( + email=user_email, + hashed_password=hash_password(password), + name=name, + role=role, + tenant_id=tenant_id, + is_active=True, + ) + session.add(user) + await session.flush() + return user + + return _create + + +@pytest.fixture +def create_test_device(): + """Factory to create a test device via admin session.""" + + async def _create( + session: AsyncSession, + tenant_id: uuid.UUID, + hostname: str | None = None, + ip_address: str | None = None, + status: str = "online", + ): + from app.models.device import Device + + device_hostname = hostname or f"router-{uuid.uuid4().hex[:8]}" + device_ip = ip_address or f"10.0.{uuid.uuid4().int % 256}.{uuid.uuid4().int % 256}" + device = Device( + tenant_id=tenant_id, + hostname=device_hostname, + ip_address=device_ip, + api_port=8728, + api_ssl_port=8729, + status=status, + ) + session.add(device) + await session.flush() + return device + + return _create + + +@pytest.fixture +def auth_headers_factory(client, create_test_tenant, create_test_user): + """Factory to create authenticated headers for a test user. + + Creates a tenant + user, logs in via the test client, and returns + the Authorization headers dict ready for use in subsequent requests. + """ + + async def _create( + admin_session: AsyncSession, + email: str | None = None, + password: str = "TestPass123!", + role: str = "tenant_admin", + tenant_name: str | None = None, + existing_tenant_id: uuid.UUID | None = None, + ) -> dict[str, Any]: + """Create user, login, return headers + tenant/user info.""" + if existing_tenant_id: + tenant_id = existing_tenant_id + else: + tenant = await create_test_tenant(admin_session, name=tenant_name) + tenant_id = tenant.id + + user = await create_test_user( + admin_session, + tenant_id=tenant_id, + email=email, + password=password, + role=role, + ) + await admin_session.commit() + + user_email = user.email + + # Login via the API + login_resp = await client.post( + "/api/auth/login", + json={"email": user_email, "password": password}, + ) + assert login_resp.status_code == 200, f"Login failed: {login_resp.text}" + tokens = login_resp.json() + + return { + "headers": {"Authorization": f"Bearer {tokens['access_token']}"}, + "access_token": tokens["access_token"], + "refresh_token": tokens.get("refresh_token"), + "tenant_id": str(tenant_id), + "user_id": str(user.id), + "user_email": user_email, + } + + return _create diff --git a/backend/tests/integration/test_alerts_api.py b/backend/tests/integration/test_alerts_api.py new file mode 100644 index 0000000..561de0d --- /dev/null +++ b/backend/tests/integration/test_alerts_api.py @@ -0,0 +1,275 @@ +""" +Integration tests for the Alerts API endpoints. + +Tests exercise: +- GET /api/tenants/{tenant_id}/alert-rules -- list rules +- POST /api/tenants/{tenant_id}/alert-rules -- create rule +- PUT /api/tenants/{tenant_id}/alert-rules/{rule_id} -- update rule +- DELETE /api/tenants/{tenant_id}/alert-rules/{rule_id} -- delete rule +- PATCH /api/tenants/{tenant_id}/alert-rules/{rule_id}/toggle +- GET /api/tenants/{tenant_id}/alerts -- list events +- GET /api/tenants/{tenant_id}/alerts/active-count -- active count +- GET /api/tenants/{tenant_id}/devices/{device_id}/alerts -- device alerts + +All tests run against real PostgreSQL. +""" + +import uuid + +import pytest + +pytestmark = pytest.mark.integration + + +VALID_ALERT_RULE = { + "name": "High CPU Alert", + "metric": "cpu_load", + "operator": "gt", + "threshold": 90.0, + "duration_polls": 3, + "severity": "warning", + "enabled": True, + "channel_ids": [], +} + + +class TestAlertRulesCRUD: + """Alert rules CRUD endpoints.""" + + async def test_list_alert_rules_empty( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/alert-rules returns 200 with empty list.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/alert-rules", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + async def test_create_alert_rule( + self, + client, + auth_headers_factory, + admin_session, + ): + """POST /api/tenants/{tenant_id}/alert-rules creates a rule.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + rule_data = {**VALID_ALERT_RULE, "name": f"CPU Alert {uuid.uuid4().hex[:6]}"} + + resp = await client.post( + f"/api/tenants/{tenant_id}/alert-rules", + json=rule_data, + headers=auth["headers"], + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == rule_data["name"] + assert data["metric"] == "cpu_load" + assert data["operator"] == "gt" + assert data["threshold"] == 90.0 + assert data["severity"] == "warning" + assert "id" in data + + async def test_update_alert_rule( + self, + client, + auth_headers_factory, + admin_session, + ): + """PUT /api/tenants/{tenant_id}/alert-rules/{rule_id} updates a rule.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + # Create a rule first + rule_data = {**VALID_ALERT_RULE, "name": f"Update Test {uuid.uuid4().hex[:6]}"} + create_resp = await client.post( + f"/api/tenants/{tenant_id}/alert-rules", + json=rule_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + rule_id = create_resp.json()["id"] + + # Update it + updated_data = {**rule_data, "threshold": 95.0, "severity": "critical"} + update_resp = await client.put( + f"/api/tenants/{tenant_id}/alert-rules/{rule_id}", + json=updated_data, + headers=auth["headers"], + ) + assert update_resp.status_code == 200 + data = update_resp.json() + assert data["threshold"] == 95.0 + assert data["severity"] == "critical" + + async def test_delete_alert_rule( + self, + client, + auth_headers_factory, + admin_session, + ): + """DELETE /api/tenants/{tenant_id}/alert-rules/{rule_id} deletes a rule.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + # Create a non-default rule + rule_data = {**VALID_ALERT_RULE, "name": f"Delete Test {uuid.uuid4().hex[:6]}"} + create_resp = await client.post( + f"/api/tenants/{tenant_id}/alert-rules", + json=rule_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + rule_id = create_resp.json()["id"] + + # Delete it + del_resp = await client.delete( + f"/api/tenants/{tenant_id}/alert-rules/{rule_id}", + headers=auth["headers"], + ) + assert del_resp.status_code == 204 + + async def test_toggle_alert_rule( + self, + client, + auth_headers_factory, + admin_session, + ): + """PATCH toggle flips the enabled state of a rule.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + # Create a rule (enabled=True) + rule_data = {**VALID_ALERT_RULE, "name": f"Toggle Test {uuid.uuid4().hex[:6]}"} + create_resp = await client.post( + f"/api/tenants/{tenant_id}/alert-rules", + json=rule_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + rule_id = create_resp.json()["id"] + + # Toggle it + toggle_resp = await client.patch( + f"/api/tenants/{tenant_id}/alert-rules/{rule_id}/toggle", + headers=auth["headers"], + ) + assert toggle_resp.status_code == 200 + data = toggle_resp.json() + assert data["enabled"] is False # Was True, toggled to False + + async def test_create_alert_rule_invalid_metric( + self, + client, + auth_headers_factory, + admin_session, + ): + """POST with invalid metric returns 422.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + rule_data = {**VALID_ALERT_RULE, "metric": "invalid_metric"} + resp = await client.post( + f"/api/tenants/{tenant_id}/alert-rules", + json=rule_data, + headers=auth["headers"], + ) + assert resp.status_code == 422 + + async def test_create_alert_rule_viewer_forbidden( + self, + client, + auth_headers_factory, + admin_session, + ): + """POST as viewer returns 403.""" + auth = await auth_headers_factory(admin_session, role="viewer") + tenant_id = auth["tenant_id"] + + resp = await client.post( + f"/api/tenants/{tenant_id}/alert-rules", + json=VALID_ALERT_RULE, + headers=auth["headers"], + ) + assert resp.status_code == 403 + + +class TestAlertEvents: + """Alert events listing endpoints.""" + + async def test_list_alerts_empty( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/alerts returns 200 with paginated empty response.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/alerts", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + assert data["total"] >= 0 + assert isinstance(data["items"], list) + + async def test_active_alert_count( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET active-count returns count of firing alerts.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/alerts/active-count", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert "count" in data + assert isinstance(data["count"], int) + assert data["count"] >= 0 + + async def test_device_alerts_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET /api/tenants/{tenant_id}/devices/{device_id}/alerts returns paginated response.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/alerts", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py new file mode 100644 index 0000000..591765b --- /dev/null +++ b/backend/tests/integration/test_auth_api.py @@ -0,0 +1,302 @@ +""" +Auth API endpoint integration tests (TEST-04 partial). + +Tests auth endpoints end-to-end against real PostgreSQL: +- POST /api/auth/login (success, wrong password, nonexistent user) +- POST /api/auth/refresh (token refresh flow) +- GET /api/auth/me (current user info) +- Protected endpoint access without/with invalid token +""" + +import uuid + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from app.models.tenant import Tenant +from app.models.user import User +from app.services.auth import hash_password + +pytestmark = pytest.mark.integration + +from tests.integration.conftest import TEST_DATABASE_URL + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _admin_commit(url, callback): + """Open a fresh admin connection, run callback, commit, close.""" + engine = create_async_engine(url, echo=False) + async with engine.connect() as conn: + session = AsyncSession(bind=conn, expire_on_commit=False) + result = await callback(session) + await session.commit() + await engine.dispose() + return result + + +async def _admin_cleanup(url, *table_names): + """Delete from specified tables via admin engine.""" + from sqlalchemy import text + + engine = create_async_engine(url, echo=False) + async with engine.connect() as conn: + for table in table_names: + await conn.execute(text(f"DELETE FROM {table}")) + await conn.commit() + await engine.dispose() + + +# --------------------------------------------------------------------------- +# Test 1: Login success +# --------------------------------------------------------------------------- + + +async def test_login_success(client, admin_engine): + """POST /api/auth/login with correct credentials returns 200 and tokens.""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + tenant = Tenant(name=f"auth-login-{uid}") + session.add(tenant) + await session.flush() + + user = User( + email=f"auth-login-{uid}@example.com", + hashed_password=hash_password("SecurePass123!"), + name="Auth Test User", + role="tenant_admin", + tenant_id=tenant.id, + is_active=True, + ) + session.add(user) + await session.flush() + return {"email": user.email, "tenant_id": str(tenant.id)} + + data = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + resp = await client.post( + "/api/auth/login", + json={"email": data["email"], "password": "SecurePass123!"}, + ) + assert resp.status_code == 200, f"Login failed: {resp.text}" + + body = resp.json() + assert "access_token" in body + assert "refresh_token" in body + assert body["token_type"] == "bearer" + assert len(body["access_token"]) > 0 + assert len(body["refresh_token"]) > 0 + + # Verify httpOnly cookie is set + cookies = resp.cookies + # Cookie may or may not appear in httpx depending on secure flag + # Just verify the response contains Set-Cookie header + set_cookie = resp.headers.get("set-cookie", "") + assert "access_token" in set_cookie or len(body["access_token"]) > 0 + finally: + await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants") + + +# --------------------------------------------------------------------------- +# Test 2: Login with wrong password +# --------------------------------------------------------------------------- + + +async def test_login_wrong_password(client, admin_engine): + """POST /api/auth/login with wrong password returns 401.""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + tenant = Tenant(name=f"auth-wrongpw-{uid}") + session.add(tenant) + await session.flush() + + user = User( + email=f"auth-wrongpw-{uid}@example.com", + hashed_password=hash_password("CorrectPass123!"), + name="Wrong PW User", + role="tenant_admin", + tenant_id=tenant.id, + is_active=True, + ) + session.add(user) + await session.flush() + return {"email": user.email} + + data = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + resp = await client.post( + "/api/auth/login", + json={"email": data["email"], "password": "WrongPassword!"}, + ) + assert resp.status_code == 401 + assert "Invalid credentials" in resp.json()["detail"] + finally: + await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants") + + +# --------------------------------------------------------------------------- +# Test 3: Login with nonexistent user +# --------------------------------------------------------------------------- + + +async def test_login_nonexistent_user(client): + """POST /api/auth/login with email that doesn't exist returns 401.""" + resp = await client.post( + "/api/auth/login", + json={"email": f"doesnotexist-{uuid.uuid4().hex[:6]}@example.com", "password": "Anything!"}, + ) + assert resp.status_code == 401 + assert "Invalid credentials" in resp.json()["detail"] + + +# --------------------------------------------------------------------------- +# Test 4: Token refresh +# --------------------------------------------------------------------------- + + +async def test_token_refresh(client, admin_engine): + """POST /api/auth/refresh with valid refresh token returns new tokens.""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + tenant = Tenant(name=f"auth-refresh-{uid}") + session.add(tenant) + await session.flush() + + user = User( + email=f"auth-refresh-{uid}@example.com", + hashed_password=hash_password("RefreshPass123!"), + name="Refresh User", + role="tenant_admin", + tenant_id=tenant.id, + is_active=True, + ) + session.add(user) + await session.flush() + return {"email": user.email} + + data = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + # Login first to get refresh token + login_resp = await client.post( + "/api/auth/login", + json={"email": data["email"], "password": "RefreshPass123!"}, + ) + assert login_resp.status_code == 200 + tokens = login_resp.json() + refresh_token = tokens["refresh_token"] + original_access = tokens["access_token"] + + # Use refresh token to get new access token + refresh_resp = await client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert refresh_resp.status_code == 200 + + new_tokens = refresh_resp.json() + assert "access_token" in new_tokens + assert "refresh_token" in new_tokens + assert new_tokens["token_type"] == "bearer" + # Verify the new access token is a valid JWT (can be same if within same second) + assert len(new_tokens["access_token"]) > 0 + assert len(new_tokens["refresh_token"]) > 0 + + # Verify the new access token works for /me + me_resp = await client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {new_tokens['access_token']}"}, + ) + assert me_resp.status_code == 200 + assert me_resp.json()["email"] == data["email"] + finally: + await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants") + + +# --------------------------------------------------------------------------- +# Test 5: Get current user +# --------------------------------------------------------------------------- + + +async def test_get_current_user(client, admin_engine): + """GET /api/auth/me with valid token returns current user info.""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + tenant = Tenant(name=f"auth-me-{uid}") + session.add(tenant) + await session.flush() + + user = User( + email=f"auth-me-{uid}@example.com", + hashed_password=hash_password("MePass123!"), + name="Me User", + role="tenant_admin", + tenant_id=tenant.id, + is_active=True, + ) + session.add(user) + await session.flush() + return {"email": user.email, "tenant_id": str(tenant.id), "user_id": str(user.id)} + + data = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + # Login + login_resp = await client.post( + "/api/auth/login", + json={"email": data["email"], "password": "MePass123!"}, + ) + assert login_resp.status_code == 200 + token = login_resp.json()["access_token"] + + # Get /me + me_resp = await client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert me_resp.status_code == 200 + + me_data = me_resp.json() + assert me_data["email"] == data["email"] + assert me_data["name"] == "Me User" + assert me_data["role"] == "tenant_admin" + assert me_data["tenant_id"] == data["tenant_id"] + assert me_data["id"] == data["user_id"] + finally: + await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants") + + +# --------------------------------------------------------------------------- +# Test 6: Protected endpoint without token +# --------------------------------------------------------------------------- + + +async def test_protected_endpoint_without_token(client): + """GET /api/tenants/{id}/devices without auth headers returns 401.""" + fake_tenant_id = str(uuid.uuid4()) + resp = await client.get(f"/api/tenants/{fake_tenant_id}/devices") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Test 7: Protected endpoint with invalid token +# --------------------------------------------------------------------------- + + +async def test_protected_endpoint_with_invalid_token(client): + """GET /api/tenants/{id}/devices with invalid Bearer token returns 401.""" + fake_tenant_id = str(uuid.uuid4()) + resp = await client.get( + f"/api/tenants/{fake_tenant_id}/devices", + headers={"Authorization": "Bearer totally-invalid-jwt-token"}, + ) + assert resp.status_code == 401 diff --git a/backend/tests/integration/test_config_api.py b/backend/tests/integration/test_config_api.py new file mode 100644 index 0000000..4fcaeb6 --- /dev/null +++ b/backend/tests/integration/test_config_api.py @@ -0,0 +1,149 @@ +""" +Integration tests for the Config Backup API endpoints. + +Tests exercise: +- GET /api/tenants/{tenant_id}/devices/{device_id}/config/backups +- GET /api/tenants/{tenant_id}/devices/{device_id}/config/schedules +- PUT /api/tenants/{tenant_id}/devices/{device_id}/config/schedules + +POST /backups (trigger) and POST /restore require actual RouterOS connections +and git store, so we only test that the endpoints exist and respond appropriately. + +All tests run against real PostgreSQL. +""" + +import uuid + +import pytest + +pytestmark = pytest.mark.integration + + +class TestConfigBackups: + """Config backup listing and schedule endpoints.""" + + async def test_list_config_backups_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET config backups for a device with no backups returns 200 + empty list.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/config/backups", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 0 + + async def test_get_backup_schedule_default( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET schedule returns synthetic default when no schedule configured.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/config/schedules", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["is_default"] is True + assert data["cron_expression"] == "0 2 * * *" + assert data["enabled"] is True + + async def test_update_backup_schedule( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """PUT schedule creates/updates device-specific backup schedule.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id, role="operator" + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + schedule_data = { + "cron_expression": "0 3 * * 1", # Monday at 3am + "enabled": True, + } + resp = await client.put( + f"/api/tenants/{tenant_id}/devices/{device.id}/config/schedules", + json=schedule_data, + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["cron_expression"] == "0 3 * * 1" + assert data["enabled"] is True + assert data["is_default"] is False + assert data["device_id"] == str(device.id) + + async def test_backup_endpoints_respond( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """Config backup router responds (not 404) for expected paths.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + # List backups -- should respond + backups_resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/config/backups", + headers=auth["headers"], + ) + assert backups_resp.status_code != 404 + + # Get schedule -- should respond + schedule_resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/config/schedules", + headers=auth["headers"], + ) + assert schedule_resp.status_code != 404 + + async def test_config_backups_unauthenticated(self, client): + """GET config backups without auth returns 401.""" + tenant_id = str(uuid.uuid4()) + device_id = str(uuid.uuid4()) + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device_id}/config/backups" + ) + assert resp.status_code == 401 diff --git a/backend/tests/integration/test_devices_api.py b/backend/tests/integration/test_devices_api.py new file mode 100644 index 0000000..555df7b --- /dev/null +++ b/backend/tests/integration/test_devices_api.py @@ -0,0 +1,227 @@ +""" +Integration tests for the Device CRUD API endpoints. + +Tests exercise /api/tenants/{tenant_id}/devices/* endpoints against +real PostgreSQL+TimescaleDB with full auth + RLS enforcement. + +All tests are independent and create their own test data. +""" + +import uuid + +import pytest +import pytest_asyncio + + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def _unique_suffix(): + """Return a short unique suffix for test data.""" + return uuid.uuid4().hex[:8] + + +class TestDevicesCRUD: + """Device list, create, get, update, delete endpoints.""" + + async def test_list_devices_empty( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/devices returns 200 with empty list.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["items"] == [] + assert data["total"] == 0 + + async def test_create_device( + self, + client, + auth_headers_factory, + admin_session, + ): + """POST /api/tenants/{tenant_id}/devices creates a device and returns 201.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + device_data = { + "hostname": f"test-router-{uuid.uuid4().hex[:8]}", + "ip_address": "192.168.88.1", + "api_port": 8728, + "api_ssl_port": 8729, + "username": "admin", + "password": "admin123", + } + + resp = await client.post( + f"/api/tenants/{tenant_id}/devices", + json=device_data, + headers=auth["headers"], + ) + # create_device does TCP probe -- may fail in test env without real device + # Accept either 201 (success) or 502/422 (connectivity check failure) + if resp.status_code == 201: + data = resp.json() + assert data["hostname"] == device_data["hostname"] + assert data["ip_address"] == device_data["ip_address"] + assert "id" in data + # Credentials should never be returned in response + assert "password" not in data + assert "username" not in data + assert "encrypted_credentials" not in data + + async def test_get_device( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET /api/tenants/{tenant_id}/devices/{device_id} returns correct device.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == str(device.id) + assert data["hostname"] == device.hostname + assert data["ip_address"] == device.ip_address + + async def test_update_device( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """PUT /api/tenants/{tenant_id}/devices/{device_id} updates device fields.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id, role="operator" + ) + tenant_id = auth["tenant_id"] + + device = await create_test_device(admin_session, tenant.id, hostname="old-hostname") + await admin_session.commit() + + update_data = {"hostname": f"new-hostname-{uuid.uuid4().hex[:8]}"} + resp = await client.put( + f"/api/tenants/{tenant_id}/devices/{device.id}", + json=update_data, + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["hostname"] == update_data["hostname"] + + async def test_delete_device( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """DELETE /api/tenants/{tenant_id}/devices/{device_id} removes the device.""" + tenant = await create_test_tenant(admin_session) + # delete requires tenant_admin or above + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id, role="tenant_admin" + ) + tenant_id = auth["tenant_id"] + + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.delete( + f"/api/tenants/{tenant_id}/devices/{device.id}", + headers=auth["headers"], + ) + assert resp.status_code == 204 + + # Verify it's gone + get_resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}", + headers=auth["headers"], + ) + assert get_resp.status_code == 404 + + async def test_list_devices_with_status_filter( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET /api/tenants/{tenant_id}/devices?status=online returns filtered results.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + + # Create devices with different statuses + await create_test_device( + admin_session, tenant.id, hostname="online-device", status="online" + ) + await create_test_device( + admin_session, tenant.id, hostname="offline-device", status="offline" + ) + await admin_session.commit() + + # Filter for online only + resp = await client.get( + f"/api/tenants/{tenant_id}/devices?status=online", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + for item in data["items"]: + assert item["status"] == "online" + + async def test_get_device_not_found( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/devices/{nonexistent} returns 404.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + fake_id = str(uuid.uuid4()) + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{fake_id}", + headers=auth["headers"], + ) + assert resp.status_code == 404 + + async def test_list_devices_unauthenticated(self, client): + """GET /api/tenants/{tenant_id}/devices without auth returns 401.""" + tenant_id = str(uuid.uuid4()) + resp = await client.get(f"/api/tenants/{tenant_id}/devices") + assert resp.status_code == 401 diff --git a/backend/tests/integration/test_firmware_api.py b/backend/tests/integration/test_firmware_api.py new file mode 100644 index 0000000..42bf18d --- /dev/null +++ b/backend/tests/integration/test_firmware_api.py @@ -0,0 +1,183 @@ +""" +Integration tests for the Firmware API endpoints. + +Tests exercise: +- GET /api/firmware/versions -- list firmware versions (global) +- GET /api/tenants/{tenant_id}/firmware/overview -- firmware overview per tenant +- GET /api/tenants/{tenant_id}/firmware/upgrades -- list upgrade jobs +- PATCH /api/tenants/{tenant_id}/devices/{device_id}/preferred-channel + +Upgrade endpoints (POST .../upgrade, .../mass-upgrade) require actual RouterOS +connections and NATS, so we verify the endpoint exists and handles missing +services gracefully. Download/cache endpoints require super_admin. + +All tests run against real PostgreSQL. +""" + +import uuid + +import pytest + +pytestmark = pytest.mark.integration + + +class TestFirmwareVersions: + """Firmware version listing endpoints.""" + + async def test_list_firmware_versions( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/firmware/versions returns 200 with list (may be empty).""" + auth = await auth_headers_factory(admin_session) + + resp = await client.get( + "/api/firmware/versions", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + async def test_list_firmware_versions_with_filters( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/firmware/versions with filters returns 200.""" + auth = await auth_headers_factory(admin_session) + + resp = await client.get( + "/api/firmware/versions", + params={"architecture": "arm", "channel": "stable"}, + headers=auth["headers"], + ) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +class TestFirmwareOverview: + """Tenant-scoped firmware overview.""" + + async def test_firmware_overview( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/firmware/overview returns 200.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/firmware/overview", + headers=auth["headers"], + ) + # May return 200 or 500 if firmware_service depends on external state + # At minimum, it should not be 404 + assert resp.status_code != 404 + + +class TestPreferredChannel: + """Device preferred firmware channel endpoint.""" + + async def test_set_device_preferred_channel( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """PATCH preferred channel updates the device firmware channel preference.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id, role="operator" + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.patch( + f"/api/tenants/{tenant_id}/devices/{device.id}/preferred-channel", + json={"preferred_channel": "long-term"}, + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["preferred_channel"] == "long-term" + assert data["status"] == "ok" + + async def test_set_invalid_preferred_channel( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """PATCH with invalid channel returns 422.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id, role="operator" + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.patch( + f"/api/tenants/{tenant_id}/devices/{device.id}/preferred-channel", + json={"preferred_channel": "invalid"}, + headers=auth["headers"], + ) + assert resp.status_code == 422 + + +class TestUpgradeJobs: + """Upgrade job listing endpoints.""" + + async def test_list_upgrade_jobs_empty( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/firmware/upgrades returns paginated response.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/firmware/upgrades", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + assert isinstance(data["items"], list) + assert data["total"] >= 0 + + async def test_get_upgrade_job_not_found( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/firmware/upgrades/{fake_id} returns 404.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + fake_id = str(uuid.uuid4()) + + resp = await client.get( + f"/api/tenants/{tenant_id}/firmware/upgrades/{fake_id}", + headers=auth["headers"], + ) + assert resp.status_code == 404 + + async def test_firmware_unauthenticated(self, client): + """GET firmware versions without auth returns 401.""" + resp = await client.get("/api/firmware/versions") + assert resp.status_code == 401 diff --git a/backend/tests/integration/test_monitoring_api.py b/backend/tests/integration/test_monitoring_api.py new file mode 100644 index 0000000..738fb18 --- /dev/null +++ b/backend/tests/integration/test_monitoring_api.py @@ -0,0 +1,323 @@ +""" +Integration tests for the Monitoring / Metrics API endpoints. + +Tests exercise: +- /api/tenants/{tenant_id}/devices/{device_id}/metrics/health +- /api/tenants/{tenant_id}/devices/{device_id}/metrics/interfaces +- /api/tenants/{tenant_id}/devices/{device_id}/metrics/interfaces/list +- /api/tenants/{tenant_id}/devices/{device_id}/metrics/wireless +- /api/tenants/{tenant_id}/devices/{device_id}/metrics/wireless/latest +- /api/tenants/{tenant_id}/devices/{device_id}/metrics/sparkline +- /api/tenants/{tenant_id}/fleet/summary +- /api/fleet/summary (super_admin only) + +All tests run against real PostgreSQL+TimescaleDB. +""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import text + +pytestmark = pytest.mark.integration + + +class TestHealthMetrics: + """Device health metrics endpoints.""" + + async def test_get_device_health_metrics_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET health metrics for a device with no data returns 200 + empty list.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + now = datetime.now(timezone.utc) + start = (now - timedelta(hours=1)).isoformat() + end = now.isoformat() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/metrics/health", + params={"start": start, "end": end}, + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 0 + + async def test_get_device_health_metrics_with_data( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET health metrics returns bucketed data when rows exist.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.flush() + + # Insert test metric rows directly via admin session + now = datetime.now(timezone.utc) + for i in range(5): + ts = now - timedelta(minutes=i * 5) + await admin_session.execute( + text( + "INSERT INTO health_metrics " + "(device_id, time, cpu_load, free_memory, total_memory, " + "free_disk, total_disk, temperature) " + "VALUES (:device_id, :ts, :cpu, :free_mem, :total_mem, " + ":free_disk, :total_disk, :temp)" + ), + { + "device_id": str(device.id), + "ts": ts, + "cpu": 30 + i * 5, + "free_mem": 500000000, + "total_mem": 1000000000, + "free_disk": 200000000, + "total_disk": 500000000, + "temp": 45, + }, + ) + await admin_session.commit() + + start = (now - timedelta(hours=1)).isoformat() + end = now.isoformat() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/metrics/health", + params={"start": start, "end": end}, + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) > 0 + # Each bucket should have expected fields + for bucket in data: + assert "bucket" in bucket + assert "avg_cpu" in bucket + + +class TestInterfaceMetrics: + """Interface traffic metrics endpoints.""" + + async def test_get_interface_metrics_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET interface metrics for device with no data returns 200 + empty list.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + now = datetime.now(timezone.utc) + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/metrics/interfaces", + params={ + "start": (now - timedelta(hours=1)).isoformat(), + "end": now.isoformat(), + }, + headers=auth["headers"], + ) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + async def test_get_interface_list_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET interface list for device with no data returns 200 + empty list.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/metrics/interfaces/list", + headers=auth["headers"], + ) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +class TestSparkline: + """Sparkline endpoint.""" + + async def test_sparkline_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET sparkline for device with no data returns 200 + empty list.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/metrics/sparkline", + headers=auth["headers"], + ) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +class TestFleetSummary: + """Fleet summary endpoints.""" + + async def test_fleet_summary_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_tenant, + ): + """GET /api/tenants/{tenant_id}/fleet/summary returns 200 with empty fleet.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/fleet/summary", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + async def test_fleet_summary_with_devices( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET fleet summary returns device data when devices exist.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + + await create_test_device(admin_session, tenant.id, hostname="fleet-dev-1") + await create_test_device(admin_session, tenant.id, hostname="fleet-dev-2") + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/fleet/summary", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 2 + hostnames = [d["hostname"] for d in data] + assert "fleet-dev-1" in hostnames + assert "fleet-dev-2" in hostnames + + async def test_fleet_summary_unauthenticated(self, client): + """GET fleet summary without auth returns 401.""" + tenant_id = str(uuid.uuid4()) + resp = await client.get(f"/api/tenants/{tenant_id}/fleet/summary") + assert resp.status_code == 401 + + +class TestWirelessMetrics: + """Wireless metrics endpoints.""" + + async def test_wireless_metrics_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET wireless metrics for device with no data returns 200 + empty list.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + now = datetime.now(timezone.utc) + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/metrics/wireless", + params={ + "start": (now - timedelta(hours=1)).isoformat(), + "end": now.isoformat(), + }, + headers=auth["headers"], + ) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + async def test_wireless_latest_empty( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """GET wireless latest for device with no data returns 200 + empty list.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id + ) + tenant_id = auth["tenant_id"] + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + resp = await client.get( + f"/api/tenants/{tenant_id}/devices/{device.id}/metrics/wireless/latest", + headers=auth["headers"], + ) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) diff --git a/backend/tests/integration/test_rls_isolation.py b/backend/tests/integration/test_rls_isolation.py new file mode 100644 index 0000000..bbd1366 --- /dev/null +++ b/backend/tests/integration/test_rls_isolation.py @@ -0,0 +1,437 @@ +""" +RLS (Row Level Security) tenant isolation integration tests. + +Verifies that PostgreSQL RLS policies correctly isolate tenant data: +- Tenant A cannot see Tenant B's devices, alerts, or device groups +- Tenant A cannot insert data into Tenant B's namespace +- super_admin context sees all tenants +- API-level isolation matches DB-level isolation + +These tests commit real data to PostgreSQL and use the app_user engine +(which enforces RLS) to validate isolation. Each test creates unique +entity names to avoid collisions and cleans up via admin engine. +""" + +import uuid + +import pytest +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from app.database import set_tenant_context +from app.models.alert import AlertRule +from app.models.device import Device, DeviceGroup +from app.models.tenant import Tenant +from app.models.user import User +from app.services.auth import hash_password + +pytestmark = pytest.mark.integration + +# Use the same test DB URLs as conftest +from tests.integration.conftest import TEST_APP_USER_DATABASE_URL, TEST_DATABASE_URL + + +# --------------------------------------------------------------------------- +# Helpers: create and commit entities, and cleanup +# --------------------------------------------------------------------------- + + +async def _admin_commit(url, callback): + """Open a fresh admin connection, run callback, commit, close.""" + engine = create_async_engine(url, echo=False) + async with engine.connect() as conn: + session = AsyncSession(bind=conn, expire_on_commit=False) + result = await callback(session) + await session.commit() + await engine.dispose() + return result + + +async def _app_query(url, tenant_id, model_class): + """Open a fresh app_user connection, set tenant context, query model, close.""" + engine = create_async_engine(url, echo=False) + async with engine.connect() as conn: + session = AsyncSession(bind=conn, expire_on_commit=False) + await set_tenant_context(session, tenant_id) + result = await session.execute(select(model_class)) + rows = result.scalars().all() + await engine.dispose() + return rows + + +async def _admin_cleanup(url, *table_names): + """Truncate specified tables via admin engine.""" + engine = create_async_engine(url, echo=False) + async with engine.connect() as conn: + for table in table_names: + await conn.execute(text(f"DELETE FROM {table}")) + await conn.commit() + await engine.dispose() + + +# --------------------------------------------------------------------------- +# Test 1: Tenant A cannot see Tenant B devices +# --------------------------------------------------------------------------- + + +async def test_tenant_a_cannot_see_tenant_b_devices(): + """Tenant A app_user session only returns Tenant A devices.""" + uid = uuid.uuid4().hex[:6] + + # Create tenants via admin + async def setup(session): + ta = Tenant(name=f"rls-dev-ta-{uid}") + tb = Tenant(name=f"rls-dev-tb-{uid}") + session.add_all([ta, tb]) + await session.flush() + + da = Device( + tenant_id=ta.id, hostname=f"rls-ra-{uid}", ip_address="10.1.1.1", + api_port=8728, api_ssl_port=8729, status="online", + ) + db = Device( + tenant_id=tb.id, hostname=f"rls-rb-{uid}", ip_address="10.1.1.2", + api_port=8728, api_ssl_port=8729, status="online", + ) + session.add_all([da, db]) + await session.flush() + return {"ta_id": str(ta.id), "tb_id": str(tb.id)} + + ids = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + # Query as Tenant A + devices_a = await _app_query(TEST_APP_USER_DATABASE_URL, ids["ta_id"], Device) + assert len(devices_a) == 1 + assert devices_a[0].hostname == f"rls-ra-{uid}" + + # Query as Tenant B + devices_b = await _app_query(TEST_APP_USER_DATABASE_URL, ids["tb_id"], Device) + assert len(devices_b) == 1 + assert devices_b[0].hostname == f"rls-rb-{uid}" + finally: + await _admin_cleanup(TEST_DATABASE_URL, "devices", "tenants") + + +# --------------------------------------------------------------------------- +# Test 2: Tenant A cannot see Tenant B alerts +# --------------------------------------------------------------------------- + + +async def test_tenant_a_cannot_see_tenant_b_alerts(): + """Tenant A only sees its own alert rules.""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + ta = Tenant(name=f"rls-alrt-ta-{uid}") + tb = Tenant(name=f"rls-alrt-tb-{uid}") + session.add_all([ta, tb]) + await session.flush() + + ra = AlertRule( + tenant_id=ta.id, name=f"CPU Alert A {uid}", + metric="cpu_load", operator=">", threshold=90.0, severity="warning", + ) + rb = AlertRule( + tenant_id=tb.id, name=f"CPU Alert B {uid}", + metric="cpu_load", operator=">", threshold=85.0, severity="critical", + ) + session.add_all([ra, rb]) + await session.flush() + return {"ta_id": str(ta.id), "tb_id": str(tb.id)} + + ids = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + rules_a = await _app_query(TEST_APP_USER_DATABASE_URL, ids["ta_id"], AlertRule) + assert len(rules_a) == 1 + assert rules_a[0].name == f"CPU Alert A {uid}" + finally: + await _admin_cleanup(TEST_DATABASE_URL, "alert_rules", "tenants") + + +# --------------------------------------------------------------------------- +# Test 3: Tenant A cannot see Tenant B device groups +# --------------------------------------------------------------------------- + + +async def test_tenant_a_cannot_see_tenant_b_device_groups(): + """Tenant A only sees its own device groups.""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + ta = Tenant(name=f"rls-grp-ta-{uid}") + tb = Tenant(name=f"rls-grp-tb-{uid}") + session.add_all([ta, tb]) + await session.flush() + + ga = DeviceGroup(tenant_id=ta.id, name=f"Group A {uid}") + gb = DeviceGroup(tenant_id=tb.id, name=f"Group B {uid}") + session.add_all([ga, gb]) + await session.flush() + return {"ta_id": str(ta.id), "tb_id": str(tb.id)} + + ids = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + groups_a = await _app_query(TEST_APP_USER_DATABASE_URL, ids["ta_id"], DeviceGroup) + assert len(groups_a) == 1 + assert groups_a[0].name == f"Group A {uid}" + finally: + await _admin_cleanup(TEST_DATABASE_URL, "device_groups", "tenants") + + +# --------------------------------------------------------------------------- +# Test 4: Tenant A cannot insert device into Tenant B +# --------------------------------------------------------------------------- + + +async def test_tenant_a_cannot_insert_device_into_tenant_b(): + """Inserting a device with tenant_b's ID while in tenant_a context should fail or be invisible.""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + ta = Tenant(name=f"rls-ins-ta-{uid}") + tb = Tenant(name=f"rls-ins-tb-{uid}") + session.add_all([ta, tb]) + await session.flush() + return {"ta_id": str(ta.id), "tb_id": str(tb.id)} + + ids = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + engine = create_async_engine(TEST_APP_USER_DATABASE_URL, echo=False) + async with engine.connect() as conn: + session = AsyncSession(bind=conn, expire_on_commit=False) + await set_tenant_context(session, ids["ta_id"]) + + # Attempt to insert a device with tenant_b's tenant_id + device = Device( + tenant_id=uuid.UUID(ids["tb_id"]), + hostname=f"evil-device-{uid}", + ip_address="10.99.99.99", + api_port=8728, + api_ssl_port=8729, + status="online", + ) + session.add(device) + + # RLS policy should prevent this -- either by raising an error + # or by making the row invisible after insert + try: + await session.flush() + # If the insert succeeded, verify the device is NOT visible + result = await session.execute(select(Device)) + visible = result.scalars().all() + cross_tenant = [d for d in visible if d.hostname == f"evil-device-{uid}"] + assert len(cross_tenant) == 0, ( + "Cross-tenant device should not be visible to tenant_a" + ) + except Exception: + # RLS violation raised -- this is the expected behavior + pass + await engine.dispose() + finally: + await _admin_cleanup(TEST_DATABASE_URL, "devices", "tenants") + + +# --------------------------------------------------------------------------- +# Test 5: super_admin sees all tenants +# --------------------------------------------------------------------------- + + +async def test_super_admin_sees_all_tenants(): + """super_admin bypasses RLS via admin engine (superuser) and sees all devices. + + The RLS policy does NOT have a special 'super_admin' tenant context. + Instead, super_admin users use the admin engine (PostgreSQL superuser) + which bypasses all RLS policies entirely. + """ + uid = uuid.uuid4().hex[:6] + + async def setup(session): + ta = Tenant(name=f"rls-sa-ta-{uid}") + tb = Tenant(name=f"rls-sa-tb-{uid}") + session.add_all([ta, tb]) + await session.flush() + + da = Device( + tenant_id=ta.id, hostname=f"sa-ra-{uid}", ip_address="10.2.1.1", + api_port=8728, api_ssl_port=8729, status="online", + ) + db = Device( + tenant_id=tb.id, hostname=f"sa-rb-{uid}", ip_address="10.2.1.2", + api_port=8728, api_ssl_port=8729, status="online", + ) + session.add_all([da, db]) + await session.flush() + return {"ta_id": str(ta.id), "tb_id": str(tb.id)} + + ids = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + # super_admin uses admin engine (superuser) which bypasses RLS + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + async with engine.connect() as conn: + session = AsyncSession(bind=conn, expire_on_commit=False) + result = await session.execute(select(Device)) + devices = result.scalars().all() + await engine.dispose() + + # Admin engine (superuser) should see devices from both tenants + hostnames = {d.hostname for d in devices} + assert f"sa-ra-{uid}" in hostnames, "admin engine should see tenant_a device" + assert f"sa-rb-{uid}" in hostnames, "admin engine should see tenant_b device" + + # Verify that app_user engine with a specific tenant only sees that tenant + devices_a = await _app_query(TEST_APP_USER_DATABASE_URL, ids["ta_id"], Device) + hostnames_a = {d.hostname for d in devices_a} + assert f"sa-ra-{uid}" in hostnames_a + assert f"sa-rb-{uid}" not in hostnames_a + finally: + await _admin_cleanup(TEST_DATABASE_URL, "devices", "tenants") + + +# --------------------------------------------------------------------------- +# Test 6: API-level RLS isolation (devices endpoint) +# --------------------------------------------------------------------------- + + +async def test_api_rls_isolation_devices_endpoint(client, admin_engine): + """Each user only sees their own tenant's devices via the API.""" + uid = uuid.uuid4().hex[:6] + + # Create data via admin engine (committed for API visibility) + async def setup(session): + ta = Tenant(name=f"api-rls-ta-{uid}") + tb = Tenant(name=f"api-rls-tb-{uid}") + session.add_all([ta, tb]) + await session.flush() + + ua = User( + email=f"api-ua-{uid}@example.com", + hashed_password=hash_password("TestPass123!"), + name="User A", role="tenant_admin", + tenant_id=ta.id, is_active=True, + ) + ub = User( + email=f"api-ub-{uid}@example.com", + hashed_password=hash_password("TestPass123!"), + name="User B", role="tenant_admin", + tenant_id=tb.id, is_active=True, + ) + session.add_all([ua, ub]) + await session.flush() + + da = Device( + tenant_id=ta.id, hostname=f"api-ra-{uid}", ip_address="10.3.1.1", + api_port=8728, api_ssl_port=8729, status="online", + ) + db = Device( + tenant_id=tb.id, hostname=f"api-rb-{uid}", ip_address="10.3.1.2", + api_port=8728, api_ssl_port=8729, status="online", + ) + session.add_all([da, db]) + await session.flush() + return { + "ta_id": str(ta.id), "tb_id": str(tb.id), + "ua_email": ua.email, "ub_email": ub.email, + } + + ids = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + # Login as user A + login_a = await client.post( + "/api/auth/login", + json={"email": ids["ua_email"], "password": "TestPass123!"}, + ) + assert login_a.status_code == 200, f"Login A failed: {login_a.text}" + token_a = login_a.json()["access_token"] + + # Login as user B + login_b = await client.post( + "/api/auth/login", + json={"email": ids["ub_email"], "password": "TestPass123!"}, + ) + assert login_b.status_code == 200, f"Login B failed: {login_b.text}" + token_b = login_b.json()["access_token"] + + # User A lists devices for tenant A + resp_a = await client.get( + f"/api/tenants/{ids['ta_id']}/devices", + headers={"Authorization": f"Bearer {token_a}"}, + ) + assert resp_a.status_code == 200 + hostnames_a = [d["hostname"] for d in resp_a.json()["items"]] + assert f"api-ra-{uid}" in hostnames_a + assert f"api-rb-{uid}" not in hostnames_a + + # User B lists devices for tenant B + resp_b = await client.get( + f"/api/tenants/{ids['tb_id']}/devices", + headers={"Authorization": f"Bearer {token_b}"}, + ) + assert resp_b.status_code == 200 + hostnames_b = [d["hostname"] for d in resp_b.json()["items"]] + assert f"api-rb-{uid}" in hostnames_b + assert f"api-ra-{uid}" not in hostnames_b + finally: + await _admin_cleanup(TEST_DATABASE_URL, "devices", "users", "tenants") + + +# --------------------------------------------------------------------------- +# Test 7: API-level cross-tenant device access +# --------------------------------------------------------------------------- + + +async def test_api_rls_isolation_cross_tenant_device_access(client, admin_engine): + """Accessing another tenant's endpoint returns 403 (tenant access check).""" + uid = uuid.uuid4().hex[:6] + + async def setup(session): + ta = Tenant(name=f"api-xt-ta-{uid}") + tb = Tenant(name=f"api-xt-tb-{uid}") + session.add_all([ta, tb]) + await session.flush() + + ua = User( + email=f"api-xt-ua-{uid}@example.com", + hashed_password=hash_password("TestPass123!"), + name="User A", role="tenant_admin", + tenant_id=ta.id, is_active=True, + ) + session.add(ua) + await session.flush() + + db = Device( + tenant_id=tb.id, hostname=f"api-xt-rb-{uid}", ip_address="10.4.1.1", + api_port=8728, api_ssl_port=8729, status="online", + ) + session.add(db) + await session.flush() + return { + "ta_id": str(ta.id), "tb_id": str(tb.id), + "ua_email": ua.email, "db_id": str(db.id), + } + + ids = await _admin_commit(TEST_DATABASE_URL, setup) + + try: + # Login as user A + login_a = await client.post( + "/api/auth/login", + json={"email": ids["ua_email"], "password": "TestPass123!"}, + ) + assert login_a.status_code == 200 + token_a = login_a.json()["access_token"] + + # User A tries to access tenant B's devices endpoint + resp = await client.get( + f"/api/tenants/{ids['tb_id']}/devices", + headers={"Authorization": f"Bearer {token_a}"}, + ) + # Should be 403 -- tenant access check prevents cross-tenant access + assert resp.status_code == 403 + finally: + await _admin_cleanup(TEST_DATABASE_URL, "devices", "users", "tenants") diff --git a/backend/tests/integration/test_templates_api.py b/backend/tests/integration/test_templates_api.py new file mode 100644 index 0000000..1d1a378 --- /dev/null +++ b/backend/tests/integration/test_templates_api.py @@ -0,0 +1,322 @@ +""" +Integration tests for the Config Templates API endpoints. + +Tests exercise: +- GET /api/tenants/{tenant_id}/templates -- list templates +- POST /api/tenants/{tenant_id}/templates -- create template +- GET /api/tenants/{tenant_id}/templates/{id} -- get template +- PUT /api/tenants/{tenant_id}/templates/{id} -- update template +- DELETE /api/tenants/{tenant_id}/templates/{id} -- delete template +- POST /api/tenants/{tenant_id}/templates/{id}/preview -- preview rendered template + +Push endpoints (POST .../push) require actual RouterOS connections, so we +only test the preview endpoint which only needs a database device record. + +All tests run against real PostgreSQL. +""" + +import uuid + +import pytest + +pytestmark = pytest.mark.integration + +TEMPLATE_CONTENT = """/ip address add address={{ ip_address }}/24 interface=ether1 +/system identity set name={{ hostname }} +""" + +TEMPLATE_VARIABLES = [ + {"name": "ip_address", "type": "ip", "default": "192.168.1.1"}, + {"name": "hostname", "type": "string", "default": "router"}, +] + + +class TestTemplatesCRUD: + """Template list, create, get, update, delete endpoints.""" + + async def test_list_templates_empty( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/templates returns 200 with empty list.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + + resp = await client.get( + f"/api/tenants/{tenant_id}/templates", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + async def test_create_template( + self, + client, + auth_headers_factory, + admin_session, + ): + """POST /api/tenants/{tenant_id}/templates creates a template.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + template_data = { + "name": f"Test Template {uuid.uuid4().hex[:6]}", + "description": "A test config template", + "content": TEMPLATE_CONTENT, + "variables": TEMPLATE_VARIABLES, + "tags": ["test", "integration"], + } + + resp = await client.post( + f"/api/tenants/{tenant_id}/templates", + json=template_data, + headers=auth["headers"], + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == template_data["name"] + assert data["description"] == "A test config template" + assert "id" in data + assert "content" in data + assert data["content"] == TEMPLATE_CONTENT + assert data["variable_count"] == 2 + assert set(data["tags"]) == {"test", "integration"} + + async def test_get_template( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET /api/tenants/{tenant_id}/templates/{id} returns full template with content.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + # Create first + create_data = { + "name": f"Get Test {uuid.uuid4().hex[:6]}", + "content": TEMPLATE_CONTENT, + "variables": TEMPLATE_VARIABLES, + "tags": [], + } + create_resp = await client.post( + f"/api/tenants/{tenant_id}/templates", + json=create_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + template_id = create_resp.json()["id"] + + # Get it + resp = await client.get( + f"/api/tenants/{tenant_id}/templates/{template_id}", + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == template_id + assert data["content"] == TEMPLATE_CONTENT + assert "variables" in data + assert len(data["variables"]) == 2 + + async def test_update_template( + self, + client, + auth_headers_factory, + admin_session, + ): + """PUT /api/tenants/{tenant_id}/templates/{id} updates template content.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + # Create first + create_data = { + "name": f"Update Test {uuid.uuid4().hex[:6]}", + "content": TEMPLATE_CONTENT, + "variables": TEMPLATE_VARIABLES, + "tags": ["original"], + } + create_resp = await client.post( + f"/api/tenants/{tenant_id}/templates", + json=create_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + template_id = create_resp.json()["id"] + + # Update it + updated_content = "/system identity set name={{ hostname }}-updated\n" + update_data = { + "name": create_data["name"], + "content": updated_content, + "variables": [{"name": "hostname", "type": "string"}], + "tags": ["updated"], + } + resp = await client.put( + f"/api/tenants/{tenant_id}/templates/{template_id}", + json=update_data, + headers=auth["headers"], + ) + assert resp.status_code == 200 + data = resp.json() + assert data["content"] == updated_content + assert data["variable_count"] == 1 + assert "updated" in data["tags"] + + async def test_delete_template( + self, + client, + auth_headers_factory, + admin_session, + ): + """DELETE /api/tenants/{tenant_id}/templates/{id} removes the template.""" + auth = await auth_headers_factory(admin_session, role="operator") + tenant_id = auth["tenant_id"] + + # Create first + create_data = { + "name": f"Delete Test {uuid.uuid4().hex[:6]}", + "content": "/system identity set name=test\n", + "variables": [], + "tags": [], + } + create_resp = await client.post( + f"/api/tenants/{tenant_id}/templates", + json=create_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + template_id = create_resp.json()["id"] + + # Delete it + resp = await client.delete( + f"/api/tenants/{tenant_id}/templates/{template_id}", + headers=auth["headers"], + ) + assert resp.status_code == 204 + + # Verify it's gone + get_resp = await client.get( + f"/api/tenants/{tenant_id}/templates/{template_id}", + headers=auth["headers"], + ) + assert get_resp.status_code == 404 + + async def test_get_template_not_found( + self, + client, + auth_headers_factory, + admin_session, + ): + """GET non-existent template returns 404.""" + auth = await auth_headers_factory(admin_session) + tenant_id = auth["tenant_id"] + fake_id = str(uuid.uuid4()) + + resp = await client.get( + f"/api/tenants/{tenant_id}/templates/{fake_id}", + headers=auth["headers"], + ) + assert resp.status_code == 404 + + +class TestTemplatePreview: + """Template preview endpoint.""" + + async def test_template_preview( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """POST /api/tenants/{tenant_id}/templates/{id}/preview renders template for device.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id, role="operator" + ) + tenant_id = auth["tenant_id"] + + # Create device for preview context + device = await create_test_device( + admin_session, tenant.id, hostname="preview-router", ip_address="10.0.1.1" + ) + await admin_session.commit() + + # Create template + template_data = { + "name": f"Preview Test {uuid.uuid4().hex[:6]}", + "content": "/system identity set name={{ hostname }}\n", + "variables": [], + "tags": [], + } + create_resp = await client.post( + f"/api/tenants/{tenant_id}/templates", + json=template_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + template_id = create_resp.json()["id"] + + # Preview it + preview_resp = await client.post( + f"/api/tenants/{tenant_id}/templates/{template_id}/preview", + json={"device_id": str(device.id), "variables": {}}, + headers=auth["headers"], + ) + assert preview_resp.status_code == 200 + data = preview_resp.json() + assert "rendered" in data + assert "preview-router" in data["rendered"] + assert data["device_hostname"] == "preview-router" + + async def test_template_preview_with_variables( + self, + client, + auth_headers_factory, + admin_session, + create_test_device, + create_test_tenant, + ): + """Preview with custom variables renders them into the template.""" + tenant = await create_test_tenant(admin_session) + auth = await auth_headers_factory( + admin_session, existing_tenant_id=tenant.id, role="operator" + ) + tenant_id = auth["tenant_id"] + + device = await create_test_device(admin_session, tenant.id) + await admin_session.commit() + + template_data = { + "name": f"VarPreview {uuid.uuid4().hex[:6]}", + "content": "/ip address add address={{ custom_ip }}/24 interface=ether1\n", + "variables": [{"name": "custom_ip", "type": "ip", "default": "192.168.1.1"}], + "tags": [], + } + create_resp = await client.post( + f"/api/tenants/{tenant_id}/templates", + json=template_data, + headers=auth["headers"], + ) + assert create_resp.status_code == 201 + template_id = create_resp.json()["id"] + + preview_resp = await client.post( + f"/api/tenants/{tenant_id}/templates/{template_id}/preview", + json={"device_id": str(device.id), "variables": {"custom_ip": "10.10.10.1"}}, + headers=auth["headers"], + ) + assert preview_resp.status_code == 200 + data = preview_resp.json() + assert "10.10.10.1" in data["rendered"] + + async def test_templates_unauthenticated(self, client): + """GET templates without auth returns 401.""" + tenant_id = str(uuid.uuid4()) + resp = await client.get(f"/api/tenants/{tenant_id}/templates") + assert resp.status_code == 401 diff --git a/backend/tests/test_backup_scheduler.py b/backend/tests/test_backup_scheduler.py new file mode 100644 index 0000000..1f278ba --- /dev/null +++ b/backend/tests/test_backup_scheduler.py @@ -0,0 +1,42 @@ +"""Tests for dynamic backup scheduling.""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +from app.services.backup_scheduler import ( + build_schedule_map, + _cron_to_trigger, +) + + +def test_cron_to_trigger_parses_standard_cron(): + """Parse '0 2 * * *' into CronTrigger with hour=2, minute=0.""" + trigger = _cron_to_trigger("0 2 * * *") + assert trigger is not None + + +def test_cron_to_trigger_parses_every_6_hours(): + """Parse '0 */6 * * *' into CronTrigger.""" + trigger = _cron_to_trigger("0 */6 * * *") + assert trigger is not None + + +def test_cron_to_trigger_invalid_returns_none(): + """Invalid cron returns None (fallback to default).""" + trigger = _cron_to_trigger("not a cron") + assert trigger is None + + +@pytest.mark.asyncio +async def test_build_schedule_map_groups_by_cron(): + """Devices sharing a cron expression should be grouped together.""" + schedules = [ + MagicMock(device_id="dev1", tenant_id="t1", cron_expression="0 2 * * *", enabled=True), + MagicMock(device_id="dev2", tenant_id="t1", cron_expression="0 2 * * *", enabled=True), + MagicMock(device_id="dev3", tenant_id="t2", cron_expression="0 6 * * *", enabled=True), + ] + schedule_map = build_schedule_map(schedules) + assert "0 2 * * *" in schedule_map + assert "0 6 * * *" in schedule_map + assert len(schedule_map["0 2 * * *"]) == 2 + assert len(schedule_map["0 6 * * *"]) == 1 diff --git a/backend/tests/test_config_change_subscriber.py b/backend/tests/test_config_change_subscriber.py new file mode 100644 index 0000000..50168bc --- /dev/null +++ b/backend/tests/test_config_change_subscriber.py @@ -0,0 +1,55 @@ +"""Tests for config change NATS subscriber.""" + +import pytest +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch, MagicMock +from uuid import uuid4 + +from app.services.config_change_subscriber import handle_config_changed + + +@pytest.mark.asyncio +async def test_triggers_backup_on_config_change(): + """Config change event should trigger a backup.""" + event = { + "device_id": str(uuid4()), + "tenant_id": str(uuid4()), + "old_timestamp": "2026-03-07 11:00:00", + "new_timestamp": "2026-03-07 12:00:00", + } + + with patch( + "app.services.config_change_subscriber.backup_service.run_backup", + new_callable=AsyncMock, + ) as mock_backup, patch( + "app.services.config_change_subscriber._last_backup_within_dedup_window", + new_callable=AsyncMock, + return_value=False, + ): + await handle_config_changed(event) + + mock_backup.assert_called_once() + assert mock_backup.call_args[1]["trigger_type"] == "config-change" + + +@pytest.mark.asyncio +async def test_skips_backup_within_dedup_window(): + """Should skip backup if last backup was < 5 minutes ago.""" + event = { + "device_id": str(uuid4()), + "tenant_id": str(uuid4()), + "old_timestamp": "2026-03-07 11:00:00", + "new_timestamp": "2026-03-07 12:00:00", + } + + with patch( + "app.services.config_change_subscriber.backup_service.run_backup", + new_callable=AsyncMock, + ) as mock_backup, patch( + "app.services.config_change_subscriber._last_backup_within_dedup_window", + new_callable=AsyncMock, + return_value=True, + ): + await handle_config_changed(event) + + mock_backup.assert_not_called() diff --git a/backend/tests/test_config_checkpoint.py b/backend/tests/test_config_checkpoint.py new file mode 100644 index 0000000..31e6a2d --- /dev/null +++ b/backend/tests/test_config_checkpoint.py @@ -0,0 +1,82 @@ +"""Tests for config checkpoint endpoint.""" + +import uuid +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest + + +class TestCheckpointEndpointExists: + """Verify the checkpoint route is registered on the config_backups router.""" + + def test_router_has_checkpoint_route(self): + from app.routers.config_backups import router + + paths = [r.path for r in router.routes] + assert any("checkpoint" in p for p in paths), ( + f"No checkpoint route found. Routes: {paths}" + ) + + def test_checkpoint_route_is_post(self): + from app.routers.config_backups import router + + for route in router.routes: + if hasattr(route, "path") and "checkpoint" in route.path: + assert "POST" in route.methods, ( + f"Checkpoint route should be POST, got {route.methods}" + ) + break + else: + pytest.fail("No checkpoint route found") + + +class TestCheckpointFunction: + """Test the create_checkpoint handler logic.""" + + @pytest.mark.asyncio + async def test_checkpoint_calls_backup_service_with_checkpoint_trigger(self): + """create_checkpoint should call backup_service.run_backup with trigger_type='checkpoint'.""" + from app.routers.config_backups import create_checkpoint + + mock_result = { + "commit_sha": "abc1234", + "trigger_type": "checkpoint", + "lines_added": 100, + "lines_removed": 0, + } + + mock_db = AsyncMock() + mock_user = MagicMock() + + tenant_id = uuid.uuid4() + device_id = uuid.uuid4() + + mock_request = MagicMock() + + with patch( + "app.routers.config_backups.backup_service.run_backup", + new_callable=AsyncMock, + return_value=mock_result, + ) as mock_backup, patch( + "app.routers.config_backups._check_tenant_access", + new_callable=AsyncMock, + ), patch( + "app.routers.config_backups.limiter.enabled", + False, + ): + result = await create_checkpoint( + request=mock_request, + tenant_id=tenant_id, + device_id=device_id, + db=mock_db, + current_user=mock_user, + ) + + assert result["trigger_type"] == "checkpoint" + assert result["commit_sha"] == "abc1234" + mock_backup.assert_called_once_with( + device_id=str(device_id), + tenant_id=str(tenant_id), + trigger_type="checkpoint", + db_session=mock_db, + ) diff --git a/backend/tests/test_push_recovery.py b/backend/tests/test_push_recovery.py new file mode 100644 index 0000000..62aad3e --- /dev/null +++ b/backend/tests/test_push_recovery.py @@ -0,0 +1,120 @@ +"""Tests for stale push operation recovery on API startup.""" + +import pytest +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch, MagicMock +from uuid import uuid4 + +from app.services.restore_service import recover_stale_push_operations + + +@pytest.mark.asyncio +async def test_recovery_commits_reachable_device_with_scheduler(): + """If device is reachable and panic-revert scheduler exists, delete it and commit.""" + push_op = MagicMock() + push_op.id = uuid4() + push_op.device_id = uuid4() + push_op.tenant_id = uuid4() + push_op.status = "pending_verification" + push_op.scheduler_name = "mikrotik-portal-panic-revert" + push_op.started_at = datetime.now(timezone.utc) - timedelta(minutes=10) + + device = MagicMock() + device.ip_address = "192.168.1.1" + device.api_port = 8729 + device.ssh_port = 22 + + mock_session = AsyncMock() + # Return stale ops query + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [push_op] + mock_session.execute = AsyncMock(side_effect=[mock_result, MagicMock()]) + + # Mock device query result (second execute call) + dev_result = MagicMock() + dev_result.scalar_one_or_none.return_value = device + mock_session.execute = AsyncMock(side_effect=[mock_result, dev_result]) + + with patch( + "app.services.restore_service._check_reachability", + new_callable=AsyncMock, + return_value=True, + ), patch( + "app.services.restore_service._remove_panic_scheduler", + new_callable=AsyncMock, + return_value=True, + ), patch( + "app.services.restore_service._update_push_op_status", + new_callable=AsyncMock, + ) as mock_update, patch( + "app.services.restore_service._publish_push_progress", + new_callable=AsyncMock, + ), patch( + "app.services.crypto.decrypt_credentials_hybrid", + new_callable=AsyncMock, + return_value='{"username": "admin", "password": "test123"}', + ), patch( + "app.services.restore_service.settings", + ): + await recover_stale_push_operations(mock_session) + + mock_update.assert_called_once() + call_args = mock_update.call_args + assert call_args[0][1] == "committed" or call_args[1].get("new_status") == "committed" + + +@pytest.mark.asyncio +async def test_recovery_marks_unreachable_device_failed(): + """If device is unreachable, mark operation as failed.""" + push_op = MagicMock() + push_op.id = uuid4() + push_op.device_id = uuid4() + push_op.tenant_id = uuid4() + push_op.status = "pending_verification" + push_op.scheduler_name = "mikrotik-portal-panic-revert" + push_op.started_at = datetime.now(timezone.utc) - timedelta(minutes=10) + + device = MagicMock() + device.ip_address = "192.168.1.1" + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [push_op] + dev_result = MagicMock() + dev_result.scalar_one_or_none.return_value = device + mock_session.execute = AsyncMock(side_effect=[mock_result, dev_result]) + + with patch( + "app.services.restore_service._check_reachability", + new_callable=AsyncMock, + return_value=False, + ), patch( + "app.services.restore_service._update_push_op_status", + new_callable=AsyncMock, + ) as mock_update, patch( + "app.services.restore_service._publish_push_progress", + new_callable=AsyncMock, + ), patch( + "app.services.crypto.decrypt_credentials_hybrid", + new_callable=AsyncMock, + return_value='{"username": "admin", "password": "test123"}', + ), patch( + "app.services.restore_service.settings", + ): + await recover_stale_push_operations(mock_session) + + mock_update.assert_called_once() + call_args = mock_update.call_args + assert call_args[0][1] == "failed" or call_args[1].get("new_status") == "failed" + + +@pytest.mark.asyncio +async def test_recovery_skips_recent_ops(): + """Operations less than 5 minutes old should not be recovered (still in progress).""" + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] # Query filters by age + mock_session.execute = AsyncMock(return_value=mock_result) + + await recover_stale_push_operations(mock_session) + # No errors, no updates — just returns cleanly diff --git a/backend/tests/test_push_rollback_subscriber.py b/backend/tests/test_push_rollback_subscriber.py new file mode 100644 index 0000000..e517c83 --- /dev/null +++ b/backend/tests/test_push_rollback_subscriber.py @@ -0,0 +1,156 @@ +"""Tests for push rollback NATS subscriber.""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from uuid import uuid4 + +from app.services.push_rollback_subscriber import ( + handle_push_rollback, + handle_push_alert, +) + + +@pytest.mark.asyncio +async def test_rollback_triggers_restore(): + """Push rollback should call restore_config with the pre-push commit SHA.""" + event = { + "device_id": str(uuid4()), + "tenant_id": str(uuid4()), + "push_operation_id": str(uuid4()), + "pre_push_commit_sha": "abc1234", + } + + mock_session = AsyncMock() + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=False) + + with ( + patch( + "app.services.push_rollback_subscriber.restore_service.restore_config", + new_callable=AsyncMock, + return_value={"status": "committed"}, + ) as mock_restore, + patch( + "app.services.push_rollback_subscriber.AdminAsyncSessionLocal", + return_value=mock_cm, + ), + ): + await handle_push_rollback(event) + + mock_restore.assert_called_once() + call_kwargs = mock_restore.call_args[1] + assert call_kwargs["device_id"] == event["device_id"] + assert call_kwargs["tenant_id"] == event["tenant_id"] + assert call_kwargs["commit_sha"] == "abc1234" + assert call_kwargs["db_session"] is mock_session + + +@pytest.mark.asyncio +async def test_rollback_missing_fields_skips(): + """Rollback with missing fields should log warning and return.""" + event = {"device_id": str(uuid4())} # missing tenant_id and commit_sha + + with patch( + "app.services.push_rollback_subscriber.restore_service.restore_config", + new_callable=AsyncMock, + ) as mock_restore: + await handle_push_rollback(event) + + mock_restore.assert_not_called() + + +@pytest.mark.asyncio +async def test_rollback_failure_creates_alert(): + """When restore_config raises, an alert should be created.""" + event = { + "device_id": str(uuid4()), + "tenant_id": str(uuid4()), + "pre_push_commit_sha": "abc1234", + } + + mock_session = AsyncMock() + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=False) + + with ( + patch( + "app.services.push_rollback_subscriber.restore_service.restore_config", + new_callable=AsyncMock, + side_effect=RuntimeError("SSH failed"), + ), + patch( + "app.services.push_rollback_subscriber.AdminAsyncSessionLocal", + return_value=mock_cm, + ), + patch( + "app.services.push_rollback_subscriber._create_push_alert", + new_callable=AsyncMock, + ) as mock_alert, + ): + await handle_push_rollback(event) + + mock_alert.assert_called_once_with( + event["device_id"], + event["tenant_id"], + "template (auto-rollback failed)", + ) + + +@pytest.mark.asyncio +async def test_alert_creates_alert_record(): + """Editor push alert should create a high-priority alert.""" + event = { + "device_id": str(uuid4()), + "tenant_id": str(uuid4()), + "push_type": "editor", + } + + with patch( + "app.services.push_rollback_subscriber._create_push_alert", + new_callable=AsyncMock, + ) as mock_alert: + await handle_push_alert(event) + + mock_alert.assert_called_once_with( + event["device_id"], + event["tenant_id"], + "editor", + ) + + +@pytest.mark.asyncio +async def test_alert_missing_fields_skips(): + """Alert with missing fields should skip.""" + event = {"device_id": str(uuid4())} # missing tenant_id + + with patch( + "app.services.push_rollback_subscriber._create_push_alert", + new_callable=AsyncMock, + ) as mock_alert: + await handle_push_alert(event) + + mock_alert.assert_not_called() + + +@pytest.mark.asyncio +async def test_alert_defaults_to_editor_push_type(): + """Alert without push_type should default to 'editor'.""" + event = { + "device_id": str(uuid4()), + "tenant_id": str(uuid4()), + # no push_type + } + + with patch( + "app.services.push_rollback_subscriber._create_push_alert", + new_callable=AsyncMock, + ) as mock_alert: + await handle_push_alert(event) + + mock_alert.assert_called_once_with( + event["device_id"], + event["tenant_id"], + "editor", + ) diff --git a/backend/tests/test_restore_preview.py b/backend/tests/test_restore_preview.py new file mode 100644 index 0000000..8cfa0f7 --- /dev/null +++ b/backend/tests/test_restore_preview.py @@ -0,0 +1,211 @@ +"""Tests for the preview-restore endpoint.""" + +import uuid +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest + + +class TestPreviewRestoreEndpointExists: + """Verify the preview-restore route is registered on the config_backups router.""" + + def test_router_has_preview_restore_route(self): + from app.routers.config_backups import router + + paths = [r.path for r in router.routes] + assert any("preview-restore" in p for p in paths), ( + f"No preview-restore route found. Routes: {paths}" + ) + + def test_preview_restore_route_is_post(self): + from app.routers.config_backups import router + + for route in router.routes: + if hasattr(route, "path") and "preview-restore" in route.path: + assert "POST" in route.methods, ( + f"preview-restore route should be POST, got {route.methods}" + ) + break + else: + pytest.fail("No preview-restore route found") + + +class TestPreviewRestoreFunction: + """Test the preview_restore handler logic.""" + + @pytest.mark.asyncio + async def test_preview_returns_impact_analysis(self): + """preview_restore should return diff, categories, warnings, validation.""" + from app.routers.config_backups import preview_restore, RestoreRequest + + tenant_id = uuid.uuid4() + device_id = uuid.uuid4() + + current_export = "/ip address\nadd address=192.168.1.1/24 interface=ether1\n" + target_export = "/ip address\nadd address=10.0.0.1/24 interface=ether1\n" + + mock_db = AsyncMock() + mock_user = MagicMock() + mock_request = MagicMock() + body = RestoreRequest(commit_sha="abc1234") + + # Mock device query result + mock_device = MagicMock() + mock_device.ip_address = "192.168.88.1" + mock_device.encrypted_credentials_transit = "vault:v1:abc" + mock_device.encrypted_credentials = None + mock_device.tenant_id = tenant_id + + mock_scalar = MagicMock() + mock_scalar.scalar_one_or_none.return_value = mock_device + mock_db.execute.return_value = mock_scalar + + with patch( + "app.routers.config_backups._check_tenant_access", + new_callable=AsyncMock, + ), patch( + "app.routers.config_backups.limiter.enabled", + False, + ), patch( + "app.routers.config_backups.git_store.read_file", + return_value=target_export.encode(), + ), patch( + "app.routers.config_backups.backup_service.capture_export", + new_callable=AsyncMock, + return_value=current_export, + ), patch( + "app.routers.config_backups.decrypt_credentials_hybrid", + new_callable=AsyncMock, + return_value='{"username": "admin", "password": "pass"}', + ), patch( + "app.routers.config_backups.settings", + ): + result = await preview_restore( + request=mock_request, + tenant_id=tenant_id, + device_id=device_id, + body=body, + db=mock_db, + current_user=mock_user, + ) + + assert "diff" in result + assert "categories" in result + assert "warnings" in result + assert "validation" in result + # Both exports have /ip address with different commands + assert isinstance(result["categories"], list) + assert isinstance(result["diff"], dict) + assert "added" in result["diff"] + assert "removed" in result["diff"] + + @pytest.mark.asyncio + async def test_preview_falls_back_to_latest_backup_when_device_unreachable(self): + """When live capture fails, preview should fall back to the latest backup.""" + from app.routers.config_backups import preview_restore, RestoreRequest + + tenant_id = uuid.uuid4() + device_id = uuid.uuid4() + + current_export = "/ip address\nadd address=192.168.1.1/24 interface=ether1\n" + target_export = "/ip address\nadd address=10.0.0.1/24 interface=ether1\n" + + mock_db = AsyncMock() + mock_user = MagicMock() + mock_request = MagicMock() + body = RestoreRequest(commit_sha="abc1234") + + # Mock device query result + mock_device = MagicMock() + mock_device.ip_address = "192.168.88.1" + mock_device.encrypted_credentials_transit = "vault:v1:abc" + mock_device.encrypted_credentials = None + mock_device.tenant_id = tenant_id + + # First call: device query, second call: latest backup query + mock_device_result = MagicMock() + mock_device_result.scalar_one_or_none.return_value = mock_device + + mock_latest_run = MagicMock() + mock_latest_run.commit_sha = "latest123" + mock_backup_result = MagicMock() + mock_backup_result.scalar_one_or_none.return_value = mock_latest_run + + mock_db.execute.side_effect = [mock_device_result, mock_backup_result] + + def mock_read_file(tid, sha, did, filename): + if sha == "abc1234": + return target_export.encode() + elif sha == "latest123": + return current_export.encode() + return b"" + + with patch( + "app.routers.config_backups._check_tenant_access", + new_callable=AsyncMock, + ), patch( + "app.routers.config_backups.limiter.enabled", + False, + ), patch( + "app.routers.config_backups.git_store.read_file", + side_effect=mock_read_file, + ), patch( + "app.routers.config_backups.backup_service.capture_export", + new_callable=AsyncMock, + side_effect=ConnectionError("Device unreachable"), + ), patch( + "app.routers.config_backups.decrypt_credentials_hybrid", + new_callable=AsyncMock, + return_value='{"username": "admin", "password": "pass"}', + ), patch( + "app.routers.config_backups.settings", + ): + result = await preview_restore( + request=mock_request, + tenant_id=tenant_id, + device_id=device_id, + body=body, + db=mock_db, + current_user=mock_user, + ) + + assert "diff" in result + assert "categories" in result + assert "warnings" in result + assert "validation" in result + + @pytest.mark.asyncio + async def test_preview_404_when_backup_not_found(self): + """preview_restore should return 404 when the target backup doesn't exist.""" + from app.routers.config_backups import preview_restore, RestoreRequest + from fastapi import HTTPException + + tenant_id = uuid.uuid4() + device_id = uuid.uuid4() + + mock_db = AsyncMock() + mock_user = MagicMock() + mock_request = MagicMock() + body = RestoreRequest(commit_sha="nonexistent") + + with patch( + "app.routers.config_backups._check_tenant_access", + new_callable=AsyncMock, + ), patch( + "app.routers.config_backups.limiter.enabled", + False, + ), patch( + "app.routers.config_backups.git_store.read_file", + side_effect=KeyError("not found"), + ): + with pytest.raises(HTTPException) as exc_info: + await preview_restore( + request=mock_request, + tenant_id=tenant_id, + device_id=device_id, + body=body, + db=mock_db, + current_user=mock_user, + ) + + assert exc_info.value.status_code == 404 diff --git a/backend/tests/test_rsc_parser.py b/backend/tests/test_rsc_parser.py new file mode 100644 index 0000000..de68ccf --- /dev/null +++ b/backend/tests/test_rsc_parser.py @@ -0,0 +1,106 @@ +"""Tests for RouterOS RSC export parser.""" + +import pytest +from app.services.rsc_parser import parse_rsc, validate_rsc, compute_impact + + +SAMPLE_EXPORT = """\ +# 2026-03-07 12:00:00 by RouterOS 7.16.2 +# software id = ABCD-1234 +# +# model = RB750Gr3 +/interface bridge +add name=bridge1 +/ip address +add address=192.168.88.1/24 interface=ether1 network=192.168.88.0 +add address=10.0.0.1/24 interface=bridge1 network=10.0.0.0 +/ip firewall filter +add action=accept chain=input comment="allow established" \\ + connection-state=established,related +add action=drop chain=input in-interface-list=WAN +/ip dns +set servers=8.8.8.8,8.8.4.4 +/system identity +set name=test-router +""" + + +class TestParseRsc: + def test_extracts_categories(self): + result = parse_rsc(SAMPLE_EXPORT) + paths = [c["path"] for c in result["categories"]] + assert "/interface bridge" in paths + assert "/ip address" in paths + assert "/ip firewall filter" in paths + assert "/ip dns" in paths + assert "/system identity" in paths + + def test_counts_commands_per_category(self): + result = parse_rsc(SAMPLE_EXPORT) + cat_map = {c["path"]: c for c in result["categories"]} + assert cat_map["/ip address"]["adds"] == 2 + assert cat_map["/ip address"]["sets"] == 0 + assert cat_map["/ip firewall filter"]["adds"] == 2 + assert cat_map["/ip dns"]["sets"] == 1 + assert cat_map["/system identity"]["sets"] == 1 + + def test_handles_continuation_lines(self): + result = parse_rsc(SAMPLE_EXPORT) + cat_map = {c["path"]: c for c in result["categories"]} + # The firewall filter has a continuation line — should still count as 2 adds + assert cat_map["/ip firewall filter"]["adds"] == 2 + + def test_ignores_comments_and_blank_lines(self): + result = parse_rsc(SAMPLE_EXPORT) + # Comments at top should not create categories + paths = [c["path"] for c in result["categories"]] + assert "#" not in paths + + def test_empty_input(self): + result = parse_rsc("") + assert result["categories"] == [] + + +class TestValidateRsc: + def test_valid_export_passes(self): + result = validate_rsc(SAMPLE_EXPORT) + assert result["valid"] is True + assert result["errors"] == [] + + def test_unbalanced_quotes_detected(self): + bad = '/system identity\nset name="missing-end-quote\n' + result = validate_rsc(bad) + assert result["valid"] is False + assert any("quote" in e.lower() for e in result["errors"]) + + def test_truncated_continuation_detected(self): + bad = '/ip address\nadd address=192.168.1.1/24 \\\n' + result = validate_rsc(bad) + assert result["valid"] is False + assert any("truncat" in e.lower() or "continuation" in e.lower() for e in result["errors"]) + + +class TestComputeImpact: + def test_high_risk_for_firewall_input(self): + current = '/ip firewall filter\nadd action=accept chain=input\n' + target = '/ip firewall filter\nadd action=drop chain=input\n' + result = compute_impact(parse_rsc(current), parse_rsc(target)) + assert any(c["risk"] == "high" for c in result["categories"]) + + def test_high_risk_for_ip_address_changes(self): + current = '/ip address\nadd address=192.168.1.1/24 interface=ether1\n' + target = '/ip address\nadd address=10.0.0.1/24 interface=ether1\n' + result = compute_impact(parse_rsc(current), parse_rsc(target)) + ip_cat = next(c for c in result["categories"] if c["path"] == "/ip address") + assert ip_cat["risk"] in ("high", "medium") + + def test_warnings_for_management_access(self): + current = "" + target = '/ip firewall filter\nadd action=drop chain=input protocol=tcp dst-port=22\n' + result = compute_impact(parse_rsc(current), parse_rsc(target)) + assert len(result["warnings"]) > 0 + + def test_no_changes_no_warnings(self): + same = '/ip dns\nset servers=8.8.8.8\n' + result = compute_impact(parse_rsc(same), parse_rsc(same)) + assert result["warnings"] == [] or all(c["risk"] == "none" for c in result["categories"]) diff --git a/backend/tests/test_srp_interop.py b/backend/tests/test_srp_interop.py new file mode 100644 index 0000000..1bc6b56 --- /dev/null +++ b/backend/tests/test_srp_interop.py @@ -0,0 +1,128 @@ +"""SRP-6a interop verification. + +Uses srptools to perform a complete SRP handshake with fixed inputs, +then prints all intermediate hex values. The TypeScript SRP client +(frontend/src/lib/crypto/srp.ts) can be verified against these +known-good values to catch encoding mismatches. + +Run standalone: + cd backend && python -m tests.test_srp_interop + +Or via pytest: + cd backend && python -m pytest tests/test_srp_interop.py -v +""" + +from srptools import SRPContext, SRPClientSession, SRPServerSession +from srptools.constants import PRIME_2048, PRIME_2048_GEN + + +# Fixed test inputs +EMAIL = "test@example.com" +PASSWORD = "test-password" + + +def test_srp_roundtrip(): + """Verify srptools produces a successful handshake end-to-end. + + This test ensures the server-side library completes a full SRP + handshake without errors. The printed intermediate values serve as + reference data for the TypeScript client interop test. + """ + # Step 1: Registration -- compute salt + verifier (needs password in context) + context = SRPContext(EMAIL, password=PASSWORD, prime=PRIME_2048, generator=PRIME_2048_GEN) + username, verifier, salt = context.get_user_data_triplet() + + print(f"\n--- SRP Interop Reference Values ---") + print(f"email (I): {EMAIL}") + print(f"salt (s): {salt}") + print(f"verifier (v): {verifier[:64]}... (len={len(verifier)})") + + # Step 2: Server init -- generate B (server only needs verifier, no password) + server_context = SRPContext(EMAIL, prime=PRIME_2048, generator=PRIME_2048_GEN) + server_session = SRPServerSession(server_context, verifier) + server_public = server_session.public + + print(f"server_public (B): {server_public[:64]}... (len={len(server_public)})") + + # Step 3: Client init -- generate A (client needs password for proof) + client_context = SRPContext(EMAIL, password=PASSWORD, prime=PRIME_2048, generator=PRIME_2048_GEN) + client_session = SRPClientSession(client_context) + client_public = client_session.public + + print(f"client_public (A): {client_public[:64]}... (len={len(client_public)})") + + # Step 4: Client processes B + client_session.process(server_public, salt) + + # Step 5: Server processes A + server_session.process(client_public, salt) + + # Step 6: Client generates proof M1 + client_proof = client_session.key_proof + + print(f"client_proof (M1): {client_proof}") + + # Step 7: Server verifies M1 and generates M2 + server_session.verify_proof(client_proof) + server_proof = server_session.key_proof_hash + + print(f"server_proof (M2): {server_proof}") + + # Step 8: Client verifies M2 + client_session.verify_proof(server_proof) + + # Step 9: Verify session keys match + assert client_session.key == server_session.key, ( + f"Session key mismatch: client={client_session.key[:32]}... " + f"server={server_session.key[:32]}..." + ) + + print(f"session_key (K): {client_session.key[:64]}... (len={len(client_session.key)})") + print(f"--- Handshake PASSED ---\n") + + +def test_srp_bad_proof_rejected(): + """Verify that an incorrect M1 proof is rejected by the server.""" + context = SRPContext(EMAIL, password=PASSWORD, prime=PRIME_2048, generator=PRIME_2048_GEN) + _, verifier, salt = context.get_user_data_triplet() + + server_context = SRPContext(EMAIL, prime=PRIME_2048, generator=PRIME_2048_GEN) + server_session = SRPServerSession(server_context, verifier) + + client_context = SRPContext(EMAIL, password=PASSWORD, prime=PRIME_2048, generator=PRIME_2048_GEN) + client_session = SRPClientSession(client_context) + + client_session.process(server_session.public, salt) + server_session.process(client_session.public, salt) + + # Tamper with proof + bad_proof = "00" * 32 + + try: + server_session.verify_proof(bad_proof) + assert False, "Server should have rejected bad proof" + except Exception: + pass # Expected: bad proof rejected + + +def test_srp_deterministic_verifier(): + """Verify that the same salt + identity produce consistent verifiers.""" + context1 = SRPContext(EMAIL, password=PASSWORD, prime=PRIME_2048, generator=PRIME_2048_GEN) + _, v1, s1 = context1.get_user_data_triplet() + + # Same email + password, new context + context2 = SRPContext(EMAIL, password=PASSWORD, prime=PRIME_2048, generator=PRIME_2048_GEN) + _, v2, s2 = context2.get_user_data_triplet() + + # srptools generates random salt each time, so verifiers will differ. + # But the output format is consistent. + assert len(v1) > 0 + assert len(v2) > 0 + assert len(s1) == len(s2), "Salt lengths should be consistent" + + +if __name__ == "__main__": + test_srp_roundtrip() + test_srp_bad_proof_rejected() + test_srp_deterministic_verifier() + print("All SRP interop tests passed.") diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_api_key_service.py b/backend/tests/unit/test_api_key_service.py new file mode 100644 index 0000000..7445a77 --- /dev/null +++ b/backend/tests/unit/test_api_key_service.py @@ -0,0 +1,76 @@ +"""Unit tests for API key service. + +Tests cover: +- Key generation format (mktp_ prefix, sufficient length) +- Key hashing (SHA-256 hex digest, 64 chars) +- Scope validation against allowed list +- Key prefix extraction + +These are pure function tests -- no database or async required. +""" + +import hashlib + +from app.services.api_key_service import ( + ALLOWED_SCOPES, + generate_raw_key, + hash_key, +) + + +class TestKeyGeneration: + """Tests for API key generation.""" + + def test_key_starts_with_prefix(self): + key = generate_raw_key() + assert key.startswith("mktp_") + + def test_key_has_sufficient_length(self): + """Key should be mktp_ + at least 32 chars of randomness.""" + key = generate_raw_key() + assert len(key) >= 37 # "mktp_" (5) + 32 + + def test_key_uniqueness(self): + """Two generated keys should never be the same.""" + key1 = generate_raw_key() + key2 = generate_raw_key() + assert key1 != key2 + + +class TestKeyHashing: + """Tests for SHA-256 key hashing.""" + + def test_hash_produces_64_char_hex(self): + key = "mktp_test1234567890abcdef" + h = hash_key(key) + assert len(h) == 64 + assert all(c in "0123456789abcdef" for c in h) + + def test_hash_is_sha256(self): + key = "mktp_test1234567890abcdef" + expected = hashlib.sha256(key.encode()).hexdigest() + assert hash_key(key) == expected + + def test_hash_deterministic(self): + key = generate_raw_key() + assert hash_key(key) == hash_key(key) + + def test_different_keys_different_hashes(self): + key1 = generate_raw_key() + key2 = generate_raw_key() + assert hash_key(key1) != hash_key(key2) + + +class TestAllowedScopes: + """Tests for scope definitions.""" + + def test_allowed_scopes_contains_expected(self): + expected = { + "devices:read", + "devices:write", + "config:read", + "config:write", + "alerts:read", + "firmware:write", + } + assert expected == ALLOWED_SCOPES diff --git a/backend/tests/unit/test_audit_service.py b/backend/tests/unit/test_audit_service.py new file mode 100644 index 0000000..a319821 --- /dev/null +++ b/backend/tests/unit/test_audit_service.py @@ -0,0 +1,75 @@ +"""Unit tests for the audit service and model. + +Tests cover: +- AuditLog model can be imported +- log_action function signature is correct +- Audit logs router is importable with expected endpoints +- CSV export endpoint exists +""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class TestAuditLogModel: + """Tests for the AuditLog ORM model.""" + + def test_model_importable(self): + from app.models.audit_log import AuditLog + assert AuditLog.__tablename__ == "audit_logs" + + def test_model_has_required_columns(self): + from app.models.audit_log import AuditLog + mapper = AuditLog.__table__.columns + expected_columns = { + "id", "tenant_id", "user_id", "action", + "resource_type", "resource_id", "device_id", + "details", "ip_address", "created_at", + } + actual_columns = {c.name for c in mapper} + assert expected_columns.issubset(actual_columns), ( + f"Missing columns: {expected_columns - actual_columns}" + ) + + def test_model_exported_from_init(self): + from app.models import AuditLog + assert AuditLog.__tablename__ == "audit_logs" + + +class TestAuditService: + """Tests for the audit service log_action function.""" + + def test_log_action_importable(self): + from app.services.audit_service import log_action + assert callable(log_action) + + @pytest.mark.asyncio + async def test_log_action_does_not_raise_on_db_error(self): + """log_action must swallow exceptions so it never breaks the caller.""" + from app.services.audit_service import log_action + + mock_db = AsyncMock() + mock_db.execute = AsyncMock(side_effect=Exception("DB down")) + + # Should NOT raise even though the DB call fails + await log_action( + db=mock_db, + tenant_id=uuid.uuid4(), + user_id=uuid.uuid4(), + action="test_action", + ) + + +class TestAuditRouter: + """Tests for the audit logs router.""" + + def test_router_importable(self): + from app.routers.audit_logs import router + assert router is not None + + def test_router_has_audit_logs_endpoint(self): + from app.routers.audit_logs import router + paths = [route.path for route in router.routes] + assert "/audit-logs" in paths or any("/audit-logs" in p for p in paths) diff --git a/backend/tests/unit/test_auth.py b/backend/tests/unit/test_auth.py new file mode 100644 index 0000000..be65f92 --- /dev/null +++ b/backend/tests/unit/test_auth.py @@ -0,0 +1,169 @@ +"""Unit tests for the JWT authentication service. + +Tests cover: +- Password hashing and verification (bcrypt) +- JWT access token creation and validation +- JWT refresh token creation and validation +- Token rejection for wrong type, expired, invalid, missing subject + +These are pure function tests -- no database or async required. +""" + +import uuid +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest +from fastapi import HTTPException +from jose import jwt + +from app.services.auth import ( + create_access_token, + create_refresh_token, + hash_password, + verify_password, + verify_token, +) +from app.config import settings + + +class TestPasswordHashing: + """Tests for bcrypt password hashing.""" + + def test_hash_returns_different_string(self): + password = "test-password-123!" + hashed = hash_password(password) + assert hashed != password + + def test_hash_verify_roundtrip(self): + password = "test-password-123!" + hashed = hash_password(password) + assert verify_password(password, hashed) is True + + def test_verify_rejects_wrong_password(self): + hashed = hash_password("correct-password") + assert verify_password("wrong-password", hashed) is False + + def test_hash_uses_unique_salts(self): + """Each hash should be different even for the same password (random salt).""" + hash1 = hash_password("same-password") + hash2 = hash_password("same-password") + assert hash1 != hash2 + + def test_verify_both_hashes_valid(self): + """Both unique hashes should verify against the original password.""" + password = "same-password" + hash1 = hash_password(password) + hash2 = hash_password(password) + assert verify_password(password, hash1) is True + assert verify_password(password, hash2) is True + + +class TestAccessToken: + """Tests for JWT access token creation and validation.""" + + def test_create_and_verify_roundtrip(self): + user_id = uuid.uuid4() + tenant_id = uuid.uuid4() + token = create_access_token(user_id=user_id, tenant_id=tenant_id, role="admin") + payload = verify_token(token, expected_type="access") + + assert payload["sub"] == str(user_id) + assert payload["tenant_id"] == str(tenant_id) + assert payload["role"] == "admin" + assert payload["type"] == "access" + + def test_super_admin_null_tenant(self): + user_id = uuid.uuid4() + token = create_access_token(user_id=user_id, tenant_id=None, role="super_admin") + payload = verify_token(token, expected_type="access") + + assert payload["sub"] == str(user_id) + assert payload["tenant_id"] is None + assert payload["role"] == "super_admin" + + def test_contains_expiry(self): + token = create_access_token( + user_id=uuid.uuid4(), tenant_id=uuid.uuid4(), role="viewer" + ) + payload = verify_token(token, expected_type="access") + assert "exp" in payload + assert "iat" in payload + + +class TestRefreshToken: + """Tests for JWT refresh token creation and validation.""" + + def test_create_and_verify_roundtrip(self): + user_id = uuid.uuid4() + token = create_refresh_token(user_id=user_id) + payload = verify_token(token, expected_type="refresh") + + assert payload["sub"] == str(user_id) + assert payload["type"] == "refresh" + + def test_refresh_token_has_no_tenant_or_role(self): + token = create_refresh_token(user_id=uuid.uuid4()) + payload = verify_token(token, expected_type="refresh") + + # Refresh tokens intentionally omit tenant_id and role + assert "tenant_id" not in payload + assert "role" not in payload + + +class TestTokenRejection: + """Tests for JWT token validation failure cases.""" + + def test_rejects_wrong_type(self): + """Access token should not verify as refresh, and vice versa.""" + access_token = create_access_token( + user_id=uuid.uuid4(), tenant_id=uuid.uuid4(), role="admin" + ) + with pytest.raises(HTTPException) as exc_info: + verify_token(access_token, expected_type="refresh") + assert exc_info.value.status_code == 401 + + def test_rejects_expired_token(self): + """Manually craft an expired token and verify it is rejected.""" + expired_payload = { + "sub": str(uuid.uuid4()), + "type": "access", + "exp": datetime.now(UTC) - timedelta(hours=1), + "iat": datetime.now(UTC) - timedelta(hours=2), + } + expired_token = jwt.encode( + expired_payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM + ) + with pytest.raises(HTTPException) as exc_info: + verify_token(expired_token, expected_type="access") + assert exc_info.value.status_code == 401 + + def test_rejects_invalid_token(self): + with pytest.raises(HTTPException) as exc_info: + verify_token("not-a-valid-jwt", expected_type="access") + assert exc_info.value.status_code == 401 + + def test_rejects_wrong_signing_key(self): + """Token signed with a different key should be rejected.""" + payload = { + "sub": str(uuid.uuid4()), + "type": "access", + "exp": datetime.now(UTC) + timedelta(hours=1), + } + wrong_key_token = jwt.encode(payload, "wrong-secret-key", algorithm="HS256") + with pytest.raises(HTTPException) as exc_info: + verify_token(wrong_key_token, expected_type="access") + assert exc_info.value.status_code == 401 + + def test_rejects_missing_subject(self): + """Token without 'sub' claim should be rejected.""" + no_sub_payload = { + "type": "access", + "exp": datetime.now(UTC) + timedelta(hours=1), + } + no_sub_token = jwt.encode( + no_sub_payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM + ) + with pytest.raises(HTTPException) as exc_info: + verify_token(no_sub_token, expected_type="access") + assert exc_info.value.status_code == 401 diff --git a/backend/tests/unit/test_crypto.py b/backend/tests/unit/test_crypto.py new file mode 100644 index 0000000..f05d325 --- /dev/null +++ b/backend/tests/unit/test_crypto.py @@ -0,0 +1,126 @@ +"""Unit tests for the credential encryption/decryption service. + +Tests cover: +- Encryption/decryption round-trip with valid key +- Random nonce ensures different ciphertext per encryption +- Wrong key rejection (InvalidTag) +- Invalid key length rejection (ValueError) +- Unicode and JSON payload handling +- Tampered ciphertext detection + +These are pure function tests -- no database or async required. +""" + +import json +import os + +import pytest +from cryptography.exceptions import InvalidTag + +from app.services.crypto import decrypt_credentials, encrypt_credentials + + +class TestEncryptDecryptRoundTrip: + """Tests for successful encryption/decryption cycles.""" + + def test_basic_roundtrip(self): + key = os.urandom(32) + plaintext = "secret-password" + ciphertext = encrypt_credentials(plaintext, key) + result = decrypt_credentials(ciphertext, key) + assert result == plaintext + + def test_json_credentials_roundtrip(self): + """The actual use case: encrypting JSON credential objects.""" + key = os.urandom(32) + creds = json.dumps({"username": "admin", "password": "RouterOS!123"}) + ciphertext = encrypt_credentials(creds, key) + result = decrypt_credentials(ciphertext, key) + parsed = json.loads(result) + assert parsed["username"] == "admin" + assert parsed["password"] == "RouterOS!123" + + def test_unicode_roundtrip(self): + key = os.urandom(32) + plaintext = "password-with-unicode-\u00e9\u00e8\u00ea" + ciphertext = encrypt_credentials(plaintext, key) + result = decrypt_credentials(ciphertext, key) + assert result == plaintext + + def test_empty_string_roundtrip(self): + key = os.urandom(32) + ciphertext = encrypt_credentials("", key) + result = decrypt_credentials(ciphertext, key) + assert result == "" + + def test_long_payload_roundtrip(self): + """Ensure large payloads work (e.g., SSH keys in credentials).""" + key = os.urandom(32) + plaintext = "x" * 10000 + ciphertext = encrypt_credentials(plaintext, key) + result = decrypt_credentials(ciphertext, key) + assert result == plaintext + + +class TestNonceRandomness: + """Tests that encryption uses random nonces.""" + + def test_different_ciphertext_each_time(self): + """Two encryptions of the same plaintext should produce different ciphertext + because a random 12-byte nonce is generated each time.""" + key = os.urandom(32) + plaintext = "same-plaintext" + ct1 = encrypt_credentials(plaintext, key) + ct2 = encrypt_credentials(plaintext, key) + assert ct1 != ct2 + + def test_both_decrypt_correctly(self): + """Both different ciphertexts should decrypt to the same plaintext.""" + key = os.urandom(32) + plaintext = "same-plaintext" + ct1 = encrypt_credentials(plaintext, key) + ct2 = encrypt_credentials(plaintext, key) + assert decrypt_credentials(ct1, key) == plaintext + assert decrypt_credentials(ct2, key) == plaintext + + +class TestDecryptionFailures: + """Tests for proper rejection of invalid inputs.""" + + def test_wrong_key_raises_invalid_tag(self): + key1 = os.urandom(32) + key2 = os.urandom(32) + ciphertext = encrypt_credentials("secret", key1) + with pytest.raises(InvalidTag): + decrypt_credentials(ciphertext, key2) + + def test_tampered_ciphertext_raises_invalid_tag(self): + """Flipping a byte in the ciphertext should cause authentication failure.""" + key = os.urandom(32) + ciphertext = bytearray(encrypt_credentials("secret", key)) + # Flip a byte in the encrypted portion (after the 12-byte nonce) + ciphertext[15] ^= 0xFF + with pytest.raises(InvalidTag): + decrypt_credentials(bytes(ciphertext), key) + + +class TestKeyValidation: + """Tests for encryption key length validation.""" + + def test_short_key_encrypt_raises(self): + with pytest.raises(ValueError, match="32 bytes"): + encrypt_credentials("test", os.urandom(16)) + + def test_long_key_encrypt_raises(self): + with pytest.raises(ValueError, match="32 bytes"): + encrypt_credentials("test", os.urandom(64)) + + def test_short_key_decrypt_raises(self): + key = os.urandom(32) + ciphertext = encrypt_credentials("test", key) + with pytest.raises(ValueError, match="32 bytes"): + decrypt_credentials(ciphertext, os.urandom(16)) + + def test_empty_key_raises(self): + with pytest.raises(ValueError, match="32 bytes"): + encrypt_credentials("test", b"") diff --git a/backend/tests/unit/test_maintenance_windows.py b/backend/tests/unit/test_maintenance_windows.py new file mode 100644 index 0000000..67b0cb5 --- /dev/null +++ b/backend/tests/unit/test_maintenance_windows.py @@ -0,0 +1,121 @@ +"""Unit tests for maintenance window model, router schemas, and alert suppression. + +Tests cover: +- MaintenanceWindow ORM model imports and field definitions +- MaintenanceWindowCreate/Update/Response Pydantic schema validation +- Alert evaluator _is_device_in_maintenance integration +- Router registration in main app +""" + +import uuid +from datetime import datetime, timezone, timedelta + +import pytest +from pydantic import ValidationError + + +class TestMaintenanceWindowModel: + """Test that the MaintenanceWindow ORM model is importable and has correct fields.""" + + def test_model_importable(self): + from app.models.maintenance_window import MaintenanceWindow + assert MaintenanceWindow.__tablename__ == "maintenance_windows" + + def test_model_exported_from_init(self): + from app.models import MaintenanceWindow + assert MaintenanceWindow.__tablename__ == "maintenance_windows" + + def test_model_has_required_columns(self): + from app.models.maintenance_window import MaintenanceWindow + mapper = MaintenanceWindow.__mapper__ + column_names = {c.key for c in mapper.columns} + expected = { + "id", "tenant_id", "name", "device_ids", + "start_at", "end_at", "suppress_alerts", + "notes", "created_by", "created_at", "updated_at", + } + assert expected.issubset(column_names), f"Missing columns: {expected - column_names}" + + +class TestMaintenanceWindowSchemas: + """Test Pydantic schemas for request/response validation.""" + + def test_create_schema_valid(self): + from app.routers.maintenance_windows import MaintenanceWindowCreate + data = MaintenanceWindowCreate( + name="Nightly update", + device_ids=["abc-123"], + start_at=datetime.now(timezone.utc), + end_at=datetime.now(timezone.utc) + timedelta(hours=2), + suppress_alerts=True, + notes="Scheduled maintenance", + ) + assert data.name == "Nightly update" + assert data.suppress_alerts is True + + def test_create_schema_defaults(self): + from app.routers.maintenance_windows import MaintenanceWindowCreate + data = MaintenanceWindowCreate( + name="Quick reboot", + device_ids=[], + start_at=datetime.now(timezone.utc), + end_at=datetime.now(timezone.utc) + timedelta(hours=1), + ) + assert data.suppress_alerts is True # default + assert data.notes is None + + def test_update_schema_partial(self): + from app.routers.maintenance_windows import MaintenanceWindowUpdate + data = MaintenanceWindowUpdate(name="Updated name") + assert data.name == "Updated name" + assert data.device_ids is None # all optional + + def test_response_schema(self): + from app.routers.maintenance_windows import MaintenanceWindowResponse + data = MaintenanceWindowResponse( + id="abc", + tenant_id="def", + name="Test", + device_ids=["x"], + start_at=datetime.now(timezone.utc).isoformat(), + end_at=datetime.now(timezone.utc).isoformat(), + suppress_alerts=True, + notes=None, + created_by="ghi", + created_at=datetime.now(timezone.utc).isoformat(), + ) + assert data.id == "abc" + + +class TestRouterRegistration: + """Test that the maintenance_windows router is properly registered.""" + + def test_router_importable(self): + from app.routers.maintenance_windows import router + assert router is not None + + def test_router_has_routes(self): + from app.routers.maintenance_windows import router + paths = [r.path for r in router.routes] + assert any("maintenance-windows" in p for p in paths) + + def test_main_app_includes_router(self): + try: + from app.main import app + except ImportError: + pytest.skip("app.main requires full dependencies (prometheus, etc.)") + route_paths = [r.path for r in app.routes] + route_paths_str = " ".join(route_paths) + assert "maintenance-windows" in route_paths_str + + +class TestAlertEvaluatorMaintenance: + """Test that alert_evaluator has maintenance window check capability.""" + + def test_maintenance_cache_exists(self): + from app.services import alert_evaluator + assert hasattr(alert_evaluator, "_maintenance_cache") + + def test_is_device_in_maintenance_function_exists(self): + from app.services.alert_evaluator import _is_device_in_maintenance + assert callable(_is_device_in_maintenance) diff --git a/backend/tests/unit/test_security.py b/backend/tests/unit/test_security.py new file mode 100644 index 0000000..29b7706 --- /dev/null +++ b/backend/tests/unit/test_security.py @@ -0,0 +1,231 @@ +"""Unit tests for security hardening. + +Tests cover: +- Production startup validation (insecure defaults rejection) +- Security headers middleware (per-environment header behavior) + +These are pure function/middleware tests -- no database or async required +for startup validation, async only for middleware tests. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from app.config import KNOWN_INSECURE_DEFAULTS, validate_production_settings + + +class TestStartupValidation: + """Tests for validate_production_settings().""" + + def _make_settings(self, **kwargs): + """Create a mock settings object with given field values.""" + defaults = { + "ENVIRONMENT": "dev", + "JWT_SECRET_KEY": "change-this-in-production-use-a-long-random-string", + "CREDENTIAL_ENCRYPTION_KEY": "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=", + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + def test_production_rejects_insecure_jwt_secret(self): + """Production with default JWT secret must exit.""" + settings = self._make_settings( + ENVIRONMENT="production", + JWT_SECRET_KEY=KNOWN_INSECURE_DEFAULTS["JWT_SECRET_KEY"][0], + ) + with pytest.raises(SystemExit) as exc_info: + validate_production_settings(settings) + assert exc_info.value.code == 1 + + def test_production_rejects_insecure_encryption_key(self): + """Production with default encryption key must exit.""" + settings = self._make_settings( + ENVIRONMENT="production", + JWT_SECRET_KEY="a-real-secure-jwt-secret-that-is-long-enough", + CREDENTIAL_ENCRYPTION_KEY=KNOWN_INSECURE_DEFAULTS["CREDENTIAL_ENCRYPTION_KEY"][0], + ) + with pytest.raises(SystemExit) as exc_info: + validate_production_settings(settings) + assert exc_info.value.code == 1 + + def test_dev_allows_insecure_defaults(self): + """Dev environment allows insecure defaults without error.""" + settings = self._make_settings( + ENVIRONMENT="dev", + JWT_SECRET_KEY=KNOWN_INSECURE_DEFAULTS["JWT_SECRET_KEY"][0], + CREDENTIAL_ENCRYPTION_KEY=KNOWN_INSECURE_DEFAULTS["CREDENTIAL_ENCRYPTION_KEY"][0], + ) + # Should NOT raise + validate_production_settings(settings) + + def test_production_allows_secure_values(self): + """Production with non-default secrets should pass.""" + settings = self._make_settings( + ENVIRONMENT="production", + JWT_SECRET_KEY="a-real-secure-jwt-secret-that-is-long-enough-for-production", + CREDENTIAL_ENCRYPTION_KEY="dGhpcyBpcyBhIHNlY3VyZSBrZXkgdGhhdCBpcw==", + ) + # Should NOT raise + validate_production_settings(settings) + + +class TestSecurityHeadersMiddleware: + """Tests for SecurityHeadersMiddleware.""" + + @pytest.fixture + def prod_app(self): + """Create a minimal FastAPI app with security middleware in production mode.""" + from fastapi import FastAPI + from app.middleware.security_headers import SecurityHeadersMiddleware + + app = FastAPI() + app.add_middleware(SecurityHeadersMiddleware, environment="production") + + @app.get("/test") + async def test_endpoint(): + return {"status": "ok"} + + return app + + @pytest.fixture + def dev_app(self): + """Create a minimal FastAPI app with security middleware in dev mode.""" + from fastapi import FastAPI + from app.middleware.security_headers import SecurityHeadersMiddleware + + app = FastAPI() + app.add_middleware(SecurityHeadersMiddleware, environment="dev") + + @app.get("/test") + async def test_endpoint(): + return {"status": "ok"} + + return app + + @pytest.mark.asyncio + async def test_production_includes_hsts(self, prod_app): + """Production responses must include HSTS header.""" + import httpx + + transport = httpx.ASGITransport(app=prod_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + assert response.status_code == 200 + assert response.headers["strict-transport-security"] == "max-age=31536000; includeSubDomains" + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["cache-control"] == "no-store" + + @pytest.mark.asyncio + async def test_dev_excludes_hsts(self, dev_app): + """Dev responses must NOT include HSTS (breaks plain HTTP).""" + import httpx + + transport = httpx.ASGITransport(app=dev_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + assert response.status_code == 200 + assert "strict-transport-security" not in response.headers + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["cache-control"] == "no-store" + + @pytest.mark.asyncio + async def test_csp_header_present_production(self, prod_app): + """Production responses must include CSP header.""" + import httpx + + transport = httpx.ASGITransport(app=prod_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + assert "content-security-policy" in response.headers + csp = response.headers["content-security-policy"] + assert "default-src 'self'" in csp + assert "script-src" in csp + + @pytest.mark.asyncio + async def test_csp_header_present_dev(self, dev_app): + """Dev responses must include CSP header.""" + import httpx + + transport = httpx.ASGITransport(app=dev_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + assert "content-security-policy" in response.headers + csp = response.headers["content-security-policy"] + assert "default-src 'self'" in csp + + @pytest.mark.asyncio + async def test_csp_production_blocks_inline_scripts(self, prod_app): + """Production CSP must block inline scripts (no unsafe-inline in script-src).""" + import httpx + + transport = httpx.ASGITransport(app=prod_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + csp = response.headers["content-security-policy"] + # Extract the script-src directive value + script_src = [d for d in csp.split(";") if "script-src" in d][0] + assert "'unsafe-inline'" not in script_src + assert "'unsafe-eval'" not in script_src + assert "'self'" in script_src + + @pytest.mark.asyncio + async def test_csp_dev_allows_unsafe_inline(self, dev_app): + """Dev CSP must allow unsafe-inline and unsafe-eval for Vite HMR.""" + import httpx + + transport = httpx.ASGITransport(app=dev_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + csp = response.headers["content-security-policy"] + script_src = [d for d in csp.split(";") if "script-src" in d][0] + assert "'unsafe-inline'" in script_src + assert "'unsafe-eval'" in script_src + + @pytest.mark.asyncio + async def test_csp_production_allows_inline_styles(self, prod_app): + """Production CSP must allow unsafe-inline for styles (Tailwind, Framer Motion, Radix).""" + import httpx + + transport = httpx.ASGITransport(app=prod_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + csp = response.headers["content-security-policy"] + style_src = [d for d in csp.split(";") if "style-src" in d][0] + assert "'unsafe-inline'" in style_src + + @pytest.mark.asyncio + async def test_csp_allows_websocket_connections(self, prod_app): + """CSP must allow wss: and ws: for SSE/WebSocket connections.""" + import httpx + + transport = httpx.ASGITransport(app=prod_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + csp = response.headers["content-security-policy"] + connect_src = [d for d in csp.split(";") if "connect-src" in d][0] + assert "wss:" in connect_src + assert "ws:" in connect_src + + @pytest.mark.asyncio + async def test_csp_frame_ancestors_none(self, prod_app): + """CSP must include frame-ancestors 'none' (anti-clickjacking).""" + import httpx + + transport = httpx.ASGITransport(app=prod_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test") + + csp = response.headers["content-security-policy"] + assert "frame-ancestors 'none'" in csp diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml new file mode 100644 index 0000000..23e75cb --- /dev/null +++ b/docker-compose.observability.yml @@ -0,0 +1,49 @@ +# docker-compose.observability.yml -- Observability stack (Prometheus + Grafana) +# Usage: docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d +# Or with dev services: docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.observability.yml up -d + +services: + prometheus: + image: prom/prometheus:latest + container_name: tod_prometheus + ports: + - "9090:9090" + volumes: + - ./infrastructure/observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./docker-data/prometheus:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=15d' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + deploy: + resources: + limits: + memory: 256M + networks: + - tod + + grafana: + image: grafana/grafana:latest + container_name: tod_grafana + ports: + - "3001:3000" + volumes: + - ./infrastructure/observability/grafana/provisioning:/etc/grafana/provisioning:ro + - ./infrastructure/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro + - ./docker-data/grafana:/var/lib/grafana + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/api-overview.json + deploy: + resources: + limits: + memory: 128M + depends_on: + - prometheus + networks: + - tod diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..4488130 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,111 @@ +# docker-compose.override.yml -- Dev environment (auto-loaded by `docker compose up`) +# Adds application services with hot reload, debug logging, and dev defaults. + +services: + api: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.api + container_name: tod_api + ports: + - "8001:8000" + env_file: .env + environment: + ENVIRONMENT: dev + LOG_LEVEL: debug + DEBUG: "true" + GUNICORN_WORKERS: "1" + DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/mikrotik + SYNC_DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/mikrotik + APP_USER_DATABASE_URL: postgresql+asyncpg://app_user:app_password@postgres:5432/mikrotik + REDIS_URL: redis://redis:6379/0 + NATS_URL: nats://nats:4222 + FIRST_ADMIN_EMAIL: ${FIRST_ADMIN_EMAIL:-admin@mikrotik-portal.dev} + FIRST_ADMIN_PASSWORD: ${FIRST_ADMIN_PASSWORD:-changeme-in-production} + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY:?Set CREDENTIAL_ENCRYPTION_KEY in .env} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?Set JWT_SECRET_KEY in .env} + OPENBAO_ADDR: http://openbao:8200 + OPENBAO_TOKEN: dev-openbao-token + GIT_STORE_PATH: /data/git-store + WIREGUARD_CONFIG_PATH: /data/wireguard + WIREGUARD_GATEWAY: wireguard + cap_add: + - NET_ADMIN + user: root + command: > + sh -c " + if [ -n \"$$WIREGUARD_GATEWAY\" ]; then + apt-get update -qq && apt-get install -y -qq iproute2 >/dev/null 2>&1 || true; + GW_IP=$$(getent hosts $$WIREGUARD_GATEWAY 2>/dev/null | awk '{print $$1}'); + [ -z \"$$GW_IP\" ] && GW_IP=$$WIREGUARD_GATEWAY; + ip route add 10.10.0.0/16 via $$GW_IP 2>/dev/null || true; + echo VPN route: 10.10.0.0/16 via $$GW_IP; + fi; + exec su -s /bin/sh appuser -c 'gunicorn app.main:app --config gunicorn.conf.py' + " + volumes: + - ./backend:/app + - ./docker-data/git-store:/data/git-store + - ./docker-data/wireguard:/data/wireguard + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + nats: + condition: service_healthy + deploy: + resources: + limits: + memory: 512M + networks: + - tod + + poller: + build: + context: ./poller + dockerfile: ./Dockerfile + container_name: tod_poller + env_file: .env + environment: + ENVIRONMENT: dev + LOG_LEVEL: debug + DATABASE_URL: postgres://poller_user:poller_password@postgres:5432/mikrotik + REDIS_URL: redis://redis:6379/0 + NATS_URL: nats://nats:4222 + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY:?Set CREDENTIAL_ENCRYPTION_KEY in .env} + OPENBAO_ADDR: http://openbao:8200 + OPENBAO_TOKEN: dev-openbao-token + POLL_INTERVAL_SECONDS: 60 + WIREGUARD_GATEWAY: wireguard + cap_add: + - NET_ADMIN + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + nats: + condition: service_healthy + deploy: + resources: + limits: + memory: 256M + networks: + - tod + + frontend: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.frontend + container_name: tod_frontend + ports: + - "3000:80" + depends_on: + - api + deploy: + resources: + limits: + memory: 64M + networks: + - tod diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..27f1dbb --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,82 @@ +# docker-compose.prod.yml -- Production environment override +# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d + +services: + api: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.api + container_name: tod_api + env_file: .env.prod + environment: + ENVIRONMENT: production + LOG_LEVEL: info + GUNICORN_WORKERS: "2" + command: ["gunicorn", "app.main:app", "--config", "gunicorn.conf.py"] + volumes: + - ./docker-data/git-store:/data/git-store + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + nats: + condition: service_healthy + deploy: + resources: + limits: + memory: 512M + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: + - tod + + poller: + build: + context: ./poller + dockerfile: ./Dockerfile + container_name: tod_poller + env_file: .env.prod + environment: + ENVIRONMENT: production + LOG_LEVEL: info + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + nats: + condition: service_healthy + deploy: + resources: + limits: + memory: 256M + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: + - tod + + frontend: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.frontend + container_name: tod_frontend + ports: + - "80:80" + depends_on: + - api + deploy: + resources: + limits: + memory: 64M + restart: unless-stopped + networks: + - tod diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..fada08a --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,88 @@ +# docker-compose.staging.yml -- Staging environment override +# Usage: docker compose -f docker-compose.yml -f docker-compose.staging.yml --env-file .env.staging up -d +# +# Staging mirrors production behavior (gunicorn, info logging, restart policies) +# but exposes the API port for debugging and uses distinct container names/ports +# so it can coexist with dev on the same host if needed. + +services: + api: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.api + container_name: tod_staging_api + ports: + - "8081:8000" + env_file: .env.staging + environment: + ENVIRONMENT: staging + LOG_LEVEL: info + GUNICORN_WORKERS: "2" + command: ["gunicorn", "app.main:app", "--config", "gunicorn.conf.py"] + volumes: + - ./docker-data/staging-git-store:/data/git-store + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + nats: + condition: service_healthy + deploy: + resources: + limits: + memory: 512M + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: + - tod + + poller: + build: + context: ./poller + dockerfile: ./Dockerfile + container_name: tod_staging_poller + env_file: .env.staging + environment: + ENVIRONMENT: staging + LOG_LEVEL: info + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + nats: + condition: service_healthy + deploy: + resources: + limits: + memory: 256M + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: + - tod + + frontend: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.frontend + container_name: tod_staging_frontend + ports: + - "3080:80" + depends_on: + - api + deploy: + resources: + limits: + memory: 64M + restart: unless-stopped + networks: + - tod diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01834d0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,164 @@ +# ─── Low-RAM build note ────────────────────────────────────────────────────── +# On a 2-core / 2-4 GB server, build images ONE AT A TIME to avoid OOM: +# +# docker compose build api +# docker compose build poller +# docker compose build frontend +# +# Running `docker compose build` (all at once) will trigger three concurrent +# multi-stage builds (Go, Python/pip, Node/tsc/Vite) that together can peak at +# 3-4 GB RAM, crashing the machine before any image finishes. +# +# Once built, starting the stack uses far less RAM (nginx + uvicorn + Go binary). +# ───────────────────────────────────────────────────────────────────────────── + +services: + postgres: + image: timescale/timescaledb:2.17.2-pg17 + container_name: tod_postgres + env_file: .env + environment: + POSTGRES_DB: ${POSTGRES_DB:-mikrotik} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + ports: + - "5432:5432" + volumes: + - /Volumes/ssd01/mikrotik/docker-data/postgres:/var/lib/postgresql/data + - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d mikrotik"] + interval: 5s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 512M + networks: + - mikrotik + + redis: + image: redis:7-alpine + container_name: tod_redis + env_file: .env + ports: + - "6379:6379" + volumes: + - /Volumes/ssd01/mikrotik/docker-data/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + deploy: + resources: + limits: + memory: 128M + networks: + - mikrotik + + nats: + image: nats:2-alpine + container_name: tod_nats + command: ["--jetstream", "--store_dir", "/data", "-m", "8222"] + env_file: .env + ports: + - "4222:4222" + - "8222:8222" + volumes: + - /Volumes/ssd01/mikrotik/docker-data/nats:/data + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:8222/healthz || exit 1"] + interval: 5s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 128M + networks: + - mikrotik + + openbao: + image: openbao/openbao:2.1 + container_name: tod_openbao + entrypoint: /bin/sh + command: + - -c + - | + # Start OpenBao in background + bao server -dev -dev-listen-address=0.0.0.0:8200 & + BAO_PID=$$! + # Wait for ready and run init + sleep 2 + /init/init.sh + # Wait for OpenBao process + wait $$BAO_PID + environment: + BAO_DEV_ROOT_TOKEN_ID: dev-openbao-token + BAO_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" + ports: + - "8200:8200" + volumes: + - ./infrastructure/openbao/init.sh:/init/init.sh:ro + cap_add: + - IPC_LOCK + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8200/v1/sys/health | grep -q '\"sealed\":false' || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + deploy: + resources: + limits: + memory: 256M + networks: + - mikrotik + + wireguard: + image: lscr.io/linuxserver/wireguard:latest + container_name: tod_wireguard + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + volumes: + - /Volumes/ssd01/mikrotik/docker-data/wireguard:/config + - /Volumes/ssd01/mikrotik/docker-data/wireguard/custom-cont-init.d:/custom-cont-init.d + ports: + - "51820:51820/udp" + cap_add: + - NET_ADMIN + sysctls: + - net.ipv4.ip_forward=1 + - net.ipv4.conf.all.src_valid_mark=1 + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "ip link show wg0 2>/dev/null || exit 0"] + interval: 10s + timeout: 5s + retries: 3 + deploy: + resources: + limits: + memory: 128M + networks: + - mikrotik + + mailpit: + image: axllent/mailpit:latest + profiles: ["mail-testing"] + ports: + - "8026:8025" + - "1026:1025" + networks: + - mikrotik + deploy: + resources: + limits: + memory: 64M + +networks: + mikrotik: + driver: bridge diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..747d587 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,117 @@ +# API Reference + +## Overview + +TOD exposes a REST API built with FastAPI. Interactive documentation is available at: + +- Swagger UI: `http://:/docs` (dev environment only) +- ReDoc: `http://:/redoc` (dev environment only) + +Both Swagger and ReDoc are disabled in staging/production environments. + +## Authentication + +### SRP-6a Login + +- `POST /api/auth/login` -- SRP-6a authentication (returns JWT access + refresh tokens) +- `POST /api/auth/refresh` -- Refresh an expired access token +- `POST /api/auth/logout` -- Invalidate the current session + +All authenticated endpoints require one of: + +- `Authorization: Bearer ` header +- httpOnly cookie (set automatically by the login flow) + +Access tokens expire after 15 minutes. Refresh tokens are valid for 7 days. + +### API Key Authentication + +- Create API keys in Admin > API Keys +- Use header: `X-API-Key: mktp_` +- Keys have operator-level RBAC permissions +- Prefix: `mktp_`, stored as SHA-256 hash + +## Endpoint Groups + +All API routes are mounted under the `/api` prefix. + +| Group | Prefix | Description | +|-------|--------|-------------| +| Auth | `/api/auth/*` | Login, register, SRP exchange, password reset, token refresh | +| Tenants | `/api/tenants/*` | Tenant/organization CRUD | +| Users | `/api/users/*` | User management, RBAC role assignment | +| Devices | `/api/devices/*` | Device CRUD, scanning, status | +| Device Groups | `/api/device-groups/*` | Logical device grouping | +| Device Tags | `/api/device-tags/*` | Tag-based device labeling | +| Metrics | `/api/metrics/*` | TimescaleDB device metrics (CPU, memory, traffic) | +| Config Backups | `/api/config-backups/*` | Automated RouterOS config backup history | +| Config Editor | `/api/config-editor/*` | Live RouterOS config browsing and editing | +| Firmware | `/api/firmware/*` | RouterOS firmware version management and upgrades | +| Alerts | `/api/alerts/*` | Alert rule CRUD, alert history | +| Events | `/api/events/*` | Device event log | +| Device Logs | `/api/device-logs/*` | RouterOS syslog entries | +| Templates | `/api/templates/*` | Config templates for batch operations | +| Clients | `/api/clients/*` | Connected client (DHCP lease) data | +| Topology | `/api/topology/*` | Network topology map data | +| SSE | `/api/sse/*` | Server-Sent Events for real-time updates | +| Audit Logs | `/api/audit-logs/*` | Immutable audit trail | +| Reports | `/api/reports/*` | PDF report generation (Jinja2 + WeasyPrint) | +| API Keys | `/api/api-keys/*` | API key CRUD | +| Maintenance Windows | `/api/maintenance-windows/*` | Scheduled maintenance window management | +| VPN | `/api/vpn/*` | WireGuard VPN tunnel management | +| Certificates | `/api/certificates/*` | Internal CA and device certificate management | +| Transparency | `/api/transparency/*` | KMS access event dashboard | + +## Health Checks + +| Endpoint | Type | Description | +|----------|------|-------------| +| `GET /health` | Liveness | Always returns 200 if the API process is alive. Response includes `version`. | +| `GET /health/ready` | Readiness | Returns 200 only when PostgreSQL, Redis, and NATS are all healthy. Returns 503 otherwise. | +| `GET /api/health` | Liveness | Backward-compatible alias under `/api` prefix. | + +## Rate Limiting + +- Auth endpoints: 5 requests/minute per IP +- General endpoints: no global rate limit (per-route limits may apply) + +Rate limit violations return HTTP 429 with a JSON error body. + +## Error Format + +All error responses use a standard JSON format: + +```json +{ + "detail": "Human-readable error message" +} +``` + +HTTP status codes follow REST conventions: + +| Code | Meaning | +|------|---------| +| 400 | Bad request / validation error | +| 401 | Unauthorized (missing or expired token) | +| 403 | Forbidden (insufficient RBAC permissions) | +| 404 | Resource not found | +| 409 | Conflict (duplicate resource) | +| 422 | Unprocessable entity (Pydantic validation) | +| 429 | Rate limit exceeded | +| 500 | Internal server error | +| 503 | Service unavailable (readiness check failed) | + +## RBAC Roles + +Endpoints enforce role-based access control. The four roles in descending privilege order: + +| Role | Scope | Description | +|------|-------|-------------| +| `super_admin` | Global (no tenant) | Full platform access, tenant management | +| `admin` | Tenant | Full access within their tenant | +| `operator` | Tenant | Device operations, config changes | +| `viewer` | Tenant | Read-only access | + +## Multi-Tenancy + +Tenant isolation is enforced at the database level via PostgreSQL Row-Level Security (RLS). The `app_user` database role automatically filters all queries by the authenticated user's `tenant_id`. Super admins operate outside tenant scope. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..160ace0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,329 @@ +# Architecture + +## System Overview + +TOD (The Other Dude) is a containerized MSP fleet management platform for MikroTik RouterOS devices. It uses a three-service architecture: a React frontend, a Python FastAPI backend, and a Go poller. All services communicate through PostgreSQL, Redis, and NATS JetStream. Multi-tenancy is enforced at the database level via PostgreSQL Row-Level Security (RLS). + +``` +┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ Frontend │────▶│ Backend API │◀───▶│ Go Poller │ +│ React/nginx │ │ FastAPI │ │ go-routeros │ +└─────────────┘ └────────┬────────┘ └──────┬───────┘ + │ │ + ┌──────────────┼──────────────────────┤ + │ │ │ + ┌────────▼──┐ ┌──────▼──────┐ ┌──────────▼──┐ + │ Redis │ │ PostgreSQL │ │ NATS │ + │ locks, │ │ 17 + Timescale│ │ JetStream │ + │ cache │ │ DB + RLS │ │ pub/sub │ + └───────────┘ └─────────────┘ └─────────────┘ + │ + ┌──────▼──────┐ + │ OpenBao │ + │ Transit KMS │ + └─────────────┘ +``` + +## Services + +### Frontend (React / nginx) + +- **Stack**: React 19, TypeScript, TanStack Router (file-based routing), TanStack Query (data fetching), Tailwind CSS 3.4, Vite +- **Production**: Static build served by nginx on port 80 (exposed as port 3000) +- **Development**: Vite dev server with hot module replacement +- **Design system**: Geist Sans + Geist Mono fonts, HSL color tokens via CSS custom properties, class-based dark/light mode +- **Real-time**: Server-Sent Events (SSE) for live device status updates, alerts, and operation progress +- **Client-side encryption**: SRP-6a authentication flow with 2SKD key derivation; Emergency Kit PDF generation +- **UX features**: Command palette (Cmd+K), Framer Motion page transitions, collapsible sidebar, skeleton loaders +- **Memory limit**: 64MB + +### Backend API (FastAPI) + +- **Stack**: Python 3.12+, FastAPI 0.115+, SQLAlchemy 2.0 async, asyncpg, Gunicorn +- **Two database engines**: + - `admin_engine` (superuser) -- used only for auth/bootstrap and NATS subscribers that need cross-tenant access + - `app_engine` (non-superuser `app_user` role) -- used for all device/data routes, enforces RLS +- **Authentication**: JWT tokens (15min access, 7d refresh), SRP-6a zero-knowledge proof, RBAC (super_admin, admin, operator, viewer) +- **NATS subscribers**: Three independent subscribers for device status, metrics, and firmware events. Non-fatal startup -- API serves requests even if NATS is unavailable +- **Background services**: APScheduler for nightly config backups and daily firmware version checks +- **OpenBao integration**: Provisions per-tenant Transit encryption keys on startup, dual-read fallback if OpenBao is unavailable +- **Startup sequence**: Configure logging -> Run Alembic migrations -> Bootstrap first admin -> Start NATS subscribers -> Ensure SSE streams -> Start schedulers -> Provision OpenBao keys +- **API documentation**: OpenAPI docs at `/docs` and `/redoc` (dev environment only) +- **Health endpoints**: `/health` (liveness), `/health/ready` (readiness -- checks PostgreSQL, Redis, NATS) +- **Middleware stack** (LIFO order): RequestID -> SecurityHeaders -> RateLimiting -> CORS -> Route handler +- **Memory limit**: 512MB + +#### API Routers + +The backend exposes 21 route groups under the `/api` prefix: + +| Router | Purpose | +|--------|---------| +| `auth` | Login (SRP-6a + legacy), token refresh, registration | +| `tenants` | Tenant CRUD (super_admin only) | +| `users` | User management, RBAC | +| `devices` | Device CRUD, status, commands | +| `device_groups` | Logical device grouping | +| `device_tags` | Tagging and filtering | +| `metrics` | Time-series metrics (TimescaleDB) | +| `config_backups` | Configuration backup history | +| `config_editor` | Live RouterOS config editing | +| `firmware` | Firmware version tracking and upgrades | +| `alerts` | Alert rules and active alerts | +| `events` | Device event log | +| `device_logs` | RouterOS system logs | +| `templates` | Configuration templates | +| `clients` | Connected client devices | +| `topology` | Network topology (ReactFlow data) | +| `sse` | Server-Sent Events streams | +| `audit_logs` | Immutable audit trail | +| `reports` | PDF report generation (Jinja2 + weasyprint) | +| `api_keys` | API key management (mktp_ prefix) | +| `maintenance_windows` | Scheduled maintenance with alert suppression | +| `vpn` | WireGuard VPN management | +| `certificates` | Internal CA and device TLS certificates | +| `transparency` | KMS access event dashboard | + +### Go Poller + +- **Stack**: Go 1.23, go-routeros/v3, pgx/v5, nats.go +- **Polling model**: Synchronous per-device polling on a configurable interval (default 60s) +- **Device communication**: RouterOS binary API over TLS (port 8729), InsecureSkipVerify for self-signed certs +- **TLS fallback**: Three-tier strategy -- CA-verified -> InsecureSkipVerify -> plain API +- **Distributed locking**: Redis locks prevent concurrent polling of the same device (safe for multi-instance deployment) +- **Circuit breaker**: Backs off from unreachable devices to avoid wasting poll cycles +- **Credential decryption**: OpenBao Transit with LRU cache (1024 entries, 5min TTL) to minimize KMS calls +- **Output**: Publishes poll results to NATS JetStream; the API's NATS subscribers process and persist them +- **Database access**: Uses `poller_user` role which bypasses RLS (needs cross-tenant device access) +- **VPN routing**: Adds static route to WireGuard gateway for reaching remote devices +- **Memory limit**: 256MB + +## Infrastructure Services + +### PostgreSQL 17 + TimescaleDB + +- **Image**: `timescale/timescaledb:2.17.2-pg17` +- **Row-Level Security (RLS)**: Enforces tenant isolation at the database level. All data tables have a `tenant_id` column; RLS policies filter by `current_setting('app.tenant_id')` +- **Database roles**: + - `postgres` (superuser) -- admin engine, auth/bootstrap, migrations + - `app_user` (non-superuser) -- RLS-enforced, used by API for data routes + - `poller_user` -- bypasses RLS, used by Go poller for cross-tenant device access +- **TimescaleDB hypertables**: Time-series storage for device metrics (CPU, memory, interface traffic, etc.) +- **Migrations**: Alembic, run automatically on API startup +- **Initialization**: `scripts/init-postgres.sql` creates roles and enables extensions +- **Data volume**: `./docker-data/postgres` +- **Memory limit**: 512MB + +### Redis + +- **Image**: `redis:7-alpine` +- **Uses**: + - Distributed locking for the Go poller (prevents concurrent polling of the same device) + - Rate limiting on auth endpoints (5 requests/min) + - Credential cache for OpenBao Transit responses +- **Data volume**: `./docker-data/redis` +- **Memory limit**: 128MB + +### NATS JetStream + +- **Image**: `nats:2-alpine` +- **Role**: Message bus between the Go poller and the Python API +- **Streams**: DEVICE_EVENTS (poll results, status changes), ALERT_EVENTS (SSE delivery), OPERATION_EVENTS (SSE delivery) +- **Durable consumers**: Ensure no message loss during API restarts +- **Monitoring port**: 8222 +- **Data volume**: `./docker-data/nats` +- **Memory limit**: 128MB + +### OpenBao (HashiCorp Vault fork) + +- **Image**: `openbao/openbao:2.1` +- **Mode**: Dev server (auto-unsealed, in-memory storage) +- **Transit secrets engine**: Provides envelope encryption for device credentials at rest +- **Per-tenant keys**: Each tenant gets a dedicated Transit encryption key +- **Init script**: `infrastructure/openbao/init.sh` enables Transit engine and creates initial keys +- **Dev token**: `dev-openbao-token` (must be replaced in production) +- **Memory limit**: 256MB + +### WireGuard + +- **Image**: `lscr.io/linuxserver/wireguard` +- **Role**: VPN gateway for reaching RouterOS devices on remote networks +- **Port**: 51820/UDP +- **Routing**: API and Poller containers add static routes through the WireGuard container to reach device subnets (e.g., `10.10.0.0/16`) +- **Data volume**: `./docker-data/wireguard` +- **Memory limit**: 128MB + +## Data Flow + +### Device Polling Cycle + +``` +Go Poller Redis OpenBao RouterOS NATS API PostgreSQL + │ │ │ │ │ │ │ + ├──query device list──────▶│ │ │ │ │ │ + │◀─────────────────────────┤ │ │ │ │ │ + ├──acquire lock────────────▶│ │ │ │ │ │ + │◀──lock granted───────────┤ │ │ │ │ │ + ├──decrypt credentials (cache miss)────────▶│ │ │ │ │ + │◀──plaintext credentials──────────────────┤ │ │ │ │ + ├──binary API (8729 TLS)───────────────────────────────────▶│ │ │ │ + │◀──system info, interfaces, metrics───────────────────────┤ │ │ │ + ├──publish poll result──────────────────────────────────────────────────▶│ │ │ + │ │ │ │ │ ──subscribe──▶│ │ + │ │ │ │ │ ├──upsert data──▶│ + ├──release lock────────────▶│ │ │ │ │ │ +``` + +1. Poller queries PostgreSQL for the list of active devices +2. Acquires a Redis distributed lock per device (prevents duplicate polling) +3. Decrypts device credentials via OpenBao Transit (LRU cache avoids repeated KMS calls) +4. Connects to the RouterOS binary API on port 8729 over TLS +5. Collects system info, interface stats, routing tables, and metrics +6. Publishes results to NATS JetStream +7. API NATS subscriber processes results and upserts into PostgreSQL +8. Releases Redis lock + +### Config Push (Two-Phase with Panic Revert) + +``` +Frontend API RouterOS + │ │ │ + ├──push config─▶│ │ + │ ├──apply config─▶│ + │ ├──set revert timer─▶│ + │ │◀──ack────────┤ + │◀──pending────┤ │ + │ │ │ (timer counting down) + ├──confirm─────▶│ │ + │ ├──cancel timer─▶│ + │ │◀──ack────────┤ + │◀──confirmed──┤ │ +``` + +1. Frontend sends config commands to the API +2. API connects to the device and applies the configuration +3. Sets a revert timer on the device (RouterOS safe mode / scheduler) +4. Returns pending status to the frontend +5. User confirms the change works (e.g., connectivity still up) +6. If confirmed: API cancels the revert timer, config is permanent +7. If timeout or rejected: device automatically reverts to the previous configuration + +This pattern prevents lockouts from misconfigured firewall rules or IP changes. + +### Authentication (SRP-6a Zero-Knowledge Proof) + +``` +Browser API PostgreSQL + │ │ │ + │──register────────────────▶│ │ + │ (email, salt, verifier) │──store verifier──────▶│ + │ │ │ + │──login step 1────────────▶│ │ + │ (email, client_public) │──lookup verifier─────▶│ + │◀──(salt, server_public)──┤◀─────────────────────┤ + │ │ │ + │──login step 2────────────▶│ │ + │ (client_proof) │──verify proof────────│ + │◀──(server_proof, JWT)────┤ │ +``` + +1. **Registration**: Client derives a verifier from `password + secret_key` using PBKDF2 (650K iterations) + HKDF + XOR (2SKD). Only the salt and verifier are sent to the server -- never the password +2. **Login step 1**: Client sends email and ephemeral public value; server responds with stored salt and its own ephemeral public value +3. **Login step 2**: Client computes a proof from the shared session key; server validates the proof without ever seeing the password +4. **Token issuance**: On successful proof, server issues JWT (15min access + 7d refresh) +5. **Emergency Kit**: A downloadable PDF containing the user's secret key for account recovery + +## Multi-Tenancy Model + +- Every data table includes a `tenant_id` column +- PostgreSQL RLS policies filter rows by `current_setting('app.tenant_id')` +- The API sets tenant context (`SET app.tenant_id = ...`) on each database session +- `super_admin` role has NULL `tenant_id` and can access all tenants +- `poller_user` bypasses RLS intentionally (needs cross-tenant device access for polling) +- Tenant isolation is enforced at the database level, not the application level -- even a compromised API cannot leak cross-tenant data through `app_user` connections + +## Security Layers + +| Layer | Mechanism | Purpose | +|-------|-----------|---------| +| **Authentication** | SRP-6a | Zero-knowledge proof -- password never transmitted or stored | +| **Key Derivation** | 2SKD (PBKDF2 650K + HKDF + XOR) | Two-secret key derivation from password + secret key | +| **Encryption at Rest** | OpenBao Transit | Envelope encryption for device credentials | +| **Tenant Isolation** | PostgreSQL RLS | Database-level row filtering by tenant_id | +| **Access Control** | JWT + RBAC | Role-based permissions (super_admin, admin, operator, viewer) | +| **Rate Limiting** | Redis-backed | Auth endpoints limited to 5 requests/min | +| **TLS Certificates** | Internal CA | Certificate management and deployment to RouterOS devices | +| **Security Headers** | Middleware | CSP, SRI hashes on JS bundles, X-Frame-Options, etc. | +| **Secret Validation** | Startup check | Rejects known-insecure defaults in non-dev environments | + +## Network Topology + +All services communicate over a single Docker bridge network (`tod`). External ports: + +| Service | Internal Port | External Port | Protocol | +|---------|--------------|---------------|----------| +| Frontend | 80 | 3000 | HTTP | +| API | 8000 | 8001 | HTTP | +| PostgreSQL | 5432 | 5432 | TCP | +| Redis | 6379 | 6379 | TCP | +| NATS | 4222 | 4222 | TCP | +| NATS Monitor | 8222 | 8222 | HTTP | +| OpenBao | 8200 | 8200 | HTTP | +| WireGuard | 51820 | 51820 | UDP | + +## File Structure + +``` +backend/ FastAPI Python backend + app/ + main.py Application entry point, lifespan, router registration + config.py Pydantic Settings configuration + database.py SQLAlchemy engines (admin + app_user) + models/ SQLAlchemy ORM models + routers/ FastAPI route handlers (21 modules) + services/ Business logic, NATS subscribers, schedulers + middleware/ Rate limiting, request ID, security headers +frontend/ React TypeScript frontend + src/ + routes/ TanStack Router file-based routes + components/ Reusable UI components + lib/ API client, crypto, utilities +poller/ Go microservice for device polling + main.go Entry point + Dockerfile Multi-stage build +infrastructure/ Deployment configuration + docker/ Dockerfiles for api, frontend + helm/ Kubernetes Helm charts + openbao/ OpenBao init scripts +scripts/ Database init scripts +docker-compose.yml Infrastructure services (postgres, redis, nats, openbao, wireguard) +docker-compose.override.yml Application services for dev (api, poller, frontend) +``` + +## Running the Stack + +```bash +# Infrastructure only (postgres, redis, nats, openbao, wireguard) +docker compose up -d + +# Full stack including application services (api, poller, frontend) +docker compose up -d # override.yml is auto-loaded in dev + +# Build images sequentially to avoid OOM on low-RAM machines +docker compose build api +docker compose build poller +docker compose build frontend +``` + +## Container Memory Limits + +| Service | Limit | +|---------|-------| +| PostgreSQL | 512MB | +| API | 512MB | +| Go Poller | 256MB | +| OpenBao | 256MB | +| Redis | 128MB | +| NATS | 128MB | +| WireGuard | 128MB | +| Frontend (nginx) | 64MB | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..64dadd7 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,127 @@ +# Configuration Reference + +TOD uses Pydantic Settings for configuration. All values can be set via environment variables or a `.env` file in the backend working directory. + +## Environment Variables + +### Application + +| Variable | Default | Description | +|----------|---------|-------------| +| `APP_NAME` | `TOD - The Other Dude` | Application display name | +| `APP_VERSION` | `0.1.0` | Semantic version string | +| `ENVIRONMENT` | `dev` | Runtime environment: `dev`, `staging`, or `production` | +| `DEBUG` | `false` | Enable debug mode | +| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,http://localhost:8080` | Comma-separated list of allowed CORS origins | +| `APP_BASE_URL` | `http://localhost:5173` | Frontend base URL (used in password reset emails) | + +### Authentication & JWT + +| Variable | Default | Description | +|----------|---------|-------------| +| `JWT_SECRET_KEY` | *(insecure dev default)* | HMAC signing key for JWTs. **Must be changed in production.** Generate with: `python -c "import secrets; print(secrets.token_urlsafe(64))"` | +| `JWT_ALGORITHM` | `HS256` | JWT signing algorithm | +| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | Access token lifetime in minutes | +| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | `7` | Refresh token lifetime in days | +| `PASSWORD_RESET_TOKEN_EXPIRE_MINUTES` | `30` | Password reset link validity in minutes | + +### Database + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `postgresql+asyncpg://postgres:postgres@localhost:5432/mikrotik` | Admin (superuser) async database URL. Used for migrations and bootstrap operations. | +| `SYNC_DATABASE_URL` | `postgresql+psycopg2://postgres:postgres@localhost:5432/mikrotik` | Synchronous database URL used by Alembic migrations only. | +| `APP_USER_DATABASE_URL` | `postgresql+asyncpg://app_user:app_password@localhost:5432/mikrotik` | Non-superuser async database URL. Enforces PostgreSQL RLS for tenant isolation. | +| `DB_POOL_SIZE` | `20` | App user connection pool size | +| `DB_MAX_OVERFLOW` | `40` | App user pool max overflow connections | +| `DB_ADMIN_POOL_SIZE` | `10` | Admin connection pool size | +| `DB_ADMIN_MAX_OVERFLOW` | `20` | Admin pool max overflow connections | + +### Security + +| Variable | Default | Description | +|----------|---------|-------------| +| `CREDENTIAL_ENCRYPTION_KEY` | *(insecure dev default)* | AES-256-GCM encryption key for device credentials at rest. Must be exactly 32 bytes, base64-encoded. **Must be changed in production.** Generate with: `python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"` | + +### OpenBao / Vault (KMS) + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENBAO_ADDR` | `http://localhost:8200` | OpenBao Transit server address for per-tenant envelope encryption | +| `OPENBAO_TOKEN` | *(insecure dev default)* | OpenBao authentication token. **Must be changed in production.** | + +### NATS + +| Variable | Default | Description | +|----------|---------|-------------| +| `NATS_URL` | `nats://localhost:4222` | NATS JetStream server URL for pub/sub between Go poller and Python API | + +### Redis + +| Variable | Default | Description | +|----------|---------|-------------| +| `REDIS_URL` | `redis://localhost:6379/0` | Redis URL for caching, distributed locks, and rate limiting | + +### SMTP (Notifications) + +| Variable | Default | Description | +|----------|---------|-------------| +| `SMTP_HOST` | `localhost` | SMTP server hostname | +| `SMTP_PORT` | `587` | SMTP server port | +| `SMTP_USER` | *(none)* | SMTP authentication username | +| `SMTP_PASSWORD` | *(none)* | SMTP authentication password | +| `SMTP_USE_TLS` | `false` | Enable STARTTLS for SMTP connections | +| `SMTP_FROM_ADDRESS` | `noreply@mikrotik-portal.local` | Sender address for outbound emails | + +### Firmware + +| Variable | Default | Description | +|----------|---------|-------------| +| `FIRMWARE_CACHE_DIR` | `/data/firmware-cache` | Path to firmware download cache (PVC mount in production) | +| `FIRMWARE_CHECK_INTERVAL_HOURS` | `24` | Hours between automatic RouterOS version checks | + +### Storage Paths + +| Variable | Default | Description | +|----------|---------|-------------| +| `GIT_STORE_PATH` | `./git-store` | Path to bare git repos for config backup history (one repo per tenant). In production: `/data/git-store` on a ReadWriteMany PVC. | +| `WIREGUARD_CONFIG_PATH` | `/data/wireguard` | Shared volume path for WireGuard configuration files | + +### Bootstrap + +| Variable | Default | Description | +|----------|---------|-------------| +| `FIRST_ADMIN_EMAIL` | *(none)* | Email for the initial super_admin user. Only used if no users exist in the database. | +| `FIRST_ADMIN_PASSWORD` | *(none)* | Password for the initial super_admin user. The user is created with `must_upgrade_auth=True`, triggering SRP registration on first login. | + +## Production Safety + +TOD refuses to start in `staging` or `production` environments if any of these variables still have their insecure dev defaults: + +- `JWT_SECRET_KEY` +- `CREDENTIAL_ENCRYPTION_KEY` +- `OPENBAO_TOKEN` + +The process exits with code 1 and a clear error message indicating which variable needs to be rotated. + +## Docker Compose Profiles + +| Profile | Command | Services | +|---------|---------|----------| +| *(default)* | `docker compose up -d` | Infrastructure only: PostgreSQL, Redis, NATS, OpenBao | +| `full` | `docker compose --profile full up -d` | All services: infrastructure + API, Poller, Frontend | + +## Container Memory Limits + +All containers have enforced memory limits to prevent OOM on the host: + +| Service | Memory Limit | +|---------|-------------| +| PostgreSQL | 512 MB | +| Redis | 128 MB | +| NATS | 128 MB | +| API | 512 MB | +| Poller | 256 MB | +| Frontend | 64 MB | + +Build Docker images sequentially (not in parallel) to avoid OOM during builds. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..52ebcb7 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,257 @@ +# TOD - The Other Dude — Deployment Guide + +## Overview + +TOD (The Other Dude) is a containerized fleet management platform for RouterOS devices. This guide covers Docker Compose deployment for production environments. + +### Architecture + +- **Backend API** (Python/FastAPI) -- REST API with JWT authentication and PostgreSQL RLS +- **Go Poller** -- Polls RouterOS devices via binary API, publishes events to NATS +- **Frontend** (React/nginx) -- Single-page application served by nginx +- **PostgreSQL + TimescaleDB** -- Primary database with time-series extensions +- **Redis** -- Distributed locking and rate limiting +- **NATS JetStream** -- Message bus for device events + +## Prerequisites + +- Docker Engine 24+ with Docker Compose v2 +- At least 4GB RAM (2GB absolute minimum -- builds are memory-intensive) +- External SSD or fast storage recommended for Docker volumes +- Network access to RouterOS devices on ports 8728 (API) and 8729 (API-SSL) + +## Quick Start + +### 1. Clone and Configure + +```bash +git clone tod +cd tod + +# Copy environment template +cp .env.example .env.prod +``` + +### 2. Generate Secrets + +```bash +# Generate JWT secret +python3 -c "import secrets; print(secrets.token_urlsafe(64))" + +# Generate credential encryption key (32 bytes, base64-encoded) +python3 -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())" +``` + +Edit `.env.prod` with the generated values: + +```env +ENVIRONMENT=production +JWT_SECRET_KEY= +CREDENTIAL_ENCRYPTION_KEY= +POSTGRES_PASSWORD= + +# First admin user (created on first startup) +FIRST_ADMIN_EMAIL=admin@example.com +FIRST_ADMIN_PASSWORD= +``` + +### 3. Build Images + +Build images **one at a time** to avoid out-of-memory crashes on constrained hosts: + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml build api +docker compose -f docker-compose.yml -f docker-compose.prod.yml build poller +docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend +``` + +### 4. Start the Stack + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +### 5. Verify + +```bash +# Check all services are running +docker compose ps + +# Check API health (liveness) +curl http://localhost:8000/health + +# Check readiness (PostgreSQL, Redis, NATS connected) +curl http://localhost:8000/health/ready + +# Access the portal +open http://localhost +``` + +Log in with the `FIRST_ADMIN_EMAIL` and `FIRST_ADMIN_PASSWORD` credentials set in step 2. + +## Environment Configuration + +### Required Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `ENVIRONMENT` | Deployment environment | `production` | +| `JWT_SECRET_KEY` | JWT signing secret (min 32 chars) | `` | +| `CREDENTIAL_ENCRYPTION_KEY` | AES-256 key for device credentials (base64) | `` | +| `POSTGRES_PASSWORD` | PostgreSQL superuser password | `` | +| `FIRST_ADMIN_EMAIL` | Initial admin account email | `admin@example.com` | +| `FIRST_ADMIN_PASSWORD` | Initial admin account password | `` | + +### Optional Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `GUNICORN_WORKERS` | `2` | API worker process count | +| `DB_POOL_SIZE` | `20` | App database connection pool size | +| `DB_MAX_OVERFLOW` | `40` | Max overflow connections above pool | +| `DB_ADMIN_POOL_SIZE` | `10` | Admin database connection pool size | +| `DB_ADMIN_MAX_OVERFLOW` | `20` | Admin max overflow connections | +| `POLL_INTERVAL_SECONDS` | `60` | Device polling interval | +| `CONNECTION_TIMEOUT_SECONDS` | `10` | RouterOS connection timeout | +| `COMMAND_TIMEOUT_SECONDS` | `30` | RouterOS per-command timeout | +| `CIRCUIT_BREAKER_MAX_FAILURES` | `5` | Consecutive failures before backoff | +| `CIRCUIT_BREAKER_BASE_BACKOFF_SECONDS` | `30` | Initial backoff duration | +| `CIRCUIT_BREAKER_MAX_BACKOFF_SECONDS` | `900` | Maximum backoff (15 min) | +| `LOG_LEVEL` | `info` | Logging verbosity (`debug`/`info`/`warn`/`error`) | +| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated CORS origins | + +### Security Notes + +- **Never use default secrets in production.** The application refuses to start if it detects known insecure defaults (like the dev JWT secret) in non-dev environments. +- **Credential encryption key** is used to encrypt RouterOS device passwords at rest. Losing this key means re-entering all device credentials. +- **CORS_ORIGINS** should be set to your actual domain in production. +- **RLS enforcement**: The app_user database role enforces row-level security. Tenants cannot access each other's data even with a compromised JWT. + +## Storage Configuration + +Docker volumes mount to the host filesystem. Default locations are configured in `docker-compose.yml`: + +- **PostgreSQL data**: `./docker-data/postgres` +- **Redis data**: `./docker-data/redis` +- **NATS data**: `./docker-data/nats` +- **Git store (config backups)**: `./docker-data/git-store` + +To change storage locations, edit the volume mounts in `docker-compose.yml`. + +## Resource Limits + +Container memory limits are enforced in `docker-compose.prod.yml` to prevent OOM crashes: + +| Service | Memory Limit | +|---------|-------------| +| PostgreSQL | 512MB | +| Redis | 128MB | +| NATS | 128MB | +| API | 512MB | +| Poller | 256MB | +| Frontend | 64MB | + +Adjust under `deploy.resources.limits.memory` in `docker-compose.prod.yml`. + +## API Documentation + +The backend serves interactive API documentation at: + +- **Swagger UI**: `http://localhost:8000/docs` +- **ReDoc**: `http://localhost:8000/redoc` + +All endpoints include descriptions, request/response schemas, and authentication requirements. + +## Monitoring (Optional) + +Enable Prometheus and Grafana monitoring with the observability compose overlay: + +```bash +docker compose \ + -f docker-compose.yml \ + -f docker-compose.prod.yml \ + -f docker-compose.observability.yml \ + --env-file .env.prod up -d +``` + +- **Prometheus**: `http://localhost:9090` +- **Grafana**: `http://localhost:3001` (default: admin/admin) + +### Exported Metrics + +The API and poller export Prometheus metrics: + +| Metric | Source | Description | +|--------|--------|-------------| +| `http_requests_total` | API | HTTP request count by method, path, status | +| `http_request_duration_seconds` | API | Request latency histogram | +| `mikrotik_poll_total` | Poller | Poll cycles by status (success/error/skipped) | +| `mikrotik_poll_duration_seconds` | Poller | Poll cycle duration histogram | +| `mikrotik_devices_active` | Poller | Number of devices being polled | +| `mikrotik_circuit_breaker_skips_total` | Poller | Polls skipped due to backoff | +| `mikrotik_nats_publish_total` | Poller | NATS publishes by subject and status | + +## Maintenance + +### Backup Strategy + +- **Database**: Use `pg_dump` or configure PostgreSQL streaming replication +- **Config backups**: Git repositories in the git-store volume (automatic nightly backups) +- **Encryption key**: Store `CREDENTIAL_ENCRYPTION_KEY` securely -- required to decrypt device credentials + +### Updating + +```bash +git pull +docker compose -f docker-compose.yml -f docker-compose.prod.yml build api +docker compose -f docker-compose.yml -f docker-compose.prod.yml build poller +docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend +docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +Database migrations run automatically on API startup via Alembic. + +### Logs + +```bash +# All services +docker compose logs -f + +# Specific service +docker compose logs -f api + +# Filter structured JSON logs with jq +docker compose logs api --no-log-prefix 2>&1 | jq 'select(.event != null)' + +# View audit logs (config editor operations) +docker compose logs api --no-log-prefix 2>&1 | jq 'select(.event | startswith("routeros_"))' +``` + +### Graceful Shutdown + +All services handle SIGTERM for graceful shutdown: + +- **API (gunicorn)**: Finishes in-flight requests within `GUNICORN_GRACEFUL_TIMEOUT` (default 30s), then disposes database connection pools +- **Poller (Go)**: Cancels all device polling goroutines via context propagation, waits for in-flight polls to complete +- **Frontend (nginx)**: Stops accepting new connections and finishes serving active requests + +```bash +# Graceful stop (sends SIGTERM, waits 30s) +docker compose stop + +# Restart a single service +docker compose restart api +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| API won't start with secret error | Generate production secrets (see step 2 above) | +| Build crashes with OOM | Build images one at a time (see step 3 above) | +| Device shows offline | Check network access to device API port (8728/8729) | +| Health check fails | Check `docker compose logs api` for startup errors | +| Rate limited (429) | Wait 60 seconds or check Redis connectivity | +| Migration fails | Check `docker compose logs api` for Alembic errors | +| NATS subscriber won't start | Non-fatal -- API runs without NATS; check NATS container health | +| Poller circuit breaker active | Device unreachable; check `CIRCUIT_BREAKER_*` env vars to tune backoff | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..caa8674 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,203 @@ +# The Other Dude + +**Fleet management for MikroTik RouterOS devices.** Built for MSPs who manage hundreds of routers across multiple tenants. Think "UniFi Controller, but for MikroTik." + +The Other Dude is a self-hosted, multi-tenant platform that gives you centralized visibility, configuration management, real-time monitoring, and zero-knowledge security across your entire MikroTik fleet -- from a single pane of glass. + +--- + +## Features + +### Fleet + +- **Dashboard** -- At-a-glance fleet health with device counts, uptime sparklines, and status breakdowns per organization. +- **Device Management** -- Detailed device pages with system info, interfaces, routes, firewall rules, DHCP leases, and real-time resource metrics. +- **Fleet Table** -- Virtual-scrolled table (TanStack Virtual) that handles hundreds of devices without breaking a sweat. +- **Device Map** -- Geographic view of device locations. +- **Subnet Scanner** -- Discover new RouterOS devices on your network and onboard them in clicks. + +### Configuration + +- **Config Editor** -- Browse and edit RouterOS configuration sections with a structured command interface. Two-phase config push with automatic panic-revert ensures you never brick a remote device. +- **Batch Config** -- Apply configuration changes across multiple devices simultaneously with template support. +- **Bulk Commands** -- Execute arbitrary RouterOS commands across device groups. +- **Templates** -- Reusable configuration templates with variable substitution. +- **Simple Config** -- A Linksys/Ubiquiti-style simplified interface covering Internet, LAN/DHCP, WiFi, Port Forwarding, Firewall, DNS, and System settings. No RouterOS CLI knowledge required. +- **Config Backup & Diff** -- Git-backed configuration storage with full version history and side-by-side diffs. Restore any previous configuration with one click. + +### Monitoring + +- **Network Topology** -- Interactive topology map (ReactFlow + Dagre layout) showing device interconnections and shared subnets. +- **Real-Time Metrics** -- Live CPU, memory, disk, and interface traffic via Server-Sent Events (SSE) backed by NATS JetStream. +- **Alert Rules** -- Configurable threshold-based alerts for any metric (CPU > 90%, interface down, uptime reset, etc.). +- **Notification Channels** -- Route alerts to email, webhooks, or Slack. +- **Audit Trail** -- Immutable log of every action taken in the portal, with user attribution and exportable records. +- **Transparency Dashboard** -- KMS access event monitoring for tenant admins (who accessed what encryption keys, when). +- **Reports** -- Generate PDF reports (fleet summary, device detail, security audit, performance) with Jinja2 + WeasyPrint. + +### Security + +- **Zero-Knowledge Architecture** -- 1Password-style hybrid design. SRP-6a authentication means the server never sees your password. Two-Secret Key Derivation (2SKD) with PBKDF2 (650K iterations) + HKDF + XOR. +- **Secret Key** -- 128-bit `A3-XXXXXX` format key stored in IndexedDB with Emergency Kit PDF export. +- **OpenBao KMS** -- Per-tenant envelope encryption via Transit secret engine. Go poller uses LRU cache (1024 keys / 5-min TTL) for performance. +- **Internal Certificate Authority** -- Issue and deploy TLS certificates to RouterOS devices via SFTP. Three-tier TLS fallback: CA-verified, InsecureSkipVerify, plain API. +- **WireGuard VPN** -- Manage WireGuard tunnels for secure device access across NAT boundaries. +- **Credential Encryption** -- AES-256-GCM (Fernet) encryption of all stored device credentials at rest. +- **RBAC** -- Four roles: `super_admin`, `admin`, `operator`, `viewer`. PostgreSQL Row-Level Security enforces tenant isolation at the database layer. + +### Administration + +- **Multi-Tenancy** -- Full organization isolation with PostgreSQL RLS. Super admins manage all tenants; tenant admins see only their own devices and users. +- **User Management** -- Per-tenant user administration with role assignment. +- **API Keys** -- Generate `mktp_`-prefixed API keys with SHA-256 hash storage and operator-level RBAC for automation and integrations. +- **Firmware Management** -- Track RouterOS versions across your fleet, plan upgrades, and push firmware updates. +- **Maintenance Windows** -- Schedule maintenance periods with automatic alert suppression. +- **Setup Wizard** -- Guided 3-step onboarding for first-time deployment. + +### UX + +- **Command Palette** -- `Cmd+K` / `Ctrl+K` quick navigation (cmdk). +- **Keyboard Shortcuts** -- Vim-style sequence shortcuts (`g d` for dashboard, `g t` for topology, `[` to toggle sidebar). +- **Dark / Light Mode** -- Class-based theming with flicker-free initialization. +- **Page Transitions** -- Smooth route transitions with Framer Motion. +- **Skeleton Loaders** -- Shimmer-gradient loading states throughout the UI. + +--- + +## Architecture + +``` + +-----------+ + | Frontend | + | React/nginx| + +-----+-----+ + | + /api/ proxy + | + +-----v-----+ + | API | + | FastAPI | + +--+--+--+--+ + | | | + +-------------+ | +--------------+ + | | | + +-----v------+ +-----v-----+ +-------v-------+ + | PostgreSQL | | Redis | | NATS | + | TimescaleDB | | (locks, | | JetStream | + | (RLS) | | caching) | | (pub/sub) | + +-----^------+ +-----^-----+ +-------^-------+ + | | | + +-----+-------+-------+---------+-------+ + | Poller (Go) | + | Polls RouterOS devices via binary API | + | port 8729 TLS | + +----------------------------------------+ + | + +--------v---------+ + | RouterOS Fleet | + | (your devices) | + +-------------------+ +``` + +- **Frontend** serves the React SPA via nginx and proxies `/api/` to the backend. +- **API** handles all business logic, authentication, and database access with RLS-enforced tenant isolation. +- **Poller** is a Go microservice that polls RouterOS devices on a configurable interval using the RouterOS binary API, publishing results to NATS and persisting to PostgreSQL. +- **PostgreSQL + TimescaleDB** stores all relational data with hypertables for time-series metrics. +- **Redis** provides distributed locks (one poller per device) and rate limiting. +- **NATS JetStream** delivers real-time events from the poller to the API (and onward to the frontend via SSE). +- **OpenBao** provides Transit secret engine for per-tenant envelope encryption (zero-knowledge key management). + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | React 19, TanStack Router + Query, Tailwind CSS 3.4, Vite, Framer Motion | +| Backend | Python 3.12, FastAPI 0.115, SQLAlchemy 2.0 async, asyncpg, Pydantic v2 | +| Poller | Go 1.24, go-routeros/v3, pgx/v5, nats.go | +| Database | PostgreSQL 17 + TimescaleDB 2.17, Row-Level Security | +| Cache | Redis 7 | +| Message Bus | NATS with JetStream | +| KMS | OpenBao 2.1 (Transit secret engine) | +| VPN | WireGuard | +| Auth | SRP-6a (zero-knowledge), JWT (15m access / 7d refresh) | +| Reports | Jinja2 + WeasyPrint (PDF generation) | +| Containerization | Docker Compose (dev, staging, production profiles) | + +--- + +## Quick Start + +See the full [Quick Start Guide](../QUICKSTART.md) for detailed instructions. + +```bash +# Clone and configure +cp .env.example .env + +# Start infrastructure +docker compose up -d + +# Build app images (one at a time to avoid OOM) +docker compose build api +docker compose build poller +docker compose build frontend + +# Start the full stack +docker compose up -d + +# Verify +curl http://localhost:8001/health +open http://localhost:3000 +``` + +Three environment profiles are available: + +| Environment | Frontend | API | Notes | +|-------------|----------|-----|-------| +| Dev | `localhost:3000` | `localhost:8001` | Hot-reload, volume-mounted source | +| Staging | `localhost:3080` | `localhost:8081` | Built images, staging secrets | +| Production | `localhost` (port 80) | Internal (proxied) | Gunicorn workers, log rotation | + +--- + +## Documentation + +| Document | Description | +|----------|-------------| +| [Quick Start](../QUICKSTART.md) | Get running in minutes | +| [Deployment Guide](DEPLOYMENT.md) | Production deployment, TLS, backups | +| [Architecture](ARCHITECTURE.md) | System design, data flows, multi-tenancy | +| [Security Model](SECURITY.md) | Zero-knowledge auth, encryption, RLS, RBAC | +| [User Guide](USER-GUIDE.md) | End-user guide for all features | +| [API Reference](API.md) | REST API endpoints and authentication | +| [Configuration](CONFIGURATION.md) | Environment variables and tuning | + +--- + +## Screenshots + +See the [documentation site](https://theotherdude.net) for screenshots. + +--- + +## Project Structure + +``` +backend/ Python FastAPI backend +frontend/ React TypeScript frontend +poller/ Go microservice for device polling +infrastructure/ Helm charts, Dockerfiles, OpenBao init +docs/ Documentation +docker-compose.yml Base compose (infrastructure services) +docker-compose.override.yml Dev overrides (hot-reload) +docker-compose.staging.yml Staging profile +docker-compose.prod.yml Production profile +docker-compose.observability.yml Prometheus + Grafana +``` + +--- + +## License + +Open-source. Self-hosted. Your data stays on your infrastructure. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..14769ff --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,149 @@ +# Security Model + +## Overview + +TOD (The Other Dude) implements a 1Password-inspired zero-knowledge security architecture. The server never stores or sees user passwords. All data is stored on infrastructure you own and control — no external telemetry, analytics, or third-party data transmission. + +## Authentication: SRP-6a Zero-Knowledge Proof + +TOD uses the Secure Remote Password (SRP-6a) protocol for authentication, ensuring the server never receives, transmits, or stores user passwords. + +- **SRP-6a protocol:** Password is verified via a zero-knowledge proof — only a cryptographic verifier derived from the password is stored on the server, never the password itself. +- **Two-Secret Key Derivation (2SKD):** Combines the user password with a 128-bit Secret Key using a multi-step derivation process, ensuring that compromise of either factor alone is insufficient. +- **Key derivation pipeline:** PBKDF2 with 650,000 iterations + HKDF expansion + XOR combination of both factors. +- **Secret Key format:** `A3-XXXXXX` (128-bit), stored exclusively in the browser's IndexedDB. The server never sees or stores the Secret Key. +- **Emergency Kit:** Downloadable PDF containing the Secret Key for account recovery. Generated client-side. +- **Session management:** JWT tokens with 15-minute access token lifetime and 7-day refresh token lifetime, delivered via httpOnly cookies. +- **SRP session state:** Ephemeral SRP handshake data stored in Redis with automatic expiration. + +### Authentication Flow + +``` +Client Server + | | + | POST /auth/srp/init {email} | + |------------------------------------>| + | {salt, server_ephemeral_B} | + |<------------------------------------| + | | + | [Client derives session key from | + | password + Secret Key + salt + B] | + | | + | POST /auth/srp/verify {A, M1} | + |------------------------------------>| + | [Server verifies M1 proof] | + | {M2, access_token, refresh_token} | + |<------------------------------------| +``` + +## Credential Encryption + +Device credentials (RouterOS usernames and passwords) are encrypted at rest using envelope encryption: + +- **Encryption algorithm:** AES-256-GCM (via Fernet symmetric encryption). +- **Key management:** OpenBao Transit secrets engine provides the master encryption keys. +- **Per-tenant isolation:** Each tenant has its own encryption key in OpenBao Transit. +- **Envelope encryption:** Data is encrypted with a data encryption key (DEK), which is itself encrypted by the tenant's Transit key. +- **Go poller decryption:** The poller service decrypts credentials at runtime via the Transit API, with an LRU cache (1,024 entries, 5-minute TTL) to reduce KMS round-trips. +- **CA private keys:** Encrypted with AES-256-GCM before database storage. PEM key material is never logged. + +## Tenant Isolation + +Multi-tenancy is enforced at the database level, making cross-tenant data access structurally impossible: + +- **PostgreSQL Row-Level Security (RLS):** All data tables have RLS policies that filter rows by `tenant_id`. +- **`app_user` database role:** All application queries run through a non-superuser role that enforces RLS. Even a SQL injection attack cannot cross tenant boundaries. +- **Session context:** `tenant_id` is set via PostgreSQL session variables (`SET app.current_tenant`) on every request, derived from the authenticated user's JWT. +- **`super_admin` role:** Users with NULL `tenant_id` can access all tenants for platform administration. Represented as `'super_admin'` in the RLS context. +- **`poller_user` role:** Bypasses RLS by design — the polling service needs cross-tenant device access to poll all devices. This is an intentional security trade-off documented in the architecture. + +## Role-Based Access Control (RBAC) + +| Role | Scope | Capabilities | +|------|-------|-------------| +| `super_admin` | Global | Full system access, tenant management, user management across all tenants | +| `admin` | Tenant | Manage devices, users, settings, certificates within their tenant | +| `operator` | Tenant | Device operations, configuration changes, monitoring | +| `viewer` | Tenant | Read-only access to devices, metrics, and dashboards | + +- RBAC is enforced at both the API middleware layer and database level. +- API keys inherit the `operator` permission level and are scoped to a single tenant. +- API key tokens use the `mktp_` prefix and are stored as SHA-256 hashes (the plaintext token is shown once at creation and never stored). + +## Internal Certificate Authority + +TOD includes a per-tenant Internal Certificate Authority for managing TLS certificates on RouterOS devices: + +- **Per-tenant CA:** Each tenant can generate its own self-signed Certificate Authority. +- **Device certificate lifecycle:** Certificates follow a state machine: `issued` -> `deploying` -> `deployed` -> `expiring`/`revoked`/`superseded`. +- **Deployment:** Certificates are deployed to devices via SFTP. +- **Three-tier TLS fallback:** The Go poller attempts connections in order: + 1. CA-verified TLS (using the tenant's CA certificate) + 2. InsecureSkipVerify TLS (for self-signed RouterOS certs) + 3. Plain API connection (fallback) +- **Key protection:** CA private keys are encrypted with AES-256-GCM before database storage. PEM key material is never logged or exposed via API responses. +- **Certificate rotation and revocation:** Supported via the certificate lifecycle state machine. + +## Network Security + +- **RouterOS communication:** All device communication uses the RouterOS binary API over TLS (port 8729). InsecureSkipVerify is enabled by default because RouterOS devices typically use self-signed certificates. +- **CORS enforcement:** Strict CORS policy in production, configured via `CORS_ORIGINS` environment variable. +- **Rate limiting:** Authentication endpoints are rate-limited to 5 requests per minute per IP to prevent brute-force attacks. +- **Cookie security:** httpOnly cookies prevent JavaScript access to session tokens. The `Secure` flag is auto-detected based on whether CORS origins use HTTPS. + +## Data Protection + +- **Config backups:** Encrypted at rest via OpenBao Transit envelope encryption before database storage. +- **Audit logs:** Encrypted at rest via Transit encryption — audit log content is protected even from database administrators. +- **Subresource Integrity (SRI):** SHA-384 hashes on JavaScript bundles prevent tampering with frontend code. +- **Content Security Policy (CSP):** Strict CSP headers prevent XSS, code injection, and unauthorized resource loading. +- **No external dependencies:** Fully self-hosted with no external analytics, telemetry, CDNs, or third-party services. The only outbound connections are: + - RouterOS firmware update checks (no device data sent) + - SMTP for email notifications (if configured) + - Webhooks for alerts (if configured) + +## Security Headers + +The following security headers are enforced on all responses: + +| Header | Value | Purpose | +|--------|-------|---------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Force HTTPS connections | +| `X-Content-Type-Options` | `nosniff` | Prevent MIME-type sniffing | +| `X-Frame-Options` | `DENY` | Prevent clickjacking via iframes | +| `Content-Security-Policy` | Strict policy | Prevent XSS and code injection | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer information leakage | + +## Audit Trail + +- **Immutable audit log:** All significant actions are recorded in the `audit_logs` table — logins, configuration changes, device operations, admin actions. +- **Fire-and-forget logging:** The `log_action()` function records audit events asynchronously without blocking the main request. +- **Per-tenant access:** Tenants can only view their own audit logs (enforced by RLS). +- **Encryption at rest:** Audit log content is encrypted via OpenBao Transit. +- **CSV export:** Audit logs can be exported in CSV format for compliance and reporting. +- **Account deletion:** When a user deletes their account, audit log entries are anonymized (PII removed) but the action records are retained for security compliance. + +## Data Retention + +| Data Type | Retention | Notes | +|-----------|-----------|-------| +| User accounts | Until deleted | Users can self-delete from Settings | +| Device metrics | 90 days | Purged by TimescaleDB retention policy | +| Configuration backups | Indefinite | Stored in git repositories on your server | +| Audit logs | Indefinite | Anonymized on account deletion | +| API keys | Until revoked | Cascade-deleted with user account | +| Encrypted key material | Until user deleted | Cascade-deleted with user account | +| Session data (Redis) | 15 min / 7 days | Auto-expiring access/refresh tokens | +| Password reset tokens | 30 minutes | Auto-expire | +| SRP session state | Short-lived | Auto-expire in Redis | + +## GDPR Compliance + +TOD provides built-in tools for GDPR compliance: + +- **Right of Access (Art. 15):** Users can view their account information on the Settings page. +- **Right to Data Portability (Art. 20):** Users can export all personal data in JSON format from Settings. +- **Right to Erasure (Art. 17):** Users can permanently delete their account and all associated data. Audit logs are anonymized (PII removed) with a deletion receipt generated for compliance verification. +- **Right to Rectification (Art. 16):** Account information can be updated by the tenant administrator. + +As a self-hosted application, the deployment operator is the data controller and is responsible for compliance with applicable data protection laws. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md new file mode 100644 index 0000000..3865011 --- /dev/null +++ b/docs/USER-GUIDE.md @@ -0,0 +1,246 @@ +# TOD - The Other Dude: User Guide + +MSP fleet management platform for MikroTik RouterOS devices. + +--- + +## Getting Started + +### First Login + +1. Navigate to the portal URL provided by your administrator. +2. Log in with the admin credentials created during initial deployment. +3. Complete **SRP security enrollment** -- the portal uses zero-knowledge authentication (SRP-6a), so a unique Secret Key is generated for your account. +4. **Save your Emergency Kit PDF immediately.** This PDF contains your Secret Key, which you will need to log in from any new browser or device. Without it, you cannot recover access. +5. Complete the **Setup Wizard** to create your first organization and add your first device. + +### Setup Wizard + +The Setup Wizard launches automatically for first-time super_admin users. It walks through three steps: + +- **Step 1 -- Create Organization**: Enter a name for your tenant (organization). This is the top-level container for all your devices, users, and configuration. +- **Step 2 -- Add Device**: Enter the IP address, API port (default 8729 for TLS), and RouterOS credentials for your first device. The portal will attempt to connect and verify the device. +- **Step 3 -- Verify & Complete**: The portal polls the device to confirm connectivity. Once verified, you are taken to the dashboard. + +You can always add more organizations and devices later from the sidebar. + +--- + +## Navigation + +TOD uses a collapsible sidebar with four sections. Press `[` to toggle the sidebar between expanded (240px) and collapsed (48px) views. On mobile, the sidebar opens as an overlay. + +### Fleet + +| Item | Description | +|------|-------------| +| **Dashboard** | Overview of your fleet with device status cards, active alerts, and metrics sparklines. The landing page after login. | +| **Devices** | Fleet table with search, sort, and filter. Click any device row to open its detail page. | +| **Map** | Geographic map view of device locations. | + +### Manage + +| Item | Description | +|------|-------------| +| **Config Editor** | Browse and edit RouterOS configuration paths in real-time. Select a device from the header dropdown. | +| **Batch Config** | Apply configuration changes across multiple devices simultaneously using templates. | +| **Bulk Commands** | Execute RouterOS CLI commands across selected devices in bulk. | +| **Templates** | Create and manage reusable configuration templates. | +| **Firmware** | Check for RouterOS updates and schedule firmware upgrades across your fleet. | +| **Maintenance** | Schedule maintenance windows to suppress alerts during planned work. | +| **VPN** | WireGuard VPN tunnel management -- create, deploy, and monitor tunnels between devices. | +| **Certificates** | Internal Certificate Authority management -- generate, deploy, and rotate TLS certificates for your devices. | +### Monitor + +| Item | Description | +|------|-------------| +| **Topology** | Interactive network map showing device connections and shared subnets, rendered with ReactFlow and Dagre layout. | +| **Alerts** | Live alert feed with filtering by severity (info, warning, critical) and acknowledgment actions. | +| **Alert Rules** | Define threshold-based alert rules on device metrics with configurable severity and notification channels. | +| **Audit Trail** | Immutable, append-only log of all operations -- configuration changes, logins, user management, and admin actions. | +| **Transparency** | KMS access event dashboard showing encryption key usage across your organization (admin only). | +| **Reports** | Generate and export PDF reports: fleet summary, device health, compliance, and SLA. | + +### Admin + +| Item | Description | +|------|-------------| +| **Users** | User management with role-based access control (RBAC). Assign roles: super_admin, admin, operator, viewer. | +| **Organizations** | Create and manage tenants for multi-tenant MSP operation. Each tenant has isolated data via PostgreSQL row-level security. | +| **API Keys** | Generate and manage programmatic access tokens (prefixed `mktp_`) with operator-level permissions. | +| **Settings** | System configuration, theme toggle (dark/light), and profile settings. | +| **About** | Platform version, feature summary, and project information. | + +--- + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Cmd+K` / `Ctrl+K` | Open command palette for quick navigation and actions | +| `[` | Toggle sidebar collapsed/expanded | +| `?` | Show keyboard shortcut help dialog | +| `g d` | Go to Dashboard | +| `g f` | Go to Firmware | +| `g t` | Go to Topology | +| `g a` | Go to Alerts | + +The command palette (`Cmd+K`) provides fuzzy search across all pages, devices, and common actions. It is accessible in both dark and light themes. + +--- + +## Device Management + +### Adding Devices + +There are several ways to add devices to your fleet: + +1. **Setup Wizard** -- automatically offered on first login. +2. **Fleet Table** -- click the "Add Device" button from the Devices page. +3. **Subnet Scanner** -- enter a CIDR range (e.g., `192.168.1.0/24`) to auto-discover MikroTik devices on the network. + +When adding a device, provide: + +- **IP Address** -- the management IP of the RouterOS device. +- **API Port** -- default is 8729 (TLS). The portal connects via the RouterOS binary API protocol. +- **Credentials** -- username and password for the device. Credentials are encrypted at rest with AES-256-GCM. + +### Device Detail Page + +Click any device in the fleet table to open its detail page. Tabs include: + +| Tab | Description | +|-----|-------------| +| **Overview** | System info, uptime, hardware model, RouterOS version, resource usage, and interface status summary. | +| **Interfaces** | Real-time traffic graphs for each network interface. | +| **Config** | Browse the full device configuration tree by RouterOS path. | +| **Firewall** | View and manage firewall filter rules, NAT rules, and address lists. | +| **DHCP** | Active DHCP leases, server configuration, and address pools. | +| **Backups** | Configuration backup timeline with side-by-side diff viewer to compare changes over time. | +| **Clients** | Connected clients and wireless registrations. | + +### Config Editor + +The Config Editor provides direct access to RouterOS configuration paths (e.g., `/ip/address`, `/ip/firewall/filter`, `/interface/bridge`). + +- Select a device from the header dropdown. +- Navigate the configuration tree to browse, add, edit, or delete entries. +- Two apply modes are available: + - **Standard Apply** -- changes are applied immediately. + - **Safe Apply** -- two-phase commit with automatic panic-revert. Changes are applied, and you have a confirmation window to accept them. If the confirmation times out (device becomes unreachable), changes automatically revert to prevent lockouts. + +Safe Apply is strongly recommended for firewall rules and routing changes on remote devices. + +### Simple Config + +Simple Config provides a consumer-router-style interface modeled after Linksys and Ubiquiti UIs. It is designed for operators who prefer guided configuration over raw RouterOS paths. + +Seven category tabs: + +1. **Internet** -- WAN connection type, PPPoE, DHCP client settings. +2. **LAN / DHCP** -- LAN addressing, DHCP server and pool configuration. +3. **WiFi** -- Wireless SSID, security, and channel settings. +4. **Port Forwarding** -- NAT destination rules for inbound services. +5. **Firewall** -- Simplified firewall rule management. +6. **DNS** -- DNS server and static DNS entries. +7. **System** -- Device identity, timezone, NTP, admin password. + +Toggle between **Simple** (guided) and **Standard** (full config editor) modes at any time. Per-device settings are stored in browser localStorage. + +--- + +## Monitoring & Alerts + +### Alert Rules + +Create threshold-based rules that fire when device metrics cross defined boundaries: + +- Select the metric to monitor (CPU, memory, disk, interface traffic, uptime, etc.). +- Set the threshold value and comparison operator. +- Choose severity: **info**, **warning**, or **critical**. +- Assign one or more notification channels. + +### Notification Channels + +Alerts can be delivered through multiple channels: + +| Channel | Description | +|---------|-------------| +| **Email** | SMTP-based email notifications. Configure server, port, and recipients. | +| **Webhook** | HTTP POST to any URL with a JSON payload containing alert details. | +| **Slack** | Slack incoming webhook with Block Kit formatting for rich alert messages. | + +### Maintenance Windows + +Schedule maintenance periods to suppress alerts during planned work: + +- Define start and end times. +- Apply to specific devices or fleet-wide. +- Alerts generated during the window are recorded but do not trigger notifications. +- Maintenance windows can be recurring or one-time. + +--- + +## Reports + +Generate PDF reports from the Reports page. Four report types are available: + +| Report | Content | +|--------|---------| +| **Fleet Summary** | Overall fleet health, device counts by status, top alerts, and aggregate statistics. | +| **Device Health** | Per-device detailed report with hardware info, resource trends, and recent events. | +| **Compliance** | Security posture audit -- firmware versions, default credentials, firewall policy checks. | +| **SLA** | Uptime and availability metrics over a selected period with percentage calculations. | + +Reports are generated as downloadable PDFs using server-side rendering. + +--- + +## Security + +### Zero-Knowledge Architecture + +TOD uses a 1Password-style hybrid zero-knowledge model: + +- **SRP-6a authentication** -- your password never leaves the browser. The server verifies a cryptographic proof without knowing the password. +- **Secret Key** -- a 128-bit key in `A3-XXXXXX` format, generated during enrollment. Combined with your password for two-secret key derivation (2SKD). +- **Emergency Kit** -- a downloadable PDF containing your Secret Key. Store it securely offline; you need it to log in from new browsers. +- **Envelope encryption** -- configuration backups and audit logs are encrypted at rest using per-tenant keys managed by the KMS (OpenBao Transit). + +### Roles and Permissions + +| Role | Capabilities | +|------|-------------| +| **super_admin** | Full platform access across all tenants. Can create organizations, manage all users, and access system settings. | +| **admin** | Full access within their tenant. Can manage users, devices, and configuration for their organization. | +| **operator** | Can view devices, apply configurations, and acknowledge alerts. Cannot manage users or organization settings. | +| **viewer** | Read-only access to devices, dashboards, and reports within their tenant. | + +### Credential Storage + +Device credentials (RouterOS username/password) are encrypted at rest with AES-256-GCM (Fernet) and only decrypted in memory by the poller when connecting to devices. + +--- + +## Theme + +TOD supports dark and light modes: + +- **Dark mode** (default) uses the Midnight Slate palette. +- **Light mode** provides a clean, high-contrast alternative. +- Toggle in **Settings** or let the portal follow your system preference. +- The command palette and all UI components adapt to the active theme. + +--- + +## Tips + +- Use the **command palette** (`Cmd+K`) for the fastest way to navigate. It searches pages, devices, and actions. +- The **Audit Trail** is immutable -- every configuration change, login, and admin action is recorded and cannot be deleted. +- **Safe Apply** is your safety net for remote devices. If a firewall change locks you out, the automatic revert restores access. +- **API Keys** (prefixed `mktp_`) provide programmatic access at operator-level permissions for automation and scripting. +- The **Topology** view uses automatic Dagre layout. Toggle shared subnet edges to reduce visual clutter on complex networks. + +--- + +*TOD -- The Other Dude is not affiliated with or endorsed by MikroTik (SIA Mikrotikls).* diff --git a/docs/website/CNAME b/docs/website/CNAME new file mode 100644 index 0000000..f335ab6 --- /dev/null +++ b/docs/website/CNAME @@ -0,0 +1 @@ +theotherdude.net \ No newline at end of file diff --git a/docs/website/assets/alerts.png b/docs/website/assets/alerts.png new file mode 100644 index 0000000000000000000000000000000000000000..1eecba227846c494a2c822b1df4a6260304956b7 GIT binary patch literal 86776 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%0AH+wtS8pU%JiCeQHt;_>m~ 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
  • 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 z zw|5Fx_dkg4(z}a>$o5nG-R1Fdy71fW#W43G73pm08i(l$a1fO)zplXJ^Ket7Y zrYD^EXWjkn-5FWJ86$|wiz4g87ruGAna#v zuQdM6?@sHWnzLVO8sB->oN1mZAcVNaYp?{SzD6&Jd`=#k)sY}LI**n97yn792yPs= zt%}*mZC0JQiSKVRqlAr3%&x~Y%Ae0py%WW5nDx8>;GC7Qkmm*JVlYK(U}$|tqQ36K z){qh%?vgB_eV2#kV=Ff;t&yqbDB+h?rKQpzBiQWIeSI}79Qyq;;{c#3T#Oc6&FXsn zx?b%4vE(tSM6H*LL>2~f;8Q1T@N3CA^v`N=V}HD&S$cr1s$9bcaFZY6BQ4#x_gx%T z+dA7%ALgH_Ni`Y(1ePPZ{VZK)=M@sl>t#6WlF3}53TYYjY-k;D{QHD4wlDR%=@Orp zus*qs>}YP&EL)+(qwp3whwTN*CrIMJ9dp`;3>3x{jmgDT)&-kSRx3;M@P{(NPXg{8 z;gr}ufhLz;Da64{UPd;8&u|5jM5b*sHS_vAg;DGB!h8-psAH;V+y?!RNxIi~=ykh- zUAkG5%^?)Ow$}ErK|<^yrOs^di&j>h(w*Trovwxa?JRZbfD+f~g+ZwcDe^auk4T7s zy6I{pVr-ogR^)i>iK`v_^O>**`frWhWdx9F8za%V&mpqbiQ_ImEf3@3s>gnwk?>Lb zs(1Ur$O=MPw4sZmmD;k2F#x78@w+~YGuk=(2WZ<13

    ;`XWO@36S_EFo8mPG=y~o zQb;n--}1S5Dfv+x-u3H>3M#bx*Joy91l*+U<|4+di@YU85j8dA^5}gB08$!~I1_T* zbSEYfuyMgHyIEjAAu207x?R7svj?(BO?rAlb5){&WAizSOqfxp7FZC*-+FDW5-fuN zhobUld{K5bZ`dFc3lqQnLAVjwaM3D6C*$F~t>kTeb1> z30%c{E6Fou0d0_fhg^J6T!?`65~2)a!luzyC>$UE=1tYXukH9C=c5A=Qi>iUp_PRN zm>r9RO7rCGzH#C06<&->Y1JJdTpgIClV&)IdBz*7cFDW0#ITM3cwJ$`WzZnXokuT=&ZZ8~(p%2QVs}6rn@6ao)Xt`D8<@Kz@ z2*{D~F)hTc?nZp4ePm{C+_kuDd-v|i_Q!V3^X^-H z4;w`*$NEqg-JRsYtv~+k` zLx>Lc!_+&V#f>;&_~bf- zuoUqC)4sS#L3-V8>=1YF>>u7o9UUw<_M3#Z+oHJa)h%46IOnkOYT6=r&=b4spdO)G zyDBndT$W<7OJ!wf(#drd ztDd_LBZX;NSMBX=KD`aSL_hnTnmR#Gk0m+zq*i|R?y@z`mHE~SBsGGj{=+_NI84-B zjv@|Fr2yb~T&4Irs2DUriIS;VF>?^nGq4z!ufIR+b#r%xL-;zeq`mAWM97K-Qf@L0 zei=Yi0dHUQl&NG$4OReNJq((HlFc3M)d`(f>F}^T(!+AI)GN6&R^QBYQE(0z!YT{ZMnje z!cE*869!WVw!k#H(LjqEn1q*>oHx=*xtYb*f(|4CZ{I$s{3ROB`C+5*0DyLoSjF6#D2m~QZoG%RpfNDHHU z%0+T~*B7Of-q&jhpKcgv+{usY7$B``?dJMvCsNEHPAtrR@Wn5nps*0ap2AL_a2s)h zbB)WL81DD251sfC38w?lf_PSv#q2O>?zoV0{2CNdW4+H2^~j)y+1=#BkREpkCjJl0 z>dXlJX^FK%LXE;yUH@U%=Maz`CyX_gv_V)%EbVVPmlExdU(>1)Dt^q1|6hzE#Qgk! z)M+8lJf7_R(a+=AB>riC0#o>`IY2yJ{?}kbEYvv|$Te;@f9~{(@SOjx(eG5+RxNwK z+FJjET|#N@8#@do^NLjZIFNW;oP_8aEmEwFa{L^u!L*E1Hp=m)fz`;}c3Sg|od1K2=K`)G=6<0XC{dVi`yaj)|A*5Z|F@U) pNErSKjkf&u6=vRIojX3C&R`{9)ZxD~%!XOwQetwVSr4DR`5(6Fs&4=Q literal 0 HcmV?d00001 diff --git a/docs/website/assets/dashboard-lebowski-lanes.png b/docs/website/assets/dashboard-lebowski-lanes.png new file mode 100644 index 0000000000000000000000000000000000000000..d87386130a5ac44ca0fb4d07c739c0bab979914a GIT binary patch literal 146317 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

    H@xTxXc*{y0o; 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 literal 0 HcmV?d00001 diff --git a/docs/website/assets/dashboard-strangers-ranch.png b/docs/website/assets/dashboard-strangers-ranch.png new file mode 100644 index 0000000000000000000000000000000000000000..bac9fd4958a682b69e64f3f63d9a557d94111fbb GIT binary patch literal 110220 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/website/assets/device-list.png b/docs/website/assets/device-list.png new file mode 100644 index 0000000000000000000000000000000000000000..460a981d502d47d53f215d4cef0464453e9afa10 GIT binary patch literal 117383 zcmce-Ra9Kt7A=Z9gI>GIYs#?RN1x#z6(muSfJhJ!5UA2pV#*K@usIMA&_M`L;J+*|Lk2@YphHND zfmGcxPBLNji3Vl|?oynnO-)U;Oh?B=(v{SLgaUq#El@pfcl|$|C#zAjM&aWZw?#pr%wpRPhVFJiBM(tn$0E0A{d5MWSy6K!iK^TC@efdEW8m^xtTT<%6e)Jbq z|NI|+Z?f^VU6W2Ua5mZiRH^$8yM{++D~(H3PCHtgJULyi=M+bs_8W_Z2Es)AG{>3s64T(3rD z)s?&fMa*oQP)z^Wm5ykJ&gzL+UecrXjdR9G&+hM)wzgbp=ED;*8#PU$7_g0Gw!a8J z^qaHa-hwwVlQ%jW=$tGFVmjMSCWiOVAgQ^Ydd5;Y^VUQK+;2 zKaLo846&}TusA2PLbg*wLfvRke`O2AM+s?dMEk7I^-KemJD90pYxRW%e1!%^1O2ngvt{Zg97yjU?}2NbGA<^u9|mzMCt5ekMZH;`*(;j1o7K7pI1G>4*m{5 zVH>7v$)js64xHWSoBCeouD+h0qRzm; z?dON9=smV{)?k;N!T2+q($RzV8j`J~P=#Vgcdx>{

    f{KMtQY#C&XSxH7^Ysgmf; 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 A5dZ)H literal 0 HcmV?d00001 diff --git a/docs/website/assets/login.png b/docs/website/assets/login.png new file mode 100644 index 0000000000000000000000000000000000000000..a9a84ec3f36f28d66086df64ee9b1bfb1d1e26b0 GIT binary patch literal 41969 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 Wdp>}lI`%4)$Vn?bEqwCo-TwnvwoIJ> literal 0 HcmV?d00001 diff --git a/docs/website/assets/topology.png b/docs/website/assets/topology.png new file mode 100644 index 0000000000000000000000000000000000000000..7632bf6052f19081e5e5cd82010e5e5fcc16a640 GIT binary patch literal 70403 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 literal 0 HcmV?d00001 diff --git a/docs/website/docs.html b/docs/website/docs.html new file mode 100644 index 0000000..07fb893 --- /dev/null +++ b/docs/website/docs.html @@ -0,0 +1,1412 @@ + + + + + + Documentation — The Other Dude | MikroTik Fleet Management Setup, API & Architecture Guide + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    + + +
    + + + + + +
    + + + + + + +
    +

    TOD — The Other Dude

    +

    Fleet management for MikroTik RouterOS devices. Built for MSPs who manage hundreds of routers across multiple tenants. Think “UniFi Controller, but for MikroTik.”

    +

    TOD is a self-hosted, multi-tenant platform that gives you centralized visibility, configuration management, real-time monitoring, and zero-knowledge security across your entire MikroTik fleet.

    + +

    Features

    +
      +
    • Fleet — Dashboard with at-a-glance fleet health, virtual-scrolled device table, geographic map, and subnet scanner for device discovery.
    • +
    • Configuration — Config Editor with two-phase safe apply, batch configuration across devices, bulk CLI commands, reusable templates, Simple Config (Linksys/Ubiquiti-style UI), and git-backed config backup with diff viewer.
    • +
    • Monitoring — Interactive network topology (ReactFlow + Dagre), real-time metrics via SSE/NATS, configurable alert rules, notification channels (email, webhook, Slack), audit trail, KMS transparency dashboard, and PDF reports.
    • +
    • Security — 1Password-style zero-knowledge architecture with SRP-6a auth, 2SKD key derivation, Secret Key with Emergency Kit, OpenBao KMS for per-tenant envelope encryption, Internal CA with SFTP cert deployment, WireGuard VPN, and AES-256-GCM credential encryption.
    • +
    • Administration — Full multi-tenancy with PostgreSQL RLS, user management with RBAC, API keys (mktp_ prefix), firmware management, maintenance windows, and setup wizard.
    • +
    • UX — Command palette (Cmd+K), Vim-style keyboard shortcuts, dark/light mode, Framer Motion page transitions, and shimmer skeleton loaders.
    • +
    + +

    Tech Stack

    + + + + + + + + + + + + + + +
    LayerTechnology
    FrontendReact 19, TanStack Router + Query, Tailwind CSS 3.4, Vite
    BackendPython 3.12, FastAPI 0.115, SQLAlchemy 2.0, asyncpg
    PollerGo 1.24, go-routeros/v3, pgx/v5, nats.go
    DatabasePostgreSQL 17 + TimescaleDB, Row-Level Security
    CacheRedis 7
    Message BusNATS with JetStream
    KMSOpenBao 2.1 (Transit)
    AuthSRP-6a (zero-knowledge), JWT
    +
    + + +
    +

    Quick Start

    +
    # Clone and configure
    +cp .env.example .env
    +
    +# Start infrastructure
    +docker compose up -d
    +
    +# Build app images (one at a time to avoid OOM)
    +docker compose build api
    +docker compose build poller
    +docker compose build frontend
    +
    +# Start the full stack
    +docker compose up -d
    +
    +# Verify
    +curl http://localhost:8001/health
    +open http://localhost:3000
    + +

    Environment Profiles

    + + + + + + + + + +
    EnvironmentFrontendAPINotes
    Devlocalhost:3000localhost:8001Hot-reload, volume-mounted source
    Staginglocalhost:3080localhost:8081Built images, staging secrets
    Productionlocalhost (port 80)Internal (proxied)Gunicorn workers, log rotation
    +
    + + +
    +

    Deployment

    + +

    Prerequisites

    +
      +
    • Docker Engine 24+ with Docker Compose v2
    • +
    • At least 4 GB RAM (2 GB absolute minimum — builds are memory-intensive)
    • +
    • External SSD or fast storage recommended for Docker volumes
    • +
    • Network access to RouterOS devices on ports 8728 (API) and 8729 (API-SSL)
    • +
    + +

    1. Clone and Configure

    +
    git clone <repository-url> tod
    +cd tod
    +
    +# Copy environment template
    +cp .env.example .env.prod
    + +

    2. Generate Secrets

    +
    # Generate JWT secret
    +python3 -c "import secrets; print(secrets.token_urlsafe(64))"
    +
    +# Generate credential encryption key (32 bytes, base64-encoded)
    +python3 -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"
    +

    Edit .env.prod with the generated values:

    +
    ENVIRONMENT=production
    +JWT_SECRET_KEY=<generated-jwt-secret>
    +CREDENTIAL_ENCRYPTION_KEY=<generated-encryption-key>
    +POSTGRES_PASSWORD=<strong-password>
    +
    +# First admin user (created on first startup)
    +FIRST_ADMIN_EMAIL=admin@example.com
    +FIRST_ADMIN_PASSWORD=<strong-password>
    + +

    3. Build Images

    +

    Build images one at a time to avoid out-of-memory crashes on constrained hosts:

    +
    docker compose -f docker-compose.yml -f docker-compose.prod.yml build api
    +docker compose -f docker-compose.yml -f docker-compose.prod.yml build poller
    +docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend
    + +

    4. Start the Stack

    +
    docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d
    + +

    5. Verify

    +
    # Check all services are running
    +docker compose ps
    +
    +# Check API health (liveness)
    +curl http://localhost:8000/health
    +
    +# Check readiness (PostgreSQL, Redis, NATS connected)
    +curl http://localhost:8000/health/ready
    +
    +# Access the portal
    +open http://localhost
    +

    Log in with the FIRST_ADMIN_EMAIL and FIRST_ADMIN_PASSWORD credentials set in step 2.

    + +

    Required Environment Variables

    + + + + + + + + + + + + +
    VariableDescriptionExample
    ENVIRONMENTDeployment environmentproduction
    JWT_SECRET_KEYJWT signing secret (min 32 chars)<generated>
    CREDENTIAL_ENCRYPTION_KEYAES-256 key for device credentials (base64)<generated>
    POSTGRES_PASSWORDPostgreSQL superuser password<strong-password>
    FIRST_ADMIN_EMAILInitial admin account emailadmin@example.com
    FIRST_ADMIN_PASSWORDInitial admin account password<strong-password>
    + +

    Optional Environment Variables

    + + + + + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    GUNICORN_WORKERS2API worker process count
    DB_POOL_SIZE20App database connection pool size
    DB_MAX_OVERFLOW40Max overflow connections above pool
    DB_ADMIN_POOL_SIZE10Admin database connection pool size
    DB_ADMIN_MAX_OVERFLOW20Admin max overflow connections
    POLL_INTERVAL_SECONDS60Device polling interval
    CONNECTION_TIMEOUT_SECONDS10RouterOS connection timeout
    COMMAND_TIMEOUT_SECONDS30RouterOS per-command timeout
    CIRCUIT_BREAKER_MAX_FAILURES5Consecutive failures before backoff
    CIRCUIT_BREAKER_BASE_BACKOFF_SECONDS30Initial backoff duration
    CIRCUIT_BREAKER_MAX_BACKOFF_SECONDS900Maximum backoff (15 min)
    LOG_LEVELinfoLogging verbosity (debug/info/warn/error)
    CORS_ORIGINShttp://localhost:3000Comma-separated CORS origins
    + +

    Storage Configuration

    +

    Docker volumes mount to the host filesystem. Default locations:

    +
      +
    • PostgreSQL data: ./docker-data/postgres
    • +
    • Redis data: ./docker-data/redis
    • +
    • NATS data: ./docker-data/nats
    • +
    • Git store (config backups): ./docker-data/git-store
    • +
    +

    To change storage locations, edit the volume mounts in docker-compose.yml.

    + +

    Resource Limits

    +

    Container memory limits are enforced in docker-compose.prod.yml to prevent OOM crashes:

    + + + + + + + + + + + + +
    ServiceMemory Limit
    PostgreSQL512 MB
    Redis128 MB
    NATS128 MB
    API512 MB
    Poller256 MB
    Frontend64 MB
    +

    Adjust under deploy.resources.limits.memory in docker-compose.prod.yml.

    + +

    Monitoring (Optional)

    +

    Enable Prometheus and Grafana monitoring with the observability compose overlay:

    +
    docker compose \
    +  -f docker-compose.yml \
    +  -f docker-compose.prod.yml \
    +  -f docker-compose.observability.yml \
    +  --env-file .env.prod up -d
    +
      +
    • Prometheus: http://localhost:9090
    • +
    • Grafana: http://localhost:3001 (default: admin/admin)
    • +
    + +

    Exported Metrics

    + + + + + + + + + + + + + +
    MetricSourceDescription
    http_requests_totalAPIHTTP request count by method, path, status
    http_request_duration_secondsAPIRequest latency histogram
    mikrotik_poll_totalPollerPoll cycles by status (success/error/skipped)
    mikrotik_poll_duration_secondsPollerPoll cycle duration histogram
    mikrotik_devices_activePollerNumber of devices being polled
    mikrotik_circuit_breaker_skips_totalPollerPolls skipped due to backoff
    mikrotik_nats_publish_totalPollerNATS publishes by subject and status
    + +

    Troubleshooting

    + + + + + + + + + + + + + + +
    IssueSolution
    API won’t start with secret errorGenerate production secrets (see step 2 above)
    Build crashes with OOMBuild images one at a time (see step 3 above)
    Device shows offlineCheck network access to device API port (8728/8729)
    Health check failsCheck docker compose logs api for startup errors
    Rate limited (429)Wait 60 seconds or check Redis connectivity
    Migration failsCheck docker compose logs api for Alembic errors
    NATS subscriber won’t startNon-fatal — API runs without NATS; check NATS container health
    Poller circuit breaker activeDevice unreachable; check CIRCUIT_BREAKER_* env vars to tune backoff
    +
    + + + + + + +
    +

    System Overview

    +

    TOD is a containerized MSP fleet management platform for MikroTik RouterOS devices. It uses a three-service architecture: a React frontend, a Python FastAPI backend, and a Go poller. All services communicate through PostgreSQL, Redis, and NATS JetStream. Multi-tenancy is enforced at the database level via PostgreSQL Row-Level Security (RLS).

    + +

    Architecture Diagram

    +
    +--------------+     +------------------+     +---------------+
    +|   Frontend   |---->|   Backend API    |<--->|   Go Poller   |
    +|  React/nginx |     |    FastAPI       |     |  go-routeros  |
    ++--------------+     +--------+---------+     +-------+-------+
    +                              |                       |
    +               +--------------+-------------------+---+
    +               |              |                   |
    +      +--------v---+   +-----v-------+   +-------v-------+
    +      |   Redis    |   | PostgreSQL  |   |    NATS       |
    +      |  locks,    |   | 17+Timescale|   |  JetStream    |
    +      |  cache     |   | DB + RLS    |   |  pub/sub      |
    +      +------------+   +-------------+   +-------+-------+
    +                                                 |
    +                                          +------v-------+
    +                                          |   OpenBao    |
    +                                          | Transit KMS  |
    +                                          +--------------+
    + +

    Services

    + +

    Frontend (React / nginx)

    +
      +
    • Stack: React 19, TypeScript, TanStack Router (file-based routing), TanStack Query (data fetching), Tailwind CSS 3.4, Vite
    • +
    • Production: Static build served by nginx on port 80 (exposed as port 3000)
    • +
    • Development: Vite dev server with hot module replacement
    • +
    • Design system: Geist Sans + Geist Mono fonts, HSL color tokens via CSS custom properties, class-based dark/light mode
    • +
    • Real-time: Server-Sent Events (SSE) for live device status updates, alerts, and operation progress
    • +
    • Client-side encryption: SRP-6a authentication flow with 2SKD key derivation; Emergency Kit PDF generation
    • +
    • UX features: Command palette (Cmd+K), Framer Motion page transitions, collapsible sidebar, skeleton loaders
    • +
    • Memory limit: 64 MB
    • +
    + +

    Backend API (FastAPI)

    +
      +
    • Stack: Python 3.12+, FastAPI 0.115+, SQLAlchemy 2.0 async, asyncpg, Gunicorn
    • +
    • Two database engines: +
        +
      • admin_engine (superuser) — used only for auth/bootstrap and NATS subscribers that need cross-tenant access
      • +
      • app_engine (non-superuser app_user role) — used for all device/data routes, enforces RLS
      • +
      +
    • +
    • Authentication: JWT tokens (15min access, 7d refresh), SRP-6a zero-knowledge proof, RBAC (super_admin, admin, operator, viewer)
    • +
    • NATS subscribers: Three independent subscribers for device status, metrics, and firmware events. Non-fatal startup — API serves requests even if NATS is unavailable
    • +
    • Background services: APScheduler for nightly config backups and daily firmware version checks
    • +
    • Middleware stack (LIFO): RequestID → SecurityHeaders → RateLimiting → CORS → Route handler
    • +
    • Health endpoints: /health (liveness), /health/ready (readiness — checks PostgreSQL, Redis, NATS)
    • +
    • Memory limit: 512 MB
    • +
    + +

    API Routers

    +

    The backend exposes route groups under the /api prefix:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    RouterPurpose
    authLogin (SRP-6a + legacy), token refresh, registration
    tenantsTenant CRUD (super_admin only)
    usersUser management, RBAC
    devicesDevice CRUD, status, commands
    device_groupsLogical device grouping
    device_tagsTagging and filtering
    metricsTime-series metrics (TimescaleDB)
    config_backupsConfiguration backup history
    config_editorLive RouterOS config editing
    firmwareFirmware version tracking and upgrades
    alertsAlert rules and active alerts
    eventsDevice event log
    device_logsRouterOS system logs
    templatesConfiguration templates
    clientsConnected client devices
    topologyNetwork topology (ReactFlow data)
    sseServer-Sent Events streams
    audit_logsImmutable audit trail
    reportsPDF report generation (Jinja2 + WeasyPrint)
    api_keysAPI key management (mktp_ prefix)
    maintenance_windowsScheduled maintenance with alert suppression
    vpnWireGuard VPN management
    certificatesInternal CA and device TLS certificates
    transparencyKMS access event dashboard
    + +

    Go Poller

    +
      +
    • Stack: Go 1.23, go-routeros/v3, pgx/v5, nats.go
    • +
    • Polling model: Synchronous per-device polling on a configurable interval (default 60s)
    • +
    • Device communication: RouterOS binary API over TLS (port 8729), InsecureSkipVerify for self-signed certs
    • +
    • TLS fallback: Three-tier strategy — CA-verified → InsecureSkipVerify → plain API
    • +
    • Distributed locking: Redis locks prevent concurrent polling of the same device (safe for multi-instance deployment)
    • +
    • Circuit breaker: Backs off from unreachable devices to avoid wasting poll cycles
    • +
    • Credential decryption: OpenBao Transit with LRU cache (1024 entries, 5min TTL) to minimize KMS calls
    • +
    • Output: Publishes poll results to NATS JetStream; the API’s NATS subscribers process and persist them
    • +
    • Database access: Uses poller_user role which bypasses RLS (needs cross-tenant device access)
    • +
    • Memory limit: 256 MB
    • +
    + +

    Infrastructure Services

    + +

    PostgreSQL 17 + TimescaleDB

    +
      +
    • Image: timescale/timescaledb:2.17.2-pg17
    • +
    • Row-Level Security (RLS): Enforces tenant isolation at the database level. All data tables have a tenant_id column; RLS policies filter by current_setting('app.tenant_id')
    • +
    • Database roles: +
        +
      • postgres (superuser) — admin engine, auth/bootstrap, migrations
      • +
      • app_user (non-superuser) — RLS-enforced, used by API for data routes
      • +
      • poller_user — bypasses RLS, used by Go poller for cross-tenant device access
      • +
      +
    • +
    • TimescaleDB hypertables: Time-series storage for device metrics (CPU, memory, interface traffic, etc.)
    • +
    • Migrations: Alembic, run automatically on API startup
    • +
    • Memory limit: 512 MB
    • +
    + +

    Redis

    +
      +
    • Image: redis:7-alpine
    • +
    • Distributed locking for the Go poller (prevents concurrent polling of the same device)
    • +
    • Rate limiting on auth endpoints (5 requests/min)
    • +
    • Credential cache for OpenBao Transit responses
    • +
    • Memory limit: 128 MB
    • +
    + +

    NATS JetStream

    +
      +
    • Image: nats:2-alpine
    • +
    • Role: Message bus between the Go poller and the Python API
    • +
    • Streams: DEVICE_EVENTS (poll results, status changes), ALERT_EVENTS (SSE delivery), OPERATION_EVENTS (SSE delivery)
    • +
    • Durable consumers: Ensure no message loss during API restarts
    • +
    • Memory limit: 128 MB
    • +
    + +

    OpenBao (HashiCorp Vault fork)

    +
      +
    • Image: openbao/openbao:2.1
    • +
    • Transit secrets engine: Provides envelope encryption for device credentials at rest
    • +
    • Per-tenant keys: Each tenant gets a dedicated Transit encryption key
    • +
    • Memory limit: 256 MB
    • +
    + +

    WireGuard

    +
      +
    • Image: lscr.io/linuxserver/wireguard
    • +
    • Role: VPN gateway for reaching RouterOS devices on remote networks
    • +
    • Port: 51820/UDP
    • +
    • Memory limit: 128 MB
    • +
    + +

    Container Memory Limits

    + + + + + + + + + + + + + + +
    ServiceLimit
    PostgreSQL512 MB
    API512 MB
    Go Poller256 MB
    OpenBao256 MB
    Redis128 MB
    NATS128 MB
    WireGuard128 MB
    Frontend (nginx)64 MB
    + +

    Network Ports

    + + + + + + + + + + + + + + +
    ServiceInternal PortExternal PortProtocol
    Frontend803000HTTP
    API80008001HTTP
    PostgreSQL54325432TCP
    Redis63796379TCP
    NATS42224222TCP
    NATS Monitor82228222HTTP
    OpenBao82008200HTTP
    WireGuard5182051820UDP
    +
    + + +
    +

    Data Flow

    + +

    Device Polling Cycle

    +
    Go Poller        Redis      OpenBao    RouterOS     NATS        API        PostgreSQL
    +   |               |           |           |           |           |            |
    +   +--query list-->|           |           |           |           |            |
    +   |<--------------+           |           |           |           |            |
    +   +--acquire lock->|          |           |           |           |            |
    +   |<--lock granted-+          |           |           |           |            |
    +   +--decrypt creds (miss)---->|           |           |           |            |
    +   |<--plaintext creds--------+           |           |           |            |
    +   +--binary API (8729 TLS)--------------->|           |           |            |
    +   |<--system info, interfaces, metrics---+           |           |            |
    +   +--publish poll result--------------------------------->|       |            |
    +   |               |           |           |           |  subscribe>|           |
    +   |               |           |           |           |           +--upsert--->|
    +   +--release lock->|          |           |           |           |            |
    +
      +
    1. Poller queries PostgreSQL for the list of active devices
    2. +
    3. Acquires a Redis distributed lock per device (prevents duplicate polling)
    4. +
    5. Decrypts device credentials via OpenBao Transit (LRU cache avoids repeated KMS calls)
    6. +
    7. Connects to the RouterOS binary API on port 8729 over TLS
    8. +
    9. Collects system info, interface stats, routing tables, and metrics
    10. +
    11. Publishes results to NATS JetStream
    12. +
    13. API NATS subscriber processes results and upserts into PostgreSQL
    14. +
    15. Releases Redis lock
    16. +
    + +

    Config Push (Two-Phase with Panic Revert)

    +
    Frontend        API           RouterOS
    +   |              |               |
    +   +--push config->|              |
    +   |              +--apply config->|
    +   |              +--set revert--->|
    +   |              |<--ack---------+
    +   |<--pending----+               |
    +   |              |               |  (timer counting down)
    +   +--confirm----->|              |
    +   |              +--cancel timer->|
    +   |              |<--ack---------+
    +   |<--confirmed--+               |
    +
      +
    1. Frontend sends config commands to the API
    2. +
    3. API connects to the device and applies the configuration
    4. +
    5. Sets a revert timer on the device (RouterOS safe mode / scheduler)
    6. +
    7. Returns pending status to the frontend
    8. +
    9. User confirms the change works (e.g., connectivity still up)
    10. +
    11. If confirmed: API cancels the revert timer, config is permanent
    12. +
    13. If timeout or rejected: device automatically reverts to the previous configuration
    14. +
    +

    This pattern prevents lockouts from misconfigured firewall rules or IP changes.

    + +

    SRP-6a Authentication Flow

    +
    Browser                     API                   PostgreSQL
    +   |                          |                       |
    +   +--register---------------->|                      |
    +   |  (email, salt, verifier) +--store verifier------>|
    +   |                          |                       |
    +   +--login step 1------------>|                      |
    +   |  (email, client_public)  +--lookup verifier----->|
    +   |<--(salt, server_public)--+<----------------------+
    +   |                          |                       |
    +   +--login step 2------------>|                      |
    +   |  (client_proof)          +--verify proof---------+
    +   |<--(server_proof, JWT)----+                       |
    +
      +
    1. Registration: Client derives a verifier from password + secret_key using PBKDF2 (650K iterations) + HKDF + XOR (2SKD). Only the salt and verifier are sent to the server — never the password.
    2. +
    3. Login step 1: Client sends email and ephemeral public value; server responds with stored salt and its own ephemeral public value.
    4. +
    5. Login step 2: Client computes a proof from the shared session key; server validates the proof without ever seeing the password.
    6. +
    7. Token issuance: On successful proof, server issues JWT (15min access + 7d refresh).
    8. +
    9. Emergency Kit: A downloadable PDF containing the user’s secret key for account recovery.
    10. +
    +
    + + +
    +

    Multi-Tenancy

    +

    TOD enforces tenant isolation at the database level using PostgreSQL Row-Level Security (RLS), making cross-tenant data access structurally impossible.

    + +

    How It Works

    +
      +
    • Every data table includes a tenant_id column.
    • +
    • PostgreSQL RLS policies filter rows by current_setting('app.tenant_id').
    • +
    • The API sets tenant context (SET app.tenant_id = ...) on each database session, derived from the authenticated user’s JWT.
    • +
    • super_admin role has NULL tenant_id and can access all tenants.
    • +
    • poller_user bypasses RLS intentionally (needs cross-tenant device access for polling).
    • +
    • Tenant isolation is enforced at the database level, not the application level — even a compromised API cannot leak cross-tenant data through app_user connections.
    • +
    + +

    Database Roles

    + + + + + + + + + +
    RoleRLSPurpose
    postgresBypasses (superuser)Admin engine, auth/bootstrap, migrations
    app_userEnforcedAll device/data routes in the API
    poller_userBypassesCross-tenant device access for Go poller
    + +

    Security Layers

    + + + + + + + + + + + + + + + +
    LayerMechanismPurpose
    AuthenticationSRP-6aZero-knowledge proof — password never transmitted or stored
    Key Derivation2SKD (PBKDF2 650K + HKDF + XOR)Two-secret key derivation from password + secret key
    Encryption at RestOpenBao TransitEnvelope encryption for device credentials
    Tenant IsolationPostgreSQL RLSDatabase-level row filtering by tenant_id
    Access ControlJWT + RBACRole-based permissions (super_admin, admin, operator, viewer)
    Rate LimitingRedis-backedAuth endpoints limited to 5 requests/min
    TLS CertificatesInternal CACertificate management and deployment to RouterOS devices
    Security HeadersMiddlewareCSP, SRI hashes on JS bundles, X-Frame-Options, etc.
    Secret ValidationStartup checkRejects known-insecure defaults in non-dev environments
    +
    + + + + + + +
    +

    First Login

    +
      +
    1. Navigate to the portal URL provided by your administrator.
    2. +
    3. Log in with the admin credentials created during initial deployment.
    4. +
    5. Complete SRP security enrollment — the portal uses zero-knowledge authentication (SRP-6a), so a unique Secret Key is generated for your account.
    6. +
    7. Save your Emergency Kit PDF immediately. This PDF contains your Secret Key, which you will need to log in from any new browser or device. Without it, you cannot recover access.
    8. +
    9. Complete the Setup Wizard to create your first organization and add your first device.
    10. +
    + +

    Setup Wizard

    +

    The Setup Wizard launches automatically for first-time super_admin users. It walks through three steps:

    +
      +
    • Step 1 — Create Organization: Enter a name for your tenant (organization). This is the top-level container for all your devices, users, and configuration.
    • +
    • Step 2 — Add Device: Enter the IP address, API port (default 8729 for TLS), and RouterOS credentials for your first device. The portal will attempt to connect and verify the device.
    • +
    • Step 3 — Verify & Complete: The portal polls the device to confirm connectivity. Once verified, you are taken to the dashboard.
    • +
    +

    You can always add more organizations and devices later from the sidebar.

    +
    + + + + + +
    +

    Device Management

    + +

    Adding Devices

    +

    There are three ways to add devices to your fleet:

    +
      +
    1. Setup Wizard — automatically offered on first login.
    2. +
    3. Fleet Table — click the “Add Device” button from the Devices page.
    4. +
    5. Subnet Scanner — enter a CIDR range (e.g., 192.168.1.0/24) to auto-discover MikroTik devices on the network.
    6. +
    +

    When adding a device, provide:

    +
      +
    • IP Address — the management IP of the RouterOS device.
    • +
    • API Port — default is 8729 (TLS). The portal connects via the RouterOS binary API protocol.
    • +
    • Credentials — username and password for the device. Credentials are encrypted at rest with AES-256-GCM.
    • +
    + +

    Device Detail Tabs

    + + + + + + + + + + + + + +
    TabDescription
    OverviewSystem info, uptime, hardware model, RouterOS version, resource usage, and interface status summary.
    InterfacesReal-time traffic graphs for each network interface.
    ConfigBrowse the full device configuration tree by RouterOS path.
    FirewallView and manage firewall filter rules, NAT rules, and address lists.
    DHCPActive DHCP leases, server configuration, and address pools.
    BackupsConfiguration backup timeline with side-by-side diff viewer to compare changes over time.
    ClientsConnected clients and wireless registrations.
    + +

    Simple Config

    +

    Simple Config provides a consumer-router-style interface modeled after Linksys and Ubiquiti UIs. It is designed for operators who prefer guided configuration over raw RouterOS paths.

    +

    Seven category tabs:

    +
      +
    1. Internet — WAN connection type, PPPoE, DHCP client settings.
    2. +
    3. LAN / DHCP — LAN addressing, DHCP server and pool configuration.
    4. +
    5. WiFi — Wireless SSID, security, and channel settings.
    6. +
    7. Port Forwarding — NAT destination rules for inbound services.
    8. +
    9. Firewall — Simplified firewall rule management.
    10. +
    11. DNS — DNS server and static DNS entries.
    12. +
    13. System — Device identity, timezone, NTP, admin password.
    14. +
    +

    Toggle between Simple (guided) and Standard (full config editor) modes at any time. Per-device settings are stored in browser localStorage.

    +
    + + +
    +

    Config Editor

    +

    The Config Editor provides direct access to RouterOS configuration paths (e.g., /ip/address, /ip/firewall/filter, /interface/bridge).

    +
      +
    • Select a device from the header dropdown.
    • +
    • Navigate the configuration tree to browse, add, edit, or delete entries.
    • +
    + +

    Apply Modes

    +
      +
    • Standard Apply — changes are applied immediately.
    • +
    • Safe Apply — two-phase commit with automatic panic-revert. Changes are applied, and you have a confirmation window to accept them. If the confirmation times out (device becomes unreachable), changes automatically revert to prevent lockouts.
    • +
    +

    Safe Apply is strongly recommended for firewall rules and routing changes on remote devices.

    +
    + + +
    +

    Monitoring & Alerts

    + +

    Alert Rules

    +

    Create threshold-based rules that fire when device metrics cross defined boundaries:

    +
      +
    • Select the metric to monitor (CPU, memory, disk, interface traffic, uptime, etc.).
    • +
    • Set the threshold value and comparison operator.
    • +
    • Choose severity: info, warning, or critical.
    • +
    • Assign one or more notification channels.
    • +
    + +

    Notification Channels

    + + + + + + + + + +
    ChannelDescription
    EmailSMTP-based email notifications. Configure server, port, and recipients.
    WebhookHTTP POST to any URL with a JSON payload containing alert details.
    SlackSlack incoming webhook with Block Kit formatting for rich alert messages.
    + +

    Maintenance Windows

    +
      +
    • Define start and end times.
    • +
    • Apply to specific devices or fleet-wide.
    • +
    • Alerts generated during the window are recorded but do not trigger notifications.
    • +
    • Maintenance windows can be recurring or one-time.
    • +
    +
    + + +
    +

    Reports

    +

    Generate PDF reports from the Reports page. Four report types are available:

    + + + + + + + + + + +
    ReportContent
    Fleet SummaryOverall fleet health, device counts by status, top alerts, and aggregate statistics.
    Device HealthPer-device detailed report with hardware info, resource trends, and recent events.
    ComplianceSecurity posture audit — firmware versions, default credentials, firewall policy checks.
    SLAUptime and availability metrics over a selected period with percentage calculations.
    +

    Reports are generated as downloadable PDFs using server-side rendering (Jinja2 + WeasyPrint).

    +
    + + + + + + +
    +

    Security Model

    +

    TOD implements a 1Password-inspired zero-knowledge security architecture. The server never stores or sees user passwords. All data is stored on infrastructure you own and control — no external telemetry, analytics, or third-party data transmission.

    + +

    Data Protection

    +
      +
    • Config backups: Encrypted at rest via OpenBao Transit envelope encryption before database storage.
    • +
    • Audit logs: Encrypted at rest via Transit encryption — audit log content is protected even from database administrators.
    • +
    • Subresource Integrity (SRI): SHA-384 hashes on JavaScript bundles prevent tampering with frontend code.
    • +
    • Content Security Policy (CSP): Strict CSP headers prevent XSS, code injection, and unauthorized resource loading.
    • +
    • No external dependencies: Fully self-hosted with no external analytics, telemetry, CDNs, or third-party services. The only outbound connections are: +
        +
      • RouterOS firmware update checks (no device data sent)
      • +
      • SMTP for email notifications (if configured)
      • +
      • Webhooks for alerts (if configured)
      • +
      +
    • +
    + +

    Security Headers

    + + + + + + + + + + + +
    HeaderValuePurpose
    Strict-Transport-Securitymax-age=31536000; includeSubDomainsForce HTTPS connections
    X-Content-Type-OptionsnosniffPrevent MIME-type sniffing
    X-Frame-OptionsDENYPrevent clickjacking via iframes
    Content-Security-PolicyStrict policyPrevent XSS and code injection
    Referrer-Policystrict-origin-when-cross-originLimit referrer information leakage
    + +

    Audit Trail

    +
      +
    • Immutable audit log: All significant actions are recorded — logins, configuration changes, device operations, admin actions.
    • +
    • Fire-and-forget logging: The log_action() function records audit events asynchronously without blocking the main request.
    • +
    • Per-tenant access: Tenants can only view their own audit logs (enforced by RLS).
    • +
    • Encryption at rest: Audit log content is encrypted via OpenBao Transit.
    • +
    • CSV export: Audit logs can be exported in CSV format for compliance and reporting.
    • +
    • Account deletion: When a user deletes their account, audit log entries are anonymized (PII removed) but the action records are retained for security compliance.
    • +
    + +

    Data Retention

    + + + + + + + + + + + + + + + +
    Data TypeRetentionNotes
    User accountsUntil deletedUsers can self-delete from Settings
    Device metrics90 daysPurged by TimescaleDB retention policy
    Configuration backupsIndefiniteStored in git repositories on your server
    Audit logsIndefiniteAnonymized on account deletion
    API keysUntil revokedCascade-deleted with user account
    Encrypted key materialUntil user deletedCascade-deleted with user account
    Session data (Redis)15 min / 7 daysAuto-expiring access/refresh tokens
    Password reset tokens30 minutesAuto-expire
    SRP session stateShort-livedAuto-expire in Redis
    + +

    GDPR Compliance

    +
      +
    • Right of Access (Art. 15): Users can view their account information on the Settings page.
    • +
    • Right to Data Portability (Art. 20): Users can export all personal data in JSON format from Settings.
    • +
    • Right to Erasure (Art. 17): Users can permanently delete their account and all associated data. Audit logs are anonymized (PII removed) with a deletion receipt generated for compliance verification.
    • +
    • Right to Rectification (Art. 16): Account information can be updated by the tenant administrator.
    • +
    +

    As a self-hosted application, the deployment operator is the data controller and is responsible for compliance with applicable data protection laws.

    +
    + + +
    +

    Authentication

    + +

    SRP-6a Zero-Knowledge Proof

    +

    TOD uses the Secure Remote Password (SRP-6a) protocol for authentication, ensuring the server never receives, transmits, or stores user passwords.

    +
      +
    • SRP-6a protocol: Password is verified via a zero-knowledge proof — only a cryptographic verifier derived from the password is stored on the server, never the password itself.
    • +
    • Session management: JWT tokens with 15-minute access token lifetime and 7-day refresh token lifetime, delivered via httpOnly cookies.
    • +
    • SRP session state: Ephemeral SRP handshake data stored in Redis with automatic expiration.
    • +
    + +

    Authentication Flow

    +
    Client                                Server
    +  |                                     |
    +  |  POST /auth/srp/init {email}        |
    +  |------------------------------------>|
    +  |  {salt, server_ephemeral_B}         |
    +  |<------------------------------------|
    +  |                                     |
    +  |  [Client derives session key from   |
    +  |   password + Secret Key + salt + B] |
    +  |                                     |
    +  |  POST /auth/srp/verify {A, M1}      |
    +  |------------------------------------>|
    +  |  [Server verifies M1 proof]         |
    +  |  {M2, access_token, refresh_token}  |
    +  |<------------------------------------|
    + +

    Two-Secret Key Derivation (2SKD)

    +

    Combines the user password with a 128-bit Secret Key using a multi-step derivation process, ensuring that compromise of either factor alone is insufficient:

    +
      +
    • PBKDF2 with 650,000 iterations stretches the password.
    • +
    • HKDF expansion derives the final key material.
    • +
    • XOR combination of both factors produces the verifier input.
    • +
    + +

    Secret Key & Emergency Kit

    +
      +
    • Secret Key format: A3-XXXXXX (128-bit), stored exclusively in the browser’s IndexedDB. The server never sees or stores the Secret Key.
    • +
    • Emergency Kit: Downloadable PDF containing the Secret Key for account recovery. Generated client-side.
    • +
    +
    + + +
    +

    Encryption

    + +

    Credential Encryption

    +

    Device credentials (RouterOS usernames and passwords) are encrypted at rest using envelope encryption:

    +
      +
    • Encryption algorithm: AES-256-GCM (via Fernet symmetric encryption).
    • +
    • Key management: OpenBao Transit secrets engine provides the master encryption keys.
    • +
    • Per-tenant isolation: Each tenant has its own encryption key in OpenBao Transit.
    • +
    • Envelope encryption: Data is encrypted with a data encryption key (DEK), which is itself encrypted by the tenant’s Transit key.
    • +
    + +

    Go Poller LRU Cache

    +

    The Go poller decrypts credentials at runtime via the Transit API, with an LRU cache (1,024 entries, 5-minute TTL) to reduce KMS round-trips. Cache hits avoid OpenBao API calls entirely.

    + +

    Additional Encryption

    +
      +
    • CA private keys: Encrypted with AES-256-GCM before database storage. PEM key material is never logged.
    • +
    • Config backups: Encrypted at rest via OpenBao Transit before database storage.
    • +
    • Audit logs: Content encrypted via Transit — protected even from database administrators.
    • +
    +
    + + +
    +

    RBAC & Tenants

    + +

    Role-Based Access Control

    + + + + + + + + + + +
    RoleScopeCapabilities
    super_adminGlobalFull system access, tenant management, user management across all tenants
    adminTenantManage devices, users, settings, certificates within their tenant
    operatorTenantDevice operations, configuration changes, monitoring
    viewerTenantRead-only access to devices, metrics, and dashboards
    +
      +
    • RBAC is enforced at both the API middleware layer and database level.
    • +
    • API keys inherit the operator permission level and are scoped to a single tenant.
    • +
    • API key tokens use the mktp_ prefix and are stored as SHA-256 hashes (the plaintext token is shown once at creation and never stored).
    • +
    + +

    Tenant Isolation via RLS

    +

    Multi-tenancy is enforced at the database level via PostgreSQL Row-Level Security (RLS). The app_user database role automatically filters all queries by the authenticated user’s tenant_id. Super admins operate outside tenant scope.

    + +

    Internal CA & TLS Fallback

    +

    TOD includes a per-tenant Internal Certificate Authority for managing TLS certificates on RouterOS devices:

    +
      +
    • Per-tenant CA: Each tenant can generate its own self-signed Certificate Authority.
    • +
    • Deployment: Certificates are deployed to devices via SFTP.
    • +
    • Three-tier TLS fallback: The Go poller attempts connections in order: +
        +
      1. CA-verified TLS (using the tenant’s CA certificate)
      2. +
      3. InsecureSkipVerify TLS (for self-signed RouterOS certs)
      4. +
      5. Plain API connection (fallback)
      6. +
      +
    • +
    • Key protection: CA private keys are encrypted with AES-256-GCM before database storage.
    • +
    +
    + + + + + + +
    +

    API Endpoints

    + +

    Overview

    +

    TOD exposes a REST API built with FastAPI. Interactive documentation is available at:

    +
      +
    • Swagger UI: http://<host>:<port>/docs (dev environment only)
    • +
    • ReDoc: http://<host>:<port>/redoc (dev environment only)
    • +
    +

    Both Swagger and ReDoc are disabled in staging/production environments.

    + +

    Endpoint Groups

    +

    All API routes are mounted under the /api prefix.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    GroupPrefixDescription
    Auth/api/auth/*Login, register, SRP exchange, password reset, token refresh
    Tenants/api/tenants/*Tenant/organization CRUD
    Users/api/users/*User management, RBAC role assignment
    Devices/api/devices/*Device CRUD, scanning, status
    Device Groups/api/device-groups/*Logical device grouping
    Device Tags/api/device-tags/*Tag-based device labeling
    Metrics/api/metrics/*TimescaleDB device metrics (CPU, memory, traffic)
    Config Backups/api/config-backups/*Automated RouterOS config backup history
    Config Editor/api/config-editor/*Live RouterOS config browsing and editing
    Firmware/api/firmware/*RouterOS firmware version management and upgrades
    Alerts/api/alerts/*Alert rule CRUD, alert history
    Events/api/events/*Device event log
    Device Logs/api/device-logs/*RouterOS syslog entries
    Templates/api/templates/*Config templates for batch operations
    Clients/api/clients/*Connected client (DHCP lease) data
    Topology/api/topology/*Network topology map data
    SSE/api/sse/*Server-Sent Events for real-time updates
    Audit Logs/api/audit-logs/*Immutable audit trail
    Reports/api/reports/*PDF report generation (Jinja2 + WeasyPrint)
    API Keys/api/api-keys/*API key CRUD
    Maintenance Windows/api/maintenance-windows/*Scheduled maintenance window management
    VPN/api/vpn/*WireGuard VPN tunnel management
    Certificates/api/certificates/*Internal CA and device certificate management
    Transparency/api/transparency/*KMS access event dashboard
    + +

    Health Checks

    + + + + + + + + + +
    EndpointTypeDescription
    GET /healthLivenessAlways returns 200 if the API process is alive. Response includes version.
    GET /health/readyReadinessReturns 200 only when PostgreSQL, Redis, and NATS are all healthy. Returns 503 otherwise.
    GET /api/healthLivenessBackward-compatible alias under /api prefix.
    +
    + + +
    +

    API Authentication

    + +

    SRP-6a Login

    +
      +
    • POST /api/auth/login — SRP-6a authentication (returns JWT access + refresh tokens)
    • +
    • POST /api/auth/refresh — Refresh an expired access token
    • +
    • POST /api/auth/logout — Invalidate the current session
    • +
    +

    All authenticated endpoints require one of:

    +
      +
    • Authorization: Bearer <token> header
    • +
    • httpOnly cookie (set automatically by the login flow)
    • +
    +

    Access tokens expire after 15 minutes. Refresh tokens are valid for 7 days.

    + +

    API Key Authentication

    +
      +
    • Create API keys in Admin > API Keys
    • +
    • Use header: X-API-Key: mktp_<key>
    • +
    • Keys have operator-level RBAC permissions
    • +
    • Prefix: mktp_, stored as SHA-256 hash
    • +
    + +

    Rate Limiting

    +
      +
    • Auth endpoints: 5 requests/minute per IP
    • +
    • General endpoints: no global rate limit (per-route limits may apply)
    • +
    +

    Rate limit violations return HTTP 429 with a JSON error body.

    + +

    RBAC Roles

    + + + + + + + + + + +
    RoleScopeDescription
    super_adminGlobal (no tenant)Full platform access, tenant management
    adminTenantFull access within their tenant
    operatorTenantDevice operations, config changes
    viewerTenantRead-only access
    +
    + + +
    +

    Error Handling

    + +

    Error Format

    +

    All error responses use a standard JSON format:

    +
    {
    +  "detail": "Human-readable error message"
    +}
    + +

    Status Codes

    + + + + + + + + + + + + + + + +
    CodeMeaning
    400Bad request / validation error
    401Unauthorized (missing or expired token)
    403Forbidden (insufficient RBAC permissions)
    404Resource not found
    409Conflict (duplicate resource)
    422Unprocessable entity (Pydantic validation)
    429Rate limit exceeded
    500Internal server error
    503Service unavailable (readiness check failed)
    +
    + + + + + + +
    +

    Environment Variables

    +

    TOD uses Pydantic Settings for configuration. All values can be set via environment variables or a .env file in the backend working directory.

    + +

    Application

    + + + + + + + + + + + + +
    VariableDefaultDescription
    APP_NAMETOD - The Other DudeApplication display name
    APP_VERSION0.1.0Semantic version string
    ENVIRONMENTdevRuntime environment: dev, staging, or production
    DEBUGfalseEnable debug mode
    CORS_ORIGINShttp://localhost:3000,...Comma-separated list of allowed CORS origins
    APP_BASE_URLhttp://localhost:5173Frontend base URL (used in password reset emails)
    + +

    Authentication & JWT

    + + + + + + + + + + + +
    VariableDefaultDescription
    JWT_SECRET_KEY(insecure dev default)HMAC signing key for JWTs. Must be changed in production.
    JWT_ALGORITHMHS256JWT signing algorithm
    JWT_ACCESS_TOKEN_EXPIRE_MINUTES15Access token lifetime in minutes
    JWT_REFRESH_TOKEN_EXPIRE_DAYS7Refresh token lifetime in days
    PASSWORD_RESET_TOKEN_EXPIRE_MINUTES30Password reset link validity in minutes
    + +

    Database

    + + + + + + + + + + + + + +
    VariableDefaultDescription
    DATABASE_URLpostgresql+asyncpg://postgres:postgres@localhost:5432/mikrotikAdmin (superuser) async database URL. Used for migrations and bootstrap.
    SYNC_DATABASE_URLpostgresql+psycopg2://postgres:postgres@localhost:5432/mikrotikSynchronous URL used by Alembic migrations only.
    APP_USER_DATABASE_URLpostgresql+asyncpg://app_user:app_password@localhost:5432/mikrotikNon-superuser async URL. Enforces PostgreSQL RLS for tenant isolation.
    DB_POOL_SIZE20App user connection pool size
    DB_MAX_OVERFLOW40App user pool max overflow connections
    DB_ADMIN_POOL_SIZE10Admin connection pool size
    DB_ADMIN_MAX_OVERFLOW20Admin pool max overflow connections
    + +

    Security

    + + + + + + + +
    VariableDefaultDescription
    CREDENTIAL_ENCRYPTION_KEY(insecure dev default)AES-256-GCM encryption key for device credentials at rest. Must be exactly 32 bytes, base64-encoded. Must be changed in production.
    + +

    OpenBao / Vault (KMS)

    + + + + + + + + +
    VariableDefaultDescription
    OPENBAO_ADDRhttp://localhost:8200OpenBao Transit server address for per-tenant envelope encryption
    OPENBAO_TOKEN(insecure dev default)OpenBao authentication token. Must be changed in production.
    + +

    NATS

    + + + + + + + +
    VariableDefaultDescription
    NATS_URLnats://localhost:4222NATS JetStream server URL for pub/sub between Go poller and Python API
    + +

    Redis

    + + + + + + + +
    VariableDefaultDescription
    REDIS_URLredis://localhost:6379/0Redis URL for caching, distributed locks, and rate limiting
    + +

    SMTP (Notifications)

    + + + + + + + + + + + + +
    VariableDefaultDescription
    SMTP_HOSTlocalhostSMTP server hostname
    SMTP_PORT587SMTP server port
    SMTP_USER(none)SMTP authentication username
    SMTP_PASSWORD(none)SMTP authentication password
    SMTP_USE_TLSfalseEnable STARTTLS for SMTP connections
    SMTP_FROM_ADDRESSnoreply@mikrotik-portal.localSender address for outbound emails
    + +

    Firmware

    + + + + + + + + +
    VariableDefaultDescription
    FIRMWARE_CACHE_DIR/data/firmware-cachePath to firmware download cache (PVC mount in production)
    FIRMWARE_CHECK_INTERVAL_HOURS24Hours between automatic RouterOS version checks
    + +

    Storage Paths

    + + + + + + + + +
    VariableDefaultDescription
    GIT_STORE_PATH./git-storePath to bare git repos for config backup history. In production: /data/git-store on a ReadWriteMany PVC.
    WIREGUARD_CONFIG_PATH/data/wireguardShared volume path for WireGuard configuration files
    + +

    Bootstrap

    + + + + + + + + +
    VariableDefaultDescription
    FIRST_ADMIN_EMAIL(none)Email for the initial super_admin user. Only used if no users exist in the database.
    FIRST_ADMIN_PASSWORD(none)Password for the initial super_admin user. The user is created with must_upgrade_auth=True, triggering SRP registration on first login.
    + +

    Production Safety

    +

    TOD refuses to start in staging or production environments if any of these variables still have their insecure dev defaults:

    +
      +
    • JWT_SECRET_KEY
    • +
    • CREDENTIAL_ENCRYPTION_KEY
    • +
    • OPENBAO_TOKEN
    • +
    +

    The process exits with code 1 and a clear error message indicating which variable needs to be rotated.

    +
    + + +
    +

    Docker Compose

    + +

    Profiles

    + + + + + + + + +
    ProfileCommandServices
    (default)docker compose up -dInfrastructure only: PostgreSQL, Redis, NATS, OpenBao
    fulldocker compose --profile full up -dAll services: infrastructure + API, Poller, Frontend
    + +

    Container Memory Limits

    +

    All containers have enforced memory limits to prevent OOM on the host:

    + + + + + + + + + + + + +
    ServiceMemory Limit
    PostgreSQL512 MB
    Redis128 MB
    NATS128 MB
    API512 MB
    Poller256 MB
    Frontend64 MB
    +

    Build Docker images sequentially (not in parallel) to avoid OOM during builds.

    +
    + +
    +
    + + + + + + diff --git a/docs/website/index.html b/docs/website/index.html new file mode 100644 index 0000000..139c087 --- /dev/null +++ b/docs/website/index.html @@ -0,0 +1,520 @@ + + + + + + The Other Dude — Fleet Management for MikroTik RouterOS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +
    +
    +
    +
    + +
    + Fleet Management for MikroTik +

    MikroTik Fleet Management for MSPs

    +

    Manage hundreds of MikroTik routers from a single pane of glass. Zero-knowledge security, real-time monitoring, and configuration management — built for MSPs who demand more than WinBox.

    + +
    +
    + + + + +
    +
    + +

    Everything you need to manage your fleet

    +

    From device discovery to firmware upgrades, The Other Dude gives you complete control over your MikroTik infrastructure.

    + +
    + + +
    +
    + + + + + + + + + +
    +

    Fleet Management

    +

    Dashboard with real-time status, virtual-scrolled fleet table, subnet scanning, and per-device detail pages with live metrics.

    +
    + + +
    +
    + + + + +
    +

    Configuration

    +

    Browse and edit RouterOS config in real-time. Two-phase push with panic-revert ensures you never brick a remote device. Batch templates for fleet-wide changes.

    +
    + + +
    +
    + + + +
    +

    Monitoring

    +

    Real-time CPU, memory, and traffic via SSE. Threshold-based alerts with email, webhook, Slack, and webhook push notifications. Interactive topology map.

    +
    + + +
    +
    + + + + + +
    +

    Zero-Knowledge Security

    +

    1Password-style SRP-6a auth — the server never sees your password. Per-tenant envelope encryption via OpenBao Transit. Internal CA for device TLS.

    +
    + + +
    +
    + + + + + + + + +
    +

    Multi-Tenant

    +

    PostgreSQL Row-Level Security isolates tenants at the database layer. RBAC with four roles. API keys for automation.

    +
    + + +
    +
    + + + +
    +

    Operations

    +

    Firmware management, PDF reports, audit trail, maintenance windows, config backup with git-backed version history and diff.

    +
    + +
    +
    +
    + + + + +
    +
    + +

    Built for reliability at scale

    + +
    + +
    +
    +
    + +
    +
    Frontend
    +
    React 19 · nginx
    +
    +
    +
    /api/ proxy
    + + +
    +
    +
    + +
    +
    Backend API
    +
    FastAPI · Python 3.12
    +
    +
    +
    + + +
    +
    +
    + +
    +
    PostgreSQL
    +
    TimescaleDB · RLS
    +
    +
    +
    + +
    +
    Redis
    +
    Locks · Cache
    +
    +
    +
    + +
    +
    NATS
    +
    JetStream pub/sub
    +
    +
    +
    + + +
    +
    +
    + +
    +
    Go Poller
    +
    RouterOS binary API · port 8729
    +
    +
    +
    + + +
    +
    +
    + +
    +
    RouterOS Fleet
    +
    Your MikroTik devices
    +
    +
    +
    + +
      +
    • Three-service stack: React frontend, Python API, Go poller — each independently scalable
    • +
    • PostgreSQL RLS enforces tenant isolation at the database layer, not the application layer
    • +
    • NATS JetStream delivers real-time events from poller to frontend via SSE
    • +
    • OpenBao Transit provides per-tenant envelope encryption for zero-knowledge credential storage
    • +
    +
    +
    + + + + +
    +
    + +

    Modern tools, battle-tested foundations

    +
    + React 19 + TypeScript + FastAPI + Python 3.12 + Go 1.24 + PostgreSQL 17 + TimescaleDB + Redis + NATS + Docker + OpenBao + WireGuard + Tailwind CSS + Vite +
    +
    +
    + + + + +
    +
    + +

    See it in action

    +
    +
    +
    + +
    + The Other Dude zero-knowledge SRP-6a login page +
    Zero-Knowledge SRP-6a Login
    +
    + +
    + Fleet dashboard showing Lebowski Lanes network overview +
    Fleet Dashboard — Lebowski Lanes
    +
    + +
    + Device fleet list with status monitoring across tenants +
    Device Fleet List
    +
    + +
    + Device detail view for The Dude core router +
    Device Detail View
    +
    + +
    + Network topology map with automatic device discovery +
    Network Topology Map
    +
    + +
    + RouterOS configuration editor with diff preview +
    Configuration Editor
    +
    + +
    + Alert rules and notification channel management +
    Alert Rules & Notifications
    +
    + +
    + Multi-tenant view showing The Stranger's Ranch network +
    Multi-Tenant — The Stranger’s Ranch
    +
    + +
    +
    +
    + + + + +
    +
    + +

    Up and running in minutes

    + +
    +
    + + + + Terminal +
    +
    # Clone and configure
    +cp .env.example .env
    +
    +# Start infrastructure
    +docker compose up -d
    +
    +# Build app images
    +docker compose build api && docker compose build poller && docker compose build frontend
    +
    +# Launch
    +docker compose up -d
    +
    +# Open TOD
    +open http://localhost:3000
    +
    +
    +
    + + + + +
    +
    +

    Ready to manage your fleet?

    +

    Get started in minutes. Self-hosted, open-source, and built for the MikroTik community.

    + +
    +
    + + + + +
    + + + + + + + + + + diff --git a/docs/website/robots.txt b/docs/website/robots.txt new file mode 100644 index 0000000..afe2dd7 --- /dev/null +++ b/docs/website/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://theotherdude.net/sitemap.xml diff --git a/docs/website/script.js b/docs/website/script.js new file mode 100644 index 0000000..867814d --- /dev/null +++ b/docs/website/script.js @@ -0,0 +1,241 @@ +/* TOD Documentation Website — Shared JavaScript */ + +(function () { + 'use strict'; + + /* -------------------------------------------------- */ + /* 1. Scroll Spy (docs page) */ + /* -------------------------------------------------- */ + function initScrollSpy() { + const sidebar = document.querySelector('.sidebar-nav'); + if (!sidebar) return; + + const links = Array.from(document.querySelectorAll('.sidebar-link')); + const sections = links + .map(function (link) { + var id = link.getAttribute('data-section'); + return id ? document.getElementById(id) : null; + }) + .filter(Boolean); + + if (!sections.length) return; + + var current = null; + + var observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + var id = entry.target.id; + if (id !== current) { + current = id; + links.forEach(function (l) { + l.classList.toggle( + 'sidebar-link--active', + l.getAttribute('data-section') === id + ); + }); + + /* keep active link visible in sidebar */ + var active = sidebar.querySelector('.sidebar-link--active'); + if (active) { + active.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + } + }); + }, + { rootMargin: '-80px 0px -60% 0px', threshold: 0 } + ); + + sections.forEach(function (s) { + observer.observe(s); + }); + } + + /* -------------------------------------------------- */ + /* 2. Docs Search */ + /* -------------------------------------------------- */ + function initDocsSearch() { + var input = document.getElementById('docs-search-input'); + if (!input) return; + + var content = document.getElementById('docs-content'); + if (!content) return; + + var sections = Array.from(content.querySelectorAll('section[id]')); + var links = Array.from(document.querySelectorAll('.sidebar-link')); + + input.addEventListener('input', function () { + var q = input.value.trim().toLowerCase(); + + if (!q) { + sections.forEach(function (s) { s.style.display = ''; }); + links.forEach(function (l) { l.style.display = ''; }); + return; + } + + sections.forEach(function (s) { + var text = s.textContent.toLowerCase(); + var match = text.indexOf(q) !== -1; + s.style.display = match ? '' : 'none'; + }); + + links.forEach(function (l) { + var sectionId = l.getAttribute('data-section'); + var section = sectionId ? document.getElementById(sectionId) : null; + if (section) { + l.style.display = section.style.display; + } + }); + }); + } + + /* -------------------------------------------------- */ + /* 3. Back to Top */ + /* -------------------------------------------------- */ + function initBackToTop() { + var btn = document.getElementById('back-to-top'); + if (!btn) return; + + window.addEventListener('scroll', function () { + btn.classList.toggle('back-to-top--visible', window.scrollY > 400); + }, { passive: true }); + } + + window.scrollToTop = function () { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + /* -------------------------------------------------- */ + /* 4. Sidebar Toggle (mobile) */ + /* -------------------------------------------------- */ + window.toggleSidebar = function () { + var sidebar = document.getElementById('docs-sidebar'); + if (!sidebar) return; + sidebar.classList.toggle('docs-sidebar--open'); + }; + + function initSidebarClose() { + var sidebar = document.getElementById('docs-sidebar'); + if (!sidebar) return; + + /* close on outside click */ + document.addEventListener('click', function (e) { + if ( + sidebar.classList.contains('docs-sidebar--open') && + !sidebar.contains(e.target) && + !e.target.closest('.docs-hamburger') + ) { + sidebar.classList.remove('docs-sidebar--open'); + } + }); + + /* close on link click (mobile) */ + sidebar.addEventListener('click', function (e) { + if (e.target.closest('.sidebar-link')) { + sidebar.classList.remove('docs-sidebar--open'); + } + }); + } + + /* -------------------------------------------------- */ + /* 5. Reveal Animation (landing page) */ + /* -------------------------------------------------- */ + function initReveal() { + var els = document.querySelectorAll('.reveal'); + if (!els.length) return; + + var observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add('reveal--visible'); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.1 } + ); + + els.forEach(function (el) { + observer.observe(el); + }); + } + + /* -------------------------------------------------- */ + /* 6. Smooth scroll for anchor links */ + /* -------------------------------------------------- */ + function initSmoothScroll() { + document.addEventListener('click', function (e) { + var link = e.target.closest('a[href^="#"]'); + if (!link) return; + + var id = link.getAttribute('href').slice(1); + var target = document.getElementById(id); + if (!target) return; + + e.preventDefault(); + + var offset = 80; + var top = target.getBoundingClientRect().top + window.pageYOffset - offset; + window.scrollTo({ top: top, behavior: 'smooth' }); + + /* update URL without jump */ + history.pushState(null, '', '#' + id); + }); + } + + /* -------------------------------------------------- */ + /* 7. Active nav link (landing page) */ + /* -------------------------------------------------- */ + function initActiveNav() { + var navLinks = document.querySelectorAll('.nav-link[href^="index.html#"]'); + if (!navLinks.length) return; + + /* only run on landing page */ + if (document.body.classList.contains('docs-page')) return; + + var sectionIds = []; + navLinks.forEach(function (l) { + var hash = l.getAttribute('href').split('#')[1]; + if (hash) sectionIds.push({ id: hash, link: l }); + }); + + if (!sectionIds.length) return; + + var observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + sectionIds.forEach(function (item) { + item.link.classList.toggle( + 'nav-link--active', + item.id === entry.target.id + ); + }); + } + }); + }, + { rootMargin: '-80px 0px -60% 0px', threshold: 0 } + ); + + sectionIds.forEach(function (item) { + var el = document.getElementById(item.id); + if (el) observer.observe(el); + }); + } + + /* -------------------------------------------------- */ + /* Init on DOMContentLoaded */ + /* -------------------------------------------------- */ + document.addEventListener('DOMContentLoaded', function () { + initScrollSpy(); + initDocsSearch(); + initBackToTop(); + initSidebarClose(); + initReveal(); + initSmoothScroll(); + initActiveNav(); + }); +})(); diff --git a/docs/website/sitemap.xml b/docs/website/sitemap.xml new file mode 100644 index 0000000..2454086 --- /dev/null +++ b/docs/website/sitemap.xml @@ -0,0 +1,15 @@ + + + + https://theotherdude.net/ + 2026-03-07 + weekly + 1.0 + + + https://theotherdude.net/docs.html + 2026-03-07 + weekly + 0.8 + + diff --git a/docs/website/style.css b/docs/website/style.css new file mode 100644 index 0000000..1dc5e8e --- /dev/null +++ b/docs/website/style.css @@ -0,0 +1,1868 @@ +/* ========================================================================== + TOD - The Other Dude + Fleet Management Platform for MikroTik RouterOS + + Premium stylesheet — dark landing + light docs + ========================================================================== */ + +/* -------------------------------------------------------------------------- + 0. CSS Custom Properties + -------------------------------------------------------------------------- */ + +:root { + /* Landing page (dark) */ + --bg-deep: #040810; + --bg-primary: #0A1628; + --bg-surface: #111B2E; + --bg-elevated: #182438; + --text-primary: #F1F5F9; + --text-secondary: #94A3B8; + --text-muted: #64748B; + --accent: #2A9D8F; + --accent-hover: #3DB8A9; + --accent-glow: rgba(42, 157, 143, 0.12); + --accent-secondary: #8B1A1A; + --border: rgba(148, 163, 184, 0.08); + --border-accent: rgba(42, 157, 143, 0.2); + + /* Docs page (light) — applied contextually under .docs-page */ + --docs-bg: #FAFBFC; + --docs-surface: #FFFFFF; + --docs-text: #1E293B; + --docs-text-secondary: #475569; + --docs-text-muted: #94A3B8; + --docs-border: #E2E8F0; + --docs-accent: #1F7A6F; + --docs-sidebar-bg: #F8FAFC; + --docs-code-bg: #F1F5F9; + --docs-code-border: #E2E8F0; +} + +/* -------------------------------------------------------------------------- + 1. Reset & Base + -------------------------------------------------------------------------- */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 16px; + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-deep); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + overflow-x: hidden; +} + +h1, h2, h3, h4, h5, h6 { + font-family: "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + line-height: 1.2; +} + +code, pre, kbd, samp { + font-family: "Fira Code", "SF Mono", "Cascadia Code", "Consolas", monospace; +} + +img { + max-width: 100%; + height: auto; + display: block; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + background: none; +} + +ul, ol { + list-style: none; +} + +::selection { + background: rgba(42, 157, 143, 0.25); + color: var(--text-primary); +} + +/* -------------------------------------------------------------------------- + 2. Container + -------------------------------------------------------------------------- */ + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +/* -------------------------------------------------------------------------- + 3. Navigation — .site-nav + -------------------------------------------------------------------------- */ + +.site-nav { + height: 64px; + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.site-nav--dark { + background: rgba(4, 8, 16, 0.85); + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); + border-bottom: 1px solid var(--border); +} + +.site-nav--light { + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); + border-bottom: 1px solid var(--docs-border); +} + +.nav-inner { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + width: 100%; +} + +.nav-logo { + font-family: "Outfit", sans-serif; + font-weight: 700; + font-size: 20px; + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: inherit; +} + +.nav-logo-mark { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.nav-links { + display: flex; + align-items: center; + gap: 32px; +} + +.nav-link { + font-size: 14px; + font-weight: 500; + opacity: 0.7; + transition: opacity 0.2s ease, color 0.2s ease; + text-decoration: none; +} + +.nav-link:hover { + opacity: 1; +} + +.nav-link--active { + opacity: 1; + color: var(--accent); +} + +.site-nav--light .nav-link { + color: var(--docs-text-secondary); +} + +.site-nav--light .nav-link:hover { + color: var(--docs-text); +} + +.site-nav--light .nav-link--active { + color: var(--docs-accent); +} + +.nav-cta { + display: inline-flex; + align-items: center; + background: var(--accent); + color: #0A1628; + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 14px; + padding: 8px 20px; + border-radius: 9999px; + transition: filter 0.2s ease, transform 0.15s ease; + text-decoration: none; + letter-spacing: 0.01em; +} + +.nav-cta:hover { + filter: brightness(1.1); + transform: translateY(-1px); +} + +.nav-cta:active { + transform: translateY(0); +} + +/* -------------------------------------------------------------------------- + 4. Hero — .hero + -------------------------------------------------------------------------- */ + +.hero { + min-height: calc(100vh - 64px); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.hero-bg { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; +} + +/* Animated gradient mesh */ +.hero-bg::before { + content: ""; + position: absolute; + top: -40%; + left: -20%; + width: 80%; + height: 80%; + border-radius: 50%; + background: radial-gradient(ellipse at center, rgba(42, 157, 143, 0.18) 0%, transparent 70%); + animation: meshFloat 20s ease-in-out infinite; + will-change: transform; +} + +.hero-bg::after { + content: ""; + position: absolute; + bottom: -30%; + right: -10%; + width: 70%; + height: 70%; + border-radius: 50%; + background: radial-gradient(ellipse at center, rgba(139, 26, 26, 0.12) 0%, transparent 70%); + animation: meshFloat 24s ease-in-out infinite reverse; + will-change: transform; +} + +/* Grid overlay */ +.hero-bg-grid { + position: absolute; + inset: 0; + background-image: + repeating-linear-gradient( + 0deg, + rgba(148, 163, 184, 0.03) 0px, + rgba(148, 163, 184, 0.03) 0.5px, + transparent 0.5px, + transparent 80px + ), + repeating-linear-gradient( + 90deg, + rgba(148, 163, 184, 0.03) 0px, + rgba(148, 163, 184, 0.03) 0.5px, + transparent 0.5px, + transparent 80px + ); + mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 100%); + -webkit-mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 100%); +} + +.hero-content { + position: relative; + z-index: 10; + text-align: center; + max-width: 800px; + padding: 0 24px; +} + +.hero-rosette { + margin-bottom: 32px; + animation: fadeInUp 0.6s ease both; +} + +.hero-rosette svg { + filter: drop-shadow(0 0 40px rgba(42, 157, 143, 0.3)) drop-shadow(0 0 80px rgba(139, 26, 26, 0.15)); +} + +.hero-badge { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(42, 157, 143, 0.1); + color: var(--accent); + border: 1px solid rgba(42, 157, 143, 0.2); + border-radius: 9999px; + padding: 6px 16px; + font-family: "Outfit", sans-serif; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.05em; + text-transform: uppercase; + margin-bottom: 24px; +} + +.hero-title { + font-family: "Outfit", sans-serif; + font-weight: 800; + font-size: clamp(3rem, 6vw, 5rem); + line-height: 1.05; + letter-spacing: -0.03em; + margin-bottom: 24px; + color: var(--text-primary); +} + +.hero-title .gradient-text { + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-secondary) 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero-subtitle { + font-size: clamp(1.1rem, 2vw, 1.35rem); + color: var(--text-secondary); + max-width: 600px; + margin: 0 auto 48px; + line-height: 1.7; +} + +.hero-actions { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; +} + +.btn-primary { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--accent); + color: #0A1628; + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 15px; + padding: 14px 28px; + border-radius: 12px; + border: none; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease; + letter-spacing: 0.01em; + text-decoration: none; +} + +.btn-primary:hover { + transform: scale(1.02) translateY(-1px); + box-shadow: 0 8px 30px rgba(42, 157, 143, 0.25), 0 0 60px rgba(42, 157, 143, 0.08); + filter: brightness(1.05); +} + +.btn-primary:active { + transform: scale(0.99); +} + +.btn-secondary { + display: inline-flex; + align-items: center; + gap: 8px; + background: transparent; + color: var(--text-primary); + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 15px; + padding: 14px 28px; + border-radius: 12px; + border: 1px solid var(--border); + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.15s ease; + letter-spacing: 0.01em; + text-decoration: none; +} + +.btn-secondary:hover { + background: var(--bg-elevated); + border-color: rgba(148, 163, 184, 0.15); + transform: translateY(-1px); +} + +.btn-secondary:active { + transform: translateY(0); +} + +/* -------------------------------------------------------------------------- + 5. Features — .features-section + -------------------------------------------------------------------------- */ + +.features-section { + padding: 120px 0; +} + +.section-label { + color: var(--accent); + font-family: "Outfit", sans-serif; + text-transform: uppercase; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.1em; + margin-bottom: 16px; +} + +.section-title { + font-family: "Outfit", sans-serif; + font-weight: 700; + font-size: 2.5rem; + color: var(--text-primary); + margin-bottom: 16px; + letter-spacing: -0.02em; +} + +.section-desc { + color: var(--text-secondary); + max-width: 600px; + margin-bottom: 64px; + font-size: 1.1rem; + line-height: 1.7; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.feature-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; +} + +.feature-card:hover { + border-color: var(--border-accent); + box-shadow: 0 8px 40px rgba(42, 157, 143, 0.06), 0 0 0 1px rgba(42, 157, 143, 0.08); + transform: translateY(-4px); +} + +.feature-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--accent-glow); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + font-size: 22px; + color: var(--accent); +} + +.feature-icon svg { + width: 24px; + height: 24px; + stroke: var(--accent); + stroke-width: 1.5; + fill: none; +} + +.feature-title { + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 1.15rem; + margin-bottom: 10px; + color: var(--text-primary); +} + +.feature-desc { + color: var(--text-secondary); + font-size: 0.95rem; + line-height: 1.7; +} + +/* -------------------------------------------------------------------------- + 6. Architecture — .arch-section + -------------------------------------------------------------------------- */ + +.arch-section { + padding: 120px 0; + background: var(--bg-surface); +} + +.arch-diagram { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 16px; + padding: 48px; + overflow-x: auto; +} + +.arch-visual { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 48px 24px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 16px; +} + +.arch-row { + display: flex; + justify-content: center; + gap: 20px; + width: 100%; + max-width: 700px; +} + +.arch-row--triple { + max-width: 700px; +} + +.arch-node { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 20px 28px; + border-radius: 12px; + border: 1px solid var(--border-accent); + background: rgba(42, 157, 143, 0.04); + min-width: 160px; + text-align: center; + transition: border-color 0.3s, box-shadow 0.3s; +} + +.arch-node:hover { + border-color: var(--accent); + box-shadow: 0 0 24px var(--accent-glow); +} + +.arch-node--infra { + border-color: rgba(139, 26, 26, 0.25); + background: rgba(139, 26, 26, 0.04); + flex: 1; + min-width: 0; +} + +.arch-node--infra:hover { + border-color: var(--accent-secondary); + box-shadow: 0 0 24px rgba(139, 26, 26, 0.12); +} + +.arch-node--device { + border-color: rgba(148, 163, 184, 0.2); + background: rgba(148, 163, 184, 0.04); +} + +.arch-node-icon { + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--accent-glow); +} + +.arch-node--infra .arch-node-icon { + color: var(--accent-secondary); + background: rgba(139, 26, 26, 0.1); +} + +.arch-node--device .arch-node-icon { + color: var(--text-muted); + background: rgba(148, 163, 184, 0.1); +} + +.arch-node-label { + font-family: 'Outfit', sans-serif; + font-weight: 600; + font-size: 15px; + color: var(--text-primary); +} + +.arch-node-tech { + font-size: 12px; + color: var(--text-muted); + font-family: 'Fira Code', monospace; +} + +.arch-connector { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 0; +} + +.arch-connector-line { + width: 2px; + height: 32px; + background: linear-gradient(to bottom, var(--accent), rgba(42, 157, 143, 0.2)); + border-radius: 1px; +} + +.arch-connector-line--triple { + height: 24px; + background: linear-gradient(to bottom, rgba(139, 26, 26, 0.5), rgba(139, 26, 26, 0.15)); +} + +.arch-connector-label { + font-size: 11px; + color: var(--text-muted); + font-family: 'Fira Code', monospace; + margin-top: 4px; +} + +/* -------------------------------------------------------------------------- + 7. Tech Stack — .tech-section + -------------------------------------------------------------------------- */ + +.tech-section { + padding: 80px 0; +} + +.tech-grid { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; +} + +.tech-badge { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 16px; + font-family: "Outfit", sans-serif; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; + transition: border-color 0.2s ease, color 0.2s ease, transform 0.15s ease; +} + +.tech-badge:hover { + border-color: var(--border-accent); + color: var(--text-primary); + transform: translateY(-2px); +} + +.tech-badge svg, +.tech-badge img { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +/* -------------------------------------------------------------------------- + 8. Screenshots — .screenshots-section + -------------------------------------------------------------------------- */ + +.screenshots-section { + padding: 120px 0; +} + +.screenshots-scroll { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +.screenshots-track, +.screenshot-gallery { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + padding-bottom: 24px; +} + +.screenshot-card { + margin: 0; +} + +.screenshot-card img { + width: 100%; + border-radius: 12px; + border: 1px solid var(--border); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + transition: transform 0.3s ease, box-shadow 0.3s ease; + display: block; + cursor: zoom-in; +} + +.screenshot-card img:hover { + transform: scale(1.03); + box-shadow: 0 16px 48px rgba(42, 157, 143, 0.15); +} + +/* Lightbox overlay */ +.lightbox { + position: fixed; + inset: 0; + z-index: 9999; + background: rgba(0, 0, 0, 0.92); + display: flex; + align-items: center; + justify-content: center; + cursor: zoom-out; + opacity: 0; + visibility: hidden; + transition: opacity 0.25s ease, visibility 0.25s ease; + padding: 24px; +} + +.lightbox.active { + opacity: 1; + visibility: visible; +} + +.lightbox img { + max-width: 95vw; + max-height: 92vh; + border-radius: 8px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); + transform: scale(0.95); + transition: transform 0.25s ease; +} + +.lightbox.active img { + transform: scale(1); +} + +.lightbox-caption { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + color: rgba(255, 255, 255, 0.8); + font-family: 'Outfit', sans-serif; + font-size: 15px; + font-weight: 500; + background: rgba(0, 0, 0, 0.5); + padding: 6px 16px; + border-radius: 6px; +} + +.screenshot-card figcaption { + text-align: center; + margin-top: 12px; + color: var(--text-secondary); + font-size: 14px; + font-family: 'Outfit', sans-serif; + font-weight: 500; +} + +/* -------------------------------------------------------------------------- + 9. Quick Start — .quickstart-section + -------------------------------------------------------------------------- */ + +.quickstart-section { + padding: 120px 0; +} + +.code-window { + background: #0D1117; + border-radius: 16px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.08); + max-width: 700px; + margin: 0 auto; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.4); +} + +.code-header { + background: #161B22; + padding: 12px 20px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid rgba(148, 163, 184, 0.06); +} + +.code-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.code-dot:nth-child(1) { background: #FF5F56; } +.code-dot:nth-child(2) { background: #FFBD2E; } +.code-dot:nth-child(3) { background: #27C93F; } + +.code-title { + margin-left: auto; + color: var(--text-muted); + font-family: "Fira Code", monospace; + font-size: 12px; +} + +.code-body { + padding: 24px; + font-family: "Fira Code", monospace; + font-size: 14px; + line-height: 1.8; + overflow-x: auto; + color: var(--text-primary); +} + +.code-comment { + color: var(--text-muted); + font-style: italic; +} + +.code-command { + color: var(--text-primary); +} + +.code-output { + color: var(--accent); +} + +.code-prompt { + color: var(--accent); + user-select: none; +} + +.code-string { + color: #7DD3FC; +} + +.code-flag { + color: var(--accent-secondary); +} + +/* -------------------------------------------------------------------------- + 10. CTA Section — .cta-section + -------------------------------------------------------------------------- */ + +.cta-section { + padding: 120px 0; + text-align: center; + position: relative; +} + +.cta-section::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 80%; + height: 1px; + background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%); + opacity: 0.3; +} + +.cta-title { + font-family: "Outfit", sans-serif; + font-weight: 700; + font-size: 2.5rem; + color: var(--text-primary); + margin-bottom: 16px; + letter-spacing: -0.02em; +} + +.cta-subtitle { + color: var(--text-secondary); + font-size: 1.1rem; + max-width: 500px; + margin: 0 auto 40px; + line-height: 1.7; +} + +.cta-actions { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; +} + +/* -------------------------------------------------------------------------- + 11. Footer — .site-footer + -------------------------------------------------------------------------- */ + +.site-footer { + background: var(--bg-surface); + border-top: 1px solid var(--border); + padding: 48px 0; +} + +.footer-inner { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + flex-wrap: wrap; + gap: 16px; +} + +.footer-links { + display: flex; + gap: 24px; +} + +.footer-link { + color: var(--text-muted); + font-size: 14px; + transition: color 0.2s ease; + text-decoration: none; +} + +.footer-link:hover { + color: var(--text-secondary); +} + +.footer-copy { + color: var(--text-muted); + font-size: 13px; +} + +/* -------------------------------------------------------------------------- + 12. DOCS PAGE — scoped under .docs-page + -------------------------------------------------------------------------- */ + +.docs-page { + background: var(--docs-bg); + color: var(--docs-text); + min-height: 100vh; +} + +/* Layout */ +.docs-layout { + display: flex; + min-height: calc(100vh - 64px); +} + +/* Sidebar */ +.docs-sidebar { + width: 280px; + flex-shrink: 0; + background: var(--docs-sidebar-bg); + border-right: 1px solid var(--docs-border); + padding: 32px 24px; + position: sticky; + top: 64px; + height: calc(100vh - 64px); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--docs-text-muted) transparent; +} + +.docs-sidebar::-webkit-scrollbar { + width: 5px; +} + +.docs-sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.docs-sidebar::-webkit-scrollbar-thumb { + background: var(--docs-text-muted); + border-radius: 3px; + opacity: 0; + transition: opacity 0.2s; +} + +.docs-sidebar:hover::-webkit-scrollbar-thumb { + opacity: 1; +} + +/* Sidebar navigation */ +.sidebar-nav { + display: flex; + flex-direction: column; +} + +.sidebar-section { + margin-bottom: 24px; +} + +.sidebar-section-title { + color: var(--docs-text); + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 8px; + padding: 0 12px; + opacity: 0.5; +} + +.sidebar-link { + display: block; + padding: 6px 12px; + font-size: 14px; + color: var(--docs-text-secondary); + border-radius: 6px; + transition: background-color 0.15s ease, color 0.15s ease; + text-decoration: none; + line-height: 1.5; +} + +.sidebar-link:hover { + background: rgba(226, 232, 240, 0.5); + color: var(--docs-text); +} + +.sidebar-link--active { + background: rgba(2, 132, 199, 0.08); + color: var(--docs-accent); + font-weight: 500; +} + +.sidebar-link--active:hover { + background: rgba(2, 132, 199, 0.12); + color: var(--docs-accent); +} + +/* Docs search */ +.docs-search { + margin-bottom: 24px; +} + +.docs-search input { + width: 100%; + background: var(--docs-surface); + border: 1px solid var(--docs-border); + border-radius: 8px; + padding: 8px 12px; + font-family: "DM Sans", sans-serif; + font-size: 14px; + color: var(--docs-text); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; +} + +.docs-search input::placeholder { + color: var(--docs-text-muted); +} + +.docs-search input:focus { + border-color: var(--docs-accent); + box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.1); +} + +/* Docs content area */ +.docs-content { + flex: 1; + padding: 48px 64px; + max-width: 860px; + overflow-x: hidden; + min-width: 0; +} + +/* Content typography */ +.docs-content h1 { + font-family: "Outfit", sans-serif; + font-weight: 700; + font-size: 2rem; + color: var(--docs-text); + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.docs-content .docs-subtitle { + color: var(--docs-text-secondary); + font-size: 1.1rem; + margin-bottom: 40px; + line-height: 1.7; +} + +.docs-content h2 { + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 1.5rem; + color: var(--docs-text); + margin-top: 56px; + margin-bottom: 16px; + padding-top: 32px; + border-top: 1px solid var(--docs-border); + letter-spacing: -0.01em; +} + +.docs-content h2:first-of-type { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +.docs-content h3 { + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 1.15rem; + color: var(--docs-text); + margin-top: 40px; + margin-bottom: 12px; +} + +.docs-content p { + color: var(--docs-text-secondary); + margin-bottom: 16px; + line-height: 1.8; +} + +.docs-content a { + color: var(--docs-accent); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: rgba(2, 132, 199, 0.3); + transition: text-decoration-color 0.2s ease, opacity 0.2s ease; +} + +.docs-content a:hover { + opacity: 0.8; + text-decoration-color: var(--docs-accent); +} + +/* Inline code */ +.docs-content code { + background: var(--docs-code-bg); + padding: 2px 6px; + border-radius: 4px; + font-family: "Fira Code", monospace; + font-size: 0.875em; + color: var(--docs-text); + border: 1px solid var(--docs-code-border); +} + +/* Code blocks */ +.docs-content pre { + background: #1E293B; + border-radius: 12px; + padding: 24px; + overflow-x: auto; + margin-bottom: 24px; + border: 1px solid rgba(255, 255, 255, 0.05); + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.3) transparent; +} + +.docs-content pre::-webkit-scrollbar { + height: 5px; +} + +.docs-content pre::-webkit-scrollbar-track { + background: transparent; +} + +.docs-content pre::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.3); + border-radius: 3px; +} + +.docs-content pre code { + background: transparent; + color: #E2E8F0; + padding: 0; + border: none; + font-size: 14px; + line-height: 1.7; + border-radius: 0; +} + +/* Tables */ +.docs-content table { + width: 100%; + border-collapse: collapse; + margin-bottom: 24px; + font-size: 14px; +} + +.docs-content th { + background: var(--docs-code-bg); + text-align: left; + padding: 12px 16px; + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 13px; + border-bottom: 2px solid var(--docs-border); + color: var(--docs-text); + white-space: nowrap; +} + +.docs-content td { + padding: 10px 16px; + border-bottom: 1px solid var(--docs-border); + color: var(--docs-text-secondary); + vertical-align: top; +} + +.docs-content tr:last-child td { + border-bottom: none; +} + +/* Lists */ +.docs-content ul, +.docs-content ol { + margin-bottom: 16px; + padding-left: 24px; + list-style: none; +} + +.docs-content ul li { + position: relative; + margin-bottom: 8px; + color: var(--docs-text-secondary); + line-height: 1.7; + padding-left: 4px; +} + +.docs-content ul li::before { + content: ""; + position: absolute; + left: -16px; + top: 10px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--docs-accent); + opacity: 0.5; +} + +.docs-content ol { + list-style: decimal; +} + +.docs-content ol li { + margin-bottom: 8px; + color: var(--docs-text-secondary); + line-height: 1.7; +} + +.docs-content strong { + color: var(--docs-text); + font-weight: 600; +} + +/* Blockquote */ +.docs-content blockquote { + border-left: 3px solid var(--docs-accent); + padding-left: 20px; + margin-left: 0; + margin-bottom: 16px; + color: var(--docs-text-muted); + font-style: italic; +} + +.docs-content blockquote p { + color: inherit; +} + +/* Horizontal rule */ +.docs-content hr { + border: none; + height: 1px; + background: var(--docs-border); + margin: 40px 0; +} + +/* -------------------------------------------------------------------------- + 13. Back to Top — .back-to-top + -------------------------------------------------------------------------- */ + +.back-to-top { + position: fixed; + bottom: 32px; + right: 32px; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--docs-accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 16px rgba(2, 132, 199, 0.3); + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; + border: none; + cursor: pointer; + z-index: 50; + transform: translateY(8px); +} + +.back-to-top:hover { + transform: translateY(-2px); + box-shadow: 0 6px 24px rgba(2, 132, 199, 0.4); +} + +.back-to-top--visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.back-to-top svg { + width: 20px; + height: 20px; + stroke: currentColor; + stroke-width: 2; + fill: none; +} + +/* -------------------------------------------------------------------------- + 14. Mobile Hamburger — .docs-hamburger + -------------------------------------------------------------------------- */ + +.docs-hamburger { + display: none; + width: 40px; + height: 40px; + border-radius: 8px; + background: transparent; + border: none; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + color: var(--docs-text); +} + +.docs-hamburger svg { + width: 22px; + height: 22px; + stroke: currentColor; + stroke-width: 2; + fill: none; +} + +/* Mobile sidebar overlay */ +.docs-sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 90; + opacity: 0; + transition: opacity 0.3s ease; +} + +.docs-sidebar-overlay--visible { + opacity: 1; +} + +/* -------------------------------------------------------------------------- + 15. Animations + -------------------------------------------------------------------------- */ + +@keyframes meshFloat { + 0%, 100% { + transform: translate(0, 0) scale(1); + } + 25% { + transform: translate(5%, 3%) scale(1.05); + } + 50% { + transform: translate(-3%, 6%) scale(0.97); + } + 75% { + transform: translate(4%, -2%) scale(1.03); + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmerSlide { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +/* Scroll reveal */ +.reveal { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s ease, transform 0.6s ease; +} + +.reveal--visible { + opacity: 1; + transform: none; +} + +/* Staggered children */ +.reveal-stagger > * { + opacity: 0; + transform: translateY(16px); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.reveal-stagger--visible > *:nth-child(1) { transition-delay: 0ms; opacity: 1; transform: none; } +.reveal-stagger--visible > *:nth-child(2) { transition-delay: 80ms; opacity: 1; transform: none; } +.reveal-stagger--visible > *:nth-child(3) { transition-delay: 160ms; opacity: 1; transform: none; } +.reveal-stagger--visible > *:nth-child(4) { transition-delay: 240ms; opacity: 1; transform: none; } +.reveal-stagger--visible > *:nth-child(5) { transition-delay: 320ms; opacity: 1; transform: none; } +.reveal-stagger--visible > *:nth-child(6) { transition-delay: 400ms; opacity: 1; transform: none; } + +/* Hero entrance */ +.hero-content .hero-badge { animation: fadeInUp 0.6s ease 0.1s both; } +.hero-content .hero-title { animation: fadeInUp 0.6s ease 0.2s both; } +.hero-content .hero-subtitle { animation: fadeInUp 0.6s ease 0.35s both; } +.hero-content .hero-actions { animation: fadeInUp 0.6s ease 0.5s both; } + +/* -------------------------------------------------------------------------- + 16. Scrollbar (docs page) + -------------------------------------------------------------------------- */ + +.docs-page { + scrollbar-width: thin; + scrollbar-color: var(--docs-text-muted) var(--docs-border); +} + +.docs-page::-webkit-scrollbar { + width: 8px; +} + +.docs-page::-webkit-scrollbar-track { + background: var(--docs-border); +} + +.docs-page::-webkit-scrollbar-thumb { + background: var(--docs-text-muted); + border-radius: 4px; +} + +.docs-page::-webkit-scrollbar-thumb:hover { + background: var(--docs-text-secondary); +} + +/* -------------------------------------------------------------------------- + 17. Utility Classes + -------------------------------------------------------------------------- */ + +.text-center { text-align: center; } +.text-left { text-align: left; } + +.mx-auto { margin-left: auto; margin-right: auto; } + +.mt-0 { margin-top: 0; } +.mb-0 { margin-bottom: 0; } +.mb-8 { margin-bottom: 8px; } +.mb-16 { margin-bottom: 16px; } +.mb-24 { margin-bottom: 24px; } +.mb-32 { margin-bottom: 32px; } +.mb-48 { margin-bottom: 48px; } +.mb-64 { margin-bottom: 64px; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* -------------------------------------------------------------------------- + 18. Responsive — Tablet (max-width: 768px) + -------------------------------------------------------------------------- */ + +@media (max-width: 768px) { + /* Features grid */ + .features-grid { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + /* Section spacing */ + .features-section, + .arch-section, + .screenshots-section, + .quickstart-section, + .cta-section { + padding: 80px 0; + } + + .section-title, + .cta-title { + font-size: 2rem; + } + + /* Architecture diagram */ + .arch-diagram { + padding: 24px; + } + + .arch-diagram pre { + font-size: 12px; + } + + /* Screenshots */ + .screenshots-track, + .screenshot-gallery { + grid-template-columns: repeat(2, 1fr); + } + + /* Docs layout */ + .docs-sidebar { + display: none; + position: fixed; + top: 64px; + left: 0; + z-index: 95; + width: 280px; + height: calc(100vh - 64px); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.1); + } + + .docs-sidebar--open { + display: block; + } + + .docs-sidebar-overlay--visible { + display: block; + opacity: 1; + } + + .docs-hamburger { + display: flex; + } + + .docs-content { + padding: 24px; + } + + /* Nav links */ + .nav-links { + gap: 16px; + } + + .nav-link { + font-size: 13px; + } + + /* Footer */ + .footer-inner { + flex-direction: column; + text-align: center; + gap: 24px; + } +} + +/* -------------------------------------------------------------------------- + 19. Responsive — Mobile (max-width: 480px) + -------------------------------------------------------------------------- */ + +@media (max-width: 480px) { + /* Features grid */ + .features-grid { + grid-template-columns: 1fr; + } + + /* Hero adjustments */ + .hero-actions { + flex-direction: column; + align-items: center; + } + + .btn-primary, + .btn-secondary { + width: 100%; + justify-content: center; + } + + /* Section spacing */ + .features-section, + .arch-section, + .screenshots-section, + .quickstart-section, + .cta-section { + padding: 60px 0; + } + + .section-title, + .cta-title { + font-size: 1.65rem; + } + + .section-desc { + margin-bottom: 40px; + } + + /* Feature cards */ + .feature-card { + padding: 24px; + } + + /* Code window */ + .code-body { + padding: 16px; + font-size: 13px; + } + + /* Screenshots */ + .screenshots-track, + .screenshot-gallery { + grid-template-columns: 1fr; + } + + /* Docs content */ + .docs-content h1 { + font-size: 1.65rem; + } + + .docs-content h2 { + font-size: 1.3rem; + margin-top: 40px; + padding-top: 24px; + } + + /* Nav hide on very small */ + .nav-links .nav-link:not(.nav-link--active):not(:first-child):not(:last-child) { + display: none; + } + + .nav-links { + gap: 12px; + } + + .nav-cta { + padding: 6px 14px; + font-size: 13px; + } +} + +/* -------------------------------------------------------------------------- + 20. Print Styles + -------------------------------------------------------------------------- */ + +@media print { + .site-nav, + .docs-sidebar, + .back-to-top, + .docs-hamburger, + .docs-sidebar-overlay, + .hero-bg, + .cta-section, + .site-footer { + display: none !important; + } + + body { + background: white; + color: black; + font-size: 12pt; + } + + .docs-content { + max-width: 100%; + padding: 0; + } + + .docs-content pre { + background: #f5f5f5; + border: 1px solid #ddd; + color: #333; + } + + .docs-content pre code { + color: #333; + } + + .docs-content a { + color: #000; + text-decoration: underline; + } + + .docs-content a::after { + content: " (" attr(href) ")"; + font-size: 0.85em; + color: #666; + } +} + +/* -------------------------------------------------------------------------- + 21. Focus Styles (accessibility) + -------------------------------------------------------------------------- */ + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.docs-page :focus-visible { + outline-color: var(--docs-accent); +} + +.btn-primary:focus-visible, +.btn-secondary:focus-visible, +.nav-cta:focus-visible { + outline-offset: 3px; +} + +/* -------------------------------------------------------------------------- + 22. Dark docs-page code syntax highlighting helpers + -------------------------------------------------------------------------- */ + +.docs-content pre .token-keyword { color: #C084FC; } +.docs-content pre .token-string { color: #7DD3FC; } +.docs-content pre .token-comment { color: #64748B; font-style: italic; } +.docs-content pre .token-function { color: #2A9D8F; } +.docs-content pre .token-number { color: #FB923C; } +.docs-content pre .token-operator { color: #94A3B8; } +.docs-content pre .token-type { color: #8B1A1A; } +.docs-content pre .token-variable { color: #F1F5F9; } + +/* -------------------------------------------------------------------------- + 23. Additional Section Variants + -------------------------------------------------------------------------- */ + +/* Alternating background for visual rhythm */ +.section--alt { + background: var(--bg-surface); +} + +/* Stats / metrics row */ +.stats-row { + display: flex; + gap: 48px; + justify-content: center; + padding: 40px 0; + flex-wrap: wrap; +} + +.stat-item { + text-align: center; +} + +.stat-value { + font-family: "Outfit", sans-serif; + font-weight: 800; + font-size: 2.5rem; + color: var(--accent); + letter-spacing: -0.02em; + line-height: 1; + margin-bottom: 4px; +} + +.stat-label { + font-size: 14px; + color: var(--text-muted); + font-weight: 500; +} + +/* Testimonial / quote card */ +.quote-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 40px; + max-width: 640px; + margin: 0 auto; + position: relative; +} + +.quote-card::before { + content: "\201C"; + font-family: "Outfit", sans-serif; + font-size: 4rem; + color: var(--accent); + opacity: 0.3; + position: absolute; + top: 16px; + left: 28px; + line-height: 1; +} + +.quote-text { + font-size: 1.1rem; + line-height: 1.7; + color: var(--text-secondary); + font-style: italic; + margin-bottom: 16px; +} + +.quote-author { + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); +} + +.quote-role { + font-size: 13px; + color: var(--text-muted); +} + +/* -------------------------------------------------------------------------- + 24. Reduced Motion + -------------------------------------------------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + html { + scroll-behavior: auto; + } + + .reveal { + opacity: 1; + transform: none; + } + + .reveal-stagger > * { + opacity: 1; + transform: none; + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..9dc3dd1 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Playwright +tests/e2e/.auth/ +test-results/ +playwright-report/ + +# Dev HTTPS certs (self-signed, regenerate with openssl) +certs/ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..af223c4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + TOD - The Other Dude + + +
    + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a0de7c1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,9521 @@ +{ + "name": "frontend", + "version": "9.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "9.0.1", + "dependencies": { + "@dagrejs/dagre": "^2.0.4", + "@git-diff-view/lowlight": "^0.0.39", + "@git-diff-view/react": "^0.0.39", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-router": "^1.161.3", + "@tanstack/react-router-devtools": "^1.161.3", + "@tanstack/react-virtual": "^3.13.19", + "@tanstack/router-plugin": "^1.161.3", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", + "axios": "^1.13.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "diff": "^8.0.3", + "framer-motion": "^12.34.3", + "geist": "^1.7.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.575.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.0.0", + "reactflow": "^11.11.4", + "recharts": "^3.7.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^3.4.19", + "zod": "^4.3.6", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/diff": "^7.0.2", + "@types/leaflet": "^1.9.21", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.24", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "postcss": "^8.5.6", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vite-plugin-sri3": "^1.3.0", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", + "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "3.0.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", + "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@git-diff-view/core": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@git-diff-view/core/-/core-0.0.39.tgz", + "integrity": "sha512-GJGsti+R8XV11XFWVziXiSgZ8T26pcb1/7H/e5PLSByG7JKeDU9O9JPvjvSShQokj/5Zp5kXvtNM+tgCtrRrYQ==", + "license": "MIT", + "dependencies": { + "@git-diff-view/lowlight": "^0.0.39", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/lowlight": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@git-diff-view/lowlight/-/lowlight-0.0.39.tgz", + "integrity": "sha512-S2hL5YsIl5Ao2JGeV95OswFjDnM3HRUZRlF4etVw/dbTmI27/Qp5Bnymb0cdx50ZLq8dV+BuxaeRu7w7jN8NHg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/react": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@git-diff-view/react/-/react-0.0.39.tgz", + "integrity": "sha512-p4MJOn0RMTrcLbzUYU90n5Ddsnf6wH9BM26uyPlULk8EfnCw5wHOoGlGa8zTrgl8L7ArOtmFauuhmch2d78V0w==", + "license": "MIT", + "dependencies": { + "@git-diff-view/core": "^0.0.39", + "@types/hast": "^3.0.0", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0", + "reactivity-store": "^0.3.12", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/background/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/history": { + "version": "1.154.14", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.154.14.tgz", + "integrity": "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.161.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.161.3.tgz", + "integrity": "sha512-evYPrkuFt4T6E0WVyBGGq83lWHJjsYy3E5SpPpfPY/uRnEgmgwfr6Xl570msRnWYMj7DIkYg8ZWFFwzqKrSlBw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.154.14", + "@tanstack/react-store": "^0.9.1", + "@tanstack/router-core": "1.161.3", + "isbot": "^5.1.22", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-devtools": { + "version": "1.161.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.161.3.tgz", + "integrity": "sha512-AlJPtaYvhDVuwe/TqZIYt5njmxAGxMEq6l7AXOXQLVu7UP0jysxGoQfrm2LZT+piMeUmJ5opRUTnxktpCphIFQ==", + "license": "MIT", + "dependencies": { + "@tanstack/router-devtools-core": "1.161.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.161.3", + "@tanstack/router-core": "^1.161.3", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/router-core": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", + "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.19.tgz", + "integrity": "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.161.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.161.3.tgz", + "integrity": "sha512-8EuaGXLUjugQE9Rsb8VrWSy+wImcs/DZ9JORqUJYCmiiWnJzbat8KedQItq/9LCjMJyx4vTLCt8NnZCL+j1Ayg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.154.14", + "@tanstack/store": "^0.9.1", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-devtools-core": { + "version": "1.161.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.161.3.tgz", + "integrity": "sha512-yLbBH9ovomvxAk4nbTzN+UacPX2C5r3Kq4p+4O8gZVopUjRqiYiQN7ZJ6tN6atQouJQtym2xXwa5pC4EyFlCgQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.16", + "tiny-invariant": "^1.3.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/router-core": "^1.161.3", + "csstype": "^3.0.10" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.161.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.161.3.tgz", + "integrity": "sha512-GKOrsOu7u5aoK1+lRu6KUUOmbb42mYF2ezfXf27QMiBjMx/yDHXln8wmdR7ZQ+FdSGz2YVubt2Ns3KuFsDsZJg==", + "license": "MIT", + "dependencies": { + "@tanstack/router-core": "1.161.3", + "@tanstack/router-utils": "1.158.0", + "@tanstack/virtual-file-routes": "1.154.7", + "prettier": "^3.5.0", + "recast": "^0.23.11", + "source-map": "^0.7.4", + "tsx": "^4.19.2", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.161.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.161.3.tgz", + "integrity": "sha512-3Uy4AxgHNYjmCGf2WYWB8Gy3C6m0YE5DV1SK2p3yUrA/PhCMYRe+xzjyD5pViMUSLUoPHQYGY6bOIM9OOPRI/Q==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.161.3", + "@tanstack/router-generator": "1.161.3", + "@tanstack/router-utils": "1.158.0", + "@tanstack/virtual-file-routes": "1.154.7", + "chokidar": "^3.6.0", + "unplugin": "^2.1.2", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2", + "@tanstack/react-router": "^1.161.3", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", + "vite-plugin-solid": "^2.11.10", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-plugin/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.158.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.158.0.tgz", + "integrity": "sha512-qZ76eaLKU6Ae9iI/mc5zizBX149DXXZkBVVO3/QRIll79uKLJZHQlMKR++2ba7JsciBWz1pgpIBcCJPE9S0LVg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", + "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz", + "integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.154.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.154.7.tgz", + "integrity": "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/@zxcvbn-ts/core": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-3.0.4.tgz", + "integrity": "sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==", + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "1.0.16" + } + }, + "node_modules/@zxcvbn-ts/language-common": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-common/-/language-common-3.0.4.tgz", + "integrity": "sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==", + "license": "MIT" + }, + "node_modules/@zxcvbn-ts/language-en": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-en/-/language-en-3.0.2.tgz", + "integrity": "sha512-Zp+zL+I6Un2Bj0tRXNs6VUBq3Djt+hwTwUz4dkt2qgsQz47U0/XthZ4ULrT/RxjwJRl5LwiaKOOZeOtmixHnjg==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "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/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT", + "peer": true + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", + "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.3", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "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==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/geist": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.0.tgz", + "integrity": "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==", + "license": "SIL OPEN FONT LICENSE", + "peerDependencies": { + "next": ">=13.2.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbot": { + "version": "5.1.35", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.35.tgz", + "integrity": "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz", + "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/motion-dom": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", + "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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", + "peer": true, + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-leaflet-cluster": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-4.0.0.tgz", + "integrity": "sha512-Lu75+KOu2ruGyAx8LoCQvlHuw+3CLLJQGEoSk01ymsDN/YnCiRV6ChkpsvaruVyYBPzUHwiskFw4Jo7WHj5qNw==", + "license": "SEE LICENSE IN ", + "dependencies": { + "leaflet.markercluster": "^1.5.3" + }, + "peerDependencies": { + "@react-leaflet/core": "^3.0.0", + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/reactivity-store": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/reactivity-store/-/reactivity-store-0.3.12.tgz", + "integrity": "sha512-Idz9EL4dFUtQbHySZQzckWOTUfqjdYpUtNW0iOysC32mG7IjiUGB77QrsyR5eAWBkRiS9JscF6A3fuQAIy+LrQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.5.22", + "@vue/shared": "~3.5.22", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "devOptional": 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.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", + "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz", + "integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "peer": true, + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "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/vite-plugin-sri3": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-sri3/-/vite-plugin-sri3-1.3.0.tgz", + "integrity": "sha512-wOdmXQhKQzwNOeUfsPoi7Zz3bh6KXkjup5t/n3bbOo8ITOIgrpwTi//8YwByTy8UeGTz6AHInVFQSoywka0dEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7f3bb2b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,86 @@ +{ + "name": "frontend", + "private": true, + "version": "9.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed" + }, + "dependencies": { + "@dagrejs/dagre": "^2.0.4", + "@git-diff-view/lowlight": "^0.0.39", + "@git-diff-view/react": "^0.0.39", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-router": "^1.161.3", + "@tanstack/react-router-devtools": "^1.161.3", + "@tanstack/react-virtual": "^3.13.19", + "@tanstack/router-plugin": "^1.161.3", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", + "axios": "^1.13.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "diff": "^8.0.3", + "framer-motion": "^12.34.3", + "geist": "^1.7.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.575.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.0.0", + "reactflow": "^11.11.4", + "recharts": "^3.7.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^3.4.19", + "zod": "^4.3.6", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/diff": "^7.0.2", + "@types/leaflet": "^1.9.21", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.24", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "postcss": "^8.5.6", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vite-plugin-sri3": "^1.3.0", + "vitest": "^4.0.18" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..0d5365d --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30000, + expect: { timeout: 5000 }, + fullyParallel: false, // Run sequentially for stability + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + use: { browserName: 'chromium' }, + dependencies: ['setup'], + }, + ], +}) diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..983f5b5 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8ee7510 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,45 @@ +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { routeTree } from './routeTree.gen' +import { useAuth } from './lib/auth' +import { Skeleton } from './components/ui/skeleton' + +const router = createRouter({ + routeTree, + defaultPreload: 'intent', +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +function AppInner() { + const { checkAuth } = useAuth() + const [hasChecked, setHasChecked] = useState(false) + + useEffect(() => { + checkAuth().finally(() => setHasChecked(true)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Only show skeleton during initial auth check -- NOT on subsequent isLoading changes. + // Reacting to isLoading here would unmount the entire router tree (including LoginPage) + // every time an auth action sets isLoading, destroying all component local state. + if (!hasChecked) { + return ( +
    +
    + + + +
    +
    + ) + } + + return +} + +export default AppInner diff --git a/frontend/src/assets/fonts/Geist-Variable.woff2 b/frontend/src/assets/fonts/Geist-Variable.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b2f01210625c8cc9939508fb1f7214d21eb41357 GIT binary patch literal 69436 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

    <+} zV(L`CrggiU`%1^h`2fp|);K%cr#pjrYN|84Ty|#K+U8qieH+@h+QHlRMqAxW{>;z& z@lR_QLLbU)Pgz=$MKerrz=beNo5?4na#1gtkh;r0Q7L!0!ZKSeypCt=?&geW6r&u& zj;?&sP5cYzGXM==$1v?`F?bXn|Kl3vj(_tvZVFV@loTKs0xrEk)@_P!7juWMyCL*G zVNXhV#?5D>JSXsFO|N+I8U(+u=tCVJLd8c)J~sR@qiRUXuqrar-L*Pp#xQqE$b;wIHV% z>=$X9qpEkN1n1^>Lg!%iZlDb_d9xcJ3d6nP?7zIF_vM*e667lJ^ppb>^0 u|F(! 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[@+FJuP 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⪙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 \ No newline at end of file diff --git a/frontend/src/components/__tests__/DeviceList.test.tsx b/frontend/src/components/__tests__/DeviceList.test.tsx new file mode 100644 index 0000000..5654655 --- /dev/null +++ b/frontend/src/components/__tests__/DeviceList.test.tsx @@ -0,0 +1,214 @@ +/** + * Device list (FleetTable) component tests -- verifies device data rendering, + * loading state, empty state, and table structure. + * + * Tests the FleetTable component directly since DevicesPage is tightly coupled + * to TanStack Router file-based routing (Route.useParams/useSearch). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, within } from '@/test/test-utils' +import type { DeviceListResponse } from '@/lib/api' + +// -------------------------------------------------------------------------- +// Mocks +// -------------------------------------------------------------------------- + +const mockNavigate = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mockNavigate, + Link: ({ children, ...props }: { children: React.ReactNode; to?: string }) => ( + {children} + ), +})) + +// Mock devicesApi at the module level +const mockDevicesList = vi.fn() + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api') + return { + ...actual, + devicesApi: { + ...actual.devicesApi, + list: (...args: unknown[]) => mockDevicesList(...args), + }, + } +}) + +// -------------------------------------------------------------------------- +// Test data +// -------------------------------------------------------------------------- + +const testDevices: DeviceListResponse = { + items: [ + { + id: 'dev-1', + hostname: 'router-office-1', + ip_address: '192.168.1.1', + api_port: 8728, + api_ssl_port: 8729, + model: 'RB4011', + serial_number: 'ABC123', + firmware_version: '7.12', + routeros_version: '7.12.1', + uptime_seconds: 86400, + last_seen: '2026-03-01T12:00:00Z', + latitude: null, + longitude: null, + status: 'online', + tags: [{ id: 'tag-1', name: 'core', color: '#00ff00' }], + groups: [], + created_at: '2026-01-01T00:00:00Z', + }, + { + id: 'dev-2', + hostname: 'ap-floor2', + ip_address: '192.168.1.10', + api_port: 8728, + api_ssl_port: 8729, + model: 'cAP ac', + serial_number: 'DEF456', + firmware_version: '7.10', + routeros_version: '7.10.2', + uptime_seconds: 3600, + last_seen: '2026-03-01T11:00:00Z', + latitude: null, + longitude: null, + status: 'offline', + tags: [], + groups: [], + created_at: '2026-01-15T00:00:00Z', + }, + ], + total: 2, + page: 1, + page_size: 25, +} + +const emptyDevices: DeviceListResponse = { + items: [], + total: 0, + page: 1, + page_size: 25, +} + +// -------------------------------------------------------------------------- +// Component import (after mocks) +// -------------------------------------------------------------------------- +import { FleetTable } from '@/components/fleet/FleetTable' + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +describe('FleetTable (Device List)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders device list with data', async () => { + mockDevicesList.mockResolvedValueOnce(testDevices) + + render() + + // Wait for data to load + expect(await screen.findByText('router-office-1')).toBeInTheDocument() + expect(screen.getByText('ap-floor2')).toBeInTheDocument() + expect(screen.getByText('192.168.1.1')).toBeInTheDocument() + expect(screen.getByText('192.168.1.10')).toBeInTheDocument() + }) + + it('renders device model and firmware info', async () => { + mockDevicesList.mockResolvedValueOnce(testDevices) + + render() + + expect(await screen.findByText('RB4011')).toBeInTheDocument() + expect(screen.getByText('cAP ac')).toBeInTheDocument() + expect(screen.getByText('7.12.1')).toBeInTheDocument() + expect(screen.getByText('7.10.2')).toBeInTheDocument() + }) + + it('renders empty state when no devices', async () => { + mockDevicesList.mockResolvedValueOnce(emptyDevices) + + render() + + expect(await screen.findByText('No devices found')).toBeInTheDocument() + }) + + it('renders loading state', () => { + // Make the API hang (never resolve) + mockDevicesList.mockReturnValueOnce(new Promise(() => {})) + + render() + + expect(screen.getByText('Loading devices...')).toBeInTheDocument() + }) + + it('renders table headers', async () => { + mockDevicesList.mockResolvedValueOnce(testDevices) + + render() + + await screen.findByText('router-office-1') + + expect(screen.getByText('Hostname')).toBeInTheDocument() + expect(screen.getByText('IP')).toBeInTheDocument() + expect(screen.getByText('Model')).toBeInTheDocument() + expect(screen.getByText('RouterOS')).toBeInTheDocument() + expect(screen.getByText('Firmware')).toBeInTheDocument() + expect(screen.getByText('Uptime')).toBeInTheDocument() + expect(screen.getByText('Last Seen')).toBeInTheDocument() + expect(screen.getByText('Tags')).toBeInTheDocument() + }) + + it('renders device tags', async () => { + mockDevicesList.mockResolvedValueOnce(testDevices) + + render() + + expect(await screen.findByText('core')).toBeInTheDocument() + }) + + it('renders formatted uptime', async () => { + mockDevicesList.mockResolvedValueOnce(testDevices) + + render() + + await screen.findByText('router-office-1') + + // 86400 seconds = 1d 0h + expect(screen.getByText('1d 0h')).toBeInTheDocument() + // 3600 seconds = 1h 0m + expect(screen.getByText('1h 0m')).toBeInTheDocument() + }) + + it('shows pagination info', async () => { + mockDevicesList.mockResolvedValueOnce(testDevices) + + render() + + await screen.findByText('router-office-1') + + // "Showing 1-2 of 2 devices" + expect(screen.getByText(/Showing 1/)).toBeInTheDocument() + }) + + it('renders status indicators for online and offline devices', async () => { + mockDevicesList.mockResolvedValueOnce(testDevices) + + render() + + await screen.findByText('router-office-1') + + // Status dots should be present -- find by title attribute + const onlineDot = screen.getByTitle('online') + const offlineDot = screen.getByTitle('offline') + + expect(onlineDot).toBeInTheDocument() + expect(offlineDot).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/__tests__/LoginPage.test.tsx b/frontend/src/components/__tests__/LoginPage.test.tsx new file mode 100644 index 0000000..ee2d588 --- /dev/null +++ b/frontend/src/components/__tests__/LoginPage.test.tsx @@ -0,0 +1,229 @@ +/** + * LoginPage component tests -- verifies form rendering, credential submission, + * error display, and loading state for the login flow. + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { render, screen, waitFor } from '@/test/test-utils' +import userEvent from '@testing-library/user-event' + +// -------------------------------------------------------------------------- +// Mocks +// -------------------------------------------------------------------------- + +// Mock useNavigate from TanStack Router +const mockNavigate = vi.fn() +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => ({ + component: undefined, + }), + useNavigate: () => mockNavigate, +})) + +// Mock useAuth zustand store -- track login/clearError calls +const mockLogin = vi.fn() +const mockClearError = vi.fn() +let authState = { + user: null, + isAuthenticated: false, + isLoading: false, + error: null as string | null, + login: mockLogin, + logout: vi.fn(), + checkAuth: vi.fn(), + clearError: mockClearError, +} + +vi.mock('@/lib/auth', () => ({ + useAuth: () => authState, +})) + +// -------------------------------------------------------------------------- +// Import after mocks +// -------------------------------------------------------------------------- +// We need to import LoginPage from the route file. Since createFileRoute is +// mocked, we import the default export which is the page component. +// The file exports Route (from createFileRoute) and has LoginPage as the +// component. We re-export it via a manual approach. + +// Since the login page defines LoginPage as a function inside the module and +// assigns it to Route.component, we need a different approach. Let's import +// the module and extract the component from the Route object. + +// Actually, with our mock of createFileRoute returning an object, the Route +// export won't have the component. Let's mock createFileRoute to capture it. + +let CapturedComponent: React.ComponentType | undefined + +vi.mock('@tanstack/react-router', async () => { + return { + createFileRoute: () => ({ + // The real createFileRoute('/login')({component: LoginPage}) returns + // an object. Our mock captures the component from the call. + __call: true, + }), + useNavigate: () => mockNavigate, + } +}) + +// We need a different strategy. Let's directly create the LoginPage component +// inline here since the route file couples createFileRoute with the component. +// This is a common pattern for testing file-based route components. + +// Instead, let's build a simplified LoginPage that matches the real one's +// behavior and test that. OR, we mock createFileRoute properly. + +// Best approach: mock createFileRoute to return a function that captures the +// component option. +vi.mock('@tanstack/react-router', () => { + return { + createFileRoute: () => (opts: { component: React.ComponentType }) => { + CapturedComponent = opts.component + return { component: opts.component } + }, + useNavigate: () => mockNavigate, + } +}) + +// Now importing the login module will call createFileRoute('/login')({component: LoginPage}) +// and CapturedComponent will be set to LoginPage. + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +describe('LoginPage', () => { + let LoginPage: React.ComponentType + + beforeEach(async () => { + vi.clearAllMocks() + CapturedComponent = undefined + + // Reset auth state + authState = { + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + login: mockLogin, + logout: vi.fn(), + checkAuth: vi.fn(), + clearError: mockClearError, + } + + // Dynamic import to re-trigger module evaluation + // Use cache-busting to force re-evaluation + const mod = await import('@/routes/login') + // The component is set via our mock + if (CapturedComponent) { + LoginPage = CapturedComponent + } else { + // Fallback: try to get it from the Route export + LoginPage = (mod.Route as { component?: React.ComponentType })?.component ?? (() => null) + } + }) + + it('renders login form with email and password fields', () => { + render() + + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() + }) + + it('renders branding elements', () => { + render() + + expect(screen.getByText('TOD - The Other Dude')).toBeInTheDocument() + expect(screen.getByText('MSP Fleet Management')).toBeInTheDocument() + }) + + it('shows error message on failed login', async () => { + mockLogin.mockRejectedValueOnce(new Error('Invalid credentials')) + authState.error = null + + render() + + const user = userEvent.setup() + await user.type(screen.getByLabelText(/email/i), 'test@example.com') + await user.type(screen.getByLabelText(/password/i), 'wrongpassword') + await user.click(screen.getByRole('button', { name: /sign in/i })) + + // After the failed login, the useAuth store would set error. + // Since we control the mock, we need to re-render with the error state. + // Let's update authState and re-render. + authState.error = 'Invalid credentials' + + // The component should re-render via zustand. In our mock, it won't + // automatically. Let's re-render. + render() + + expect(screen.getByText('Invalid credentials')).toBeInTheDocument() + }) + + it('submits form with entered credentials', async () => { + mockLogin.mockResolvedValueOnce(undefined) + + render() + + const user = userEvent.setup() + await user.type(screen.getByLabelText(/email/i), 'admin@example.com') + await user.type(screen.getByLabelText(/password/i), 'secret123') + await user.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('admin@example.com', 'secret123') + }) + }) + + it('navigates to home on successful login', async () => { + mockLogin.mockResolvedValueOnce(undefined) + + render() + + const user = userEvent.setup() + await user.type(screen.getByLabelText(/email/i), 'admin@example.com') + await user.type(screen.getByLabelText(/password/i), 'secret123') + await user.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ to: '/' }) + }) + }) + + it('disables submit button when fields are empty', () => { + render() + + const submitButton = screen.getByRole('button', { name: /sign in/i }) + expect(submitButton).toBeDisabled() + }) + + it('shows "Signing in..." text while submitting', async () => { + // Make login hang (never resolve) + mockLogin.mockReturnValueOnce(new Promise(() => {})) + + render() + + const user = userEvent.setup() + await user.type(screen.getByLabelText(/email/i), 'admin@example.com') + await user.type(screen.getByLabelText(/password/i), 'secret123') + await user.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /signing in/i })).toBeInTheDocument() + }) + }) + + it('clears error when user starts typing', async () => { + authState.error = 'Invalid credentials' + + render() + + expect(screen.getByText('Invalid credentials')).toBeInTheDocument() + + const user = userEvent.setup() + await user.type(screen.getByLabelText(/email/i), 'a') + + expect(mockClearError).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/__tests__/TemplatePushWizard.test.tsx b/frontend/src/components/__tests__/TemplatePushWizard.test.tsx new file mode 100644 index 0000000..79f88f4 --- /dev/null +++ b/frontend/src/components/__tests__/TemplatePushWizard.test.tsx @@ -0,0 +1,502 @@ +/** + * TemplatePushWizard component tests -- verifies multi-step wizard navigation, + * device selection, variable input, preview, and confirmation steps. + * + * The wizard has 5 steps: targets -> variables -> preview -> confirm -> progress. + * Tests mock the API layer (metricsApi.fleetSummary, deviceGroupsApi.list, + * templatesApi.preview/push) and interact via user events. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, within } from '@/test/test-utils' +import userEvent from '@testing-library/user-event' +import type { TemplateResponse, VariableDef } from '@/lib/templatesApi' +import type { FleetDevice, DeviceGroupResponse } from '@/lib/api' + +// -------------------------------------------------------------------------- +// Mocks +// -------------------------------------------------------------------------- + +const mockFleetSummary = vi.fn() +const mockGroupsList = vi.fn() +const mockPreview = vi.fn() +const mockPush = vi.fn() + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api') + return { + ...actual, + metricsApi: { + ...actual.metricsApi, + fleetSummary: (...args: unknown[]) => mockFleetSummary(...args), + }, + deviceGroupsApi: { + ...actual.deviceGroupsApi, + list: (...args: unknown[]) => mockGroupsList(...args), + }, + } +}) + +vi.mock('@/lib/templatesApi', async () => { + const actual = await vi.importActual('@/lib/templatesApi') + return { + ...actual, + templatesApi: { + ...actual.templatesApi, + preview: (...args: unknown[]) => mockPreview(...args), + push: (...args: unknown[]) => mockPush(...args), + pushStatus: vi.fn().mockResolvedValue({ rollout_id: 'r1', jobs: [] }), + }, + } +}) + +// -------------------------------------------------------------------------- +// Test data +// -------------------------------------------------------------------------- + +const testDevices: FleetDevice[] = [ + { + id: 'dev-1', + hostname: 'router-main', + ip_address: '192.168.1.1', + status: 'online', + model: 'RB4011', + last_seen: '2026-03-01T12:00:00Z', + uptime_seconds: 86400, + last_cpu_load: 15, + last_memory_used_pct: 45, + latitude: null, + longitude: null, + tenant_id: 'tenant-1', + tenant_name: 'Test Tenant', + }, + { + id: 'dev-2', + hostname: 'ap-office', + ip_address: '192.168.1.10', + status: 'online', + model: 'cAP ac', + last_seen: '2026-03-01T11:00:00Z', + uptime_seconds: 3600, + last_cpu_load: 5, + last_memory_used_pct: 30, + latitude: null, + longitude: null, + tenant_id: 'tenant-1', + tenant_name: 'Test Tenant', + }, + { + id: 'dev-3', + hostname: 'switch-floor1', + ip_address: '192.168.1.20', + status: 'offline', + model: 'CRS326', + last_seen: '2026-02-28T10:00:00Z', + uptime_seconds: null, + last_cpu_load: null, + last_memory_used_pct: null, + latitude: null, + longitude: null, + tenant_id: 'tenant-1', + tenant_name: 'Test Tenant', + }, +] + +const testGroups: DeviceGroupResponse[] = [ + { id: 'grp-1', name: 'Core Routers', description: null, device_count: 2, created_at: '2026-01-01T00:00:00Z' }, +] + +const templateWithVars: TemplateResponse = { + id: 'tmpl-1', + name: 'Firewall Rules', + description: 'Standard firewall policy', + content: '/ip firewall filter add chain=input action=drop', + variables: [ + { name: 'device', type: 'string', default: null, description: 'Auto-populated device context' }, + { name: 'dns_server', type: 'ip', default: '8.8.8.8', description: 'Primary DNS' }, + { name: 'enable_logging', type: 'boolean', default: 'false', description: 'Enable firewall logging' }, + ], + tags: ['firewall', 'security'], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', +} + +const templateNoVars: TemplateResponse = { + id: 'tmpl-2', + name: 'NTP Config', + description: 'Set NTP servers', + content: '/system ntp client set enabled=yes', + variables: [ + { name: 'device', type: 'string', default: null, description: 'Auto-populated device context' }, + ], + tags: ['ntp'], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', +} + +// -------------------------------------------------------------------------- +// Component import (after mocks) +// -------------------------------------------------------------------------- +import { TemplatePushWizard } from '@/components/templates/TemplatePushWizard' + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +describe('TemplatePushWizard', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFleetSummary.mockResolvedValue(testDevices) + mockGroupsList.mockResolvedValue(testGroups) + mockPreview.mockResolvedValue({ rendered: '/ip firewall filter add chain=input', device_hostname: 'router-main' }) + mockPush.mockResolvedValue({ rollout_id: 'rollout-1', jobs: [] }) + }) + + it('renders wizard with first step active (target selection)', async () => { + render( + + ) + + // Title shows template name and step info + expect(await screen.findByText(/Push Template: Firewall Rules/)).toBeInTheDocument() + expect(screen.getByText(/Step 1 of 4/)).toBeInTheDocument() + + // Target selection description + expect(screen.getByText(/Select devices to push the template to/)).toBeInTheDocument() + }) + + it('displays device list for selection', async () => { + render( + + ) + + // Wait for devices to load + expect(await screen.findByText('router-main')).toBeInTheDocument() + expect(screen.getByText('ap-office')).toBeInTheDocument() + expect(screen.getByText('switch-floor1')).toBeInTheDocument() + expect(screen.getByText('192.168.1.1')).toBeInTheDocument() + expect(screen.getByText('192.168.1.10')).toBeInTheDocument() + }) + + it('disables Next button when no devices selected', async () => { + render( + + ) + + await screen.findByText('router-main') + + // Next button should be disabled with 0 selected + const nextBtn = screen.getByRole('button', { name: /next/i }) + expect(nextBtn).toBeDisabled() + }) + + it('enables Next button after selecting a device', async () => { + render( + + ) + + const user = userEvent.setup() + + await screen.findByText('router-main') + + // Click on the device label to toggle the checkbox + const deviceLabel = screen.getByText('router-main') + // The device is inside a

    !v && onClose()}> + + + {isEdit ? 'Edit Alert Rule' : 'New Alert Rule'} + +
    +
    + + setName(e.target.value)} + placeholder="High CPU usage" + required + /> +
    + +
    +
    + + +
    +
    + + +
    +
    + + setThreshold(e.target.value)} + required + /> +
    +
    + +
    +
    + + setDurationPolls(e.target.value)} + required + /> +

    + Alert fires after threshold exceeded for this many poll cycles (~{Number(durationPolls) || 1} min) +

    +
    +
    + + +
    +
    + + {channels.length > 0 && ( +
    + +
    + {channels.map((ch) => ( + + ))} +
    +
    + )} + +
    + setEnabled(!!v)} + id="rule-enabled" + /> + +
    + +
    + + +
    +
    +
    +
    + ) +} + +// --------------------------------------------------------------------------- +// Channel Form Dialog +// --------------------------------------------------------------------------- + +function ChannelFormDialog({ + open, + onClose, + tenantId, + channel, +}: { + open: boolean + onClose: () => void + tenantId: string + channel: NotificationChannel | null +}) { + const queryClient = useQueryClient() + const isEdit = !!channel + + const [channelType, setChannelType] = useState(channel?.channel_type ?? 'email') + const [name, setName] = useState(channel?.name ?? '') + // Email fields + const [smtpHost, setSmtpHost] = useState(channel?.smtp_host ?? '') + const [smtpPort, setSmtpPort] = useState(String(channel?.smtp_port ?? 587)) + const [smtpUser, setSmtpUser] = useState(channel?.smtp_user ?? '') + const [smtpPassword, setSmtpPassword] = useState('') + const [smtpUseTls, setSmtpUseTls] = useState(channel?.smtp_use_tls ?? true) + const [fromAddress, setFromAddress] = useState(channel?.from_address ?? '') + const [toAddress, setToAddress] = useState(channel?.to_address ?? '') + // Provider preset + const [smtpProvider, setSmtpProvider] = useState('custom') + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [testing, setTesting] = useState(false) + // Webhook fields + const [webhookUrl, setWebhookUrl] = useState(channel?.webhook_url ?? '') + // Slack fields + const [slackWebhookUrl, setSlackWebhookUrl] = useState(channel?.slack_webhook_url ?? '') + + const handleProviderChange = (providerId: string) => { + setSmtpProvider(providerId) + const preset = SMTP_PRESETS.find((p) => p.id === providerId) + if (preset && providerId !== 'custom') { + setSmtpHost(preset.host) + setSmtpPort(String(preset.port)) + setSmtpUseTls(preset.useTls) + } + } + + const handleTestSmtp = async () => { + setTesting(true) + setTestResult(null) + try { + const result = await alertsApi.testSmtp(tenantId, { + smtp_host: smtpHost, + smtp_port: Number(smtpPort), + smtp_user: smtpUser || undefined, + smtp_password: smtpPassword || undefined, + smtp_use_tls: smtpUseTls, + from_address: fromAddress || 'alerts@example.com', + to_address: toAddress, + }) + setTestResult(result) + } catch (e: any) { + setTestResult({ success: false, message: e.response?.data?.detail || e.message }) + } finally { + setTesting(false) + } + } + + const createMutation = useMutation({ + mutationFn: (data: ChannelCreateData) => alertsApi.createChannel(tenantId, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['notification-channels'] }) + toast({ title: 'Channel created' }) + onClose() + }, + onError: () => toast({ title: 'Failed to create channel', variant: 'destructive' }), + }) + + const updateMutation = useMutation({ + mutationFn: (data: ChannelCreateData) => + alertsApi.updateChannel(tenantId, channel!.id, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['notification-channels'] }) + toast({ title: 'Channel updated' }) + onClose() + }, + onError: () => toast({ title: 'Failed to update channel', variant: 'destructive' }), + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const data: ChannelCreateData = { + name, + channel_type: channelType as 'email' | 'webhook' | 'slack', + ...(channelType === 'email' + ? { + smtp_host: smtpHost, + smtp_port: Number(smtpPort), + smtp_user: smtpUser, + ...(smtpPassword ? { smtp_password: smtpPassword } : {}), + smtp_use_tls: smtpUseTls, + from_address: fromAddress, + to_address: toAddress, + } + : channelType === 'slack' + ? { + slack_webhook_url: slackWebhookUrl, + } + : { + webhook_url: webhookUrl, + }), + } + if (isEdit) { + updateMutation.mutate(data) + } else { + createMutation.mutate(data) + } + } + + return ( + !v && onClose()}> + + + + {isEdit ? 'Edit Notification Channel' : 'New Notification Channel'} + + +
    +
    + + setName(e.target.value)} + placeholder="Ops email" + required + /> +
    + + + + + Email + + + Webhook + + + Slack + + + + +
    + + +

    + {SMTP_PRESETS.find((p) => p.id === smtpProvider)?.helpText} +

    +
    +
    +
    + + setSmtpHost(e.target.value)} + placeholder="smtp.gmail.com" + readOnly={smtpProvider !== 'custom'} + /> +
    +
    + + setSmtpPort(e.target.value)} + readOnly={smtpProvider !== 'custom'} + /> +
    +
    +
    +
    + + setSmtpUser(e.target.value)} + placeholder="user@example.com" + /> +
    +
    + + setSmtpPassword(e.target.value)} + placeholder={isEdit ? '(unchanged)' : ''} + /> +
    +
    +
    +
    + + setFromAddress(e.target.value)} + placeholder="alerts@example.com" + /> +
    +
    + + setToAddress(e.target.value)} + placeholder="ops@example.com" + /> +
    +
    +
    + setSmtpUseTls(!!v)} + id="smtp-tls" + disabled={smtpProvider !== 'custom'} + /> + +
    +
    + + {testResult && ( +

    + {testResult.message} +

    + )} +
    +
    + + +
    + + setWebhookUrl(e.target.value)} + placeholder="https://hooks.slack.com/services/..." + /> +
    +
    + + +

    + Create an Incoming Webhook in your Slack workspace settings, then paste the URL here. +

    +
    + + setSlackWebhookUrl(e.target.value)} + placeholder="https://hooks.slack.com/services/T.../B.../..." + /> +
    +
    +
    + +
    + + +
    +
    +
    +
    + ) +} + +// --------------------------------------------------------------------------- +// Main page component +// --------------------------------------------------------------------------- + +export function AlertRulesPage() { + const { user } = useAuth() + const queryClient = useQueryClient() + const [ruleDialog, setRuleDialog] = useState(false) + const [editingRule, setEditingRule] = useState(null) + const [channelDialog, setChannelDialog] = useState(false) + const [editingChannel, setEditingChannel] = useState(null) + + const { selectedTenantId } = useUIStore() + + const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '') + + const { data: rules = [] } = useQuery({ + queryKey: ['alert-rules', tenantId], + queryFn: () => alertsApi.getAlertRules(tenantId), + enabled: !!tenantId, + }) + + const { data: channels = [] } = useQuery({ + queryKey: ['notification-channels', tenantId], + queryFn: () => alertsApi.getNotificationChannels(tenantId), + enabled: !!tenantId, + }) + + const toggleMutation = useMutation({ + mutationFn: (ruleId: string) => alertsApi.toggleAlertRule(tenantId, ruleId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['alert-rules'] }) + }, + onError: () => toast({ title: 'Failed to toggle rule', variant: 'destructive' }), + }) + + const deleteRuleMutation = useMutation({ + mutationFn: (ruleId: string) => alertsApi.deleteAlertRule(tenantId, ruleId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['alert-rules'] }) + toast({ title: 'Rule deleted' }) + }, + onError: () => toast({ title: 'Failed to delete rule', variant: 'destructive' }), + }) + + const deleteChannelMutation = useMutation({ + mutationFn: (channelId: string) => alertsApi.deleteChannel(tenantId, channelId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['notification-channels'] }) + toast({ title: 'Channel deleted' }) + }, + onError: () => toast({ title: 'Failed to delete channel', variant: 'destructive' }), + }) + + const testChannelMutation = useMutation({ + mutationFn: (channelId: string) => alertsApi.testChannel(tenantId, channelId), + onSuccess: () => toast({ title: 'Test notification sent successfully' }), + onError: () => + toast({ title: 'Test notification failed', variant: 'destructive' }), + }) + + return ( +
    +
    +
    + +

    Alert Rules

    +
    + +
    +
    + + {/* ── Alert Rules Section ── */} +
    +
    +

    Threshold Rules

    + {canWrite(user) && tenantId && ( + + )} +
    + + {!tenantId ? ( +

    + Select an organization from the header to manage alert rules. +

    + ) : rules.length === 0 ? ( +

    + No alert rules configured. +

    + ) : ( +
    + {/* Header */} +
    + Name + Condition + Severity + Enabled + +
    + {rules.map((rule) => ( +
    +
    + + {rule.name} + {rule.is_default && ( + (default) + )} + +
    + + {metricLabel(rule.metric)} {operatorLabel(rule.operator)}{' '} + {rule.threshold} for {rule.duration_polls} + + + + + + + + + {canWrite(user) && ( + <> + + {!rule.is_default && ( + + )} + + )} + +
    + ))} +
    + )} +
    + + {/* ── Notification Channels Section ── */} +
    +
    +

    Notification Channels

    + {canWrite(user) && tenantId && ( + + )} +
    + + {!tenantId ? ( +

    + Select an organization from the header to manage channels. +

    + ) : channels.length === 0 ? ( +

    + No notification channels configured. +

    + ) : ( +
    + {channels.map((ch) => ( +
    +
    +
    + {ch.channel_type === 'email' ? ( + + ) : ch.channel_type === 'slack' ? ( + + ) : ( + + )} + {ch.name} + + {ch.channel_type} + +
    +
    +

    + {ch.channel_type === 'email' + ? ch.to_address ?? ch.from_address ?? 'No address' + : ch.channel_type === 'slack' + ? ch.slack_webhook_url + ? ch.slack_webhook_url.slice(0, 50) + '...' + : 'No URL' + : ch.webhook_url + ? ch.webhook_url.slice(0, 50) + '...' + : 'No URL'} +

    + {canWrite(user) && ( +
    + + + +
    + )} +
    + ))} +
    + )} +
    + + {/* Dialogs */} + {ruleDialog && ( + { + setRuleDialog(false) + setEditingRule(null) + }} + tenantId={tenantId} + rule={editingRule} + channels={channels} + /> + )} + {channelDialog && ( + { + setChannelDialog(false) + setEditingChannel(null) + }} + tenantId={tenantId} + channel={editingChannel} + /> + )} +
    + ) +} diff --git a/frontend/src/components/alerts/AlertsPage.tsx b/frontend/src/components/alerts/AlertsPage.tsx new file mode 100644 index 0000000..cb221fd --- /dev/null +++ b/frontend/src/components/alerts/AlertsPage.tsx @@ -0,0 +1,396 @@ +/** + * AlertsPage — Active alerts and alert history with filtering, acknowledge, and silence. + */ + +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { + Bell, + BellOff, + BellRing, + Building2, + CheckCircle, + AlertTriangle, + ChevronLeft, + ChevronRight, +} from 'lucide-react' +import { alertsApi, type AlertEvent, type AlertsFilterParams } from '@/lib/alertsApi' +import { useAuth, isSuperAdmin } from '@/lib/auth' +import { useUIStore } from '@/lib/store' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { toast } from '@/components/ui/toast' +import { cn, formatDateTime } from '@/lib/utils' +import { TableSkeleton } from '@/components/ui/page-skeleton' +import { EmptyState } from '@/components/ui/empty-state' + +function SeverityBadge({ severity }: { severity: string }) { + const config: Record = { + critical: 'bg-error/20 text-error border-error/40', + warning: 'bg-warning/20 text-warning border-warning/40', + info: 'bg-info/20 text-info border-info/40', + } + return ( + + {severity} + + ) +} + +function StatusIcon({ status }: { status: string }) { + if (status === 'firing') return + if (status === 'resolved') return + if (status === 'flapping') return + return +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +function AlertRow({ + alert, + tenantId, + onAcknowledge, + onSilence, +}: { + alert: AlertEvent + tenantId: string + onAcknowledge: (alertId: string) => void + onSilence: (alertId: string, minutes: number) => void +}) { + const isSilenced = + alert.silenced_until && new Date(alert.silenced_until) > new Date() + + return ( +
    + + + +
    +
    + + {alert.message ?? `${alert.metric} ${alert.value ?? ''}`} + + {alert.is_flapping && ( + + flapping + + )} + {isSilenced && } +
    +
    + + {alert.device_hostname ?? alert.device_id.slice(0, 8)} + + {alert.rule_name && {alert.rule_name}} + {alert.threshold != null && ( + + {alert.value != null ? alert.value.toFixed(1) : '?'} / {alert.threshold} + + )} + {timeAgo(alert.fired_at)} + {alert.resolved_at && ( + + resolved {timeAgo(alert.resolved_at)} + + )} +
    +
    + + {alert.status === 'firing' && !alert.acknowledged_at && ( + + )} + + {alert.status === 'firing' && ( + + + + + + onSilence(alert.id, 15)}>15 min + onSilence(alert.id, 60)}>1 hour + onSilence(alert.id, 240)}>4 hours + onSilence(alert.id, 480)}>8 hours + onSilence(alert.id, 1440)}>24 hours + + + )} +
    + ) +} + +export function AlertsPage() { + const { user } = useAuth() + const queryClient = useQueryClient() + const [tab, setTab] = useState('active') + const [severity, setSeverity] = useState('') + const [page, setPage] = useState(1) + + // For super_admin, use global org context; for normal users, use their tenant + const { selectedTenantId } = useUIStore() + + const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '') + + // Build filter params + const params: AlertsFilterParams = { + page, + per_page: 50, + } + if (tab === 'active') { + params.status = 'firing' + } + if (severity) { + params.severity = severity + } + + const { data: alertsData, isLoading } = useQuery({ + queryKey: ['alerts', tenantId, tab, severity, page], + queryFn: () => alertsApi.getAlerts(tenantId, params), + enabled: !!tenantId, + refetchInterval: tab === 'active' ? 30_000 : undefined, + }) + + const acknowledgeMutation = useMutation({ + mutationFn: (alertId: string) => alertsApi.acknowledgeAlert(tenantId, alertId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['alerts'] }) + void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] }) + toast({ title: 'Alert acknowledged' }) + }, + onError: () => toast({ title: 'Failed to acknowledge', variant: 'destructive' }), + }) + + const silenceMutation = useMutation({ + mutationFn: ({ alertId, minutes }: { alertId: string; minutes: number }) => + alertsApi.silenceAlert(tenantId, alertId, minutes), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['alerts'] }) + void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] }) + toast({ title: 'Alert silenced' }) + }, + onError: () => toast({ title: 'Failed to silence', variant: 'destructive' }), + }) + + const alerts = alertsData?.items ?? [] + const total = alertsData?.total ?? 0 + const totalPages = Math.ceil(total / 50) + + return ( +
    +
    +
    + +

    Alerts

    +
    + +
    + + {/* Filters */} +
    + +
    + + { setTab(v); setPage(1) }}> + + + Active + {tab === 'active' && total > 0 && ( + + {total} + + )} + + History + + + + {!tenantId ? ( +
    + +

    Select an organization from the header to view alerts.

    +
    + ) : isLoading ? ( + + ) : alerts.length === 0 ? ( + + ) : ( +
    + {alerts.map((alert) => ( + acknowledgeMutation.mutate(id)} + onSilence={(id, mins) => + silenceMutation.mutate({ alertId: id, minutes: mins }) + } + /> + ))} +
    + )} +
    + + + {!tenantId ? ( +
    + +

    Select an organization from the header to view alerts.

    +
    + ) : isLoading ? ( + + ) : alerts.length === 0 ? ( + + ) : ( +
    + {/* Table header */} +
    + + Severity + Status + Details + Fired + Resolved +
    + {alerts.map((alert) => ( +
    + + + + + + {alert.status} + +
    + + {alert.message ?? alert.metric ?? 'System alert'} + + + {alert.device_hostname ?? alert.device_id.slice(0, 8)} + {alert.rule_name && ` — ${alert.rule_name}`} + +
    + + {formatDateTime(alert.fired_at)} + + + {alert.resolved_at ? formatDateTime(alert.resolved_at) : '—'} + +
    + ))} +
    + )} +
    +
    + + {/* Pagination */} + {totalPages > 1 && ( +
    + + {total} alert{total !== 1 ? 's' : ''} total + +
    + + + {page} / {totalPages} + + +
    +
    + )} +
    + ) +} diff --git a/frontend/src/components/audit/AuditLogTable.tsx b/frontend/src/components/audit/AuditLogTable.tsx new file mode 100644 index 0000000..9f69550 --- /dev/null +++ b/frontend/src/components/audit/AuditLogTable.tsx @@ -0,0 +1,421 @@ +/** + * Filterable, paginated audit log table with expandable row details and CSV export. + * + * Uses TanStack Query for data fetching and design system tokens for styling. + */ + +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + ChevronDown, + ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, + Download, + Search, + ClipboardList, +} from 'lucide-react' +import { + auditLogsApi, + type AuditLogEntry, + type AuditLogParams, +} from '@/lib/api' +import { cn } from '@/lib/utils' +import { EmptyState } from '@/components/ui/empty-state' + +// Predefined action types for the filter dropdown +const ACTION_TYPES = [ + { value: '', label: 'All Actions' }, + { value: 'login', label: 'Login' }, + { value: 'logout', label: 'Logout' }, + { value: 'device_create', label: 'Device Create' }, + { value: 'device_update', label: 'Device Update' }, + { value: 'device_delete', label: 'Device Delete' }, + { value: 'config_browse', label: 'Config Browse' }, + { value: 'config_add', label: 'Config Add' }, + { value: 'config_set', label: 'Config Set' }, + { value: 'config_remove', label: 'Config Remove' }, + { value: 'config_execute', label: 'Config Execute' }, + { value: 'firmware_upgrade', label: 'Firmware Upgrade' }, + { value: 'alert_rule_create', label: 'Alert Rule Create' }, + { value: 'alert_rule_update', label: 'Alert Rule Update' }, + { value: 'bulk_command', label: 'Bulk Command' }, + { value: 'device_adopt', label: 'Device Adopt' }, +] as const + +const PER_PAGE_OPTIONS = [25, 50, 100] as const + +/** Formats an ISO timestamp into a human-readable relative time string. */ +function formatRelativeTime(iso: string): string { + const now = Date.now() + const then = new Date(iso).getTime() + const diffMs = now - then + + if (diffMs < 0) return 'just now' + + const seconds = Math.floor(diffMs / 1000) + if (seconds < 60) return 'just now' + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + + const days = Math.floor(hours / 24) + if (days < 7) return `${days}d ago` + + const weeks = Math.floor(days / 7) + if (weeks < 4) return `${weeks}w ago` + + const months = Math.floor(days / 30) + return `${months}mo ago` +} + +/** Maps action string to a styled badge color. */ +function actionBadgeClasses(action: string): string { + if (action.startsWith('config_')) return 'bg-accent/10 text-accent border-accent/20' + if (action.startsWith('device_')) return 'bg-info/10 text-info border-info/20' + if (action.startsWith('alert_')) return 'bg-warning/10 text-warning border-warning/20' + if (action === 'login' || action === 'logout') return 'bg-success/10 text-success border-success/20' + if (action.startsWith('firmware')) return 'bg-purple-500/10 text-purple-400 border-purple-500/20' + if (action.startsWith('bulk_')) return 'bg-error/10 text-error border-error/20' + return 'bg-elevated text-text-secondary border-border' +} + +interface AuditLogTableProps { + tenantId: string +} + +export function AuditLogTable({ tenantId }: AuditLogTableProps) { + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(50) + const [actionFilter, setActionFilter] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [userSearch, setUserSearch] = useState('') + const [expandedId, setExpandedId] = useState(null) + const [exporting, setExporting] = useState(false) + + const params: AuditLogParams = { + page, + per_page: perPage, + ...(actionFilter ? { action: actionFilter } : {}), + ...(dateFrom ? { date_from: new Date(dateFrom).toISOString() } : {}), + ...(dateTo ? { date_to: new Date(dateTo + 'T23:59:59').toISOString() } : {}), + } + + const { data, isLoading, isError } = useQuery({ + queryKey: ['audit-logs', tenantId, page, perPage, actionFilter, dateFrom, dateTo], + queryFn: () => auditLogsApi.list(tenantId, params), + enabled: !!tenantId, + }) + + const totalPages = data ? Math.ceil(data.total / perPage) : 0 + + // Client-side user email filter (since user search is by text, not UUID) + const filteredItems = data?.items.filter((item) => { + if (!userSearch) return true + return item.user_email?.toLowerCase().includes(userSearch.toLowerCase()) + }) ?? [] + + const handleExport = async () => { + setExporting(true) + try { + await auditLogsApi.exportCsv(tenantId, { + ...(actionFilter ? { action: actionFilter } : {}), + ...(dateFrom ? { date_from: new Date(dateFrom).toISOString() } : {}), + ...(dateTo ? { date_to: new Date(dateTo + 'T23:59:59').toISOString() } : {}), + }) + } finally { + setExporting(false) + } + } + + return ( +
    + {/* Filter bar */} +
    + {/* Action filter */} + + + {/* Date from */} +
    + From + { setDateFrom(e.target.value); setPage(1) }} + className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" + /> +
    + + {/* Date to */} +
    + To + { setDateTo(e.target.value); setPage(1) }} + className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" + /> +
    + + {/* User search */} +
    + + setUserSearch(e.target.value)} + className="h-8 rounded-md border border-border bg-surface pl-7 pr-2 text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent w-40" + /> +
    + + {/* Spacer */} +
    + + {/* Export CSV */} + +
    + + {/* Table */} +
    + {isLoading ? ( +
    +
    +

    Loading audit logs...

    +
    + ) : isError ? ( +
    +

    Failed to load audit logs.

    +
    + ) : filteredItems.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {filteredItems.map((item) => ( + + setExpandedId(expandedId === item.id ? null : item.id) + } + /> + ))} + +
    + + Timestamp + + User + + Action + + Resource + + Device + + IP Address +
    + )} +
    + + {/* Pagination */} + {data && data.total > 0 && ( +
    +
    + Rows per page: + + + {(page - 1) * perPage + 1}-- + {Math.min(page * perPage, data.total)} of {data.total} + +
    + +
    + + + + Page {page} of {totalPages} + + + +
    +
    + )} +
    + ) +} + +// --------------------------------------------------------------------------- +// Row sub-component +// --------------------------------------------------------------------------- + +interface AuditLogRowProps { + item: AuditLogEntry + isExpanded: boolean + onToggle: () => void +} + +function AuditLogRow({ item, isExpanded, onToggle }: AuditLogRowProps) { + const hasDetails = + item.details && Object.keys(item.details).length > 0 + + return ( + <> + + + {hasDetails ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + + {formatRelativeTime(item.created_at)} + + + + {item.user_email ?? '--'} + + + + {item.action.replace(/_/g, ' ')} + + + + {item.resource_type ? ( + <> + {item.resource_type} + {item.resource_id && ( + + {item.resource_id.length > 12 + ? item.resource_id.substring(0, 12) + '...' + : item.resource_id} + + )} + + ) : ( + '--' + )} + + + {item.device_name ?? '--'} + + + {item.ip_address ?? '--'} + + + + {/* Expanded details row */} + {isExpanded && hasDetails && ( + + +
    + Details +
    +
    +              {JSON.stringify(item.details, null, 2)}
    +            
    + + + )} + + ) +} diff --git a/frontend/src/components/auth/EmergencyKitDialog.tsx b/frontend/src/components/auth/EmergencyKitDialog.tsx new file mode 100644 index 0000000..633c6b2 --- /dev/null +++ b/frontend/src/components/auth/EmergencyKitDialog.tsx @@ -0,0 +1,196 @@ +/** + * Emergency Kit dialog shown after successful SRP registration. + * + * Displays the Secret Key (which NEVER touches the server) and provides: + * - Copy to clipboard button + * - Download Emergency Kit PDF (server-generated template without Secret Key) + * - Mandatory acknowledgment checkbox before closing + * + * The Secret Key is only shown once — if the user closes this dialog + * without saving it, they cannot recover it from the server. + */ + +import { useState, useCallback } from 'react'; +import { ShieldAlert, Copy, Download, Check } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { authApi } from '@/lib/api'; + +interface EmergencyKitDialogProps { + open: boolean; + onClose: () => void; + secretKey: string; // Formatted A3-XXXXXX-... + email: string; +} + +export function EmergencyKitDialog({ + open, + onClose, + secretKey, + email, +}: EmergencyKitDialogProps) { + const [acknowledged, setAcknowledged] = useState(false); + const [copied, setCopied] = useState(false); + const [downloading, setDownloading] = useState(false); + const [showHelp, setShowHelp] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(secretKey); + setCopied(true); + toast.success('Secret Key copied to clipboard'); + setTimeout(() => setCopied(false), 3000); + } catch { + // Fallback for environments without clipboard API + const textarea = document.createElement('textarea'); + textarea.value = secretKey; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + setCopied(true); + toast.success('Secret Key copied to clipboard'); + setTimeout(() => setCopied(false), 3000); + } + }, [secretKey]); + + const handleDownloadPDF = useCallback(async () => { + setDownloading(true); + try { + const blob = await authApi.getEmergencyKitPDF(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'MikroTik-Portal-Emergency-Kit.pdf'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + toast.success('Emergency Kit PDF downloaded'); + } catch { + toast.error('Failed to download Emergency Kit PDF'); + } finally { + setDownloading(false); + } + }, []); + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + +
    +
    + +
    + Save Your Emergency Kit +
    + + Your Secret Key is shown below. This is the only time it + will be displayed. You need it when signing in from a new browser or computer. + +
    + + {/* Secret Key Display */} +
    +
    + Your Secret Key +
    +
    + {secretKey} +
    +
    + Account: {email} +
    +
    + + {/* Action Buttons */} +
    + + +
    + + {/* Instructions */} +
    + Write your Secret Key on the Emergency Kit PDF after printing it, or save it + in your password manager. Do NOT store it digitally alongside your password. +
    + + {/* Help toggle */} + + {showHelp && ( +
    + Your Secret Key is a unique code generated on your device. Combined with your password, + it creates the encryption keys that protect your data. The server never sees your Secret Key + or your password — this is called zero-knowledge encryption. If you lose both your Secret Key + and your password, your data cannot be recovered. +
    + )} + + + {/* Acknowledgment Checkbox */} + + + {/* Close Button */} + + +
    +
    + ); +} diff --git a/frontend/src/components/auth/PasswordStrengthMeter.tsx b/frontend/src/components/auth/PasswordStrengthMeter.tsx new file mode 100644 index 0000000..419883d --- /dev/null +++ b/frontend/src/components/auth/PasswordStrengthMeter.tsx @@ -0,0 +1,131 @@ +/** + * PasswordStrengthMeter -- Visual password strength indicator using zxcvbn-ts. + * + * Evaluates password strength on every keystroke (zxcvbn is fast) and shows: + * - Colored segmented progress bar (0-4 segments) + * - Strength label: Very Weak, Weak, Fair, Strong, Very Strong + * - Feedback suggestions when score < 3 + * + * Also exports getPasswordScore() helper for form validation. + */ + +import { useMemo } from 'react' +import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core' +import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common' +import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en' +import { cn } from '@/lib/utils' + +// Configure zxcvbn with language dictionaries +const options = { + graphs: zxcvbnCommonPackage.adjacencyGraphs, + dictionary: { + ...zxcvbnCommonPackage.dictionary, + ...zxcvbnEnPackage.dictionary, + }, + translations: zxcvbnEnPackage.translations, +} +zxcvbnOptions.setOptions(options) + +// --------------------------------------------------------------------------- +// Exported helper for form validation +// --------------------------------------------------------------------------- + +export function getPasswordScore(password: string): number { + if (!password) return 0 + return zxcvbn(password).score +} + +// --------------------------------------------------------------------------- +// Score configuration +// --------------------------------------------------------------------------- + +const SCORE_CONFIG: Record< + number, + { label: string; color: string; barColor: string } +> = { + 0: { + label: 'Very Weak', + color: 'text-error', + barColor: 'bg-error', + }, + 1: { + label: 'Weak', + color: 'text-orange-500', + barColor: 'bg-orange-500', + }, + 2: { + label: 'Fair', + color: 'text-yellow-500', + barColor: 'bg-yellow-500', + }, + 3: { + label: 'Strong', + color: 'text-green-500', + barColor: 'bg-green-500', + }, + 4: { + label: 'Very Strong', + color: 'text-green-400', + barColor: 'bg-green-400', + }, +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface PasswordStrengthMeterProps { + password: string + className?: string +} + +export function PasswordStrengthMeter({ + password, + className, +}: PasswordStrengthMeterProps) { + const result = useMemo(() => { + if (!password) return null + return zxcvbn(password) + }, [password]) + + if (!password || !result) return null + + const { score, feedback } = result + const config = SCORE_CONFIG[score] ?? SCORE_CONFIG[0]! + + return ( +
    + {/* Segmented strength bar */} +
    + {[0, 1, 2, 3].map((segment) => ( +
    + ))} +
    + + {/* Score label */} +
    + + {config.label} + +
    + + {/* Feedback suggestions for weak passwords */} + {score < 3 && (feedback.warning || feedback.suggestions.length > 0) && ( +
    + {feedback.warning && ( +

    {feedback.warning}

    + )} + {feedback.suggestions.map((suggestion, i) => ( +

    {suggestion}

    + ))} +
    + )} +
    + ) +} diff --git a/frontend/src/components/auth/SecretKeyInput.tsx b/frontend/src/components/auth/SecretKeyInput.tsx new file mode 100644 index 0000000..2c998da --- /dev/null +++ b/frontend/src/components/auth/SecretKeyInput.tsx @@ -0,0 +1,159 @@ +import { useCallback, useRef, useState } from 'react' +import { Input } from '@/components/ui/input' + +/** + * Valid characters for the Secret Key: 22 letters + 8 digits = 30 chars. + * Uppercase only, ambiguous characters removed (O, I, L, S, 0, 1). + */ +const VALID_CHARS = /^[ABCDEFGHJKMNPQRTUVWXYZ23456789]+$/ + +interface SecretKeyInputProps { + value: string + onChange: (value: string) => void + error?: boolean +} + +/** + * Secret Key entry component with 5 grouped inputs matching A3-XXXXXX format. + * + * The "A3" prefix is shown as a static label. The user enters 5 groups of + * 6 characters each. Auto-advances to the next group on fill, supports + * paste of the full key across all groups, and validates characters. + */ +export function SecretKeyInput({ value, onChange, error }: SecretKeyInputProps) { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + // Parse the value into 5 groups (strip "A3-" prefix and hyphens) + const parseGroups = useCallback((raw: string): string[] => { + const cleaned = raw + .replace(/^A3[-\s]*/i, '') + .replace(/[-\s]/g, '') + .toUpperCase() + const groups: string[] = [] + for (let i = 0; i < 5; i++) { + groups.push(cleaned.slice(i * 6, (i + 1) * 6)) + } + return groups + }, []) + + const [groups, setGroups] = useState(() => parseGroups(value)) + + // Reconstruct the full key from groups + const buildKey = useCallback((g: string[]) => { + const joined = g.join('') + if (joined.length === 0) return '' + return `A3-${g.filter(Boolean).join('-')}` + }, []) + + const handleGroupChange = useCallback( + (index: number, input: string) => { + // Allow only valid charset characters + const filtered = input + .toUpperCase() + .split('') + .filter((c) => VALID_CHARS.test(c)) + .join('') + .slice(0, 6) + + const newGroups = [...groups] + newGroups[index] = filtered + setGroups(newGroups) + onChange(buildKey(newGroups)) + + // Auto-advance to next group when this one is full + if (filtered.length === 6 && index < 4) { + inputRefs.current[index + 1]?.focus() + } + }, + [groups, onChange, buildKey], + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault() + const pasted = e.clipboardData.getData('text') + const parsed = parseGroups(pasted) + + // Only apply if we got meaningful data + if (parsed.some((g) => g.length > 0)) { + setGroups(parsed) + onChange(buildKey(parsed)) + + // Focus the first incomplete group + const incompleteIdx = parsed.findIndex((g) => g.length < 6) + if (incompleteIdx >= 0) { + inputRefs.current[incompleteIdx]?.focus() + } else { + // All complete -- focus last + inputRefs.current[4]?.focus() + } + } + }, + [parseGroups, onChange, buildKey], + ) + + const handleKeyDown = useCallback( + (index: number, e: React.KeyboardEvent) => { + // Navigate back on Backspace when group is empty + if (e.key === 'Backspace' && groups[index] === '' && index > 0) { + e.preventDefault() + inputRefs.current[index - 1]?.focus() + } + }, + [groups], + ) + + // 26-char key = 4 groups of 6 + 1 group of 2 + const isComplete = + groups.slice(0, 4).every((g) => g.length === 6) && groups[4].length >= 2 + const hasContent = groups.some((g) => g.length > 0) + + const borderColor = error + ? 'border-error' + : isComplete + ? 'border-success' + : hasContent + ? 'border-warning' + : 'border-border' + + return ( +
    +
    + {/* Static A3 prefix */} + + A3 + + - + + {/* 5 input groups */} + {groups.map((group, idx) => ( +
    + {idx > 0 && -} + { + inputRefs.current[idx] = el + }} + type="text" + inputMode="text" + autoComplete="off" + autoCorrect="off" + autoCapitalize="characters" + spellCheck={false} + maxLength={6} + value={group} + onChange={(e) => handleGroupChange(idx, e.target.value)} + onKeyDown={(e) => handleKeyDown(idx, e)} + className={`w-[3.25rem] font-mono text-center text-xs tracking-wide uppercase px-0.5 ${borderColor}`} + placeholder="------" + /> +
    + ))} +
    + {error && hasContent && !isComplete && ( +

    + Enter all 30 characters of your Secret Key +

    + )} +
    + ) +} diff --git a/frontend/src/components/auth/SrpUpgradeDialog.tsx b/frontend/src/components/auth/SrpUpgradeDialog.tsx new file mode 100644 index 0000000..5521cb9 --- /dev/null +++ b/frontend/src/components/auth/SrpUpgradeDialog.tsx @@ -0,0 +1,189 @@ +/** + * SRP Upgrade Dialog shown when a legacy bcrypt user logs in and needs + * to register zero-knowledge SRP credentials. + * + * Flow: + * 1. User sees explanation of what's happening + * 2. Click "Upgrade Now" triggers client-side key generation + * 3. Registration data sent to /auth/register-srp + * 4. Emergency Kit dialog shown with Secret Key + * 5. After acknowledging, completeUpgrade() logs in via SRP + */ + +import { useState, useCallback } from 'react' +import { ShieldCheck, Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { getErrorMessage } from '@/lib/errors' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { performRegistration, assertWebCryptoAvailable } from '@/lib/crypto/registration' +import { keyStore } from '@/lib/crypto/keyStore' +import { authApi } from '@/lib/api' +import { EmergencyKitDialog } from './EmergencyKitDialog' + +interface SrpUpgradeDialogProps { + open: boolean + email: string + password: string + onComplete: () => Promise + onCancel: () => void +} + +type UpgradeStep = 'explain' | 'generating' | 'emergency-kit' + +export function SrpUpgradeDialog({ + open, + email, + password, + onComplete, + onCancel, +}: SrpUpgradeDialogProps) { + const [step, setStep] = useState('explain') + const [secretKey, setSecretKey] = useState('') + const [error, setError] = useState(null) + + // Check if Web Crypto is available (HTTPS or localhost required) + const cryptoAvailable = typeof crypto !== 'undefined' && !!crypto.subtle + + const handleUpgrade = useCallback(async () => { + setStep('generating') + setError(null) + + try { + // 1. Generate all cryptographic material client-side + const result = await performRegistration(email, password) + + // 2. Send SRP registration to server (user is temp-authenticated) + await authApi.registerSRP({ + ...result.srpRegistration, + ...result.keyBundle, + }) + + // 3. Store Secret Key in IndexedDB for this device + await keyStore.storeSecretKey(email, result.secretKeyRaw) + + // 4. Show Emergency Kit with Secret Key + setSecretKey(result.secretKey) + setStep('emergency-kit') + } catch (err) { + const msg = getErrorMessage(err, 'Security upgrade failed. Please try again.') + setError(msg) + setStep('explain') + toast.error(msg) + } + }, [email, password]) + + const handleEmergencyKitClose = useCallback(async () => { + // After user acknowledges Emergency Kit, complete the upgrade + try { + await onComplete() + } catch { + toast.error('Login failed after upgrade. Please try signing in again.') + onCancel() + } + }, [onComplete, onCancel]) + + // Emergency Kit sub-dialog + if (step === 'emergency-kit') { + return ( + void handleEmergencyKitClose()} + secretKey={secretKey} + email={email} + /> + ) + } + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + +
    +
    + +
    + Account Security Upgrade +
    + + We're upgrading your account security so your password is never stored on + our servers. This is a one-time process. + +
    + +
    + {step === 'generating' ? ( +
    + +

    + Generating encryption keys... +

    +

    + This may take a moment while we derive your security credentials. +

    +
    + ) : ( + <> +
    +

    + What happens: +

    +
      +
    • Your encryption keys are generated locally in your browser
    • +
    • A Secret Key is created that only you will have
    • +
    • Your password is never sent to or stored on the server
    • +
    • You will receive an Emergency Kit to save your Secret Key
    • +
    +
    + + {!cryptoAvailable && ( +
    +

    Secure connection required

    +

    + Encryption features require HTTPS or localhost. Please access the + application via a secure connection to complete this upgrade. +

    +
    + )} + + {error && ( +
    +

    {error}

    +
    + )} + + )} +
    + + {step === 'explain' && ( +
    + + +
    + )} +
    +
    + ) +} diff --git a/frontend/src/components/brand/RugLogo.tsx b/frontend/src/components/brand/RugLogo.tsx new file mode 100644 index 0000000..15d72d0 --- /dev/null +++ b/frontend/src/components/brand/RugLogo.tsx @@ -0,0 +1,60 @@ +interface RugLogoProps { + size?: number + className?: string +} + +export function RugLogo({ size = 48, className }: RugLogoProps) { + return ( + + {/* Outer border frame - burgundy */} + + + {/* Inner border - cream */} + + + {/* Rug field background */} + + + {/* Outer diamond frame - burgundy */} + + + {/* Middle diamond - cream */} + + + {/* Inner diamond - burgundy */} + + + {/* Rosette petals - vertical - teal */} + + + {/* Rosette petals - horizontal - cream */} + + + {/* Center medallion - burgundy */} + + + {/* Center dot - teal */} + + + {/* Corner ornaments - teal */} + + + + + + {/* Edge tick marks - cream */} + + + + + + ) +} diff --git a/frontend/src/components/certificates/BulkDeployDialog.tsx b/frontend/src/components/certificates/BulkDeployDialog.tsx new file mode 100644 index 0000000..2c6b176 --- /dev/null +++ b/frontend/src/components/certificates/BulkDeployDialog.tsx @@ -0,0 +1,326 @@ +/** + * BulkDeployDialog -- Multi-device certificate deployment dialog. + * + * Shows a checkbox list of devices without deployed certs, with Select All / Deselect All. + * On deploy, calls bulkDeploy API and shows progress + results summary. + */ + +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Layers, + Loader2, + CheckCircle, + XCircle, + Check, +} from 'lucide-react' +import { certificatesApi } from '@/lib/certificatesApi' +import { devicesApi, type DeviceResponse } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { toast } from '@/components/ui/toast' +import { cn } from '@/lib/utils' + +type BulkStep = 'select' | 'deploying' | 'results' + +interface BulkResult { + success: number + failed: number + errors: Array<{ device_id: string; error: string }> +} + +interface BulkDeployDialogProps { + open: boolean + onClose: () => void + tenantId: string +} + +export function BulkDeployDialog({ + open, + onClose, + tenantId, +}: BulkDeployDialogProps) { + const queryClient = useQueryClient() + const [selected, setSelected] = useState>(new Set()) + const [step, setStep] = useState('select') + const [result, setResult] = useState(null) + + // Fetch devices + const { data: deviceList = [] } = useQuery({ + queryKey: ['devices-for-cert', tenantId], + queryFn: async () => { + const result = await devicesApi.list(tenantId) + return (result as any).items ?? result + }, + enabled: !!tenantId && open, + }) + + // Fetch existing device certs to filter + const { data: existingCerts = [] } = useQuery({ + queryKey: ['deviceCerts', tenantId], + queryFn: () => certificatesApi.getDeviceCerts(undefined, tenantId), + enabled: !!tenantId && open, + }) + + const deployedDeviceIds = new Set( + existingCerts + .filter((c) => c.status === 'deployed' || c.status === 'deploying') + .map((c) => c.device_id), + ) + + const availableDevices = (deviceList as DeviceResponse[]).filter( + (d) => !deployedDeviceIds.has(d.id), + ) + + const toggleDevice = (id: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const selectAll = () => { + setSelected(new Set(availableDevices.map((d) => d.id))) + } + + const deselectAll = () => { + setSelected(new Set()) + } + + const handleDeploy = async () => { + if (selected.size === 0) return + + setStep('deploying') + try { + const responses = await certificatesApi.bulkDeploy(Array.from(selected), tenantId) + const succeeded = responses.filter((r) => r.success).length + const failed = responses.filter((r) => !r.success) + + const bulkResult: BulkResult = { + success: succeeded, + failed: failed.length, + errors: failed.map((f) => ({ + device_id: f.device_id, + error: f.error ?? 'Unknown error', + })), + } + + setResult(bulkResult) + setStep('results') + void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] }) + + if (failed.length === 0) { + toast({ title: `${succeeded} certificate(s) deployed successfully` }) + } else { + toast({ + title: `${succeeded} deployed, ${failed.length} failed`, + variant: 'destructive', + }) + } + } catch (e: any) { + setResult({ + success: 0, + failed: selected.size, + errors: [ + { + device_id: 'bulk', + error: e?.response?.data?.detail || 'Bulk deployment failed', + }, + ], + }) + setStep('results') + toast({ title: 'Bulk deployment failed', variant: 'destructive' }) + } + } + + const handleClose = () => { + onClose() + setSelected(new Set()) + setStep('select') + setResult(null) + } + + return ( + !v && handleClose()}> + + + Bulk Certificate Deployment + + +
    + {step === 'select' && ( + <> +

    + Select devices to sign and deploy TLS certificates in batch. +

    + + {availableDevices.length === 0 ? ( +
    + +

    + All devices have certificates +

    +

    + Every device already has a deployed certificate. +

    +
    + ) : ( + <> + {/* Select All / Deselect All */} +
    + + {selected.size} of {availableDevices.length} selected + +
    + + +
    +
    + + {/* Device list */} +
    + {availableDevices.map((d: DeviceResponse) => ( + + ))} +
    + + + + )} + + )} + + {step === 'deploying' && ( +
    + +

    + Deploying certificates... +

    +

    + Signing and deploying to {selected.size} device + {selected.size !== 1 ? 's' : ''}. This may take a moment. +

    +
    + )} + + {step === 'results' && result && ( +
    + {/* Summary */} +
    +
    + +

    + {result.success} +

    +

    Succeeded

    +
    +
    0 + ? 'border-error/30 bg-error/5' + : 'border-border bg-surface', + )} + > + 0 ? 'text-error' : 'text-text-muted', + )} + /> +

    0 ? 'text-error' : 'text-text-muted', + )} + > + {result.failed} +

    +

    Failed

    +
    +
    + + {/* Error details */} + {result.errors.length > 0 && ( +
    +

    + Failed deployments: +

    + {result.errors.map((err, i) => ( +
    + + {err.error} +
    + ))} +
    + )} + + +
    + )} +
    +
    +
    + ) +} diff --git a/frontend/src/components/certificates/CAStatusCard.tsx b/frontend/src/components/certificates/CAStatusCard.tsx new file mode 100644 index 0000000..83354f4 --- /dev/null +++ b/frontend/src/components/certificates/CAStatusCard.tsx @@ -0,0 +1,229 @@ +/** + * CAStatusCard -- Shows the CA initialization state or active CA details. + * + * When NO CA exists: centered prompt with "Initialize CA" button. + * When CA exists: card with fingerprint, validity, download, and status badge. + */ + +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + Shield, + ShieldCheck, + Download, + Copy, + CheckCircle, + Loader2, +} from 'lucide-react' +import { certificatesApi, type CAResponse } from '@/lib/certificatesApi' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { cn } from '@/lib/utils' + +interface CAStatusCardProps { + ca: CAResponse | null + canWrite: boolean + tenantId: string +} + +export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardProps) { + const queryClient = useQueryClient() + const [copied, setCopied] = useState(false) + + const initMutation = useMutation({ + mutationFn: () => certificatesApi.createCA(undefined, undefined, tenantId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['ca'] }) + toast({ title: 'Certificate Authority initialized' }) + }, + onError: (e: any) => + toast({ + title: e?.response?.data?.detail || 'Failed to initialize CA', + variant: 'destructive', + }), + }) + + const handleDownloadPEM = async () => { + try { + const pem = await certificatesApi.getCACertPEM(tenantId) + const blob = new Blob([pem], { type: 'application/x-pem-file' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'portal-ca.pem' + a.click() + URL.revokeObjectURL(url) + toast({ title: 'CA certificate downloaded' }) + } catch { + toast({ title: 'Failed to download certificate', variant: 'destructive' }) + } + } + + const copyFingerprint = (fp: string) => { + navigator.clipboard.writeText(fp) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + toast({ title: 'Fingerprint copied' }) + } + + const isExpired = ca + ? new Date(ca.not_valid_after) < new Date() + : false + + // ── No CA state ── + if (!ca) { + return ( +
    +
    +
    + +
    +
    +

    + No Certificate Authority +

    +

    + Initialize a Certificate Authority to secure device API connections + with proper TLS certificates. +

    +
    + {writable && ( + + )} +
    +
    + ) + } + + // ── CA exists state ── + return ( +
    +
    +
    +
    + +
    +
    +

    + {ca.common_name} +

    + + {isExpired ? 'Expired' : 'Active'} + +
    +
    + +
    + +
    + {/* Fingerprint */} +
    + + SHA-256 Fingerprint + +
    + + {ca.fingerprint_sha256} + + +
    +
    + + {/* Serial */} +
    + + Serial Number + + + {ca.serial_number} + +
    + + {/* Valid From */} +
    + + Valid From + + + {new Date(ca.not_valid_before).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
    + + {/* Valid Until */} +
    + + Valid Until + + + {new Date(ca.not_valid_after).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
    +
    +
    + ) +} diff --git a/frontend/src/components/certificates/CertConfirmDialog.tsx b/frontend/src/components/certificates/CertConfirmDialog.tsx new file mode 100644 index 0000000..1945edb --- /dev/null +++ b/frontend/src/components/certificates/CertConfirmDialog.tsx @@ -0,0 +1,146 @@ +/** + * CertConfirmDialog -- Confirmation dialog for certificate operations. + * + * - Rotate: Standard confirmation with consequence text. + * - Revoke: Type-to-confirm (must type hostname), destructive red styling. + * + * Uses the project's existing Dialog primitives (Radix react-dialog). + */ + +import { useState, useEffect } from 'react' +import { AlertTriangle, RefreshCw, XCircle } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +interface CertConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + action: 'rotate' | 'revoke' + deviceHostname: string + onConfirm: () => void +} + +export function CertConfirmDialog({ + open, + onOpenChange, + action, + deviceHostname, + onConfirm, +}: CertConfirmDialogProps) { + const [confirmText, setConfirmText] = useState('') + const isRevoke = action === 'revoke' + const canConfirm = isRevoke ? confirmText === deviceHostname : true + + // Reset confirm text when dialog opens/closes or action changes + useEffect(() => { + if (open) { + setConfirmText('') + } + }, [open, action]) + + const handleConfirm = () => { + if (!canConfirm) return + onConfirm() + } + + return ( + + + +
    +
    + {isRevoke ? ( + + ) : ( + + )} +
    + + {isRevoke ? 'Revoke Certificate' : 'Rotate Certificate'} + +
    + + {isRevoke + ? `This will permanently revoke the certificate for ${deviceHostname}. The device will fall back to insecure TLS mode.` + : `This will generate a new certificate for ${deviceHostname}. The old certificate will be superseded.`} + +
    + +
    + {/* Warning callout */} +
    + +

    + {isRevoke + ? 'This action cannot be undone. The device will lose its verified TLS certificate and revert to self-signed mode until a new certificate is deployed.' + : 'The current certificate will be marked as superseded. A new certificate will be signed and deployed to the device.'} +

    +
    + + {/* Type-to-confirm for revoke */} + {isRevoke && ( +
    + + setConfirmText(e.target.value)} + placeholder={deviceHostname} + autoComplete="off" + autoFocus + /> +
    + )} +
    + + + + + +
    +
    + ) +} diff --git a/frontend/src/components/certificates/CertificatesPage.tsx b/frontend/src/components/certificates/CertificatesPage.tsx new file mode 100644 index 0000000..63af8c2 --- /dev/null +++ b/frontend/src/components/certificates/CertificatesPage.tsx @@ -0,0 +1,115 @@ +/** + * CertificatesPage -- Main certificate management page. + * + * Two sections: + * 1. CA Status Card -- shows CA state or initialization prompt + * 2. Device Certificates Table -- per-device cert status with actions + */ + +import { useQuery } from '@tanstack/react-query' +import { useUIStore } from '@/lib/store' +import { Shield, Building2 } from 'lucide-react' +import { + certificatesApi, + type CAResponse, + type DeviceCertResponse, +} from '@/lib/certificatesApi' +import { useAuth, isSuperAdmin } from '@/lib/auth' +import { canWrite } from '@/lib/auth' +import { CAStatusCard } from './CAStatusCard' +import { DeviceCertTable } from './DeviceCertTable' +import { EmptyState } from '@/components/ui/empty-state' +import { TableSkeleton } from '@/components/ui/page-skeleton' + +export function CertificatesPage() { + const { user } = useAuth() + const writable = canWrite(user) + + const { selectedTenantId } = useUIStore() + const tenantId = isSuperAdmin(user) + ? (selectedTenantId ?? '') + : (user?.tenant_id ?? '') + + // ── Queries ── + + const { + data: ca, + isLoading: caLoading, + } = useQuery({ + queryKey: ['ca', tenantId], + queryFn: () => certificatesApi.getCA(tenantId), + enabled: !!tenantId, + }) + + const { + data: deviceCerts = [], + isLoading: certsLoading, + } = useQuery({ + queryKey: ['deviceCerts', tenantId], + queryFn: () => certificatesApi.getDeviceCerts(undefined, tenantId), + enabled: !!tenantId && ca !== undefined, + }) + + // Super admin needs to select a tenant from the header + if (isSuperAdmin(user) && !tenantId) { + return ( +
    +
    + +

    + Certificate Authority +

    +
    + +
    + ) + } + + if (caLoading) { + return ( +
    +
    + +

    + Certificate Authority +

    +
    + +
    + ) + } + + return ( +
    + {/* Header */} +
    + +

    + Certificate Authority +

    +
    + + {/* CA Status */} +
    + +
    + + {/* Device Certificates (only when CA exists) */} + {ca && ( +
    + +
    + )} +
    + ) +} diff --git a/frontend/src/components/certificates/DeployCertDialog.tsx b/frontend/src/components/certificates/DeployCertDialog.tsx new file mode 100644 index 0000000..b5077fa --- /dev/null +++ b/frontend/src/components/certificates/DeployCertDialog.tsx @@ -0,0 +1,256 @@ +/** + * DeployCertDialog -- Dialog for signing and deploying a certificate to a single device. + * + * Flow: select device -> sign cert -> deploy to device -> done. + * Shows progress states: Signing... -> Deploying... -> Done. + */ + +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + ShieldCheck, + Loader2, + CheckCircle, + XCircle, +} from 'lucide-react' +import { certificatesApi } from '@/lib/certificatesApi' +import { devicesApi, type DeviceResponse } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' + +type DeployStep = 'idle' | 'signing' | 'deploying' | 'done' | 'error' + +interface DeployCertDialogProps { + open: boolean + onClose: () => void + tenantId: string +} + +export function DeployCertDialog({ + open, + onClose, + tenantId, +}: DeployCertDialogProps) { + const queryClient = useQueryClient() + const [selectedDevice, setSelectedDevice] = useState('') + const [validityDays, setValidityDays] = useState('730') + const [step, setStep] = useState('idle') + const [errorMsg, setErrorMsg] = useState('') + + // Fetch devices for the selector + const { data: deviceList = [] } = useQuery({ + queryKey: ['devices-for-cert', tenantId], + queryFn: async () => { + const result = await devicesApi.list(tenantId) + // The list endpoint returns { items, total, ... } or an array + return (result as any).items ?? result + }, + enabled: !!tenantId && open, + }) + + // Fetch existing device certs to filter out devices that already have deployed certs + const { data: existingCerts = [] } = useQuery({ + queryKey: ['deviceCerts', tenantId], + queryFn: () => certificatesApi.getDeviceCerts(undefined, tenantId), + enabled: !!tenantId && open, + }) + + const deployedDeviceIds = new Set( + existingCerts + .filter((c) => c.status === 'deployed' || c.status === 'deploying') + .map((c) => c.device_id), + ) + + const availableDevices = (deviceList as DeviceResponse[]).filter( + (d) => !deployedDeviceIds.has(d.id), + ) + + const handleDeploy = async () => { + if (!selectedDevice) return + + try { + // Step 1: Sign + setStep('signing') + const cert = await certificatesApi.signCert( + selectedDevice, + Number(validityDays) || 730, + tenantId, + ) + + // Step 2: Deploy + setStep('deploying') + const result = await certificatesApi.deployCert(cert.id, tenantId) + + if (result.success) { + setStep('done') + void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] }) + toast({ title: 'Certificate signed and deployed' }) + // Auto-close after a brief delay + setTimeout(() => { + onClose() + resetState() + }, 1500) + } else { + setStep('error') + setErrorMsg(result.error ?? 'Deployment failed') + toast({ title: result.error ?? 'Deployment failed', variant: 'destructive' }) + } + } catch (e: any) { + setStep('error') + const detail = e?.response?.data?.detail || 'Failed to deploy certificate' + setErrorMsg(detail) + toast({ title: detail, variant: 'destructive' }) + } + } + + const resetState = () => { + setSelectedDevice('') + setValidityDays('730') + setStep('idle') + setErrorMsg('') + } + + const handleClose = () => { + onClose() + resetState() + } + + return ( + !v && handleClose()}> + + + Sign & Deploy Certificate + + +
    + {step === 'idle' && ( + <> +

    + Select a device to sign a TLS certificate and deploy it + automatically. +

    + + {availableDevices.length === 0 ? ( +
    + +

    + All devices have certificates +

    +

    + Every device already has a deployed certificate. Use rotate to + renew. +

    +
    + ) : ( + <> +
    + + +
    + +
    + + setValidityDays(e.target.value)} + /> +

    + Default: 730 days (2 years) +

    +
    + + + + )} + + )} + + {step === 'signing' && ( +
    + +

    + Creating secure certificate for this device... +

    +

    + Generating device certificate with your CA +

    +
    + )} + + {step === 'deploying' && ( +
    + +

    + Deploying to device... +

    +

    + Uploading certificate via SFTP and configuring TLS +

    +
    + )} + + {step === 'done' && ( +
    + +

    + Certificate deployed successfully +

    +
    + )} + + {step === 'error' && ( +
    + +

    + Deployment failed +

    +

    {errorMsg}

    + +
    + )} +
    +
    +
    + ) +} diff --git a/frontend/src/components/certificates/DeviceCertTable.tsx b/frontend/src/components/certificates/DeviceCertTable.tsx new file mode 100644 index 0000000..4878590 --- /dev/null +++ b/frontend/src/components/certificates/DeviceCertTable.tsx @@ -0,0 +1,434 @@ +/** + * DeviceCertTable -- Table of device certificates with status badges, + * action dropdown (deploy/rotate/revoke), toolbar with Sign & Deploy / Bulk Deploy buttons. + */ + +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + ShieldCheck, + ShieldAlert, + Plus, + Layers, + MoreHorizontal, + Upload, + RefreshCw, + XCircle, + Loader2, + Eye, + EyeOff, +} from 'lucide-react' +import { + certificatesApi, + type DeviceCertResponse, +} from '@/lib/certificatesApi' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' +import { toast } from '@/components/ui/toast' +import { cn } from '@/lib/utils' +import { TableSkeleton } from '@/components/ui/page-skeleton' +import { DeployCertDialog } from './DeployCertDialog' +import { BulkDeployDialog } from './BulkDeployDialog' +import { CertConfirmDialog } from './CertConfirmDialog' + +// --------------------------------------------------------------------------- +// Status badge config +// --------------------------------------------------------------------------- + +const STATUS_CONFIG: Record< + string, + { label: string; className: string; icon?: React.FC<{ className?: string }> } +> = { + issued: { + label: 'Issued', + className: 'bg-info/20 text-info border-info/40', + }, + deploying: { + label: 'Deploying...', + className: 'bg-amber-500/20 text-amber-500 border-amber-500/40', + icon: Loader2, + }, + deployed: { + label: 'Deployed', + className: 'bg-green-500/20 text-green-500 border-green-500/40', + }, + expiring: { + label: 'Expiring Soon', + className: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/40', + }, + expired: { + label: 'Expired', + className: 'bg-error/20 text-error border-error/40', + }, + revoked: { + label: 'Revoked', + className: 'bg-text-muted/20 text-text-muted border-text-muted/40', + }, + superseded: { + label: 'Superseded', + className: 'bg-text-muted/20 text-text-muted border-text-muted/40', + }, +} + +function StatusBadge({ status }: { status: string }) { + const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.issued + const Icon = config.icon + return ( + + {Icon && } + {config.label} + + ) +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface DeviceCertTableProps { + certs: DeviceCertResponse[] + loading: boolean + caExists: boolean + canWrite: boolean + tenantId: string +} + +export function DeviceCertTable({ + certs, + loading, + caExists, + canWrite: writable, + tenantId, +}: DeviceCertTableProps) { + const queryClient = useQueryClient() + const [showDeployDialog, setShowDeployDialog] = useState(false) + const [showBulkDialog, setShowBulkDialog] = useState(false) + const [showAll, setShowAll] = useState(false) + const [confirmAction, setConfirmAction] = useState<{ + action: 'rotate' | 'revoke' + certId: string + hostname: string + } | null>(null) + + // ── Mutations ── + + const deployMutation = useMutation({ + mutationFn: (certId: string) => certificatesApi.deployCert(certId, tenantId), + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] }) + if (result.success) { + toast({ title: 'Certificate deployed successfully' }) + } else { + toast({ title: result.error ?? 'Deployment failed', variant: 'destructive' }) + } + }, + onError: (e: any) => + toast({ + title: e?.response?.data?.detail || 'Failed to deploy certificate', + variant: 'destructive', + }), + }) + + const rotateMutation = useMutation({ + mutationFn: (certId: string) => certificatesApi.rotateCert(certId, tenantId), + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] }) + if (result.success) { + toast({ title: 'Certificate rotated successfully' }) + } else { + toast({ title: result.error ?? 'Rotation failed', variant: 'destructive' }) + } + }, + onError: (e: any) => + toast({ + title: e?.response?.data?.detail || 'Failed to rotate certificate', + variant: 'destructive', + }), + }) + + const revokeMutation = useMutation({ + mutationFn: (certId: string) => certificatesApi.revokeCert(certId, tenantId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] }) + toast({ title: 'Certificate revoked' }) + }, + onError: (e: any) => + toast({ + title: e?.response?.data?.detail || 'Failed to revoke certificate', + variant: 'destructive', + }), + }) + + // ── Filtering ── + // By default hide superseded certs; show only latest per device + const filteredCerts = showAll + ? certs + : certs.filter((c) => c.status !== 'superseded') + + const isExpiringSoon = (dateStr: string) => { + const expiry = new Date(dateStr) + const now = new Date() + const daysLeft = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + return daysLeft <= 30 + } + + if (loading) { + return + } + + return ( +
    + {/* Toolbar */} +
    +

    + Device Certificates +

    +
    + {/* Toggle superseded */} + {certs.some((c) => c.status === 'superseded') && ( + + )} + {writable && caExists && ( + <> + + + + )} +
    +
    + + {/* Empty state */} + {filteredCerts.length === 0 ? ( +
    + +

    + No device certificates yet +

    +

    + Deploy certificates to your devices to secure API connections with + proper TLS. +

    + {writable && caExists && ( + + )} +
    + ) : ( + /* Table */ +
    + + + + + + + + + {writable && ( + + )} + + + + {filteredCerts.map((cert) => { + const expired = new Date(cert.not_valid_after) < new Date() + const expiringSoon = + !expired && isExpiringSoon(cert.not_valid_after) + + return ( + + {/* Device */} + + + {/* Fingerprint */} + + + {/* Status */} + + + {/* Valid Until */} + + + {/* Deployed At */} + + + {/* Actions */} + {writable && ( + + )} + + ) + })} + +
    + Device + + Fingerprint + + Status + + Valid Until + + Deployed + + Actions +
    + + {cert.common_name} + + + + {cert.fingerprint_sha256.slice(0, 24)}... + + + + + + {new Date(cert.not_valid_after).toLocaleDateString()} + + + + {cert.deployed_at + ? new Date(cert.deployed_at).toLocaleDateString() + : '\u2014'} + + + + + + + + {cert.status === 'issued' && ( + deployMutation.mutate(cert.id)} + disabled={deployMutation.isPending} + > + + Deploy + + )} + {(cert.status === 'deployed' || cert.status === 'expiring') && ( + <> + { + setConfirmAction({ + action: 'rotate', + certId: cert.id, + hostname: cert.common_name, + }) + }} + disabled={rotateMutation.isPending} + > + + Rotate + + { + setConfirmAction({ + action: 'revoke', + certId: cert.id, + hostname: cert.common_name, + }) + }} + disabled={revokeMutation.isPending} + className="text-error focus:text-error" + > + + Revoke + + + )} + {!['issued', 'deployed', 'expiring'].includes(cert.status) && ( + + No actions available + + )} + + +
    +
    + )} + + {/* Dialogs */} + {showDeployDialog && ( + setShowDeployDialog(false)} + tenantId={tenantId} + /> + )} + {showBulkDialog && ( + setShowBulkDialog(false)} + tenantId={tenantId} + /> + )} + + {/* Certificate action confirmation dialog */} + !open && setConfirmAction(null)} + action={confirmAction?.action ?? 'rotate'} + deviceHostname={confirmAction?.hostname ?? ''} + onConfirm={() => { + if (confirmAction?.action === 'rotate') { + rotateMutation.mutate(confirmAction.certId) + } else if (confirmAction?.action === 'revoke') { + revokeMutation.mutate(confirmAction.certId) + } + setConfirmAction(null) + }} + /> +
    + ) +} diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 0000000..9f1ae3f --- /dev/null +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,320 @@ +import { useEffect, useMemo } from 'react' +import { Command } from 'cmdk' +import { useNavigate } from '@tanstack/react-router' +import { useQueryClient } from '@tanstack/react-query' +import { + Monitor, + MapPin, + Terminal, + FileCode, + Download, + Bell, + BellRing, + Users, + Building2, + Settings, + Search, + LayoutDashboard, + Moon, + Sun, + PanelLeft, +} from 'lucide-react' +import { useCommandPalette } from './useCommandPalette' +import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth' +import { useUIStore } from '@/lib/store' +import type { DeviceListResponse } from '@/lib/api' + +interface PageCommand { + label: string + href: string + icon: React.FC<{ className?: string }> + description?: string + visible: boolean +} + +export function CommandPalette() { + const { open, setOpen } = useCommandPalette() + const navigate = useNavigate() + const { user } = useAuth() + const { theme, setTheme, toggleSidebar } = useUIStore() + const queryClient = useQueryClient() + + // Global Cmd+K / Ctrl+K listener + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + setOpen(!open) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, setOpen]) + + const tenantDevicesHref = isSuperAdmin(user) + ? '/tenants' + : `/tenants/${user?.tenant_id ?? ''}/devices` + + // Build page commands based on user role + const pageCommands: PageCommand[] = useMemo( + () => [ + { + label: 'Dashboard', + href: tenantDevicesHref, + icon: LayoutDashboard, + description: 'Fleet overview', + visible: true, + }, + { + label: 'Devices', + href: tenantDevicesHref, + icon: Monitor, + description: 'Device list', + visible: true, + }, + { + label: 'Map', + href: '/map', + icon: MapPin, + description: 'Network map', + visible: true, + }, + { + label: 'Config Editor', + href: '/config-editor', + icon: Terminal, + description: 'Device configuration', + visible: true, + }, + { + label: 'Templates', + href: '/templates', + icon: FileCode, + description: 'Config templates', + visible: true, + }, + { + label: 'Firmware', + href: '/firmware', + icon: Download, + description: 'Firmware management', + visible: true, + }, + { + label: 'Alerts', + href: '/alerts', + icon: Bell, + description: 'Active alerts', + visible: true, + }, + { + label: 'Alert Rules', + href: '/alert-rules', + icon: BellRing, + description: 'Alert rule configuration', + visible: true, + }, + { + label: 'Users', + href: `/tenants/${user?.tenant_id ?? ''}/users`, + icon: Users, + description: 'User management', + visible: isTenantAdmin(user) && !!user?.tenant_id, + }, + { + label: 'Organizations', + href: '/tenants', + icon: Building2, + description: 'Organization management', + visible: isSuperAdmin(user) || isTenantAdmin(user), + }, + { + label: 'Settings', + href: '/settings', + icon: Settings, + description: 'Application settings', + visible: true, + }, + ], + [user, tenantDevicesHref], + ) + + // Get cached devices from TanStack Query (try common query key patterns) + const devicesCache = useMemo(() => { + // Try to find devices data in the query cache + const allQueries = queryClient.getQueriesData({ + queryKey: ['devices'], + }) + const devices: Array<{ + id: string + hostname: string + ip_address: string + tenant_id: string + }> = [] + + for (const [, data] of allQueries) { + if (data && 'items' in data && Array.isArray(data.items)) { + for (const device of data.items) { + devices.push({ + id: device.id, + hostname: device.hostname, + ip_address: device.ip_address, + tenant_id: user?.tenant_id ?? '', + }) + } + } + } + + // Also try fleet summary cache + const fleetQueries = queryClient.getQueriesData< + Array<{ + id: string + hostname: string + ip_address: string + tenant_id: string + }> + >({ queryKey: ['fleet'] }) + for (const [, data] of fleetQueries) { + if (Array.isArray(data)) { + for (const device of data) { + if ( + device.id && + device.hostname && + !devices.some((d) => d.id === device.id) + ) { + devices.push({ + id: device.id, + hostname: device.hostname, + ip_address: device.ip_address, + tenant_id: device.tenant_id ?? user?.tenant_id ?? '', + }) + } + } + } + } + + return devices + }, [queryClient, user?.tenant_id, open]) + + const handleSelect = (href: string) => { + setOpen(false) + void navigate({ to: href }) + } + + const visiblePages = pageCommands.filter((p) => p.visible) + + const itemClass = + 'flex items-center gap-3 px-2 py-2 rounded-lg text-sm text-text-secondary cursor-pointer data-[selected=true]:bg-accent-muted data-[selected=true]:text-accent' + const groupHeadingClass = + '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-text-muted [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider' + + return ( + +
    +
    + + +
    + + + No results found. + + + {/* Pages group */} + + {visiblePages.map((page) => { + const Icon = page.icon + return ( + handleSelect(page.href)} + className={itemClass} + > + + {page.label} + {page.description && ( + + {page.description} + + )} + + ) + })} + + + {/* Devices group (from cache) */} + {devicesCache.length > 0 && ( + + {devicesCache.slice(0, 20).map((device) => ( + + handleSelect( + `/tenants/${device.tenant_id}/devices`, + ) + } + className={itemClass} + > + + {device.hostname} + + {device.ip_address} + + + ))} + + )} + + {/* Actions group */} + + { + setTheme(theme === 'dark' ? 'light' : 'dark') + setOpen(false) + }} + className={itemClass} + > + {theme === 'dark' ? ( + + ) : ( + + )} + + Toggle {theme === 'dark' ? 'Light' : 'Dark'} Mode + + + { + toggleSidebar() + setOpen(false) + }} + className={itemClass} + > + + Toggle Sidebar + + + + + {/* Footer with shortcut hints */} +
    + Navigate with arrow keys + Open with Cmd+K +
    +
    +
    + ) +} diff --git a/frontend/src/components/command-palette/useCommandPalette.ts b/frontend/src/components/command-palette/useCommandPalette.ts new file mode 100644 index 0000000..a8c7179 --- /dev/null +++ b/frontend/src/components/command-palette/useCommandPalette.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand' + +interface CommandPaletteState { + open: boolean + setOpen: (open: boolean) => void + toggle: () => void +} + +export const useCommandPalette = create((set) => ({ + open: false, + setOpen: (open) => set({ open }), + toggle: () => set((s) => ({ open: !s.open })), +})) diff --git a/frontend/src/components/config-editor/CommandExecutor.tsx b/frontend/src/components/config-editor/CommandExecutor.tsx new file mode 100644 index 0000000..0869359 --- /dev/null +++ b/frontend/src/components/config-editor/CommandExecutor.tsx @@ -0,0 +1,168 @@ +/** + * CommandExecutor -- collapsible panel for executing arbitrary RouterOS + * CLI commands inline, with command history (up/down arrow navigation). + */ + +import { useState, useRef, useCallback } from 'react' +import { ChevronDown, ChevronUp, Terminal, Loader2, Play } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { configEditorApi } from '@/lib/configEditorApi' + +interface CommandExecutorProps { + tenantId: string + deviceId: string + currentPath: string +} + +interface CommandResult { + command: string + success: boolean + output: string + timestamp: string +} + +export function CommandExecutor({ tenantId, deviceId, currentPath }: CommandExecutorProps) { + const [expanded, setExpanded] = useState(false) + const [command, setCommand] = useState('') + const [executing, setExecuting] = useState(false) + const [results, setResults] = useState([]) + const [history, setHistory] = useState([]) + const [historyIndex, setHistoryIndex] = useState(-1) + const inputRef = useRef(null) + + const executeCommand = useCallback(async () => { + const cmd = command.trim() + if (!cmd) return + + setExecuting(true) + setHistory((prev) => { + const filtered = prev.filter((h) => h !== cmd) + return [cmd, ...filtered].slice(0, 10) + }) + setHistoryIndex(-1) + + try { + const result = await configEditorApi.execute(tenantId, deviceId, cmd) + setResults((prev) => [ + { + command: cmd, + success: result.success, + output: result.data + ? result.data.map((row) => Object.entries(row).map(([k, v]) => `${k}: ${v}`).join('\n')).join('\n---\n') + : result.error || 'Command executed (no output)', + timestamp: new Date().toLocaleTimeString(), + }, + ...prev, + ].slice(0, 20)) + } catch (err) { + setResults((prev) => [ + { + command: cmd, + success: false, + output: err instanceof Error ? err.message : 'Command failed', + timestamp: new Date().toLocaleTimeString(), + }, + ...prev, + ].slice(0, 20)) + } finally { + setExecuting(false) + setCommand('') + } + }, [command, tenantId, deviceId]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + executeCommand() + } else if (e.key === 'ArrowUp') { + e.preventDefault() + if (history.length > 0) { + const newIndex = Math.min(historyIndex + 1, history.length - 1) + setHistoryIndex(newIndex) + setCommand(history[newIndex]) + } + } else if (e.key === 'ArrowDown') { + e.preventDefault() + if (historyIndex > 0) { + const newIndex = historyIndex - 1 + setHistoryIndex(newIndex) + setCommand(history[newIndex]) + } else { + setHistoryIndex(-1) + setCommand('') + } + } + } + + return ( +
    + + + {expanded && ( +
    +
    + setCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={`${currentPath}/print`} + disabled={executing} + className="h-7 text-xs bg-elevated/50 border-border font-mono flex-1" + /> + +
    + + {results.length > 0 && ( +
    + {results.map((r, i) => ( +
    +
    + {r.timestamp} + {r.command} + + {r.success ? 'OK' : 'ERR'} + +
    +
    +                    {r.output}
    +                  
    +
    + ))} +
    + )} +
    + )} +
    + ) +} diff --git a/frontend/src/components/config-editor/ConfigEditorPage.tsx b/frontend/src/components/config-editor/ConfigEditorPage.tsx new file mode 100644 index 0000000..790476f --- /dev/null +++ b/frontend/src/components/config-editor/ConfigEditorPage.tsx @@ -0,0 +1,431 @@ +/** + * ConfigEditorPage -- main config editor page with tree sidebar navigation, + * entry table, add/edit/delete forms, and command executor. + * + * This page requires a device to be selected. If accessed via the sidebar + * without a device, it shows a device picker. Once a device is selected, + * users can browse RouterOS menu paths and manage entries. + */ + +import { useState, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Terminal, ChevronRight, Loader2, WifiOff, Building2 } from 'lucide-react' +import { configEditorApi, type BrowseResponse } from '@/lib/configEditorApi' +import { metricsApi } from '@/lib/api' +import { useAuth, isSuperAdmin } from '@/lib/auth' +import { useUIStore } from '@/lib/store' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { MenuTree } from './MenuTree' +import { EntryTable } from './EntryTable' +import { EntryForm } from './EntryForm' +import { CommandExecutor } from './CommandExecutor' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +export function ConfigEditorPage() { + const { user } = useAuth() + const queryClient = useQueryClient() + const isSuper = isSuperAdmin(user) + const { selectedTenantId } = useUIStore() + const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '') + + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + const [currentPath, setCurrentPath] = useState('/interface') + + // Form state + const [formOpen, setFormOpen] = useState(false) + const [formMode, setFormMode] = useState<'add' | 'edit'>('add') + const [editingEntry, setEditingEntry] = useState | undefined>() + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [deletingEntry, setDeletingEntry] = useState | null>(null) + + // Fetch fleet devices for device selection + const { data: devices, isLoading: devicesLoading } = useQuery({ + queryKey: ['fleet-devices', tenantId], + queryFn: () => metricsApi.fleetSummary(tenantId), + enabled: !!tenantId, + }) + + // Get selected device info + const selectedDevice = devices?.find((d) => d.id === selectedDeviceId) + const isOnline = selectedDevice?.status === 'online' + + // Browse entries at current path + const { + data: browseData, + isLoading: browsing, + error: browseError, + } = useQuery({ + queryKey: ['config-editor', selectedDeviceId, currentPath], + queryFn: () => configEditorApi.browse(tenantId, selectedDeviceId!, currentPath), + enabled: !!selectedDeviceId && isOnline, + retry: false, + }) + + const entries = browseData?.entries ?? [] + const columns = entries.length > 0 ? Object.keys(entries[0]).filter((k) => k !== '.id') : [] + + // Mutations + const addMutation = useMutation({ + mutationFn: (props: Record) => + configEditorApi.addEntry(tenantId, selectedDeviceId!, currentPath, props), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['config-editor', selectedDeviceId, currentPath] }) + toast({ title: 'Entry added successfully' }) + }, + onError: (err) => toast({ title: 'Failed to add entry', description: String(err), variant: 'destructive' }), + }) + + const setMutation = useMutation({ + mutationFn: ({ entryId, props }: { entryId: string; props: Record }) => + configEditorApi.setEntry(tenantId, selectedDeviceId!, currentPath, entryId, props), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['config-editor', selectedDeviceId, currentPath] }) + toast({ title: 'Entry updated successfully' }) + }, + onError: (err) => toast({ title: 'Failed to update entry', description: String(err), variant: 'destructive' }), + }) + + const removeMutation = useMutation({ + mutationFn: (entryId: string) => + configEditorApi.removeEntry(tenantId, selectedDeviceId!, currentPath, entryId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['config-editor', selectedDeviceId, currentPath] }) + toast({ title: 'Entry removed' }) + setDeleteConfirmOpen(false) + setDeletingEntry(null) + }, + onError: (err) => toast({ title: 'Failed to remove entry', description: String(err), variant: 'destructive' }), + }) + + const handleAdd = () => { + setFormMode('add') + setEditingEntry(undefined) + setFormOpen(true) + } + + const handleEdit = (entry: Record) => { + setFormMode('edit') + setEditingEntry(entry) + setFormOpen(true) + } + + const handleDelete = (entry: Record) => { + setDeletingEntry(entry) + setDeleteConfirmOpen(true) + } + + const handleFormSubmit = async (properties: Record) => { + if (formMode === 'add') { + await addMutation.mutateAsync(properties) + } else if (editingEntry) { + const entryId = editingEntry['.id'] + if (!entryId) throw new Error('Entry has no .id field') + await setMutation.mutateAsync({ entryId, props: properties }) + } + } + + const handleConfirmDelete = () => { + if (deletingEntry?.['.id']) { + removeMutation.mutate(deletingEntry['.id']) + } + } + + // Breadcrumb segments + const pathSegments = currentPath.split('/').filter(Boolean) + + // Super_admin with no tenant selected -- prompt to use header org selector + if (isSuper && !tenantId) { + return ( +
    +
    + +

    Config Editor

    +
    +
    +
    + +
    + Select an organization from the header to view its devices. +
    +
    + Use the organization selector in the top navigation bar. +
    +
    +
    +
    + ) + } + + // Device selection view + if (!selectedDeviceId) { + const onlineDevices = devices?.filter((d) => d.status === 'online') ?? [] + + return ( +
    +
    + +

    Config Editor

    +
    +
    +
    + +
    Select a device to open the config editor.
    + {devicesLoading ? ( +
    + + Loading devices... +
    + ) : onlineDevices.length > 0 ? ( + + ) : ( +
    + {devices && devices.length > 0 + ? 'All devices are currently offline' + : 'No devices found for this organization'} +
    + )} +
    +
    +
    + ) + } + + return ( +
    + {/* Header */} +
    + +

    Config Editor

    + | + {selectedDevice?.hostname ?? ''} + {selectedDevice?.ip_address ?? ''} +
    +
    + +
    +
    + + {/* Offline banner */} + {!isOnline && ( +
    + + This device is currently offline. The config editor needs a live connection. +
    + )} + + {/* Breadcrumb */} +
    + + {pathSegments.map((seg, i) => { + const path = '/' + pathSegments.slice(0, i + 1).join('/') + return ( + + + + + ) + })} +
    + + {/* Main content area */} +
    + {/* Left: Menu Tree */} +
    + +
    + + {/* Center: Entry Table */} +
    + +
    +
    + + {/* Bottom: Command Executor */} + + + {/* Entry Form Dialog */} + setFormOpen(false)} + mode={formMode} + entry={editingEntry} + columns={columns} + onSubmit={handleFormSubmit} + /> + + {/* Delete Confirmation Dialog */} + !o && setDeleteConfirmOpen(false)}> + + + Confirm Delete + +

    + Are you sure you want to remove this entry? This action will take effect on the device + immediately. +

    + + {/* Entry details */} + {deletingEntry && ( +
    + {deletingEntry['.id'] && ( +
    + ID + {deletingEntry['.id']} +
    + )} + {deletingEntry.chain && ( +
    + Chain + {deletingEntry.chain} +
    + )} + {deletingEntry.action && ( +
    + Action + {deletingEntry.action} +
    + )} + {deletingEntry.protocol && ( +
    + Protocol + {deletingEntry.protocol} +
    + )} + {deletingEntry['dst-port'] && ( +
    + Port + {deletingEntry['dst-port']} +
    + )} + {deletingEntry['src-address'] && ( +
    + Source + {deletingEntry['src-address']} +
    + )} + {deletingEntry.address && ( +
    + Address + {deletingEntry.address} +
    + )} + {deletingEntry.name && ( +
    + Name + {deletingEntry.name} +
    + )} + {deletingEntry.comment && ( +
    + Comment + {deletingEntry.comment} +
    + )} +
    + )} + + {/* Backup before delete option */} + + +
    + + +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/config-editor/EntryForm.tsx b/frontend/src/components/config-editor/EntryForm.tsx new file mode 100644 index 0000000..9f7b0b5 --- /dev/null +++ b/frontend/src/components/config-editor/EntryForm.tsx @@ -0,0 +1,173 @@ +/** + * EntryForm -- dialog for adding or editing a RouterOS entry. + * Dynamically generates form fields based on the entry properties + * with type heuristics for boolean, IP, and numeric fields. + */ + +import { useState } from 'react' +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +/** Fields that are read-only and should be skipped in edit mode */ +const SKIP_FIELDS = new Set(['.id', 'running', 'dynamic', 'default', 'invalid']) + +/** Fields that should render as checkboxes */ +const BOOLEAN_FIELDS = new Set([ + 'disabled', + 'running', + 'active', + 'dynamic', + 'default', + 'invalid', + 'comment', + 'passthrough', + 'logging', +]) + +function isBooleanValue(value: string): boolean { + return ['true', 'false', 'yes', 'no'].includes(value.toLowerCase()) +} + +function isIpLike(value: string): boolean { + return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?$/.test(value) +} + +function isNumeric(value: string): boolean { + return /^\d+$/.test(value) +} + +interface EntryFormProps { + open: boolean + onClose: () => void + mode: 'add' | 'edit' + entry?: Record + /** All columns from the table (used for add mode field generation) */ + columns: string[] + onSubmit: (properties: Record) => Promise +} + +export function EntryForm({ open, onClose, mode, entry, columns, onSubmit }: EntryFormProps) { + const editableFields = + mode === 'edit' && entry + ? Object.keys(entry).filter((k) => !SKIP_FIELDS.has(k)) + : columns.filter((k) => !SKIP_FIELDS.has(k)) + + const [values, setValues] = useState>(() => { + if (mode === 'edit' && entry) { + const v: Record = {} + for (const key of editableFields) { + v[key] = entry[key] ?? '' + } + return v + } + // Add mode: empty values for each column + const v: Record = {} + for (const key of editableFields) { + v[key] = '' + } + return v + }) + + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async () => { + setSubmitting(true) + setError(null) + try { + // Filter out empty values for add mode + const properties: Record = {} + for (const [k, v] of Object.entries(values)) { + if (mode === 'add' && v === '') continue + properties[k] = v + } + await onSubmit(properties) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Operation failed') + } finally { + setSubmitting(false) + } + } + + const renderField = (key: string) => { + const value = values[key] ?? '' + const originalValue = entry?.[key] ?? '' + + // Determine field type via heuristics + if (BOOLEAN_FIELDS.has(key) || isBooleanValue(originalValue || value)) { + const checked = value === 'true' || value === 'yes' + return ( +
    + + setValues((prev) => ({ ...prev, [key]: c ? 'true' : 'false' })) + } + /> + +
    + ) + } + + return ( +
    + + setValues((prev) => ({ ...prev, [key]: e.target.value }))} + placeholder={ + isIpLike(originalValue) ? '0.0.0.0/0' : isNumeric(originalValue) ? '0' : '' + } + type={isNumeric(originalValue) && !isIpLike(originalValue) ? 'number' : 'text'} + className="h-7 text-xs bg-elevated/50 border-border font-mono" + /> +
    + ) + } + + return ( + !o && onClose()}> + + + + {mode === 'add' ? 'Add New Entry' : 'Edit Entry'} + + + +
    + {editableFields.map(renderField)} +
    + + {error && ( +
    {error}
    + )} + +
    + + +
    +
    +
    + ) +} diff --git a/frontend/src/components/config-editor/EntryTable.tsx b/frontend/src/components/config-editor/EntryTable.tsx new file mode 100644 index 0000000..542528c --- /dev/null +++ b/frontend/src/components/config-editor/EntryTable.tsx @@ -0,0 +1,170 @@ +/** + * EntryTable -- displays RouterOS entries at the current menu path + * in a dynamic table with edit/delete action buttons. + */ + +import { Pencil, Trash2, Plus, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { canWrite } from '@/lib/auth' +import { useAuth } from '@/lib/auth' + +interface EntryTableProps { + entries: Record[] + currentPath: string + isLoading: boolean + error: string | null + onEdit: (entry: Record) => void + onDelete: (entry: Record) => void + onAdd: () => void +} + +/** Read-only fields that should not have edit buttons */ +const READ_ONLY_FIELDS = new Set(['.id', 'running', 'dynamic', 'default', 'invalid']) + +export function EntryTable({ + entries, + currentPath, + isLoading, + error, + onEdit, + onDelete, + onAdd, +}: EntryTableProps) { + const { user } = useAuth() + const writable = canWrite(user) + + if (error) { + const isContainerPath = + error.includes('no such command') || + error.includes('502') || + error.includes('Failed to browse') + return ( +
    + {isContainerPath ? ( + <> +

    This is a menu category

    +

    + Select a sub-menu from the tree on the left to view entries. Container paths like{' '} + {currentPath} group related sub-menus and cannot be + listed directly. +

    + + ) : ( + error + )} +
    + ) + } + + if (isLoading) { + return ( +
    +
    +
    +
    +
    + {Array.from({ length: 5 }).map((_, i) => ( +
    + ))} +
    + ) + } + + // Compute visible columns from first entry (hide .id — used internally only) + const columns = + entries.length > 0 + ? Object.keys(entries[0]) + .filter((k) => k !== '.id') + .sort((a, b) => a.localeCompare(b)) + : [] + + return ( +
    +
    +
    + {entries.length} {entries.length === 1 ? 'entry' : 'entries'} at{' '} + {currentPath} +
    + {writable && ( + + )} +
    + + {entries.length === 0 ? ( +
    + No entries found at this path +
    + ) : ( +
    + + + + {columns.map((col) => ( + + ))} + {writable && ( + + )} + + + + {entries.map((entry, i) => ( + + {columns.map((col) => ( + + ))} + {writable && ( + + )} + + ))} + +
    + {col} + + Actions +
    + {entry[col] ?? ''} + +
    + + +
    +
    +
    + )} +
    + ) +} diff --git a/frontend/src/components/config-editor/MenuTree.tsx b/frontend/src/components/config-editor/MenuTree.tsx new file mode 100644 index 0000000..e95a6e7 --- /dev/null +++ b/frontend/src/components/config-editor/MenuTree.tsx @@ -0,0 +1,236 @@ +/** + * MenuTree -- left sidebar tree navigation for common RouterOS menu paths. + * Includes a custom path input for arbitrary paths. + */ + +import { useState } from 'react' +import { ChevronDown, ChevronRight, Folder, FolderOpen, File } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Input } from '@/components/ui/input' + +interface TreeNode { + label: string + path: string + children?: TreeNode[] +} + +const MENU_TREE: TreeNode[] = [ + { + label: 'interface', + path: '/interface', + children: [ + { label: 'bridge', path: '/interface/bridge' }, + { label: 'ethernet', path: '/interface/ethernet' }, + { label: 'vlan', path: '/interface/vlan' }, + { label: 'wireless', path: '/interface/wireless' }, + { label: 'bonding', path: '/interface/bonding' }, + { label: 'list', path: '/interface/list' }, + ], + }, + { + label: 'ip', + path: '/ip', + children: [ + { label: 'address', path: '/ip/address' }, + { label: 'route', path: '/ip/route' }, + { label: 'dns', path: '/ip/dns' }, + { label: 'dhcp-client', path: '/ip/dhcp-client' }, + { label: 'dhcp-server', path: '/ip/dhcp-server' }, + { + label: 'firewall', + path: '/ip/firewall', + children: [ + { label: 'filter', path: '/ip/firewall/filter' }, + { label: 'nat', path: '/ip/firewall/nat' }, + { label: 'mangle', path: '/ip/firewall/mangle' }, + { label: 'raw', path: '/ip/firewall/raw' }, + { label: 'address-list', path: '/ip/firewall/address-list' }, + { label: 'connection', path: '/ip/firewall/connection' }, + ], + }, + { label: 'pool', path: '/ip/pool' }, + { label: 'service', path: '/ip/service' }, + { label: 'neighbor', path: '/ip/neighbor' }, + ], + }, + { + label: 'system', + path: '/system', + children: [ + { label: 'identity', path: '/system/identity' }, + { label: 'clock', path: '/system/clock' }, + { label: 'ntp', path: '/system/ntp' }, + { label: 'resource', path: '/system/resource' }, + { label: 'routerboard', path: '/system/routerboard' }, + { label: 'scheduler', path: '/system/scheduler' }, + { label: 'script', path: '/system/script' }, + { label: 'logging', path: '/system/logging' }, + { label: 'package', path: '/system/package' }, + ], + }, + { + label: 'routing', + path: '/routing', + children: [ + { + label: 'ospf', + path: '/routing/ospf', + children: [ + { label: 'instance', path: '/routing/ospf/instance' }, + { label: 'area', path: '/routing/ospf/area' }, + { label: 'interface-template', path: '/routing/ospf/interface-template' }, + { label: 'static-neighbor', path: '/routing/ospf/static-neighbor' }, + ], + }, + { + label: 'bgp', + path: '/routing/bgp', + children: [ + { label: 'connection', path: '/routing/bgp/connection' }, + { label: 'template', path: '/routing/bgp/template' }, + ], + }, + { label: 'filter rule', path: '/routing/filter/rule' }, + { label: 'table', path: '/routing/table' }, + { label: 'rule', path: '/routing/rule' }, + ], + }, + { + label: 'queue', + path: '/queue', + children: [ + { label: 'simple', path: '/queue/simple' }, + { label: 'tree', path: '/queue/tree' }, + { label: 'type', path: '/queue/type' }, + ], + }, + { + label: 'tool', + path: '/tool', + children: [ + { label: 'bandwidth-server', path: '/tool/bandwidth-server' }, + { label: 'email', path: '/tool/email' }, + { label: 'fetch', path: '/tool/fetch' }, + { label: 'graphing', path: '/tool/graphing' }, + { label: 'netwatch', path: '/tool/netwatch' }, + { label: 'ping', path: '/tool/ping' }, + { label: 'sniffer', path: '/tool/sniffer' }, + ], + }, + { label: 'user', path: '/user' }, + { label: 'snmp', path: '/snmp' }, + { label: 'certificate', path: '/certificate' }, +] + +interface MenuTreeProps { + onPathSelect: (path: string) => void + currentPath: string +} + +function TreeItem({ + node, + currentPath, + onPathSelect, + depth = 0, +}: { + node: TreeNode + currentPath: string + onPathSelect: (path: string) => void + depth?: number +}) { + const [expanded, setExpanded] = useState(currentPath.startsWith(node.path)) + const hasChildren = node.children && node.children.length > 0 + const isActive = currentPath === node.path + + return ( +
    + + {expanded && hasChildren && ( +
    + {node.children!.map((child) => ( + + ))} +
    + )} +
    + ) +} + +export function MenuTree({ onPathSelect, currentPath }: MenuTreeProps) { + const [customPath, setCustomPath] = useState('') + + return ( +
    +
    +
    Menu
    +
    +
    + {MENU_TREE.map((node) => ( + + ))} +
    +
    +
    Custom path
    + setCustomPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && customPath.trim()) { + onPathSelect(customPath.trim()) + } + }} + placeholder="/caps-man/interface" + className="h-7 text-xs bg-elevated/50 border-border" + /> +
    +
    + ) +} diff --git a/frontend/src/components/config/AddressListPanel.tsx b/frontend/src/components/config/AddressListPanel.tsx new file mode 100644 index 0000000..c8608f8 --- /dev/null +++ b/frontend/src/components/config/AddressListPanel.tsx @@ -0,0 +1,282 @@ +/** + * AddressListPanel -- Firewall address lists management. + * + * View/add/edit/delete address list entries (/ip/firewall/address-list), + * grouped by list name with collapsible sections, timeout display, + * bulk import. Standard apply mode by default. + */ + +import { useState, useCallback, useMemo } from 'react' +import { Plus, Trash2, ChevronDown, ChevronRight, List, Upload } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { SafetyToggle } from './SafetyToggle' +import { ChangePreviewModal } from './ChangePreviewModal' +import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel' +import { cn } from '@/lib/utils' +import type { ConfigPanelProps } from '@/lib/configPanelTypes' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface AddressListEntry { + '.id': string + list: string + address: string + timeout: string + dynamic: string + disabled: string + comment: string + [key: string]: string +} + +interface AddressListForm { + list: string + address: string + comment: string +} + +const EMPTY_FORM: AddressListForm = { list: '', address: '', comment: '' } + +type PanelHook = ReturnType + +// --------------------------------------------------------------------------- +// AddressListPanel +// --------------------------------------------------------------------------- + +export function AddressListPanel({ tenantId, deviceId, active }: ConfigPanelProps) { + const { entries, isLoading, error, refetch } = useConfigBrowse( + tenantId, deviceId, '/ip/firewall/address-list', { enabled: active }, + ) + const panel = useConfigPanel(tenantId, deviceId, 'address-lists') + const [previewOpen, setPreviewOpen] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const [bulkOpen, setBulkOpen] = useState(false) + const [form, setForm] = useState(EMPTY_FORM) + const [bulkList, setBulkList] = useState('') + const [bulkAddresses, setBulkAddresses] = useState('') + const [collapsed, setCollapsed] = useState>(new Set()) + + const typedEntries = entries as AddressListEntry[] + + // Group by list name + const grouped = useMemo(() => { + const map = new Map() + typedEntries.forEach((e) => { + const list = e.list || 'unknown' + if (!map.has(list)) map.set(list, []) + map.get(list)!.push(e) + }) + return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)) + }, [typedEntries]) + + const listNames = useMemo(() => grouped.map(([name]) => name), [grouped]) + + const toggleCollapse = useCallback((name: string) => { + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + }, []) + + const handleAdd = useCallback(() => { + setForm(EMPTY_FORM) + setDialogOpen(true) + }, []) + + const handleDelete = useCallback( + (entry: AddressListEntry) => { + panel.addChange({ + operation: 'remove', path: '/ip/firewall/address-list', + entryId: entry['.id'], properties: {}, + description: `Remove ${entry.address} from list "${entry.list}"`, + }) + }, + [panel], + ) + + const handleSave = useCallback(() => { + if (!form.list || !form.address) return + const props: Record = { list: form.list, address: form.address } + if (form.comment) props.comment = form.comment + panel.addChange({ + operation: 'add', path: '/ip/firewall/address-list', properties: props, + description: `Add ${form.address} to list "${form.list}"`, + }) + setDialogOpen(false) + }, [form, panel]) + + const handleBulkImport = useCallback(() => { + if (!bulkList || !bulkAddresses.trim()) return + const addresses = bulkAddresses.split('\n').map((a) => a.trim()).filter(Boolean) + addresses.forEach((addr) => { + panel.addChange({ + operation: 'add', path: '/ip/firewall/address-list', + properties: { list: bulkList, address: addr }, + description: `Add ${addr} to list "${bulkList}"`, + }) + }) + setBulkOpen(false) + setBulkAddresses('') + }, [bulkList, bulkAddresses, panel]) + + if (isLoading) { + return
    Loading address lists...
    + } + if (error) { + return
    Failed to load address lists.
    + } + + return ( +
    +
    + + +
    + +
    +
    +
    + + Address Lists ({typedEntries.length} entries, {grouped.length} lists) +
    +
    + + +
    +
    + + {grouped.length === 0 ? ( +
    No address list entries found.
    + ) : ( +
    + {grouped.map(([listName, listEntries]) => ( +
    + + {!collapsed.has(listName) && ( +
    + + + {listEntries.map((entry) => ( + + + + + + + + ))} + +
    {entry.address}{entry.timeout || '—'} + {entry.dynamic === 'true' + ? dynamic + : static} + {entry.comment || ''} + {entry.dynamic !== 'true' && ( + + )} +
    +
    + )} +
    + ))} +
    + )} +
    + + {/* Add single entry dialog */} + + + + Add Address List Entry + Add an address to a firewall address list. + +
    +
    + + setForm((f) => ({ ...f, list: e.target.value }))} placeholder="blocklist" className="h-8 text-sm" list="list-names" /> + {listNames.map((n) => +
    +
    + + setForm((f) => ({ ...f, address: e.target.value }))} placeholder="192.168.1.0/24" className="h-8 text-sm font-mono" /> +
    +
    + + setForm((f) => ({ ...f, comment: e.target.value }))} placeholder="optional" className="h-8 text-sm" /> +
    +
    + + + + +
    +
    + + {/* Bulk import dialog */} + + + + Bulk Import Addresses + Paste one address per line to add them all to a list. + +
    +
    + + setBulkList(e.target.value)} placeholder="blocklist" className="h-8 text-sm" list="bulk-list-names" /> + {listNames.map((n) => +
    +
    + +