From ed8fc831bf3e483567c2b63463bff0940bcb0350 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Thu, 27 Aug 2026 17:32:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B2=D1=96=20=D1=81=D0=B5=D1=81=D1=96?= =?UTF-8?q?=D1=97=20=D1=80=D0=BE=D0=B1=D0=BE=D1=82=D0=B8:=200058=E2=80=930?= =?UTF-8?q?068,=20=D1=80=D0=BE=D0=B7=D0=B3=D0=BE=D1=80=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8F=20=D0=BE=D0=B4=D0=BD=D1=96=D1=94=D1=8E=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=BE=D1=8E,=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Один коміт, а не десяток тематичних, свідомо: теми переплетені в спільних файлах (store.go, docker-compose.yml, deploy/README.md), і розділити їх можна було б лише індексуванням шматків. Коміти, які не збираються, гірші за один великий — тим паче що це рівно той стан, який перевірявся разом. ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ 0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить події; правило з нереалізованим джерелом більше не зберігається мовчки 0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом описуються шаблоном, а не Go 0060 відкат конфігу: план як різниця, маскування паролів із підписом плану, обов'язковий контрольний збір, verifying при обриві 0061 кнопки Telegram: довге опитування, авторизація не з callback_data 0062 аудит і архів хостів; тест на AST, що падає на ключі без назви 0063 RLS: три ролі, окремий пул для фонових тактів 0064 строки зберігання даних і сторінка сховища 0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту, переклад v1→v2 за RFC 3584 дає правильний OID 0066 ескалації сповіщень 0067 алерт про вичерпання диска 0068 поля заливки конфігу переїхали в каталог профілів Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх вад, знайдених ними й виправлених, і виправлення двох інтеграційних тестів grpcapi, які мовчки пропускались півтора року. ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ netpulse установник: одна команда замість 18 змінних і 593 рядків інструкції RLS з першого запуску нова інсталяція під політиками одразу; RLS-EXISTING-INSTALL.md лишається тільки для старих інсталяцій .forgejo + CI раннер не зареєстрований Ці три перевірені компіляцією й міркуванням, але не виконанням. ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести grpcapi були зелені, бо не виконувались. Схема, довідник і протокол описували те, чого в коді не існувало, і виглядало це як готове. Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а чотири задачі закінчились не можливістю, а відмовою: правило з нереалізованим джерелом не зберігається, профіль без команд заливки каже про це замість мовчазної кнопки, міграція RLS валить сама себе на таблиці без політики, тест словника аудиту падає на ключі без назви. Подробиці — HISTORY.md, розділи за 26 і 27 серпня. --- .forgejo/workflows/ci.yml | 442 +- .gitignore | 3 + HISTORY.md | 4169 +++++++++++++++++ ROADMAP.md | 141 +- agent/cmd/netpulse-agent/main.go | 45 +- agent/internal/config/config.go | 26 + agent/internal/localfiles/bundle.go | 92 + agent/internal/localfiles/collect.go | 282 ++ agent/internal/localfiles/deny.go | 116 + agent/internal/localfiles/localfiles_test.go | 254 + agent/internal/localfiles/machine.go | 66 + agent/internal/localfiles/owner_unix.go | 24 + agent/internal/localfiles/owner_windows.go | 14 + agent/internal/localfiles/policy.go | 260 + agent/internal/modules/filecfg/filecfg.go | 76 + agent/internal/modules/snmp/snmp.go | 9 +- agent/internal/modules/snmp/walk.go | 242 + agent/internal/modules/snmp/walk_test.go | 95 + agent/internal/modules/topology/topology.go | 58 +- agent/internal/modules/traps/mib.go | 92 + agent/internal/modules/traps/parse.go | 285 ++ agent/internal/modules/traps/receiver.go | 329 ++ agent/internal/modules/traps/traps_test.go | 438 ++ agent/internal/ncmx/apply.go | 242 + agent/internal/ncmx/apply_test.go | 147 + agent/internal/ncmx/collect.go | 79 +- agent/internal/ncmx/commands.go | 144 + agent/internal/ncmx/commands_test.go | 107 + agent/internal/ncmx/transport.go | 44 +- agent/internal/scheduler/scheduler.go | 5 +- agent/internal/session/apply_jobs.go | 90 + agent/internal/session/command_jobs.go | 53 + agent/internal/session/config_jobs.go | 35 + agent/internal/session/localfiles_jobs.go | 73 + agent/internal/session/logs.go | 68 +- agent/internal/session/session.go | 36 +- db/profiles/README.md | 83 +- db/profiles/apply.generated.sql | 132 + db/profiles/build.py | 166 +- db/profiles/catalog.json | 110 +- deploy/.env.example | 45 +- deploy/Dockerfile.server | 14 +- deploy/README.md | 27 +- deploy/RLS-EXISTING-INSTALL.md | 419 ++ deploy/act-runner.config.yml | 104 + deploy/docker-compose.ci.yml | 146 + deploy/files.conf.example | 88 + docker-compose.yml | 130 +- gen/go/netpulse/v1/ncm.pb.go | 272 +- netpulse | 1821 +++++++ netpulse.conf.example | 123 + proto/netpulse/v1/ncm.proto | 56 + scripts/check-eol.sh | 100 + scripts/check.sh | 138 + server/API.md | 69 +- server/cmd/netpulse-api/main.go | 47 +- server/cmd/netpulse-gitsync/main.go | 21 +- server/cmd/netpulse-migrate/main.go | 305 +- server/cmd/netpulse-migrate/roles_test.go | 96 + server/cmd/netpulse-server/main.go | 38 +- server/internal/alerting/engine.go | 85 +- server/internal/alerting/escalation.go | 149 + server/internal/alerting/escalation_test.go | 97 + server/internal/alerting/events.go | 820 ++++ server/internal/alerting/events_test.go | 113 + server/internal/alerting/notify.go | 106 +- server/internal/alerting/telegram_bot.go | 662 +++ server/internal/alerting/telegram_callback.go | 263 ++ .../alerting/telegram_callback_test.go | 255 + server/internal/alerting/traps_test.go | 142 + server/internal/gitstore/branches.go | 180 + server/internal/gitstore/branches_test.go | 143 + server/internal/gitstore/deploykey.go | 77 + server/internal/gitstore/deploykey_test.go | 56 + server/internal/gitstore/mirror.go | 624 +++ server/internal/gitstore/mirror_test.go | 201 + server/internal/gitstore/store.go | 46 + server/internal/grpcapi/integration_test.go | 37 +- server/internal/grpcapi/ncm_commands.go | 216 + server/internal/grpcapi/ncm_dispatch.go | 75 + server/internal/grpcapi/ncm_mirror.go | 335 ++ server/internal/grpcapi/ncm_retention.go | 98 + server/internal/grpcapi/ncm_rollback.go | 139 + server/internal/grpcapi/ncm_schedule.go | 2 +- server/internal/grpcapi/plan_sync.go | 38 + server/internal/grpcapi/service.go | 35 + server/internal/grpcapi/storage_retention.go | 155 + server/internal/grpcapi/streams.go | 193 +- server/internal/httpapi/alerts.go | 222 +- server/internal/httpapi/audit.go | 193 + server/internal/httpapi/checks.go | 33 + server/internal/httpapi/compliance.go | 25 + server/internal/httpapi/device_detail.go | 74 + server/internal/httpapi/devices_bulk.go | 628 +++ server/internal/httpapi/downloads.go | 130 + server/internal/httpapi/git_mirror.go | 329 ++ server/internal/httpapi/maps_write.go | 52 + server/internal/httpapi/metrics_devices.go | 44 + server/internal/httpapi/ncm_commands.go | 336 ++ server/internal/httpapi/ncm_config_devices.go | 74 + server/internal/httpapi/ncm_configs.go | 176 + server/internal/httpapi/ncm_localfiles.go | 138 + server/internal/httpapi/ncm_rollback.go | 368 ++ server/internal/httpapi/queues.go | 231 + server/internal/httpapi/roles.go | 293 ++ server/internal/httpapi/server.go | 182 +- server/internal/httpapi/storage.go | 236 + server/internal/httpapi/telegram_link.go | 97 + server/internal/httpapi/templates.go | 86 +- server/internal/httpapi/templates_iffilter.go | 77 + server/internal/httpapi/traps.go | 223 + server/internal/httpapi/tv.go | 3 +- server/internal/store/agents.go | 34 +- server/internal/store/alerts.go | 62 +- server/internal/store/alerts_channels.go | 11 +- server/internal/store/alerts_escalation.go | 653 +++ .../store/alerts_escalation_db_test.go | 238 + .../internal/store/alerts_escalation_test.go | 484 ++ server/internal/store/alerts_events.go | 579 +++ server/internal/store/alerts_events_test.go | 221 + server/internal/store/alerts_query.go | 174 +- server/internal/store/alerts_state.go | 13 +- server/internal/store/apitokens.go | 4 +- server/internal/store/audit.go | 70 + server/internal/store/audit_actions.go | 349 ++ .../store/audit_actions_source_test.go | 409 ++ server/internal/store/audit_read.go | 815 ++++ server/internal/store/audit_read_test.go | 160 + server/internal/store/autoassign.go | 352 +- server/internal/store/autochecks.go | 415 +- server/internal/store/checks.go | 45 +- server/internal/store/dashboards_public.go | 2 +- server/internal/store/device_detail.go | 708 +++ server/internal/store/device_detail_test.go | 181 + server/internal/store/device_filter.go | 102 + server/internal/store/device_filter_test.go | 83 + server/internal/store/devices_bulk.go | 864 ++++ server/internal/store/devices_bulk_checks.go | 434 ++ .../store/devices_bulk_checks_test.go | 96 + server/internal/store/devices_purge.go | 542 +++ server/internal/store/devices_purge_test.go | 254 + server/internal/store/devices_restore.go | 310 ++ server/internal/store/devices_restore_test.go | 126 + server/internal/store/downloads.go | 107 + server/internal/store/enrollment.go | 2 +- server/internal/store/events.go | 8 +- server/internal/store/groups.go | 47 +- server/internal/store/identify.go | 146 + server/internal/store/iffilter.go | 347 ++ server/internal/store/iffilter_preview.go | 363 ++ server/internal/store/iffilter_test.go | 304 ++ server/internal/store/inventory.go | 148 +- server/internal/store/maps.go | 145 +- server/internal/store/maps_topology.go | 272 ++ server/internal/store/maps_write.go | 125 +- server/internal/store/metrics_devices.go | 172 + server/internal/store/metrics_devices_test.go | 62 + server/internal/store/ncm.go | 88 +- server/internal/store/ncm_commands.go | 1057 +++++ server/internal/store/ncm_commands_test.go | 88 + server/internal/store/ncm_compliance.go | 62 +- server/internal/store/ncm_config_devices.go | 295 ++ server/internal/store/ncm_git.go | 19 +- server/internal/store/ncm_jobs.go | 76 +- server/internal/store/ncm_localfiles.go | 358 ++ server/internal/store/ncm_paths.go | 130 + server/internal/store/ncm_paths_test.go | 100 + server/internal/store/ncm_refqueue.go | 209 + server/internal/store/ncm_report.go | 407 ++ server/internal/store/ncm_repos.go | 639 +++ server/internal/store/ncm_retention.go | 519 ++ server/internal/store/ncm_rollback.go | 1229 +++++ server/internal/store/ncm_rollback_plan.go | 495 ++ .../internal/store/ncm_rollback_plan_test.go | 258 + server/internal/store/ncm_schedule.go | 4 +- server/internal/store/prototypes.go | 918 ++++ server/internal/store/prototypes_test.go | 184 + server/internal/store/queues.go | 555 +++ server/internal/store/queues_detail.go | 1409 ++++++ server/internal/store/queues_detail_test.go | 190 + server/internal/store/queues_verdict.go | 614 +++ server/internal/store/queues_verdict_test.go | 166 + server/internal/store/retention_policy.go | 905 ++++ .../internal/store/retention_policy_test.go | 140 + server/internal/store/rls_isolation_test.go | 334 ++ server/internal/store/roles.go | 573 +++ server/internal/store/sanitize_path_test.go | 48 + server/internal/store/storage_alert.go | 477 ++ server/internal/store/storage_alert_test.go | 404 ++ server/internal/store/storage_disk_unix.go | 28 + server/internal/store/storage_disk_windows.go | 21 + server/internal/store/storage_usage.go | 631 +++ server/internal/store/store.go | 142 +- server/internal/store/telegram.go | 348 ++ server/internal/store/telemetry.go | 21 +- server/internal/store/template_triggers.go | 29 +- server/internal/store/templates.go | 238 +- server/internal/store/traps.go | 535 +++ server/internal/store/traps_mib.go | 231 + server/internal/store/traps_test.go | 138 + server/internal/store/users.go | 42 +- .../0029_ncm_profile_autoassign.sql | 74 + server/migrations/0030_fix_huawei_prompt.sql | 33 + .../migrations/0031_identify_check_type.sql | 35 + server/migrations/0032_identify_request.sql | 23 + server/migrations/0033_identify_kind.sql | 53 + server/migrations/0034_dlink_me_profile.sql | 47 + server/migrations/0035_ident_rules.sql | 97 + server/migrations/0036_command_runs.sql | 163 + server/migrations/0037_config_retention.sql | 103 + server/migrations/0038_download_tickets.sql | 82 + server/migrations/0042_identify_hardening.sql | 88 + server/migrations/0043_dlink_one_profile.sql | 50 + server/migrations/0046_check_schema_truth.sql | 57 + server/migrations/0049_map_edge_view.sql | 28 + server/migrations/0050_audit_read.sql | 238 + .../0051_template_interface_filter.sql | 71 + server/migrations/0053_roles_editor.sql | 159 + server/migrations/0054_git_mirror.sql | 135 + server/migrations/0055_server_files.sql | 74 + .../0056_compliance_config_type.sql | 44 + server/migrations/0057_device_purge.sql | 119 + server/migrations/0058_event_alerts.sql | 125 + .../migrations/0059_template_prototypes.sql | 343 ++ server/migrations/0060_ncm_rollback.sql | 361 ++ server/migrations/0061_telegram_callbacks.sql | 205 + server/migrations/0062_device_restore.sql | 95 + server/migrations/0063_rls_enforce.sql | 582 +++ server/migrations/0064_retention.sql | 517 ++ server/migrations/0065_snmp_traps.sql | 233 + server/migrations/0066_escalations.sql | 263 ++ server/migrations/0067_storage_alert.sql | 190 + .../0068_profile_apply_from_catalog.sql | 132 + web/README.md | 5 + web/TESTING.md | 155 + web/package-lock.json | 1456 +++++- web/package.json | 12 +- web/src/App.tsx | 48 + web/src/api/client.ts | 821 +++- web/src/components/AppShell.tsx | 124 +- web/src/components/BackupDefaultsForm.tsx | 144 +- web/src/components/BackupPolicyForm.tsx | 304 +- web/src/components/BulkDeviceForm.tsx | 1639 +++++++ web/src/components/ChecksEditor.tsx | 15 +- web/src/components/ConfigDiff.tsx | 635 +++ web/src/components/ConfigViewer.tsx | 488 ++ web/src/components/DeviceFilterPanel.tsx | 257 + web/src/components/DeviceForm.tsx | 155 +- web/src/components/DeviceMetricsView.tsx | 605 +++ web/src/components/DeviceNode.tsx | 80 +- web/src/components/EdgeInspector.tsx | 657 ++- web/src/components/InspectorShell.tsx | 113 + web/src/components/InterfaceFilterEditor.tsx | 492 ++ web/src/components/JsonView.tsx | 133 + web/src/components/MapAddHosts.tsx | 2 +- web/src/components/MapCanvas.tsx | 98 +- web/src/components/MapSettings.tsx | 121 +- web/src/components/MetricChart.tsx | 33 +- web/src/components/Minimap.tsx | 592 +++ web/src/components/NodeInspector.tsx | 512 +- web/src/components/PermissionPicker.tsx | 222 + web/src/components/PrototypeEditor.tsx | 471 ++ web/src/components/RollbackDialog.tsx | 362 ++ web/src/components/TemplatePicker.tsx | 2 +- web/src/components/TrafficEdge.tsx | 276 +- web/src/components/TriggerEditor.tsx | 106 +- web/src/components/device/DeviceCollected.tsx | 734 +++ web/src/components/device/DevicePassport.tsx | 565 +++ web/src/components/edgeState.ts | 112 + web/src/components/labelLayout.ts | 170 + web/src/components/linediff.ts | 393 ++ web/src/components/mapStyle.ts | 232 + web/src/components/ui.tsx | 490 +- web/src/components/widgets/WidgetView.tsx | 4 +- web/src/index.css | 50 + web/src/pages/AgentsPage.tsx | 2 +- web/src/pages/AlertsPage.tsx | 64 +- web/src/pages/AuditPage.tsx | 745 +++ web/src/pages/CommandsPage.tsx | 879 ++++ web/src/pages/CompliancePage.tsx | 47 +- web/src/pages/ConfigsPage.tsx | 966 +++- web/src/pages/CredentialsPage.tsx | 4 +- web/src/pages/DashboardPage.tsx | 2 +- web/src/pages/DevicePage.tsx | 394 ++ web/src/pages/DevicesPage.tsx | 420 +- web/src/pages/EscalationsPage.tsx | 416 ++ web/src/pages/GroupsPage.tsx | 7 +- web/src/pages/MapPage.tsx | 187 +- web/src/pages/MetricsPage.tsx | 530 +-- web/src/pages/MirrorPage.tsx | 512 ++ web/src/pages/ProfilePage.tsx | 149 +- web/src/pages/ProfilesPage.tsx | 13 +- web/src/pages/QueuesPage.tsx | 582 +++ web/src/pages/RolesPage.tsx | 506 ++ web/src/pages/RulesPage.tsx | 513 +- web/src/pages/ServerFilesPage.tsx | 348 ++ web/src/pages/StoragePage.tsx | 841 ++++ web/src/pages/TeamPage.tsx | 76 +- web/src/pages/TemplatesPage.tsx | 89 +- web/src/pages/TrapsPage.tsx | 784 ++++ web/src/test/configview.test.ts | 146 + web/src/test/confirm.test.tsx | 174 + web/src/test/datatable.test.tsx | 182 + web/src/test/filters.test.ts | 136 + web/src/test/format.test.ts | 230 + web/src/test/formatters.test.ts | 60 + web/src/test/interval.test.tsx | 101 + web/src/test/labelLayout.test.ts | 130 + web/src/test/linediff.test.ts | 232 + web/src/test/map.test.ts | 248 + web/src/test/minimap.test.tsx | 94 + web/src/test/modal.test.tsx | 247 + web/src/test/setup.ts | 28 + web/src/types.ts | 1459 +++++- web/vitest.config.ts | 29 + 315 files changed, 78587 insertions(+), 1904 deletions(-) create mode 100644 agent/internal/localfiles/bundle.go create mode 100644 agent/internal/localfiles/collect.go create mode 100644 agent/internal/localfiles/deny.go create mode 100644 agent/internal/localfiles/localfiles_test.go create mode 100644 agent/internal/localfiles/machine.go create mode 100644 agent/internal/localfiles/owner_unix.go create mode 100644 agent/internal/localfiles/owner_windows.go create mode 100644 agent/internal/localfiles/policy.go create mode 100644 agent/internal/modules/filecfg/filecfg.go create mode 100644 agent/internal/modules/snmp/walk.go create mode 100644 agent/internal/modules/snmp/walk_test.go create mode 100644 agent/internal/modules/traps/mib.go create mode 100644 agent/internal/modules/traps/parse.go create mode 100644 agent/internal/modules/traps/receiver.go create mode 100644 agent/internal/modules/traps/traps_test.go create mode 100644 agent/internal/ncmx/apply.go create mode 100644 agent/internal/ncmx/apply_test.go create mode 100644 agent/internal/ncmx/commands.go create mode 100644 agent/internal/ncmx/commands_test.go create mode 100644 agent/internal/session/apply_jobs.go create mode 100644 agent/internal/session/command_jobs.go create mode 100644 agent/internal/session/localfiles_jobs.go create mode 100644 db/profiles/apply.generated.sql create mode 100644 deploy/RLS-EXISTING-INSTALL.md create mode 100644 deploy/act-runner.config.yml create mode 100644 deploy/docker-compose.ci.yml create mode 100644 deploy/files.conf.example create mode 100644 netpulse create mode 100644 netpulse.conf.example create mode 100644 scripts/check-eol.sh create mode 100644 scripts/check.sh create mode 100644 server/cmd/netpulse-migrate/roles_test.go create mode 100644 server/internal/alerting/escalation.go create mode 100644 server/internal/alerting/escalation_test.go create mode 100644 server/internal/alerting/events.go create mode 100644 server/internal/alerting/events_test.go create mode 100644 server/internal/alerting/telegram_bot.go create mode 100644 server/internal/alerting/telegram_callback.go create mode 100644 server/internal/alerting/telegram_callback_test.go create mode 100644 server/internal/alerting/traps_test.go create mode 100644 server/internal/gitstore/branches.go create mode 100644 server/internal/gitstore/branches_test.go create mode 100644 server/internal/gitstore/deploykey.go create mode 100644 server/internal/gitstore/deploykey_test.go create mode 100644 server/internal/gitstore/mirror.go create mode 100644 server/internal/gitstore/mirror_test.go create mode 100644 server/internal/grpcapi/ncm_commands.go create mode 100644 server/internal/grpcapi/ncm_mirror.go create mode 100644 server/internal/grpcapi/ncm_retention.go create mode 100644 server/internal/grpcapi/ncm_rollback.go create mode 100644 server/internal/grpcapi/storage_retention.go create mode 100644 server/internal/httpapi/audit.go create mode 100644 server/internal/httpapi/device_detail.go create mode 100644 server/internal/httpapi/devices_bulk.go create mode 100644 server/internal/httpapi/downloads.go create mode 100644 server/internal/httpapi/git_mirror.go create mode 100644 server/internal/httpapi/metrics_devices.go create mode 100644 server/internal/httpapi/ncm_commands.go create mode 100644 server/internal/httpapi/ncm_config_devices.go create mode 100644 server/internal/httpapi/ncm_configs.go create mode 100644 server/internal/httpapi/ncm_localfiles.go create mode 100644 server/internal/httpapi/ncm_rollback.go create mode 100644 server/internal/httpapi/queues.go create mode 100644 server/internal/httpapi/roles.go create mode 100644 server/internal/httpapi/storage.go create mode 100644 server/internal/httpapi/telegram_link.go create mode 100644 server/internal/httpapi/templates_iffilter.go create mode 100644 server/internal/httpapi/traps.go create mode 100644 server/internal/store/alerts_escalation.go create mode 100644 server/internal/store/alerts_escalation_db_test.go create mode 100644 server/internal/store/alerts_escalation_test.go create mode 100644 server/internal/store/alerts_events.go create mode 100644 server/internal/store/alerts_events_test.go create mode 100644 server/internal/store/audit.go create mode 100644 server/internal/store/audit_actions.go create mode 100644 server/internal/store/audit_actions_source_test.go create mode 100644 server/internal/store/audit_read.go create mode 100644 server/internal/store/audit_read_test.go create mode 100644 server/internal/store/device_detail.go create mode 100644 server/internal/store/device_detail_test.go create mode 100644 server/internal/store/device_filter.go create mode 100644 server/internal/store/device_filter_test.go create mode 100644 server/internal/store/devices_bulk.go create mode 100644 server/internal/store/devices_bulk_checks.go create mode 100644 server/internal/store/devices_bulk_checks_test.go create mode 100644 server/internal/store/devices_purge.go create mode 100644 server/internal/store/devices_purge_test.go create mode 100644 server/internal/store/devices_restore.go create mode 100644 server/internal/store/devices_restore_test.go create mode 100644 server/internal/store/downloads.go create mode 100644 server/internal/store/identify.go create mode 100644 server/internal/store/iffilter.go create mode 100644 server/internal/store/iffilter_preview.go create mode 100644 server/internal/store/iffilter_test.go create mode 100644 server/internal/store/maps_topology.go create mode 100644 server/internal/store/metrics_devices.go create mode 100644 server/internal/store/metrics_devices_test.go create mode 100644 server/internal/store/ncm_commands.go create mode 100644 server/internal/store/ncm_commands_test.go create mode 100644 server/internal/store/ncm_config_devices.go create mode 100644 server/internal/store/ncm_localfiles.go create mode 100644 server/internal/store/ncm_paths.go create mode 100644 server/internal/store/ncm_paths_test.go create mode 100644 server/internal/store/ncm_refqueue.go create mode 100644 server/internal/store/ncm_report.go create mode 100644 server/internal/store/ncm_repos.go create mode 100644 server/internal/store/ncm_retention.go create mode 100644 server/internal/store/ncm_rollback.go create mode 100644 server/internal/store/ncm_rollback_plan.go create mode 100644 server/internal/store/ncm_rollback_plan_test.go create mode 100644 server/internal/store/prototypes.go create mode 100644 server/internal/store/prototypes_test.go create mode 100644 server/internal/store/queues.go create mode 100644 server/internal/store/queues_detail.go create mode 100644 server/internal/store/queues_detail_test.go create mode 100644 server/internal/store/queues_verdict.go create mode 100644 server/internal/store/queues_verdict_test.go create mode 100644 server/internal/store/retention_policy.go create mode 100644 server/internal/store/retention_policy_test.go create mode 100644 server/internal/store/rls_isolation_test.go create mode 100644 server/internal/store/roles.go create mode 100644 server/internal/store/sanitize_path_test.go create mode 100644 server/internal/store/storage_alert.go create mode 100644 server/internal/store/storage_alert_test.go create mode 100644 server/internal/store/storage_disk_unix.go create mode 100644 server/internal/store/storage_disk_windows.go create mode 100644 server/internal/store/storage_usage.go create mode 100644 server/internal/store/telegram.go create mode 100644 server/internal/store/traps.go create mode 100644 server/internal/store/traps_mib.go create mode 100644 server/internal/store/traps_test.go create mode 100644 server/migrations/0029_ncm_profile_autoassign.sql create mode 100644 server/migrations/0030_fix_huawei_prompt.sql create mode 100644 server/migrations/0031_identify_check_type.sql create mode 100644 server/migrations/0032_identify_request.sql create mode 100644 server/migrations/0033_identify_kind.sql create mode 100644 server/migrations/0034_dlink_me_profile.sql create mode 100644 server/migrations/0035_ident_rules.sql create mode 100644 server/migrations/0036_command_runs.sql create mode 100644 server/migrations/0037_config_retention.sql create mode 100644 server/migrations/0038_download_tickets.sql create mode 100644 server/migrations/0042_identify_hardening.sql create mode 100644 server/migrations/0043_dlink_one_profile.sql create mode 100644 server/migrations/0046_check_schema_truth.sql create mode 100644 server/migrations/0049_map_edge_view.sql create mode 100644 server/migrations/0050_audit_read.sql create mode 100644 server/migrations/0051_template_interface_filter.sql create mode 100644 server/migrations/0053_roles_editor.sql create mode 100644 server/migrations/0054_git_mirror.sql create mode 100644 server/migrations/0055_server_files.sql create mode 100644 server/migrations/0056_compliance_config_type.sql create mode 100644 server/migrations/0057_device_purge.sql create mode 100644 server/migrations/0058_event_alerts.sql create mode 100644 server/migrations/0059_template_prototypes.sql create mode 100644 server/migrations/0060_ncm_rollback.sql create mode 100644 server/migrations/0061_telegram_callbacks.sql create mode 100644 server/migrations/0062_device_restore.sql create mode 100644 server/migrations/0063_rls_enforce.sql create mode 100644 server/migrations/0064_retention.sql create mode 100644 server/migrations/0065_snmp_traps.sql create mode 100644 server/migrations/0066_escalations.sql create mode 100644 server/migrations/0067_storage_alert.sql create mode 100644 server/migrations/0068_profile_apply_from_catalog.sql create mode 100644 web/TESTING.md create mode 100644 web/src/components/BulkDeviceForm.tsx create mode 100644 web/src/components/ConfigDiff.tsx create mode 100644 web/src/components/ConfigViewer.tsx create mode 100644 web/src/components/DeviceFilterPanel.tsx create mode 100644 web/src/components/DeviceMetricsView.tsx create mode 100644 web/src/components/InspectorShell.tsx create mode 100644 web/src/components/InterfaceFilterEditor.tsx create mode 100644 web/src/components/JsonView.tsx create mode 100644 web/src/components/Minimap.tsx create mode 100644 web/src/components/PermissionPicker.tsx create mode 100644 web/src/components/PrototypeEditor.tsx create mode 100644 web/src/components/RollbackDialog.tsx create mode 100644 web/src/components/device/DeviceCollected.tsx create mode 100644 web/src/components/device/DevicePassport.tsx create mode 100644 web/src/components/edgeState.ts create mode 100644 web/src/components/labelLayout.ts create mode 100644 web/src/components/linediff.ts create mode 100644 web/src/components/mapStyle.ts create mode 100644 web/src/pages/AuditPage.tsx create mode 100644 web/src/pages/CommandsPage.tsx create mode 100644 web/src/pages/DevicePage.tsx create mode 100644 web/src/pages/EscalationsPage.tsx create mode 100644 web/src/pages/MirrorPage.tsx create mode 100644 web/src/pages/QueuesPage.tsx create mode 100644 web/src/pages/RolesPage.tsx create mode 100644 web/src/pages/ServerFilesPage.tsx create mode 100644 web/src/pages/StoragePage.tsx create mode 100644 web/src/pages/TrapsPage.tsx create mode 100644 web/src/test/configview.test.ts create mode 100644 web/src/test/confirm.test.tsx create mode 100644 web/src/test/datatable.test.tsx create mode 100644 web/src/test/filters.test.ts create mode 100644 web/src/test/format.test.ts create mode 100644 web/src/test/formatters.test.ts create mode 100644 web/src/test/interval.test.tsx create mode 100644 web/src/test/labelLayout.test.ts create mode 100644 web/src/test/linediff.test.ts create mode 100644 web/src/test/map.test.ts create mode 100644 web/src/test/minimap.test.tsx create mode 100644 web/src/test/modal.test.tsx create mode 100644 web/src/test/setup.ts create mode 100644 web/vitest.config.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8e332e1..131f6e0 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,8 +1,34 @@ # Складання і перевірки NetPulse на Forgejo Actions. # -# Три роботи паралельно, а не одна послідовна: фронтенд, сервер і зонд -# ламаються незалежно, і чекати збірки Go заради помилки типізації в -# TypeScript — марно витрачений час на кожному пуші. +# Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh`), +# плюс три речі, яких скрипт зробити не може: інтеграційні тести проти +# живої бази, звірка каталогу профілів зі згенерованою міграцією і +# перевірка закінчень рядків. +# +# ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА САМІ ТЕСТИ. Інтеграційні тести grpcapi мовчки +# пропускались півтора року: без NETPULSE_TEST_DSN вони роблять t.Skip, +# а `go test ./...` рахує пропуск успіхом. Коли їх нарешті прогнали +# проти справжньої бази, два впали одразу — вони описували світ до появи +# розпізнавання пристроїв. Тест, який не виконується, не є перевіркою; +# він є її виглядом. Тому в роботі server стоїть окремий крок, який +# ПАДАЄ, якщо інтеграційні тести пропустились: без нього цей workflow +# повторив би ту саму помилку — зелений на тестах, яких не було. +# +# ЧОМУ ТУТ НЕМАЄ ЖОДНОЇ JS-ДІЇ (actions/checkout тощо). Раннер виконує +# JS-дії тим node, який знайде В ОБРАЗІ роботи. У golang:1.25-alpine +# node немає, у node:22-alpine немає git. Ставити ubuntu-подібні образи +# заради checkout — це +2 ГБ на диску, якого на цій машині ~7 ГБ усього. +# Тому викачування зроблено звичайним `git fetch` у кроці `run`. Побічна +# вигода: CI не залежить ані від доступу до реєстру дій, ані від того, +# що в цьому реєстрі колись поміняють. +# +# ЧОМУ shell: sh. Усі образи — alpine, bash у них немає, а act за +# замовчуванням кличе саме bash. `sh -e` до того ж зупиняє крок на +# першій невдалій команді, тож перевірки не проїжджають повз. +# +# ЯК ПІДНЯТИ РАННЕР: deploy/docker-compose.ci.yml (там же реєстрація). +# Без раннера цей файл не виконується взагалі — саме в такому стані він +# і прожив увесь час свого існування. name: CI @@ -11,40 +37,183 @@ on: branches: [main] pull_request: -env: - GO_VERSION: "1.25" - NODE_VERSION: "22" +# Раннер на цій машині один і з місткістю 1: черга з пʼяти прогонів на +# одну гілку не дає нічого, крім затримки відповіді на останній пуш. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: sh jobs: + # ------------------------------------------------------------------ + # Гігієна репозиторію. Найдешевша робота й найшвидший сигнал: образ + # 8 МБ, увесь прогін — секунди. Навмисно без `needs` у решти робіт: + # коли зламано і схему, і фронтенд, корисно побачити обидва за один + # прогін, а не за два. + # ------------------------------------------------------------------ + hygiene: + runs-on: docker + timeout-minutes: 10 + container: + image: alpine:3.21 + steps: + - name: Інструменти + run: apk add --no-cache git python3 + + - name: Викачування + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # set -e явно, а не в надії на прапорці оболонки раннера: + # якщо git тихо не викачає дерево, наступні кроки побачать + # порожній каталог, gofmt не знайде жодного файлу, а робота + # позеленіє. Порожня перевірка виглядає так само, як успішна. + set -e + git config --global --add safe.directory '*' + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + # Токен у заголовку, а не в URL: інакше він осідає в + # .git/config назавжди і виїжджає в кожен `git remote -v`. + auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n') + git config http.extraheader "Authorization: basic $auth" + # Не всі інсталяції дозволяють тягнути голий sha, тому запасний + # варіант — гілка або refs/pull/N/merge з GITHUB_REF. + git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \ + || git fetch --depth=1 origin "$GITHUB_REF" + git checkout -q FETCH_HEAD + # Доказ, що дерево справді на місці, а не «команди не впали». + [ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; } + git log -1 --format='%h %s' + + # --strict, бо у свіжому клоні на Linux CRLF у робочій копії бути + # не може за побудовою: якщо він там є — зламаний сам checkout. + # Чому ця перевірка взагалі існує — у шапці scripts/check-eol.sh: + # за один день CRLF і показав чужі файли неформатованими для + # gofmt, і змінив sha256 згенерованої міграції так, що сторож + # netpulse-migrate зупинив би накат на стенді. + - name: Закінчення рядків + run: sh scripts/check-eol.sh --strict + + # Каталог профілів — джерело істини, міграція 0014 з нього + # породжується. Розійтися вони можуть тихо: людина править каталог + # і забуває перезапустити генератор. Таке розходження знайшлось + # сьогодні — випадково. + - name: Каталог профілів + run: python3 db/profiles/build.py --check + + # ------------------------------------------------------------------ + # Фронтенд. Окремою роботою, бо ламається незалежно від Go: чекати + # збірки сервера заради помилки типізації в TypeScript — марно. + # ------------------------------------------------------------------ web: runs-on: docker + timeout-minutes: 25 container: image: node:22-alpine + # Кеш npm — іменований том, а не actions/cache. На одній машині + # том просто лежить і читається; кеш-сервер натомість пакував би + # той самий вміст у tar на кожному прогоні й тримав ДВІ копії на + # тому самому диску. Томи мають бути дозволені в конфізі раннера + # (container.valid_volumes) — див. deploy/act-runner.config.yml. + volumes: + - netpulse-ci-npm:/root/.npm + env: + npm_config_cache: /root/.npm + NO_COLOR: "1" + CI: "true" steps: - - uses: actions/checkout@v4 + - name: Інструменти + run: apk add --no-cache git + + - name: Викачування + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # set -e явно, а не в надії на прапорці оболонки раннера: + # якщо git тихо не викачає дерево, наступні кроки побачать + # порожній каталог, gofmt не знайде жодного файлу, а робота + # позеленіє. Порожня перевірка виглядає так само, як успішна. + set -e + git config --global --add safe.directory '*' + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n') + git config http.extraheader "Authorization: basic $auth" + git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \ + || git fetch --depth=1 origin "$GITHUB_REF" + git checkout -q FETCH_HEAD + # Доказ, що дерево справді на місці, а не «команди не впали». + [ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; } - name: Залежності working-directory: web - run: npm ci + run: npm ci --no-audit --no-fund - name: Типи working-directory: web run: npx tsc --noEmit + # Тести — ДО збірки: збірка коштує хвилини, падіння тесту видно за + # секунди. --reporter=verbose навмисно: у логу має бути ПЕРЕЛІК + # виконаних тестів, а не лише підсумкове число. Уся ця робота + # існує через тести, про які всі думали, що вони йдуть. + # + # Що покрито й, головне, що НІ — у web/TESTING.md. Зелений прогін + # доводить рівно те, що перелічено там. + # + # --maxWorkers=2: пул vitest за замовчуванням — процеси-форки по + # числу ядер, кожен зі своїм jsdom (~200 МБ). На машині з 4 ГБ, + # де поруч живе бойовий стек, чотири форки з'їдають більше, ніж + # тут є вільного. Паралельність файлів при цьому лишається + # увімкненою — тобто ізоляція між файлами перевіряється й далі, + # на відміну від --no-file-parallelism. + - name: Тести + working-directory: web + run: npx vitest run --reporter=verbose --maxWorkers=2 + - name: Збірка working-directory: web run: npm run build - - uses: actions/upload-artifact@v3 - with: - name: web-dist - path: web/dist/ + # Прибирання за собою. Кеш npm росте з кожною зміною залежностей і + # старе з нього не зникає саме. На диску ~7 ГБ, і CI, який його + # забиває, зупинить продукт, а не покращить. + - name: Кеш npm у межах + if: always() + run: | + budget=400 + size=$(du -sm /root/.npm 2>/dev/null | cut -f1) + size=${size:-0} + echo "кеш npm: ${size} МБ (стеля ${budget} МБ)" + if [ "$size" -gt "$budget" ]; then + echo "перевищено — чищу" + npm cache clean --force || rm -rf /root/.npm/_cacache + fi + # ------------------------------------------------------------------ + # Сервер. Єдина робота з базою: тут накочуються міграції на ЧИСТУ базу + # й тут виконуються інтеграційні набори store/httpapi/grpcapi. + # ------------------------------------------------------------------ server: runs-on: docker + timeout-minutes: 40 container: image: golang:1.25-alpine + volumes: + - netpulse-ci-gomod:/go/pkg/mod + - netpulse-ci-gobuild:/root/.cache/go-build services: + # Той самий образ, що й у docker-compose.yml. Не «свіжіший» + # навмисно: CI має ловити поломки того Postgres, який реально + # крутиться на стенді, а не сусіднього. + # + # База чиста за побудовою: контейнер сервісу створюється під + # прогін і зникає разом із ним, тому міграції щоразу накочуються з + # нуля. Це не косметика — накат «з нуля» і накат «поверх» + # ламаються по-різному, а на стенді перший буває лише раз. db: image: timescale/timescaledb:2.17.2-pg16 env: @@ -52,50 +221,199 @@ jobs: POSTGRES_PASSWORD: netpulse POSTGRES_DB: netpulse_ci TIMESCALEDB_TELEMETRY: "off" + # Без цього initdb бере кодування з локалі й цілком може + # створити базу як SQL_ASCII. netpulse-migrate це помічає і + # відмовляється працювати — задаємо одразу, як у compose. + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8" + # Автотюнер образу інакше візьме чверть ПАМʼЯТІ МАШИНИ під + # shared_buffers. На машині з 4 ГБ це майже гарантований OOM у + # парі з go build. + TS_TUNE_MEMORY: 512MB + TS_TUNE_NUM_CPUS: "2" + env: + # Стемпінг VCS у контейнері вміє падати на правах на .git — + # прибираємо цілий клас відмов, який до продукту не має стосунку. + # -p=2 обмежує кількість пакетів, які Go компілює й тестує + # одночасно: без нього пік памʼяті на цій машині впирається у стелю + # разом із бойовим стеком, і ядро вибирає жертву не на нашу користь. + GOFLAGS: -buildvcs=false -p=2 + # Заборона мовчки тягнути іншу версію Go: на диску ~7 ГБ, і + # неочікуваний +300 МБ тулчейну краще побачити як помилку. + GOTOOLCHAIN: local + NETPULSE_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable + NETPULSE_TEST_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable steps: - - uses: actions/checkout@v4 - - - name: Інструменти - run: apk add --no-cache git postgresql16-client - - - name: Формат - working-directory: server - run: | - # gofmt -l друкує список, а не код виходу: без перевірки - # порожнечі крива форма проїжджає в main непоміченою. - bad=$(gofmt -l .) - if [ -n "$bad" ]; then - echo "не відформатовано:"; echo "$bad"; exit 1 - fi - - - name: Vet - working-directory: server - run: go vet ./... - - - name: Схема - working-directory: server - env: - NETPULSE_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable - run: | - until pg_isready -h db -U netpulse -d netpulse_ci; do sleep 1; done - go run ./cmd/netpulse-migrate - - - name: Тести - working-directory: server - env: - NETPULSE_TEST_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable - run: go test ./... - - agent: - runs-on: docker - container: - image: golang:1.25-alpine - steps: - - uses: actions/checkout@v4 - - name: Інструменти run: apk add --no-cache git + - name: Викачування + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # set -e явно, а не в надії на прапорці оболонки раннера: + # якщо git тихо не викачає дерево, наступні кроки побачать + # порожній каталог, gofmt не знайде жодного файлу, а робота + # позеленіє. Порожня перевірка виглядає так само, як успішна. + set -e + git config --global --add safe.directory '*' + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n') + git config http.extraheader "Authorization: basic $auth" + git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \ + || git fetch --depth=1 origin "$GITHUB_REF" + git checkout -q FETCH_HEAD + # Доказ, що дерево справді на місці, а не «команди не впали». + [ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; } + + # gofmt -l друкує список, а не код виходу: без перевірки порожнечі + # крива форма проїжджає в main непоміченою. + - name: Формат + working-directory: server + run: | + bad=$(gofmt -l .) + if [ -n "$bad" ]; then + echo "не відформатовано:"; echo "$bad"; exit 1 + fi + echo "форма в порядку" + + - name: Vet + working-directory: server + run: go vet ./... + + - name: Збірка + working-directory: server + run: go build ./... + + # Очікування бази через TCP, а не через pg_isready: клієнта + # Postgres в образі немає, а ставити його заради одного циклу — + # зайвий пакет на диску. Образ під час initdb слухає лише + # unix-сокет, тож відкритий 5432 і означає «готова». + - name: Очікування бази + run: | + for i in $(seq 1 90); do + if nc -z db 5432; then echo "база слухає (${i}с)"; break; fi + if [ "$i" = "90" ]; then echo "база не піднялась"; exit 1; fi + sleep 1 + done + + # Накат з нуля + звірка кількості. Число не зашите: воно рахується + # з каталогу. Тому крок ловить не лише невдалу міграцію, а й файл, + # який не потрапив у бінарник — embed бере `*.sql`, і міграція з + # іншим розширенням чи в підкаталозі зникла б МОВЧКИ, а на стенді + # вилізла б відсутньою таблицею. + - name: Схема з нуля + working-directory: server + run: | + set -e + want=$(ls migrations/*.sql | wc -l | tr -d ' ') + echo "у каталозі міграцій файлів: $want" + go run ./cmd/netpulse-migrate -dry-run + out=$(go run ./cmd/netpulse-migrate) + echo "$out" + if ! echo "$out" | grep -q "застосовано міграцій: $want$"; then + echo "накотилось не $want міграцій — частина не доїхала в бінарник" + exit 1 + fi + + # -count=1 обовʼязково: кеш тестів Go віддає «ok (cached)» без + # запуску, а кешований результат — той самий різновид брехні, що й + # мовчазний пропуск. -v — щоб у логу було ВИДНО, що саме виконалось. + - name: Тести + working-directory: server + run: | + if go test ./... -count=1 -v > /tmp/server-tests.log 2>&1; then + rc=0 + else + rc=$? + fi + cat /tmp/server-tests.log + exit "$rc" + + # Сторож проти повторення історії з grpcapi. + # + # Тести проти бази пропускають себе самі, коли NETPULSE_TEST_DSN + # порожній, а `go test` рахує пропуск успіхом. Якщо DSN колись + # перестане доїжджати (перейменують сервіс, зміниться пароль, + # хтось прибере env), уся ця робота лишиться ЗЕЛЕНОЮ й перестане + # перевіряти найдорожче. Тому пропуск тут — помилка, а поіменний + # перелік доводить, що конкретні набори справді відпрацювали. + - name: Інтеграційні тести справді виконались + working-directory: server + run: | + if grep -q "NETPULSE_TEST_DSN не задано" /tmp/server-tests.log; then + echo "інтеграційні тести ПРОПУЩЕНО — DSN не доїхав до go test." + echo "зелений прогін тут нічого б не довів; зупиняюсь." + grep -n "NETPULSE_TEST_DSN не задано" /tmp/server-tests.log | head + exit 1 + fi + missing="" + for t in TestRLSTenantIsolation \ + TestEscalationAgainstDB \ + TestPurgeDeviceLeavesNothing \ + TestMapStateIsRenderReady \ + TestTelemetryPersisted \ + TestDiscoveryCreatesInterfaceChecks; do + if ! grep -q "^--- PASS: $t" /tmp/server-tests.log; then + missing="$missing $t" + fi + done + if [ -n "$missing" ]; then + echo "не бачу успішного виконання:$missing" + exit 1 + fi + echo "інтеграційні набори виконались проти живої бази" + + # Кеші Go ростуть необмежено. Стеля тут, у кінці роботи, а не в + # сторонньому прибиральнику: у цей момент точно ніхто не компілює, + # і чистка не може вирвати файл з-під go build. + - name: Кеші Go у межах + if: always() + run: | + bc=$(du -sm "$(go env GOCACHE)" 2>/dev/null | cut -f1); bc=${bc:-0} + mc=$(du -sm "$(go env GOMODCACHE)" 2>/dev/null | cut -f1); mc=${mc:-0} + echo "кеш збірки: ${bc} МБ (стеля 1200), модулі: ${mc} МБ (стеля 900)" + if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi + if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi + + # ------------------------------------------------------------------ + # Зонд. Без бази: він до неї не ходить. + # ------------------------------------------------------------------ + agent: + runs-on: docker + timeout-minutes: 30 + container: + image: golang:1.25-alpine + volumes: + - netpulse-ci-gomod:/go/pkg/mod + - netpulse-ci-gobuild:/root/.cache/go-build + env: + GOFLAGS: -buildvcs=false -p=2 + GOTOOLCHAIN: local + steps: + - name: Інструменти + run: apk add --no-cache git + + - name: Викачування + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # set -e явно, а не в надії на прапорці оболонки раннера: + # якщо git тихо не викачає дерево, наступні кроки побачать + # порожній каталог, gofmt не знайде жодного файлу, а робота + # позеленіє. Порожня перевірка виглядає так само, як успішна. + set -e + git config --global --add safe.directory '*' + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n') + git config http.extraheader "Authorization: basic $auth" + git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \ + || git fetch --depth=1 origin "$GITHUB_REF" + git checkout -q FETCH_HEAD + # Доказ, що дерево справді на місці, а не «команди не впали». + [ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; } + - name: Формат working-directory: agent run: | @@ -103,17 +421,24 @@ jobs: if [ -n "$bad" ]; then echo "не відформатовано:"; echo "$bad"; exit 1 fi + echo "форма в порядку" - name: Vet working-directory: agent run: go vet ./... + - name: Збірка + working-directory: agent + run: go build ./... + - name: Тести working-directory: agent - run: go test ./... + run: go test ./... -count=1 # Зонд їде на чуже залізо: перевіряємо, що збирається під усі # платформи, які обіцяємо, а не лише під ту, де крутиться CI. + # Результат нікуди не потрібен — пишемо в /dev/null, бо пʼять + # бінарників по ~30 МБ на диску, якого ~7 ГБ, це не дрібниця. - name: Крос-збірка working-directory: agent run: | @@ -121,5 +446,14 @@ jobs: os=${target%/*}; arch=${target#*/} echo "== $os/$arch" CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath \ - -o /tmp/netpulse-agent-$os-$arch ./cmd/netpulse-agent + -o /dev/null ./cmd/netpulse-agent done + + - name: Кеші Go у межах + if: always() + run: | + bc=$(du -sm "$(go env GOCACHE)" 2>/dev/null | cut -f1); bc=${bc:-0} + mc=$(du -sm "$(go env GOMODCACHE)" 2>/dev/null | cut -f1); mc=${mc:-0} + echo "кеш збірки: ${bc} МБ (стеля 1200), модулі: ${mc} МБ (стеля 900)" + if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi + if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi diff --git a/.gitignore b/.gitignore index 1ebf24b..0e65f56 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ server/webui/dist/* !server/webui/dist/index.html !server/webui/dist/.gitignore + +# Кеш інкрементної збірки TypeScript — машинний стан, не код. +web/tsconfig.tsbuildinfo diff --git a/HISTORY.md b/HISTORY.md index 1da7b9a..896c7b6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3117,3 +3117,4172 @@ ZXAN# після збереження картка відкривається знову — половина налаштувань (розклад, доступи, перевірки) кріпиться до хоста й до його створення не існує, і повертатись у той самий хост руками не треба. + +--- + +## 2026-08-25 — Профіль збору конфігів підбирається за SNMP + +Профіль підбирався за полем «виробник», яке заповнює людина руками. Це +видно на першому ж живому хості: `Леніна.21` — поле порожнє, збір падає +з «не задано профіль». `ZTE Миронівка` — поле заповнене «ZTE», і +береться перший-ліпший ZTE-профіль, який може виявитись не тим. + +Пристрій же сам каже, що він таке. Механіка вже була — вона підбирає +шаблони опитування (`tpl.auto_assign`, міграція 0027). Тепер те саме для +профілів. + +### Що зроблено + +**`ncm.profile_auto_assign`** (міграція 0029) — 14 вбудованих правил +`sysObjectID → профіль`. Збіг за префіксом OID, бо виробник кодує в +ньому й серію: одне правило на родину, а не рядок на кожну коробку. + +**Уточнення за `sysDescr`.** Один enterprise-OID — не один тип заліза. +У ZTE `1.3.6.1.4.1.3902` — і комутатори ZXR10, і OLT ZXAN, а знімаються +вони різними командами й мають різне запрошення. Правило з `descr_regex` +точніше за правило лише з OID і виграє в нього. Перевірено: + +``` +C320 (…3902.1082, «ZXA10 C320») → zte-zxan +ZXR10 (…3902.3, «ZXR10 5952») → zte-zxr10 +Cisco (…9.1.1745) → cisco-ios +``` + +**Порядок підбору** в `resolveProfile`: явно заданий профіль → збіг за +sysObjectID → збіг за виробником. Остання гілка лишається для заліза, +яке віддає лише CLI. + +**Виробник заповнюється сам.** `ApplySystemInfo` проставляє `vendor` +хосту, у якого поле порожнє. Уже заповнене не чіпає: людина могла +написати точніше за наш довідник. + +### Головне: розірвано замкнене коло + +Автопідбір спирався на `sysObjectID`, а той нізвідки не брався, доки +хост нічим не опитують — системну групу знімав лише чек +`topology.discover`, який треба завести руками. Автоматика чекала на +дані, дані чекали на людину. + +Тепер: хост має SNMP-доступ — значить, дозволено запитати, хто він +такий. `EnsureIdentifyChecks` заводить такому хосту чек +`topology.discover` у полегшеному режимі. + +**Полегшений режим.** `{"protos": [], "collect_interfaces": false}` — +ні сусідів, ні обходу `ifTable`, лише системна група. Заради цього +`Params.Protos` став вказівником: «поля немає» (типове lldp+cdp) і +«порожній список» (свідоме «жодного») — різні речі, зрізом їх не +розрізнити. Обхід інтерфейсів пропускається — це не мікрооптимізація: +`ifTable` на комутаторі з 400 портами це тисячі PDU, а розпізнавання +коштує три OID. + +**Два інтервали.** Поки хост не розпізнано — дві хвилини: людина щойно +завела хост і дивиться на екран. Розпізнали — шість годин: питання +закрите, лишається сторожова функція (залізо в шафі міняють). Справа не +в тому, що дані швидко старіють, а в тому, що розклад агента кладе +задачі на сітку інтервалу — при шести годинах перший запуск міг би +припасти й на п'яту годину очікування. + +Чек не створюється, якщо автовиявлення вже налаштоване вручну: його +системна група так само заповнить поля. + +### У вебі + +Підказка під «— автоматично —» тепер каже, що буде саме з цим хостом: +«Розпізнано по SNMP: ZXA10 C320 — профіль підбереться сам», або «ще не +опитано по SNMP — підбереться за виробником "ZTE"», або «не розпізнано і +виробник не заданий». Різниця між налаштованим хостом і мовчазною +помилкою за добу, коли спрацює розклад. + +`DeviceSummary` возить `sys_object_id` і `sys_descr`. + +### Спіткнулись + +`CASE WHEN … THEN $3 ELSE $4 END` без явних кастів — Postgres вивів +типи параметрів як `text` і впав на `integer <> text`. Параметри в +`CASE` треба кастувати: `$3::int`. + +### Заразом: зразок запрошення huawei-vrp не збігався ніколи + +Розпізнавання спрацювало на другому живому хості — `Леніна.21` виявився +Huawei S2326TP-EI, і йому автоматично став `huawei-vrp`. Збір усе одно +впав: «не дочекались запрошення командного рядка». + +Стенограма показала, що вхід відпрацював, пристрій показав `` +і чекав на команду. Проблема була в самому зразку: у ранньому сіді +(0010) зворотні скісні подвоєні. Для JSON-полів це правильно — там це +екранування самого JSON. Для `prompt_regex`, звичайного `text`, друга +скісна лишається в даних символом. + +У Go з цього виходить: клас `[>` + дві скісні + `]` закривається +першою ж дужкою, а остання стає літералом. Тобто зразок вимагав, щоб +запрошення закінчувалось квадратною дужкою — чого `` не робить. + +Сід 0014 має правильний зразок, але йде з `ON CONFLICT DO NOTHING`, і +зіпсований рядок пережив його. Виправлено міграцією 0030. Решта 148 +вбудованих профілів не зачеплені — перевірено по `DISTINCT +prompt_regex`, зіпсований рядок був один. + +Умова в міграції написана через `position(chr(92) || chr(92) IN …)`, а +не через порівняння з рядком: питання саме в кількості скісних, і зайвий +рівень екранування в самій міграції зробив би її такою ж помилкою, яку +вона лікує. + +### Результат на живому стенді + +Обидва хости пройшли шлях цілком самі — ні профілю, ні виробника руками +ніхто не задавав: + +``` +ZTE Миронівка 1.3.6.1.4.1.3902.1015.320.1.2 «ZXR10 ROS … ZXPON C320» + → vendor ZTE, профіль zte-zxan → 32 854 рядки +Леніна.21 1.3.6.1.4.1.2011.2.23.92 «S2326TP-EI / Huawei VRP» + → vendor Huawei, профіль huawei-vrp → 398 рядків +``` + +`Леніна.21` до цього не мала виробника взагалі й падала з «не задано +профіль». Інтервал чека розпізнавання в обох після успіху сам піднявся +з 2 хв до 6 год. + +**Дорогою знайшлась ще одна причина, чому це не працювало б і так.** +Планувальник агента відправляв звіт автовиявлення тільки за наявності +сусідів або портів. У режимі самого лише розпізнавання немає ні того, ні +іншого — системна група нікуди не їхала. Тепер `Devices` теж рахуються. + +### Заразом: чек розпізнавання отримав власну назву + +Розпізнавання зробили полегшеним режимом `topology.discover`. Механіка +правильна, підпис — ні: цей тип чека зветься «Neighbor Discovery», і в +картці хоста з'являвся рядок, який обіцяє пошук сусідів, а не шукає +жодного. Назва в списку перевірок — це те, за чим оператор о третій ночі +вирішує, чи цей рядок пояснює те, що він бачить. + +Заведено окремий тип `topology.identify` — «Розпізнавання пристрою» +(міграція 0031, вже створені чеки перенесено). Префікс `topology.` +обов'язковий: `Registry.Register` вимагає, щоб тип чека починався з +ключа модуля. + +Заразом режим тепер визначається типом чека, а не параметрами: +розпізнавання не має способу випадково перетворитись на повний обхід +через недогляд у формі. + +--- + +## 2026-08-25 (продовження) — Розпізнавання: кнопка, тип хоста, дублі + +### «Розпізнати зараз» + +Розпізнавання відбувається саме, але за розкладом. Людина, яка щойно +завела хост, чекати такт не хоче. + +Кнопка не може штовхнути зонда напряму: API і колектор — різні процеси +в різних контейнерах, а сесії зондів живуть у колекторі. Тому той самий +шлях, яким уже ходить збір конфігів: REST лишає намір у базі +(`inv.devices.identify_requested_at`, міграція 0032), диспетчер +колектора його забирає й надсилає живій сесії `DiscoveryRequest`. + +Механізм поштовху вже був: `Scheduler.TriggerNow(deviceIDs, "topo.")` +зрушує задачі з розкладу зонда. Тому `RequestIdentify` спершу +переконується, що чек існує — інакше поштовх нікуди б не влучив. + +Відмова повертається як 200 з поясненням, а не 4xx: «немає SNMP-доступу» +і «зонд не на зв'язку» — не помилки запиту, а стан системи, і людині їх +треба показати у формі, а не в діалозі про збій. Текст готовий, не код: +причин кілька, кожна лікується по-своєму, і тримати цей перелік ще й у +вебі означало б забути оновити одне з двох місць. + +### Тип хоста визначається так само + +`kind` за замовчуванням `other`, і в переліку хостів це видно: всі +«інше». Поле не косметичне — за ним фільтри, значки на мапі, звіти. А +пристрій уже сказав, що він таке: OLT відрізняється від комутатора тим +самим `sysDescr`, що ZXAN від ZXR10. + +`kind` ліг у те саме правило, що вже несе `vendor` (міграція 0033): +рядок з уточненням за описом дає `olt`, загальний рядок виробника — +`switch`. Де виробник робить і комутатори, і маршрутизатори, чесніше +сказати `switch`: людина виправить одним кліком, а невірний `router` на +сотні портів вона помітить не одразу. + +`fillVendorFromOID` став `fillFromOID` і бере правило тим самим +порядком, що й підбір профілю. Інакше хост міг би отримати тип від +одного правила, а профіль від іншого — і пояснити, чому OLT підписаний +комутатором, було б нічим. Уже задане не чіпається: `other` — це +типове значення колонки, людина його не обирала, а от `switch` вона +могла поставити свідомо. + +### Ручне додавання `topology.identify` давало «внутрішню помилку» + +Система вже завела такий чек сама. Друга копія впиралась у +`checks_uniq` (хост, тип, інтерфейс, md5 параметрів), унікальне +порушення доходило до обробника як невідома помилка й показувалось як +«внутрішня помилка» — без жодної підказки, що робити. + +Полікувано з двох боків. `core.check_types.auto_managed` позначає чеки, +які заводить і планує система: з довідника тип не прибирається (перевірка +при збереженні має його знати), але в переліку для ручного додавання не +з'являється. І окремо `SetDeviceChecks` тепер розрізняє 23505 і каже +«перевірка з такими ж параметрами у цього хоста вже є» — 400, а не 500. + +### Поштовх не зачіпав нікого + +Кнопка відповідала `queued: true`, колектор писав «розпізнавання +зрушено вручну», зонд писав «сервер попросив запустити автовиявлення» — +і нічого не відбувалось. + +`TriggerNow(deviceIDs, checkTypePrefix)` фільтрує задачі за префіксом, а +викликали його з `"topo."`. Рядок `topology.identify` на `"topo."` не +починається — крапка не там. Помилці стільки ж років, скільки коду: +`DiscoveryRequest` доти не слав ніхто, і зламаний фільтр не мав на чому +проявитись. Тепер префікс — ключ модуля з крапкою, `"topology."`. + +Заразом закрито перегони: `RequestIdentify` міг щойно завести чек, а +зонд зрушує лише те, що вже має в розкладі. Диспетчер тепер спершу +звіряє план і аж потім штовхає — інакше кнопка мовчки нічого не робила б +саме там, де вона найпотрібніша: на щойно заведеному хості. + +Перевірено на живому: Juniper QFX із `other` став `switch` за +секунду після натискання. + +--- + +## 2026-08-25 (продовження) — Тихий збій, який коштував би архіву + +Користувач подивився на зібраний конфіг D-Link і побачив там один +рядок: `Command: show config`. Завдання при цьому було позначене +успішним. Двадцять байтів у сховищі замість конфігу. + +Стенограма пояснила все: + +``` +DGS-1100-06/ME:5# show config active +Command: show config + +Next possible completions: +``` + +`show config active` — команда профілю `dlink-dxs` — на цій платформі +не існує. Пристрій ввічливо запропонував варіанти, і ця пропозиція +лягла у сховище як версія конфігу. + +### Перевірка правдоподібності + +Порожній вивід ми ловили й раніше. Але пристрій рідко мовчить: на +невідому команду D-Link відповідає «Next possible completions», Cisco — +«% Invalid input», і кожна з цих відповідей непорожня. + +Тепер `looksLikeConfig` вимагає щонайменше п'ять змістовних рядків і +відсіює характерні відмови CLI. Зразки відмов шукаються **лише в +короткому виводі**: у справжньому конфізі на тридцять тисяч рядків слова +«invalid» чи «unknown» трапляються в іменах ACL і в банерах, і глушити +збір через них не можна. Поріг грубий навмисне — точного числа не +існує, а видимість точності заважала б помітити, що він спрацював. + +Помилка показує, що саме сказав пристрій. Без цього людині лишалось би +здогадуватись, чим команда з профілю не підійшла її залізу. + +Гучна помилка тут коштує одного дзвінка. Тиха — усього архіву. + +### Профіль для D-Link /ME + +Правильна команда — `show config current_config`. Але й з нею конфіг +обривався на четвертому рядку, і причина та сама, що колись у ZTE: +зразок `[>#:]\s*$` приймає будь-який рядок, що закінчується решіткою, а +конфіг /ME починається з рамки коментарів, де є рядок із самої лише +решітки. + +Зразок `:\d+#\s*$` вимагає двокрапку й цифру перед решіткою — саме так +виглядає справжнє запрошення `DGS-1100-06/ME:5#`. + +Зіставлення — за описом, а не за OID моделі (міграція 0034): у D-Link +кожна модель має власний OID, а «/ME» стоїть у sysDescr усієї серії. + +``` +було: 20 байтів, 1 рядок +стало: 15 807 байтів, 403 рядки +``` + +### SSH за ключем + +`private_key` був у протоколі від початку — і його ніхто не читав. +Транспорт брав лише пароль, тож доступ із ключем був неможливий, а +пристрій, у якого вхід за паролем вимкнений, відповідав +«no supported methods remain» без жодної підказки, чого від нас хотіли. + +Тепер ключ пробується першим, а пароль за потреби йде як парольна фраза +до нього. + +--- + +## 2026-08-25 (продовження) — Модель і версія ПЗ визначаються самі + +Колонки `model`, `os_version`, `serial_number` існували в `inv.devices` з +першої міграції — і завжди були порожні. Заповнити їх мала людина, а +людина цього не робить: у неї сотня хостів і жодного стимулу. + +Пристрій же називає себе в sysDescr. Проблема лише в тому, що кожен +робить це по-своєму, і жодного порядку в цьому немає: + +``` +Juniper Networks, Inc. ex4600-40f Ethernet Switch, kernel JUNOS 14.1X53-D27.3 +ZXR10 ROS Version V4.6.02A ZXPON C320 Software, Version V1.2.5P3 +S2326TP-EI / VRP (R) software,Version 5.70 (S2300 V100R006C01SPC100) +DGS-1100-06/ME/A1 +``` + +Тому не спільний розбір, а зразок на правило: правило вже знає, з ким +має справу, бо дійшло до нього за OID і описом. + +Зразки застосовує Go, а не Postgres — потрібні групи захоплення. RE2 не +має відкату, тож зразок із довідника не підвісить збір, навіть якщо його +напише людина. Зіпсований зразок мовчки нічого не дає, а не валить +розпізнавання: помилка в одному рядку довідника не має зупиняти +онбординг решти хостів, а помітно її буде там, де й має бути — у +порожньому полі моделі. + +Результат на живому стенді, без жодного дотику руками: + +``` +JUN.QFX-Миронівка switch Juniper ex4600-40f 14.1X53-D27.3 +ZTE Миронівка olt ZTE C320 V1.2.5P3 +Леніна.21 switch Huawei S2326TP-EI V100R006C01SPC100 +ПОЛЬОВЕ-СВ switch D-Link DGS-1100-06/ME — +ПОЛЬОВЕ.7 switch D-Link DES-1210-28/ME — +``` + +D-Link версії не показує: в його sysDescr її немає взагалі (прошивка +видно лише у вітанні CLI). Порожньо чесніше, ніж підставити щось схоже. +Серійні номери сюди теж не потрапляють — вони живуть в ENTITY-MIB, а це +вимагає нових полів у protobuf. + +### Таблиця правил переїхала + +`ncm.profile_auto_assign` заводилась, щоб підбирати профіль збору +конфігу. Далі обросла `vendor`, потім `kind`, тепер моделлю й версією — +тобто давно перестала бути «про NCM». Тепер це `inv.ident_rules`. + +Ім'я, яке бреше про призначення таблиці, коштує дорожче за одну +міграцію: наступний, хто шукатиме, де система вирішує тип хоста, +шукатиме де завгодно, тільки не в схемі ncm. Профіль лишається одним із +наслідків правила, а не його метою. + +### У картці хоста + +Блок «Розпізнано по SNMP» — окремо від полів форми. Це не налаштування, +а факт із мережі: редагована модель у сусідстві з тією, що приїхала по +SNMP, читалась би як пропозиція її поправити, а поправлене система все +одно не перезапише — і людина лишилась би з двома різними правдами. + +sysDescr показується повністю: саме з нього дістаються модель і версія, +і коли щось дісталось не так, дивитись треба туди. + +--- + +## 2026-08-25 (продовження) — Розпізнавання перестає бути мовчазним + +Користувач: «Хост Миронівка.Ядро.1 не автовизначився! Потрібно продумати +автовизначення як профі.» + +### Що насправді сталося + +Хост DGS-3420 стояв нерозпізнаним, хоча SNMP відповідав, правило +існувало й підходило. Достатньо було штовхнути розпізнавання вручну — +і виробник із типом з'явились за секунду. + +Причина: розпізнавання відбулось у вікні між перейменуванням таблиці +правил (міграція 0035) і перезбіркою колектора. Запит падав, +`ApplySystemInfo` повертав помилку — і кидав ВЕСЬ звіт, уже записавши +системну групу. + +Ззовні це виглядало як «система просто не розпізнала». Ні сліду, ні +причини, ні різниці між «немає правила», «правило не спрацювало» і «нас +ніхто не питав». Один зіпсований хост зупиняв обробку решти в тому ж +звіті. + +### Що виправлено — і це не про той хост + +**Помилка на одному хості більше не роняє звіт.** Кожен обробляється +окремо, причина осідає в `inv.devices.identify_error` — у картці, де її +шукатимуть, а не в журналі сервера, який читає той, хто вже знає, що +щось не так. У журнал іде масштаб: «частину хостів не розпізнано, +невдач: N». + +**Заміна заліза помічається окремо.** `hardware_changed_at` +заповнюється, коли sysObjectID БУВ і став іншим (перше розпізнавання +зміною не рахується). Це єдиний випадок, коли автоматика перезаписує +задане людиною: модель і виробник описують коробку, якої вже немає, і +берегти їх — берегти неправду. + +**Профіль збору закріплюється за хостом.** Досі підбирався щоразу +наново під час кожного завдання: у картці порожньо, і зміна довідника +мовчки змінювала поведінку всіх хостів. Тепер записується, а прапорець +`profile_auto` розрізняє, хто його обрав: підібраний автоматично можна +переглянути після заміни заліза, обраний людиною — ніколи. + +**Модель для решти D-Link.** Досі її діставало лише правило серії /ME, +а загальне правило виробника лишало поле порожнім — тобто кожна нова +серія вимагала б рядка в довіднику. Тепер `^([A-Z]{3}-[0-9A-Za-z/-]+)`: +`DGS-3420-28SC Gigabit Ethernet Switch` → `DGS-3420-28SC`. + +### Версія з конфігу, коли SNMP її не каже + +Користувач: «Якщо хост не віддає версію по snmp, може робити це якось +інакше?» + +D-Link у sysDescr дає саму лише модель. Але в шапці власного конфігу +пише `# Firmware: Build 1.05.B026`. + +Конфіг ми й так збираємо. Дістати з нього версію коштує нуль запитів до +пристрою й нуль нового трафіку — на відміну від окремого походу в CLI +заради одного рядка. Зразки лягли в те саме правило +(`config_version_regex`, `config_model_regex`, `config_serial_regex`) і +застосовуються після кожного успішного збору. + +Дивимось лише перші 4 КБ: шапка з моделлю й прошивкою стоїть на початку +файлу, а ганяти зразок по тридцяти тисячах рядків заради рядка, який +завжди в перших двадцяти, — платити ні за що. + +Перевірено на справжніх даних: + +``` +Миронівка.Ядро.1 DGS-3420-28SC (модель, якої раніше не було) +шапка конфігу 1.05.B026 (версія, якої немає в SNMP) +``` + +--- + +## 2026-08-25 (продовження) — Масова правка й масове видалення хостів + +Оператор має змінювати десятки хостів одним рухом. Уся складність тут +не в SQL, а в одному питанні: що означає порожнє поле. + +### «Не чіпати» — це не «стерти» + +Форма на десять полів, застосована до п'ятдесяти хостів, не має занулити +те, чого людина не заповнювала. В одиничній картці порожнє поле означає +«зітри»: людина бачить поточне значення й стирає його свідомо. У масовій +формі поле порожнє ЗАВЖДИ — спільного поточного значення у п'ятдесяти +хостів немає, — і те саме тлумачення знищило б виробників, зонди й +розклади в усьому наборі за один клік. + +Тому кожне поле має три явні стани, і жоден не вгадується з вмісту: + +``` +поля немає в JSON → не чіпати +{"op":"set", ...} → задати всім однакове +{"op":"clear"} → очистити всім +``` + +У формі це перемикач «не чіпати / задати / очистити» на кожному рядку, і +поле значення взагалі не з'являється, поки не обрано «задати». + +Масовими стали: зонд-опитувач, тип, виробник, увімкнено/вимкнено, групи, +шаблони опитування, доступи до обладнання, розклад збору конфігів. Не +стали: ім'я, адреса, нотатки — вони унікальні на хост, і масова зміна тут +означала б не швидкість, а помилку. + +### Групи: «додати» і «прибрати» — різні дії + +Набір зв'язків має чотири стани, і це не ускладнення форми, а суть +множини: хости в наборі мають РІЗНІ поточні групи, тож спільного +«задати» для них не існує, поки людина не скаже, що старе можна викинути. + +`replace` лишено навмисно, попри те що воно найнебезпечніше: без нього +неможливо навести лад там, де вже наставлено абияк. Але окремим вибором, +зі своїм попередженням, і ніколи не поведінкою за замовчуванням. + +Розклад збору конфігів, навпаки, задається цілком: це одне рішення +(«щодня о третій, профілем huawei-vrp»), а не набір незалежних дрібниць — +увімкнути збір, не сказавши коли й чим, означає завести політику, яка +мовчки нічого не збирає. + +### Набір добирається так само, як для масових команд + +Клієнт шле перелік id РАЗОМ із фільтром, сервер бере перетин, а те, що +відпало, повертає окремим списком — рівно як `ResolveCommandTargets`. +Дві масові операції в одній системі мусять поводитись однаково, інакше +людина, яка звикла до однієї, помилиться в другій. + +Межі — `Scope.Writable`, а не `Readable`: «бачу хост» і «можу його +змінити» — різні права. Хост, доступний лише на читання, не просто +відсіюється, а показується у відсіяних: інакше «обрано 50, змінено 43» +людина побачила б лише в аудиті. + +### Стеля — 200 за раз + +Не через продуктивність: UPDATE на десять тисяч рядків Postgres зробить +швидко. Через те, що правка йде однією транзакцією й разом із хостами +чіпає членство в групах, доступи й розклади — тобто тримає блокування на +пів-інвентарю, поки диспетчер колектора читає з тих самих таблиць свій +план на такт. Двісті — це ще й приблизно те, що людина здатна прочитати +в підтвердженні, а підтверджене наосліп підтвердженням не є. + +Шаблони — єдине, що застосовується поза транзакцією: кожне призначення +тягне перерахунок чеків і правил алертів зі своїми транзакціями, і +затягнути це всередину означало б тримати `core.checks` і `alr.rules` +заблокованими весь час перерахунку двохсот хостів. + +### Видалення показує наслідки числами + +М'яке, те саме `deleted_at = now(), enabled = false`, що й для одного +хоста. Перед натисканням видно точний перелік імен і адрес, а під ним — +що станеться з прив'язаним: + +``` +N перевірок лишаться, але опитування спиниться +N активних алертів закриються самі на найближчому такті правил +N розкладів збору лишаться, але збір спиниться +N вузлів на мапах ЛИШАТЬСЯ з останнім відомим станом +N версій конфігу зберігаються — заради цього видалення й м'яке +``` + +Останнє виявилось дірою і в одиничній формі: вона обіцяла, що хост +«зникне зі списків і мап», а читання мапи не фільтрує видалені хости — +вузол лишається стояти. На живому стенді вузли на мапах має п'ять хостів +із шести, тобто обіцянку бачив би кожен. Текст виправлено в обох місцях. + +Кожна масова дія — рядок у `core.audit_log` з іменами хостів, а не лише +з ідентифікаторами: через рік хостів може вже не бути, а питання «що +тоді зробили» лишиться. + +--- + +## 2026-08-25 (продовження) — Черги, мапа, і калібрування висновку + +### Сторінка «Черги» + +Знайдено вісім місць, де щось накопичується, і для кожного визначено +головне: **втрачаються дані чи лише затримуються**. Втрата важливіша за +глибину — черга на сто елементів зі збалансованим темпом здорова, а +одна викинута вибірка телеметрії не повертається ніколи. + +Висновок рахує сервер, інтерфейс лише малює: пороги, що живуть у двох +місцях, розходяться при першій же правці одного з них. + +### Перше, що вона знайшла — і чому це виправлення в двох місцях + +Сторінка одразу показала «1 перевірка пропустила такт» і три +перевірки, що мовчать понад три інтервали. + +Три «мовчазні» виявились чеками **видалених хостів**. План їх не бере +(`BuildPlan` фільтрує `deleted_at`), але рядки лишились увімкненими — і +виглядали б як вічний затор. Тому: видалення хоста тепер вимикає його +чеки (не видаляє — хост відновлюють разом із налаштуваннями), а сама +сторінка їх не рахує. + +Пропущений такт виявився справжнім: `Леніна.21`, icmp.ping з інтервалом +30 с, попередній запуск не встиг до наступного. + +**Але висновок був «критично» — і це помилка калібрування.** Один +пропуск із сімнадцяти чеків це повільний пристрій, а не аварія. +Червоне, яке горить від шуму, вчить не дивитись на сторінку взагалі — і +тоді вона не спрацює тоді, коли справді знадобиться. Тепер поріг у +частці, а не в числі: на двох чеках один пропуск це половина +моніторингу, на двох тисячах — шум. Три тести саме на калібрування. + +### Мапа: підписи, стани, справжні зв'язки + +Причин відсутності підписів було **дві, і обидві справжні**. + +Головна — даних немає: `inv.interfaces`, `topo.links` і `topo.neighbors` +порожні, бо повне автовиявлення ніхто не заводив. + +Друга — окремий баг: власний підпис ребра їхав у `data.label` і **ніде +не читався**, бо `TrafficEdge` затіняв його локальною змінною. Людина +вводила підпис, він зберігався, інспектор його показував — а на лінії +не було нічого. + +Стан ребра замість ярлика «без прив'язки» — шість станів, і кожен +пропонує дію: «знайдено в мережі» → кнопка «Прив'язати»; «суперечить +виявленому» → перевірити схему; «намальовано вручну» → «Зібрати +топологію». + +Ця кнопка й замикає коло: підвищує наявний чек розпізнавання до повного +`topology.discover`, і далі ланцюг іде сам — сусіди → інтерфейси → +`snmp.if` → трафік на лінії. Пастку враховано: чек заводиться ДО +поштовху, інакше `TriggerNow` не влучає нікуди. + +### Видалений хост більше не стоїть на мапі назавжди + +`MapNodes` не фільтрував `deleted_at`: вузол лишався з останнім відомим +станом, оновлювати його вже нікому. Обрано не тихе зникнення, а окремий +стан: підпис закреслений, колір «невідомо», в інспекторі — блок «хост +видалено» з кнопкою прибрати. Мовчазне зникнення вузла разом із лініями +виглядало б як поламана мапа. + +--- + +## 2026-08-26 — Фільтр у «Конфігах», і чому він третій, а не новий + +Користувач: «Реалізуй фільтри в конфігах, щоб можна було шукати по +назві\айпі\моделі\вендору чи версії, групі і т.д — роби розумно». + +Сторінка мала поле «пошук хоста», яке шукало лише за іменем і лише в +пам'яті браузера — по всьому інвентарю, вивантаженому цілком. + +### Третьої мови фільтрів не з'явилось + +`DeviceFilter` уже вмів усе, що назвав користувач, і ним уже +користувались дві сторінки — «Команди» й масові дії в «Хостах». Писати +третій набір понять означало б завести оператору другий словник: людина, +яка щойно відібрала «усі Huawei на Миронівці» в командах, мусила б +вчитись відбирати те саме заново. + +Але поки застосувань було два, спільною лишалась тільки структура — а +сама умова стояла двома копіями SQL. Третя копія перетворила б збіг на +систему: копії розходяться не одразу, а на першому ж новому полі, і тоді +однаково заповнений фільтр дає різні набори на різних сторінках, і +помітити це можна лише звірянням переліків руками. + +Тому умова тепер одна (`store/device_filter.go`) і вставляється в усі три +запити з власним зміщенням плейсхолдерів. Тест саме на нумерацію: зсув на +одиницю не падає й не помиляється помітно — він мовчки порівнює не ті +колонки. + +### Пошук за адресою вже працював. Не працював за майданчиком + +`query` дивився в ім'я, адресу, виробника й модель — «айпі» знаходилось. +Додано версію ПЗ й назву майданчика: людина, яка набирає «Миронівка», не +має спершу здогадатись, що це майданчик, а не частина імені. Оскільки +умова спільна, це запрацювало на всіх трьох сторінках одразу; клієнтський +відбір у «Хостах» приведено до того самого набору полів, щоб перелік на +екрані й набір, який візьме масова дія, збігались. + +### Що є в конфігах і чого немає більше ніде + +Два поняття, і обидва — окремою структурою `ConfigFilter` поверх +спільної, а не новими полями в `DeviceFilter`. Причина не в чистоті: +кожне поле спільного фільтра мусить щось означати на кожній сторінці. +«Остання версія старша за тиждень» у масових командах не означає нічого — +там немає ні версій, ні збору. Поле, яке одна сторінка мовчки ігнорує, +рано чи пізно почнуть слати з неї «про всяк випадок». + +**Стан архіву** — `none` / `stale` / `fresh` ділять хости без залишку, +`failed` навмисно перетинається з ними. Хост, у якого архів свіжий, а +останній збір щойно впав, — найцікавіший випадок: проблема вже почалась, +а вік версії про неї ще мовчить. Заховати його всередину «немає бекапу» +означало б показати його тоді, коли рятувати пізно. + +Поріг застарілості — явне число (типово тиждень), а не «прострочений +розклад» із `next_backup_at`. У хоста може не бути розкладу взагалі, а +питання «коли його востаннє зберігали» до нього все одно ставлять. І +головне: прострочений розклад рахує сам планувальник — якщо застряг він, +поле, яким ми його перевіряємо, застрягне разом із ним. Вік останньої +версії правдивий незалежно від того, що там із плануванням. + +**Профіль збору.** Перемикачі будуються з профілів, які справді комусь +призначені, а не з довідника: у довіднику їх півтори сотні, у парку +працює п'ять. Окреме значення «не закріплено» — на стенді такий хост є +(Миронівка.Ядро.1), і збирається він нормально, профіль йому підбирається +під час завдання. Але підбір мовчки зміниться разом із довідником, тож +подивитись саме на них треба вміти. + +### Підпис у переліку рахує сервер + +Поруч з іменем хоста тепер видно стан архіву — «2 верс. · 3 години тому», +«бекапу немає», «застарів», «збір впав». Питання «у кого тут біда» +ставлять до всього переліку одразу, а не клікаючи по хостах по черзі. + +`backup_state` рахує той самий SQL-вираз, що й відбір. Поріг — це +рішення, і якщо його рахувати ще й у браузері, два місця розійдуться на +першій же правці одного з них: хост стоятиме в переліку «застарілі» з +підписом «свіжий», і повірити не можна буде жодному з двох. + +### Спільна панель + +`ChipRow` і `VersionRow` жили в `CommandsPage`; тепер це +`components/DeviceFilterPanel.tsx`, а сторінки додають до нього лише +власні поля через `children`. Правило поділу: питання до ХОСТА — у +панелі, питання до того, що з ним робить сторінка, — на сторінці. + +Відбір робить сервер. На сторінці, куди можуть приїхати тисячі хостів, +фільтрація в пам'яті означає вивантажити їх усі — і зробити це знову на +кожну натиснуту літеру. + +--- + +## 2026-08-26 — Мінікарта: три помилки й одна справжня + +Користувач тричі сказав, що мініатюра конфігу нечитабельна. Кожен раз +причина була інша, і кожен раз я спершу правив на дотик. + +**Перше — «бліда».** Додав кольори й контраст. Не допомогло, бо річ була +не в кольорі. + +**Друге — суцільна пляма.** Аж коли я відмалював алгоритм на справжньому +конфізі ZTE окремим скриптом і подивився на PNG, стало видно: 32 344 +рядки в 620 пікселів — це 52 рядки на піксель, і кожен піксель брав +найдовший рядок зі своїх п'ятдесяти двох. Інформації там уже не було, +лишався силует. Рішення — як у редакторах коду: рядок отримує два +пікселі, а коли файл не влазить, смужка сама їде за прокруткою. + +**Третє — злиті слова.** Рядок малювався суцільною рисою від відступу до +кінця. Саме проміжки між словами роблять зображення схожим на текст; +без них це штрихування. Тепер малюється кожне слово окремо, а ширина +виросла з 68 до 140 — на 68 слова зливаються незалежно від того, як їх +малювати. + +### Пошук показував неправду + +Окрема помилка, знайдена дорогою: у мінікарту потрапляли лише **перші +1000** знайдених рядків. У конфізі з 7 058 збігами карта показувала +приблизно перші дві тисячі й мовчала про решту — тобто казала «збіги на +початку, далі чисто». Це рівно той висновок, заради якого на мінікарту +й дивляться. + +Стеля виглядала розумною оптимізацією рівно доти, доки не звірити її з +числами справжнього файлу. + +Заразом: колонка збігів заливалась бінарно й ставала суцільною вже від +сотні розкиданих збігів — тепер це щільність (корінь, а не пряма +пропорція, щоб поодинокий збіг не зникав поруч зі згустком). А знайдений +рядок тепер **перефарбовується**, а не заклеюється смугою на всю +ширину: смуга ховала саме ту форму рядка, яку людина шукає очима. + +### Висновок для себе + +Жодну з цих трьох причин не було видно з коду. Усі три стали очевидними +за секунду після того, як зображення було намальовано й показано. +Дешевий спосіб подивитись на результат коштував менше, ніж будь-яка з +трьох спроб вгадати. + +--- + +## 2026-08-26 — Масова правка добирає ручні перевірки + +Користувач: «У масовій зміні хостів ти не додав зміну ручних перевірок». + +Їх лишили поза формою з міркуванням «вони поодинокі за визначенням — те, +чого немає в шаблонах». Поодинока тут причина, а не кількість. «Додати +`http.status` усім вебсерверам дільниці» і «прибрати зайвий `icmp.ping` +із сорока хостів, яким його колись наставили руками» — це одне рішення +на сорок рядків, тобто рівно те, заради чого масова форма й існує. Без +неї людина або робить сорок однакових рухів, або не робить нічого, і +частіше друге. + +### Три дії, і жодна не називається «замінити» + +``` +додати перевірку тим, у кого її ще немає +прибрати перевірку за типом +змінити наявні інтервал, таймаут, повтори, увімкнено/вимкнено +``` + +Заміни всього набору немає навмисно — і це не та сама межа, що для груп, +а гостріша. У груп `replace` лишили, бо групи ВИДНО в переліку хостів: +людина, яка натискає, бачить, що стирає. Набори перевірок не видно ніде, +тож те саме натискання зітерло б невідомо що, а помітилось би тоді, коли +перестане приходити алерт. + +Ручною вважається перевірка з `template_id IS NULL AND interface_id IS +NULL` — рівно та межа, яку вже проводить `SetDeviceChecks`. Породжену +шаблоном міняють у шаблоні, інакше правка зникне на найближчій +реконсиляції; та, що стоїть на інтерфейсі, взагалі не про хост, а про +порт, і нею володіє автовиявлення. + +### Головне питання — параметри + +Чек має `params` за схемою типу, і вони різні за природою. Кількість +пакетів у `icmp.ping` у сорока хостів однакова — задати її масово +осмислено. URL у `http.status` різний у кожного, і спільного значення +для набору не існує взагалі. + +Заборонити типи з обов'язковими параметрами означало б заборонити рівно +той випадок, з якого все почалося: `url` у `http.status` обов'язковий. +Дозволити лише те, що має типове значення зі схеми, — те саме іншими +словами: типового URL немає й бути не може. + +Тому дозволено, але з підстановкою на те, чим хости в наборі ЗАКОННО +різняться, — тобто на них самих: + +``` +{{address}} адреса хоста +{{name}} ім'я +{{fqdn}} доменне ім'я +``` + +Трьох достатньо й більше не буде. Будь-яке «різне», що не є самим +хостом, — це не масова дія, а сорок окремих рішень, і робити їх треба в +сорока картках. З тієї ж причини `params` є лише в «додати»: масово +задати той самий URL сорока перевіркам, у яких він уже різний, означає +зламати сорок перевірок одним рухом. + +Порожня підстановка — помилка, а не порожній рядок. `http:///health` ліг +би в базу мовчки, а помітили б його через добу серед впалих перевірок — +за кілометр від того місця, де його створили. Хост без адреси чесно йде +в «не вдалося». + +Форма параметрів — та сама `ParamFields`, що й у картці хоста, з тієї ж +`params_schema`. Другого, «краще поінформованого» діалекту параметрів у +продукті бути не повинно: тоді дві форми розійшлися б у тому, що таке +перевірка. + +### Дублі — це не помилка + +`checks_uniq` не дасть завести другу таку саму перевірку, і масове +додавання ГАРАНТОВАНО натрапить на хости, у яких вона вже є: половину +дільниці колись налаштували руками. Показати «додано 12» і промовчати +про решту — збрехати; показати помилку — злякати на рівному місці. + +Тому те саме розрізнення, що вже несуть `dropped`: + +``` +додано перевірку заведено +уже було перевірка цього типу вже є +не вдалося немає чим підставити {{address}} +``` + +Збіг ловиться по ТИПУ, а не по параметрах, хоч індекс рахує ще й +`md5(params)`. Індекс пропустив би другий `http.status` з іншим URL — і +хост лишився б із двома перевірками одного типу, яких у картці завести +неможливо. `ON CONFLICT DO NOTHING` лишається поруч як остання сітка: +без нього 23505 на одному хості обірвав би транзакцію для всієї +дільниці. + +Через це вікно після дії з перевірками не закривається: наслідок не +збігається з наміром, і показати його більше ніде. + +### Межі — ті самі + +Стеля та сама (`MaxBulkDevices`), право те саме (`devices:write` — усе це +людина робить у картці поштучно), рядок аудиту той самий +(`inv.device.bulk_update`), і в його `meta` лягає не лише намір, а й +наслідок: «просив додати сорока, додав дванадцятьом» — це те, заради +чого журнал читають. + +Автокеровані типи (`topology.identify`) не пропонуються ні на додавання, +ні на прибирання — так само, як у картці хоста: свій система вже завела, +а прибраний заведе знову. + +### Що показала жива база + +На стенді всі сім `icmp.ping` прийшли з шаблону, жодного ручного. Тобто +на цих даних «прибрати icmp.ping» чесно відповість «не було чого +прибирати» по всіх шести хостах — і це правильна відповідь, а не +бездіяльність: ручних перевірок цього типу там справді немає. + +--- + +## 2026-08-26 — Масові ручні перевірки, і два розходження в довіднику + +### Масова правка перевірок + +Автор масових дій свідомо пропустив ручні перевірки з міркуванням «вони +поодинокі за визначенням». Користувач не погодився — і мав рацію: +«додати `http.status` усім вебсерверам дільниці» це рівно масова дія. + +Три дії, кожна називає один тип чека: додати, прибрати, змінити наявні. +«Замінити весь набір» не роблено, і межа тут гостріша, ніж у груп: +групи видно в переліку хостів, набори перевірок не видно ніде. + +**Найважче в задачі — параметри.** Обов'язковий `url` у `http.status` +не має спільного значення для сорока хостів, а без нього чек не +заводиться. Заборонити типи з обов'язковими параметрами означало б +вбити рівно той випадок, з якого задача почалась. + +Розв'язано підстановкою на сам хост: `{{address}}`, `{{name}}`, +`{{fqdn}}`. Це єдине, чим хости в наборі законно різняться; будь-яке +інше «різне» — не масова дія, а сорок окремих рішень. Порожня +підстановка (хост без адреси) дає чесне «не вдалося» по цьому хосту, а +не порожній рядок у параметрі. + +**Дублі ловляться по ТИПУ, а не по параметрах.** `checks_uniq` рахує ще +й `md5(params)` і пропустив би другий `http.status` з іншим URL — хост +лишився б із двома чеками одного типу, яких у картці завести неможливо. + +Відповідь розрізняє «додано / уже було / не вдалося», і вікно після дії +не закривається: наслідок не збігається з наміром, а побачити його +більше ніде. + +### Довідник типів перевірок казав неправду про два з них + +Обидва розходження знайшлися, коли масову форму почали будувати з тієї +самої `params_schema`, що й картку хоста: там, де опис бреше, дві форми +брешуть однаково, і помітно це стає лише поруч. + +**`snmp.get`** описував `oids` як масив рядків. Насправді агент читає +туди об'єкти: OID, ключ метрики, одиниця, множник. Без ключа метрики +зібране значення нема куди покласти — тобто форма, побудована за старим +описом, давала чек, який мовчки нічого не збирав. Множник тут не +косметика: сенсори віддають десяті градуса цілим числом, і 235 замість +23.5 — це різниця між нормою й аварією. + +**`snmp.if`** значився таким, що його заводить людина. Насправді +перелік портів у params складає `EnsureInterfaceChecks` з того, що +знайшло автовиявлення: прибраний руками чек повернеться на наступному +такті, а доданий поверх наявного дасть другий чек того ж типу з іншим +`md5(params)` — унікальний індекс такого не ловить. Позначено +`auto_managed`. + +--- + +## 2026-08-26 — Помилка, яка тихо зносила автоматику + +`auto_managed` ховає тип чека з переліку для ручного додавання. Але +форма перевірок хоста надсилає ПОВНИЙ набір, а `SetDeviceChecks` +видаляє все, чого в ньому немає. + +Наслідок: форма більше не знала про автокеровані чеки — а отже не могла +їх «лишити». Кожне збереження форми тихо зносило розпізнавання хоста й +опитування портів, тобто рівно ту автоматику, заради якої їх і сховали. + +На живому стенді з шести хостів `topology.identify` лишився в одного, +`snmp.if` не лишилось узагалі, а метрики портів перестали оновлюватись. + +Виправлено з двох боків, і другий бік важливіший за перший. + +**Перше:** видалення більше не чіпає автокеровані типи. + +**Друге:** чек розпізнавання заводиться будь-якому хосту зі +SNMP-доступом, а не лише нерозпізнаному. Стара умова «ще не +розпізнаний» здавалась очевидною — чек потрібен, щоб хост розпізнати, — +і була хибною: коли чек зникав з іншої причини, він не повертався +НІКОЛИ. Хост уже розпізнаний, під умову не підпадає, і сторожова +функція (помітити заміну заліза) припинялась назавжди. Різниця між +«розпізнати» і «стежити» лишилась там, де їй місце, — в інтервалі: +дві хвилини проти шести годин. + +**Що з цього варто запам'ятати.** Помилка з'явилась не в новому коді, а +на стику: одна зміна (сховати тип) зробила хибним припущення іншої +(«форма надсилає все, що має лишитись»). Знайшлась вона лише тому, що +стан бази звірили з тим, який був три години тому, — з коду вона не +видно ні в тому місці, ні в іншому. + +### Заразом: агенти правили живі дані + +У журналі аудиту видно `ncm.command_run.delete` і `ncm.config.delete` на +бойовому стенді. Інструкція «живу базу не змінюй» дозволяла тлумачити +перевірку через власні ручки продукту як допустиму. Формулювання +посилено: заборона тепер перелічує способи (SQL, HTTP-ручки) і прямо +вимагає одноразової бази або транзакції з відкатом. + +--- + +## 2026-08-26 — Фільтри складу портів у шаблонах + +Користувач: «додай фільтри у шаблони, щоб не збирати на приклад усі +порти, а тільки по regex». У Zabbix це фільтр правила виявлення. + +Задача не теоретична. На стенді `JUN.QFX-Миронівка` віддає 100 портів, +з них під наглядом 98; `ПОЛЬОВЕ.7` — 57, і 29 із них насправді не +порти, а рядки «802.1Q Encapsulation Tag 0101». Сто портів по десять +OID у одному чеку — це сотні змінних за такт заради графіків, на які +ніхто ніколи не подивиться. + +### Фільтр належить шаблону + +«На QFX опитуємо лише xe- і ae-» — твердження про КЛАС заліза, а не про +конкретну коробку; рівно та причина, з якої шаблони існують узагалі. +Тому `tpl.templates.if_filter jsonb`, а не поле хоста. + +Фільтри кількох шаблонів одного хоста **обʼєднуються**, а не +перетинаються. Шаблони в цьому продукті лише ДОдаються (їх чіпляє +автовиявлення саме), і другий шаблон не сміє мовчки відібрати порти, +які просить перший: «OLT: gpon» плюс «Магістраль: 10G» на одному ZTE +мають дати і те, і те. Перетин дав би нуль і зламав би обидва шаблони +одночасно, не назвавши винного. Шаблон без фільтра в обʼєднанні не +бере участі: відсутність думки не є думкою «усе». + +### Зразки застосовує Go, а не Postgres + +Три причини, і жодна не про швидкість. + +**Крива дужка.** У `~` вона валить запит, а з ним транзакцію, всередині +якої перебудовується чек: snmp.if мовчки перестає оновлюватись на +кожному такті, і побачити це можна хіба в журналі сервера. У Go зразок +компілюється наперед, і помилка називає себе у формі шаблону, де її +щойно зробили. + +**Відкат.** RE2 його не має за побудовою. Бектрекінговий рушій +Postgres на випадковому `(a+)+b` здатен з'їсти хвилини всередині +транзакції, що тримає рядки `inv.interfaces`. + +**Попередній перегляд мусить казати правду.** Перегляд іде через HTTP, +тобто через Go. Якби бойове фільтрування жило в SQL, два рушії +розійшлися б на першому ж `\d` — у POSIX ERE це просто літера d. + +Той самий вибір і з тієї ж причини вже зроблено в `autoassign.go`, +`firstGroup`. + +Зразок компілюється з `(?i)`: Huawei пише `Vlanif10`, інші вендори — +`vlan10`, і фільтр, який промахнувся через велику літеру, помиляється +мовчки. Кому потрібен регістр — пише `(?-i)`. + +### Порожній результат — відмова, а не тиша + +Фільтр, який не пропустив жодного порту, — майже завжди помилка в +зразку. Порожній snmp.if-чек ззовні не відрізнити від справного, і +помічають його тоді, коли метрик портів немає вже тиждень. + +Тому три рівні: + +* **збереження шаблону** рахує обʼєднання фільтрів для КОЖНОГО + привʼязаного хоста й відмовляє з 409, називаючи хости й числа + («Леніна.21: 0 з 29»). Перевірка стоїть усередині тієї самої + транзакції — шаблон лишається таким, яким був; +* **перебудова після автовиявлення** не чіпає склад чека взагалі й + пише попередження: краще збирати зайве, ніж перестати збирати все; +* **реконсиляція** (розпізнавання хоста, масова правка) цю відмову + ковтає навмисно — там помилка одного фільтра поклала б увесь звіт, а + сказана вона вже тричі в інших місцях. + +Питання ставиться не «чи знайшов щось цей фільтр», а «чи лишиться хоч +один порт у хоста». Інакше два коректні шаблони заважали б зберегти +один одного. + +### Порт, який випав, каже про це сам + +`inv.interfaces.filtered_out_at` — коли цей порт перестав потрапляти в +чек. Метрики лишаються в історії, але оновлюватись перестають, і без +цієї мітки обрив графіка нічим не відрізнити від аварії. Окремо від +`monitored`: то ручний вимикач людини, і плутати його з наслідком +шаблону означало б, що вимкнений руками порт сам увімкнеться, щойно +хтось поправить зразок. + +У перегляді видно ще точніше число: скільки портів **зараз +опитуються** і після цього фільтра перестануть (`eligible && !matched +&& !filtered_out_at`). Саме воно лякає, і саме його треба бачити до +збереження. + +### Що показала перевірка на справжніх іменах + +Прогін заготовок по всіх 234 портах стенду: + +``` + JUN.QFX ZTE Леніна ПОЛЬОВЕ-СВ ПОЛЬОВЕ.7 +тип = ethernet|LAG 49/98 3/35 26/29 6/14 28/57 +Juniper xe-/ge-/ae- 44/98 0/35 0/29 0/14 0/57 +швидкість >= 10G 50/98 1/35 0/29 0/14 0/57 +має опис 27/98 1/35 29/29 0/14 0/57 +``` + +Три висновки, яких не було видно з коду. + +**Найнадійніша ознака — тип, а не імʼя.** Його задає RFC 2863, а не +маркетинг вендора: одна умова прибрала з D-Link усі 29 псевдопортів +802.1Q і «System», а з Juniper — усі `.0`-підінтерфейси (вони +propVirtual), тунелі й службові lsi/dsc/irb/vtep. І ніде не дала нуля. + +**«Тільки з описом» — пастка.** На Huawei опис заповнює сама прошивка +однаковим текстом для всіх портів і не відсіює нічого; на обох D-Link +описів немає взагалі, і фільтр дав би нуль. Обидва факти тепер написані +в підказці заготовки й зафіксовані тестом. + +**«Магістральні» порожні на трьох хостах із пʼяти.** Заготовка, яка +виглядає найбезпечнішою, — головний постачальник порожніх чеків. Без +відмови при збереженні її натиснули б і пішли. + +Заразом знайшлось `ae11` — заведений, але порожній LAG зі швидкістю 0. +Жоден зразок його не відрізняє від живого; очима в переліку портів це +видно за секунду. Тому перегляд показує перелік, а не лише число. + +### Стелі не чіпано + +`MaxInterfacesPerCheck = 256` лишилась як була — вона про розмір PDU, а +не про смак. Змінився лише `LIMIT` читання: він був 257 і відрізав би +порти ще до того, як зразок їх побачить, тобто `xe-0/0/47` для фільтра +просто не існувало б. Тепер читаємо до 4096, фільтруємо, і аж потім +обрізаємо. `sameInterfaceSet`, `monitored` і реконсиляція шаблонів +працюють як раніше. + +Базові правила складу портів (`monitored`, не loopback, не notPresent) +винесені в один рядок `ifEligibleSQL` на всі три місця: розійшовшись на +одному «і», перегляд і чек почали б відрізнятись рівно на ті порти, +через які людина й прийшла дивитись. + +--- + +## 2026-08-26 — Дзеркало архіву конфігів на зовнішньому Git + +Половина задачі була спроєктована ще в `0006_ncm.sql`: `ncm.repos` мала +`remote_url`, `remote_secret_id` і `mirror_enabled` з коментарем «Опційне +дзеркалювання на зовнішній Git». Жоден рядок Go їх не читав. Модель +виявилась придатною — добудовано, а не переписано. + +### Коли пушити + +Не після коміту. Push у `StoreConfig` коротший на десяток рядків і має +рівно один наслідок: доступність чужого сервера стає умовою успішного +бекапу. Збір — нічний розклад по сотнях пристроїв; недоступний Forgejo +перетворив би його на сотню таймаутів там, де все залізо відповіло, а +конфіг уже лежить у базі. + +Дзеркало — копія. Копія не має права ламати оригінал. Тому власний такт +у колекторі, поруч із прибиральником і з тих самих причин +(`grpcapi/ncm_mirror.go`), власне advisory-блокування, і витримка +повтору в БАЗІ, а не в пам'яті: процес перезапускають, і витримка, що +живе в пам'яті, після кожного релізу починала б шквал спроб рівно тоді, +коли ми щойно перезапустились. + +Такт хвилинний, але в мережу йде лише коли змінився відбиток локальних +посилань — або раз на шість годин про всяк випадок. Відбиток каже, чи +змінилось У НАС; він нічого не каже про те, чи ціле дзеркало. Без +періодичної звірки інсталяція зі стабільними конфігами вважала б копію +живою рівно доти, доки та не знадобиться. + +### Що бачить людина при тижневій відмові + +Найгірший варіант — мовчазне «все добре»: дзеркало заводять, щоб +пережити втрату диска, і дізнатись у день втрати, що воно тиждень +мовчало, — це не мати його взагалі. + +Тому три поверхні. Сторінка «Дзеркало» першим рядком показує «остання +копія: 7 днів тому» й текст останньої помилки. Після трьох невдач +поспіль (≈8 хвилин) піднімається алерт `ncm.mirror:` — на сторінку +алертів і в лічильник у шапці, тобто туди, куди дивляться щодня; +severity `high`, якщо копії немає понад добу. Знімається він першим же +вдалим обміном. І журнал аудиту: `ncm.mirror.update` / `ncm.mirror.push`. + +Стан видно з `ncm:read`, а міняти налаштування можна лише з новим правом +`ncm:mirror` (власник і адмін). Помітити відмову має могти той, хто +щодня дивиться на конфіги; вивозити архів за периметр — рішення рівня +організації. + +### Без -f + +Refspec шаблонний і без плюса: push або перемотується вперед, або +відмовляється з поясненням. Механізм, який уміє мовчки переписати +віддалену історію, — це не копія, а другий спосіб її втратити. Перезапис +існує окремою дією з підтвердженням і гасне після першого успіху +(`mirror_force_once`). Потрібен він рівно в одному випадку: локальний +репозиторій відновили з бази (`netpulse-gitsync`), коміти склались +наново, і їхні ідентифікатори не збігаються з тими, що колись поїхали. + +### Ключ SSH-сервера звіряється — на відміну від заліза + +Для мережевого обладнання в продукті ухвалено протилежне +(`agent/internal/ncmx/transport.go`), і там воно правильне: сотня +коробок, кожна перегенеровує ключ після прошивки. Тут один сервер, і +везе він за периметр архів із ключами та хешами паролів. Ключ +показується під час перевірки з'єднання, закріплює його людина +збереженням, далі кожен обмін звіряє. Без закріпленого ключа push по SSH +не збирається взагалі. + +Автоматичне TOFU виглядало б так само зручно й не перевіряло б нічого: +довіра діставалась би тому, хто відповів першим. + +### Токен + +`core.secrets`, AES-GCM, AAD `|ncm.mirror|` — той самий +механізм, що й для паролів від обладнання. Назад в API не повертається +ніколи: форма показує «збережено», порожнє поле означає «лишити як є». + +Окремо перевірено, що він не тече в текст помилки. go-git вкладає в +помилку повну адресу, а люди вставляють у поле адреси рядок виду +`https://токен@forgejo/…` з чужої інструкції — і тоді токен несе не +транспорт, а НАШ власний текст, зібраний із `r.URL`. Саме це й було +помилкою першої редакції; ловиться тестом +`TestExplainNeverEchoesURLCredentials`. + +### Розкладка під наступний вид даних + +Шлях у дереві складався у двох місцях (`StoreConfig` і `SyncGit`) — тепер +в одному, `store.ConfigPath`. Правило: `config_type` без скісної риски +дає `<хост>/<тип>.cfg` (усе наявне — байт у байт), зі скісною — +`<хост>/<шлях як є>`. Тобто набір файлів сервера +(`files/etc/nginx/nginx.conf`) лягає в наявне дерево наявної гілки, не +зсуваючи жодного вже надісланого шляху. Зсув шляху після першого push — +найдорожча правка з можливих: віддалене дерево прийме її лише примусом. + +Дзеркалення це переживає без жодної правки: refspec шаблонний, а не +перелік гілок. + +### Що показала жива перевірка + +Одноразові Gitea й Postgres у власних контейнерах (прибрані). Перший +повний push 9,5 МБ тексту в чотирьох гілках — 1,5 с; повторний без змін +— 286 мс без мережевої роботи; приріст на одну версію — 2,5 с. Хибний +токен, токен в адресі, підмінений ключ хоста, розбіжність дерев і +свідомий перезапис — усі п'ять дали очікуваний текст, у жодному токена +немає. + + +--- + +## 2026-08-26 — Дзеркало, гілки й чотири помилки, знайдені ним + +Перше вмикання дзеркалення на зовнішній Forgejo показало більше, ніж +мало б: щоб дзеркалити, треба спершу мати що. + +### Git-архів не працював ніколи + +Том `/var/lib/netpulse` належав root, а процес працює під uid 10001. Він +не міг створити в ньому навіть підкаталогу — і кожен коміт конфігу тихо +не відбувався: у сховищі лишався контентний хеш замість SHA коміту, а +репозиторію не існувало взагалі. + +Причина в тому, що іменований том успадковує права каталогу з образу при +першому монтуванні. Каталог створювався від root — том теж. Дефект +прожив із самого розгортання й був невидимий, бо збір конфігів від нього +не залежить: база лишається джерелом істини, і все працювало. + +Каталог тепер створюється в образі під потрібним користувачем. + +### Кирилиця в шляхах + +`sanitizePath` викидала все, крім латиниці, і від українських імен +лишались хвости: «ZTE Миронівка» → `ZTE`, «Леніна.21» → `21`, +«ПОЛЬОВЕ-СВ» → `device`. Той, хто склонував би дзеркало, побачив би +каталоги «1», «7» і «21». + +Git зберігає шляхи в UTF-8 і кирилиці не боїться — боялась її лише ця +функція. Закріплено тестом на справжніх іменах зі стенду. + +Показово, що тест на розкладку вже існував — і закріплював помилку як +даність, «не тому, що гарно, а тому, що вже в історії». Міркування +правильне, висновок ні: історії ще не було, дзеркало не створене. Це був +останній момент, коли виправлення коштувало нічого. + +### Гілки за UUID + +У коді стояло свідоме рішення: гілка `device/`, «щоб пережити +перейменування, інакше історія розсипається на дві». Правильне +міркування з наслідком, якого не передбачили: у Forgejo двадцять рядків +UUID, і знайти серед них свій комутатор неможливо ані за іменем, ані за +адресою. Пошук по гілках — перше, чим користуються в дзеркалі. + +Тепер `device/<ім'я>-<адреса>`, а стабільність забезпечується інакше: +при зміні імені гілка ПЕРЕЙМЕНОВУЄТЬСЯ (`gitstore.RenameBranch`), а не +заводиться друга. Історія лишається однією. + +Адреса в імені не косметика: у мережах повно хостів «Комутатор» і «ONU». + +### Заразом перевірилось те, заради чого писався gitsync + +Локальний репозиторій видалено цілком і зібрано з нуля: 10 версій із 10. +Заявлене «база — джерело істини, Git повністю відтворюваний» виявилось +робочим шляхом, а не обіцянкою в коментарі. + +### Порівняння версій не працювало + +Порівнювати можна було лише зі СТАРІШОЮ версією — з правильного +міркування, що diff «уперед» читався б задом наперед. На живому стенді з +цього вийшло, що порівняння не працює зовсім: у чотирьох хостів із шести +версія одна, а в решти двох треба вгадати, яку з двох натиснути. + +Обмеження знято, міркування збережено інакше: старіша з двох завжди стає +лівим боком, незалежно від того, з якого боку до пари підійшли. + +### Прибирання шаблону лишало правила + +Перевірки зникали справно, а правила з тригерів — ні: реконсиляція йшла +лише по НОВОМУ набору шаблонів, тож відчеплений ніхто не переглядав. +Виходило найгірше поєднання: шаблон прибрано, перевірок немає, а тригер +із нього досі піднімає алерти — і знайти джерело нема де. Тепер +переглядається об'єднання старого й нового набору. + +### Відповідність не розрізняла типи конфігів + +Перевірка брала просто найсвіжішу версію хоста. Поки в кожного один тип +(`running`), це працює — і працює випадково. Щойно на сервері з'явиться +набір файлів під типом `files`, правило для заліза почне перевіряти його +й чесно повідомить про порушення там, де його немає. + +Правило тепер має тип (міграція 0056, типово `running`), а хост без +конфігу цього типу під нього просто не підпадає — це не порушення. +Виправлено до появи першого правила, тож мігрувати нічого не довелось. + +## 2026-08-27 — Сім задач одним заходом: що спільного виявилось у всіх + +Сім міграцій (0058–0064) робились паралельно, і за задумом їх поєднував +лише перелік справ. За фактом — дещо інше, і це варте окремого запису. + +**Схема обіцяла те, чого не існувало, у трьох місцях одразу.** Тип +перевірки `snmp.walk` стояв у довіднику з міграції 0010, а модуля на +зонді не було ніколи — чек повертався з `no_module`. Таблиця +`ncm.rollbacks` лежала зі схемою двоетапного погодження й без жодного +рядка коду. Повідомлення `ConfigApplyJob` і `ConfigApplyResult` були в +протоколі від першого дня, з гілками в обидва боки, і без обробників по +обидва боки. + +Жодне з трьох не помічалось, бо все виглядало готовим: у довіднику є, у +схемі є, у протоколі є. Обіцянка, записана в схему, читається як +виконана — і це, схоже, головний спосіб, у який цей проєкт себе +обманює. + +**Політики RLS написані на 68 таблицях і не спрацювали жодного разу.** +Застосунок ходить у базу суперкористувачем. Про це вже було відомо: у +`0050_audit_read.sql:188` це записано прямим текстом — діру помітили, +обійшли в одному місці й пішли далі. Знайдене й не полагоджене за рік +стає нормою. + +**Правило з подієвим джерелом було зламане з обох кінців.** Воно не +могло спрацювати (движок рахував три джерела з дев'яти) — і навіть якби +спрацювало, `ResolveMissing` закрив би такий алерт наступним тіком, бо +порожній перелік кандидатів він читає як «проблема зникла». + +**Тест, який нічого не перевіряв.** `TestDeleteRemoteRefIsTargeted` +складав адресу як `file://` + шлях. На Linux провідна скісна в шляху вже +є, і виходить правильне `file:///tmp/…`. На Windows виходить +`file://C:/…`, де `C:` — це ХОСТ. Тест падав на першому push, тобто +твердження, заради якого він написаний (що видалення гілки адресне й не +чіпає сусідню), не перевірялось узагалі. Списувалось на середовище. + +**І одна хибна передумова — моя.** Ставлячи задачу про очисник даних, я +записав «зараз немає жодного механізму, який щось прибирає». Насправді +стиснення налаштоване на восьми гіпертаблицях, видалення за строком на +дев'яти, і половина роботи звелась до того, щоб їх не зламати. Три +таблиці справді ростуть назавжди — але це не «немає нічого», це «є, і в +ньому дірки». Різниця істотна: перше веде до того, щоб написати своє +поверх чужого. + +### Що з цього випливає для порядку робіт + +Спільна риса всіх шести знахідок — не помилка в коді, а **впевнений +вигляд там, де нічого немає**. Схема, довідник, протокол, зелений тест, +увімкнена політика безпеки. Кожне з них — інтерфейс, за яким мала б +стояти реалізація, і кожне читається як доказ, що вона стоїть. + +Тому в чотирьох із семи задач головним результатом стала не можливість, +а ВІДМОВА: правило з нереалізованим джерелом більше не зберігається, +профіль без команд заливки каже про це замість мовчазної кнопки, +міграція RLS валить сама себе на таблиці з `tenant_id` без політики, +тест словника аудиту падає на ключі без назви. Кожна з цих відмов +закриває не окрему помилку, а спосіб, у який така помилка з'являється +непоміченою. + + +## 2026-08-27 — Подієві алерти: тригери на журнал, конфіги й відповідність + +### Що було зламано + +Движок правил рахував рівно три джерела: `metric`, `icmp`, `interface`. +Для `syslog`, `trap`, `ncm`, `agent`, `compliance` і `link` `EvaluateRule` +свідомо повертав порожньо. При цьому і форма правил, і редактор тригерів +шаблону дозволяли завести правило з такими джерелами без єдиного слова +застереження. + +Наслідок — не «функція не працює», а гірше: людина заводила тригер +«конфіг змінився», бачила його в переліку ввімкненим, із нулем активних +алертів, і читала цей нуль як «змін не було». Відсутньої функції шукають +обхідний шлях; функція, яка виглядає працюючою, обхідного шляху не шукає +ніхто. + +Знайшлася там же й друга, тихіша поломка: якби подієвий алерт усе ж +з'явився, наступний тік движка його б закрив. `EvaluateRule` для такого +правила віддавав порожній список кандидатів, а `ResolveMissing` слідом +закривав усе, чого в цьому списку немає. З погляду опитування алерт +«зник» — хоча зникнути він не може за побудовою. + +### Чому подієвий шлях, а не ще одне опитування + +Опитуване джерело має ряд вимірів: питання «чи виконується умова зараз» +осмислене, і відповідь може змінитися будь-коли. Тому вікно `for_seconds` +— це запит по часу, а гасіння — різниця множин. + +У журналу, конфігів і відповідності такого ряду немає. Питати «чи +змінився конфіг зараз» безглуздо: він змінився о 10:42 і більше нічого +про це не скаже. Опитування `ts.syslog` розкладом було б і дорожчим +(гіпертаблиця на мільярд рядків), і брехливішим — вікно опитування +завжди або пропускає події, або рахує їх двічі. + +Тому правило перевіряється рівно в ту мить, коли подія надійшла, у тому +процесі, який її прийняв: + +* `syslog` — `grpcapi.StreamLogs`, одразу після запису пачки, поруч із + наявним тригером позачергового бекапу; +* `ncm` — шлях завантаження конфігу: `changed` після `StoreConfig`, якщо + версія справді інша, і `backup_failed` у кожній гілці, де завдання + закривається як провалене; +* `compliance` — прогін `RunCompliance`, який запускають з UI. + +### Чотири рішення, які довелося ухвалити явно + +**Дедуплікація.** Один алерт на пару «правило + хост», скільки б подій під +нього не підпало. Ключ (`EventDedupKey`) навмисно не містить нічого від +самої події й збігається формою з ключем метричного алерту — два різні +формати рано чи пізно дали б два алерти про одне. Захищає той самий +унікальний індекс `alerts_active_dedup_uniq`, що й раніше. + +Але злиття не має ховати масштаб: «порт мигнув один раз» і «порт мигнув +4000 разів за годину» — різні аварії з однаковим текстом. Тому з'явився +`alr.alerts.event_count`, і його видно на дошці алертів. + +**Частота.** Дедуплікації мало: без обмежувача потік журналу дав би сотню +UPSERT-ів на секунду. Тому, по-перше, пачка рядків зводиться до +звернення до бази (`OnSyslog` збирає збіги в мапу за парою +«правило + хост»), по-друге, між двома зверненнями до одного алерту +витримується `min_interval_seconds` правила. Пропущені за цей час події +не викидаються — вони накопичуються в обмежувачі й доливаються в +лічильник наступним зверненням: економимо запити, а не правду. + +**Гасіння.** Подієвий алерт не має способу зникнути сам, і вдавати, що +має, було б брехнею. Його закриває: + +* людина — наявними `Ack` / `Close`; +* строк — `ExpireEventAlerts`, стан `expired`, а не `resolved`. Різниця + принципова: ніхто не казав, що проблему полагодили, вона просто + відстоялась. `expired` в `alr.alert_state` існував із 0007 і досі не + використовувався жодним кодом. +* виняток — відповідність. Там прогін перевіряє всі хости під правилом і + каже про кожен «пройшов» або «ні», тобто має справжній сигнал зняття. + Такий алерт закривається сам (`ResolveEventAlerts`), і це не виняток із + правила, а наслідок того, що дані інші. + +Строк за замовчуванням — доба: «встиг побачити на наступній зміні». +Менше — ризик, що нічну подію ніхто не побачить; більше — накопичення +без користі, бо сама подія лежить і в журналі, і в історії алертів. Нуль +дозволений і означає «закриє людина». + +**Доставка.** Подія приходить у `netpulse-server` (gRPC для зондів), а +ключі шифрування каналів, маршрути й тихі години читає `netpulse-api`, +де крутиться движок. Розсилати з обох процесів означало б дублювати +повідомлення й розводити дві копії маршрутизації. Тому подієвий шлях +лише піднімає алерт із позначкою `notify_pending`, а розсилає його движок +наступним тіком — під тим самим advisory-блокуванням, тобто рівно в +одному екземплярі. `TakeNotifyPending` знімає позначку тією ж командою, +що й повертає рядки: інакше два інстанси API розбудили б людину двічі. + +Плата — затримка до одного тіку (30 с). Для «конфіг змінився» чи +«порушено стандарт» це прийнятно: жодне з них не є аварією, на яку +біжать за секунди. + +### Джерела, яких свідомо не буде + +`trap`, `link` і `agent` не реалізовані, і тепер API відмовляє у їх +вмиканні з людським поясненням замість мовчазного збереження: + +* `trap` — трап приїжджає як OID і набір varbind-ів. Без словника MIB + умова звелася б до порівняння цифр із крапками, яких людина не набере + з голови. Це була б друга мовчазна обіцянка замість першої. +* `link` — лінк на мапі не має власних вимірів: він живий рівно + настільки, наскільки живі його кінці. Правило про лінк — це правило + про пристрої, і воно вже є. +* `agent` — «зонд не на зв'язку» — це стан, а не подія; він рахується + опитуванням `last_seen_at`. Місце йому серед метричних правил. + +Відмова стосується лише **ввімкнених** правил і тригерів. Шаблон +редагується як документ цілком, і якби нереалізоване джерело блокувало +збереження, людина з одним старим тригером на трапи не могла б виправити +жоден інший. Вимкнений тригер нічого не обіцяє — він не розгортається в +правило взагалі. + +Наявні правила з цими джерелами міграція **вимикає, а не видаляє**. +Видалення забрало б і текст, і саму згадку про намір; вимкнене правило +лишається на екрані сірим — найкоротший спосіб сказати «воно не +працювало й не працює», не забравши нічого. + +### Чому перевірка умови стоїть на записі + +Той самий аргумент, що й у правил відповідності (0056): про друкарську +помилку в регулярному виразі людина має дізнатися з форми, а не з +тригера, який рік мовчав. `ValidateRuleCondition` компілює зразок +`syslog`, вимагає явної події для `ncm` і живе в `store`, а не в +HTTP-шарі, — бо ту саму відповідь має дати й збереження тригера шаблону, +а два різні пояснення тієї самої відмови розходяться на першій же правці. + +### Міграція 0058 + +* `alr.rules.auto_close_seconds`, `alr.rules.min_interval_seconds`; +* `alr.alerts.event_count`, `alr.alerts.notify_pending` + два часткові + індекси під вибірки «що розіслати» й «що прострочено»; +* наявним подієвим правилам проставлено добовий строк; +* правила й тригери шаблонів із джерелами `trap`/`link`/`agent` вимкнено. + +### Інтерфейс + +Форма тригера тепер пропонує рівно ті джерела, які працюють, і для +подієвих показує їхні власні поля (зразок і рівень syslog, подія конфігу, +поріг серйозності знахідки) замість порогу й агрегації, яких у них немає. +Витримка й «враховувати топологію» для подієвих правил не питаються: перше +не має сенсу без вікна, друге питає, які хости зараз лежать, а подія хоста +не вкладає. + +Під вибором джерела для подієвих правил стоїть прямий текст про те, що +такий алерт не зникає сам. Це і є те, що система мала сказати з самого +початку. + +### Файли + +* `server/migrations/0058_event_alerts.sql` +* `server/internal/store/alerts_events.go` (новий) — класифікація джерел, + перевірка умов, `RaiseEventAlert`, `ExpireEventAlerts`, + `TakeNotifyPending`, `ResolveEventAlerts` +* `server/internal/alerting/events.go` (новий) — `EventSink`: кеш правил і + селекторів по кабінету, зведення пачок, обмежувач частоти +* `server/internal/alerting/engine.go` — подієві джерела вилучено з + циклу опитування; додано гасіння прострочених і розсилку черги +* `server/internal/store/alerts.go`, `alerts_state.go`, `alerts_query.go`, + `template_triggers.go`, `ncm_compliance.go` +* `server/internal/grpcapi/service.go`, `streams.go` +* `server/internal/httpapi/alerts.go`, `templates.go`, `compliance.go`, + `server.go` +* `server/cmd/netpulse-api/main.go`, `server/cmd/netpulse-server/main.go` +* `web/src/pages/RulesPage.tsx`, `web/src/pages/AlertsPage.tsx`, + `web/src/components/TriggerEditor.tsx`, `web/src/types.ts`, + `web/src/api/client.ts` +* тести: `server/internal/store/alerts_events_test.go`, + `server/internal/alerting/events_test.go` + +## 2026-08-27 — `snmp.walk` і прототипи елементів шаблону + +Дві речі, які досі були обіцянкою. + +Перша: тип `snmp.walk` стоїть у довіднику `core.check_types` від +міграції 0010, а модуля на зонді немає. Чек такого типу заводився, +потрапляв у план і повертався з `no_module` — система обіцяла вміння, +якого не мала, і дізнатися про це можна було лише завівши такий чек. + +Друга: шаблон описує СТАЛИЙ перелік метрик. Для `sysUpTime` цього +досить, але половина того, за чим справді стежать, лежить у таблицях зі +змінним індексом — диски, сенсори температури, VLAN, елементи стека, +оптичні модулі. Скільки їх і під якими індексами — властивість +конкретної коробки, а не класу заліза, і в шаблон її не впишеш. + +Досі єдиний такий випадок — порти — покривав захардкоджений Go +(`EnsureInterfaceChecks`). Це працює рівно доти, доки випадок один: +наступний вендор із власною таблицею вимагав би нового Go-коду, релізу +сервера й ще однієї міграції. Шаблони писались саме щоб цього не було. + +### Прототип — це два речення + +1. «Рядки цієї таблиці шукай обходом ось цього OID»; +2. «На кожен рядок збери ось ці метрики, підставивши індекс». + +Між ними стоїть сервер. Він тримає перелік знайдених рядків у +`tpl.discovered_rows` і перебудовує склад `snmp.get`-чека, коли рядки +з'являються або зникають. + +Це рівно той поділ праці, який уже є для портів: модуль `topology` +знаходить, `inv.interfaces` зберігає, `EnsureInterfaceChecks` +перебудовує чек. Різниця одна й вона головна — тут «що саме шукати» +описує шаблон, а не Go. + +### Чому зонд не збирає все сам + +Спокуса була: обійти таблицю й одразу зібрати по ній значення — тоді +рядки з'являлися б без жодної затримки, а серверу не треба було б нічого +пам'ятати. + +Відкинуто з тієї ж причини, з якої `snmp.if` отримує перелік портів у +параметрах, а не ходить по `ifTable` сам: зонд не має права вирішувати, +що опитувати. Це впирається в ліміти тарифу й у те, що оператор мусить +бачити склад чека до того, як він поїде в мережу. Крім того, метрики +пішли б повз `snmp.get` — другим, паралельним шляхом із власним +розкладом і власними багами поруч із наявним. + +### Два інтервали, а не один + +У прототипа `discovery_sec` (типово година) і `interval_sec` (типово +хвилина), і це не надмір налаштувань. + +Обхід таблиці коштує стільки пакетів, скільки в ній рядків, а диски й +сенсори не з'являються щохвилини. Збір значень — звичайна метрика. +Один інтервал на обидві дії означав би або щохвилинний walk усього +піддерева, або графік, що з'являється через годину після того, як у +сервер вставили диск. + +Наслідок у схемі: на прототип виходить два чеки — рідкий `snmp.walk` і +частий `snmp.get`. Обходи одного шаблону з однаковою частотою при цьому +злипаються в один чек: одна SNMP-сесія на всі піддерева, рівно з тієї ж +причини, з якої в один чек збираються OID звичайних елементів. + +### Фільтр рядків — той самий рушій, що фільтр портів + +Механіка з 0051 не дублювалась, а узагальнилась. `compiledCondition` +тепер судить не про структуру порту, а про інтерфейс `filterCandidate`; +набір допустимих полів став параметром компіляції. Портам лишились +`name/alias/type/speed`, рядкам дісталися `index/name`. + +Так навмисно: другий примірник цієї механіки розійшовся б із першим на +першому ж виправленні, і «не збігається» почало б означати різне у двох +сусідніх вкладках однієї форми. Заразом безкоштовно приїхали властивості, +за які вже заплачено, — RE2 без відкату, `(?i)` типово, помилка в зразку +називає себе у формі, а не через добу серед порожніх графіків. + +Полів у рядка рівно два, і це не тимчасове звуження: більше про рядок +таблиці не знає ніхто. Умова `ge/le` на неіснуючу швидкість тепер не +компілюється — інакше вона мовчки не пропускала б жодного рядка. + +### Рядок, що зник, мусить пояснювати обрив графіка + +`tpl.discovered_rows` має дві позначки замість видалення: + +* `gone_at` — рядка більше немає в таблиці (диск вийняли); +* `filtered_out_at` — рядок є, але фільтр прототипу його не пропускає. + +Причина та сама, що в `filtered_out_at` для портів у 0051: метрики +лишаються в історії, і людина, яка дивиться на обрив, мусить мати спосіб +дізнатися, що сталось. Видалений рядок відповів би мовчанням. Події +розділені, бо дії різні: перше виправляють у шафі, друге — у формі +шаблону. + +Обрізаний обхід (`truncated`) не дає права нікого ховати: до решти +рядків просто не дійшли, і позначити їх зниклими означало б стерти +половину графіків через власну стелю. + +### Рядки тримаються за КЛЮЧ прототипу, а не за його id + +`SaveTemplate` переписує прототипи цілком — так само, як елементи, — тож +їхні `id` живуть рівно до наступного натискання «Зберегти». Прив'язка до +`id` означала б, що правка назви прототипу обриває всі графіки дисків до +наступного обходу, тобто годину порожнечі за одну зайву літеру. + +### Три секунди типового таймаута тут не годяться + +`core.checks.timeout_ms` типово 3000, і для одного `Get` цього досить. +Обхід триває стільки, скільки в таблиці рядків: 64 диски — це щонайменше +три BulkGet-и поспіль, а до віддаленого вузла й більше. Чек, що не +встиг, віддає «таймаут», сервер не бачить жодного рядка й нікого не +заводить — прототип мовчки не працює взагалі, і причина не написана ніде. + +Тому обхід отримує 20 секунд, а збір значень по рядках — 15, рівно +стільки ж, скільки `EnsureInterfaceChecks` дає чеку портів із тим самим +порядком кількості OID. Таймаут переписується і при оновленні чека, а не +лише при створенні: інакше чек, заведений із помилковим значенням, ніс +би його вічно. + +### Помилка 0046 не повторена + +`params_schema` для `snmp.walk` описувала чек, якого ніколи не існувало: +один `oid` і `max_rows` — без відповіді на питання, ЧИЇ це рядки й куди +їх покласти. Схему звірено з `WalkParams` у Go рядок за рядком. + +Заразом виправлено `snmp.get`: сервер почав писати `labels` (без них два +диски злилися б в один графік — ряди розрізняє `ts.series.labels_hash`), +і схема мусила сказати про це в тій самій міграції. `omitempty` на полі +не косметика: без нього `params` усіх наявних `snmp.get`-чеків отримали +б зайве `"labels":null`, інший `md5(params)`, інший хеш плану й +одноразовий перезалив усього парку заради нічого. + +### Вбудований прототип рівно один і вимкнений + +`snmp-host-resources` — вбудований шаблон, спільний для всіх тенантів і +вже причеплений до хостів на робочих стендах. Увімкнений прототип завів +би їм по два нові чеки МОВЧКИ, під час міграції, без жодного рішення +людини; перше, що побачив би адміністратор, — приріст SNMP-трафіку +невідомо звідки. + +Вимкнений він робить рівно те, заради чого стоїть: показує в редакторі, +як прототип виглядає зібраним, і копіюється разом із шаблоном. +Реконсиляція його не бачить, тож ціна для наявних стендів — нуль. + +### Інтерфейси прототипом НЕ виражені + +Спокуса очевидна: порти — це теж таблиця зі змінним індексом, і зняти +хардкод із `autochecks.go` було б красиво. Не зроблено, і це рішення, а +не брак часу. + +`snmp.if` — не набір метрик. Він повертає `InterfaceCounters`: агент +тримає попередній замір, рахує швидкості за ФАКТИЧНИМ інтервалом, +ловить перевертання лічильника й ділить на номінальну швидкість порту. +Прототип віддав би сирі октети без жодного з цих чотирьох умінь. + +Далі — прив'язки. Лічильники лягають у `ts.if_counters` за +`interface_id`, а не в `ts.samples` за міткою. На цьому `interface_id` +тримаються анімація трафіку на мапі, інспектор лінка, `filtered_out_at` +із 0051 і правила тригерів із джерелом `interface`. Прототип породжує +серії з мітками й без `interface_id` — переїзд означав би переписати +все перелічене й перенести історію. + +І нарешті ціна помилки. На стенді шість хостів із робочими +`snmp.if`-чеками. Міграція, яка спробувала б замінити їх чеками +прототипу, у найкращому разі обірвала б графіки трафіку, у гіршому — +лишила б обидва набори чеків одночасно й подвоїла опитування. Виграш — +мінус двісті рядків Go; ризик — єдине, що на цьому продукті бачать +щодня. + +Хардкод лишається там, де він є, і тепер має поруч чесний коментар про +те, чому саме він окремий випадок. + +### Що змінено + +Зонд: + +* `agent/internal/modules/snmp/walk.go` — новий модуль: обхід піддерева, + індекс + назва рядка, стелі, дедлайн, окрема доля кожного прототипу; +* `agent/internal/modules/snmp/snmp.go` — `snmp.walk` у `CheckTypes()` + і в диспетчері; +* `agent/internal/modules/snmp/walk_test.go`. + +Сервер: + +* `server/migrations/0059_template_prototypes.sql` — `tpl.item_prototypes`, + `tpl.discovered_rows`, RLS, правдива `params_schema` для `snmp.walk` + і `snmp.get`, `auto_managed`, вимкнений вбудований прототип; +* `server/internal/store/prototypes.go` — опис, реконсиляція, приймання + рядків, розгортання прототипу в `oidSpec`; +* `server/internal/store/iffilter.go` — узагальнення рушія фільтрів; +* `server/internal/store/templates.go` — прототипи в читанні, збереженні, + реконсиляції, експорті й клонуванні; `labels` в `oidSpec`; +* `server/internal/store/prototypes_test.go`; +* `server/internal/httpapi/templates.go` — прототипи в тілі збереження + й перевірка зразків там, де їх щойно надрукували; +* `server/internal/grpcapi/streams.go` — приймання `payload_json` від + `snmp.walk`. + +Веб: + +* `web/src/components/PrototypeEditor.tsx` — нова вкладка редактора; +* `web/src/pages/TemplatesPage.tsx`, `web/src/types.ts`. + +### Чого немає + +Попереднього перегляду рядків у формі, на відміну від фільтра портів. +Порти вже лежать в інвентарі, і зразок можна прикласти до них не +виходячи з форми; рядків чужої таблиці на сервері немає доти, доки зонд +не зробить обхід. Показати їх у формі можна було б лише збігавши на +живий пристрій просто з браузера — це окрема ручка з окремою моделлю +доступів, і вона не мала б бути умовою появи прототипів. + +# 0060 — Відкат конфігу: від наміру до запису на пристрій + +Таблиця `ncm.rollbacks` стояла в базі з 0006 — з наміром, двоетапним +погодженням і полем «команди, які реально підуть на пристрій». Коду під +нею не було жодного рядка. Тобто система вміла зібрати конфіг, зберегти +версію, показати різницю, віддзеркалити в Git і перевірити на +відповідність — і не вміла єдиного, заради чого все це збирають: +**повернути** те, що працювало вчора. + +Це не «ще одна функція NCM». Це те, чим Enterprise-тариф відрізняється +від Oxidized: Oxidized теж збирає й версіонує, але на питання «поверни +як було» відповідає «ось файл, іди набирай». + +--- + +## Чому заливка — це не «надіслати файл на пристрій» + +Спокуса зробити відкат як «взяти збережений конфіг і віддати його +пристрою цілком» велика й хибна на всіх родинах, які є в мережі. + +CLI мережевого заліза не має режиму «замінити конфіг на оцей». Рядки, +віддані в режимі конфігурації, **додаються** до наявного. Хост, у якому +вчора помилково створили VLAN, після такої «заливки» матиме і вчорашній +конфіг, і той VLAN — рівно те, від чого відкочувались. Відкат, який не +прибирає зайве, відкатом не є. + +Тому на пристрій їде **різниця**: + +* рядків бракує — ідуть як є; +* рядки зайві — ідуть із префіксом заперечення родини (`no `, `undo `); +* родина заперечення не має — рядки **не вигадуються**, а показуються + людині окремим переліком «прибрати вручну». + +Останнє — про D-Link, і це не пропуск. У D-Link немає універсального +`no`: створене прибирається `delete`, налаштоване переписується +`config`, увімкнене вимикається `disable`. Вивести з рядка конфігу +потрібне дієслово автоматично неможливо — `create vlan v10 tag 10` +прибирається як `delete vlan v10`, і жодне механічне правило цього не +дасть. Тому система заливає лише додане й змінене, а зайве віддає +людині — з попередженням **до** підтвердження, а не з розбіжністю після. + +## Чому контекст відновлюється за відступом + +Рядок ` ip address 10.0.0.1 255.255.255.0` сам собою не означає нічого: +він належить інтерфейсу, назва якого стоїть вище й без відступу. Віддати +його без батьківського рядка означає віддати його в глобальний режим — і +отримати або помилку, або, що гірше, зміну не того об'єкта. + +Тому кожна зміна їде разом зі своїм ланцюжком батьків, а після блока — +`exit`. Відступ як ознака вкладеності — властивість самих конфігів +(Cisco, ZTE, Huawei друкують ієрархію відступами), а не наш здогад; +родини без ієрархії (D-Link) просто не мають відступів, і той самий код +дає для них плаский перелік. + +У межах одного блока спершу йдуть заперечення, потім додавання. Зміна +значення виглядає в різниці як пара «зайвий рядок / потрібний рядок»; +якби спершу додати новий, частина платформ відкинула б його як конфлікт +зі старим, а наступне заперечення прибрало б уже обидва. + +--- + +## Безпека + +### Погодження не обходиться + +Політика (`ncm.rollback_policy`) типово вимагає **другої людини** й +типово забороняє самопогодження. Знімок політики лежить у самому намірі: +вимкнення вимоги заднім числом не легалізує те, що вже висить у черзі. + +Вимикач цієї вимоги навмисно лежить під **іншим правом** +(`settings:write`), ніж сам відкат (`ncm:rollback`). Інакше двоетапність +зводиться до однієї галочки, яку знімає той самий, кого вона мала +стримати. Погодити машинним токеном не можна взагалі: погодження — це +підпис людини, а токен інтеграції звів би двоетапність до другого curl +із того самого скрипта. + +### Паролі не потрапляють ані в базу, ані в стенограму + +Правило проєкту («у стенограму пишуться лише байти, які надіслав сам +пристрій») лишилось чинним — і саме тому знадобився другий захист. +Заливка додає ризик, якого не було в зборі: пристрій **відлунює** те, що +ми йому шлемо, а шлемо ми рядки конфігу, зокрема з паролями. Ті байти +приходять від пристрою, тож у стенограму лягають законно, і зонд +прибрати їх не може — він не знає, які рядки в цієї родини секретні. + +Тому зроблено дві речі. + +1. У `ncm.rollbacks.commands` план лежить **замаскованим** + (`redact_patterns` профілю плюс запасний перелік). Туди ж, у + замаскованому вигляді, він іде в журнал аудиту. Тіла конфігів + зашифровані в `core.secrets`, і покласти ті самі рядки відкритим + текстом у сусідню таблицю означало б обійти власне шифрування. +2. Стенограма й вивід кожної команди маскуються перед записом у базу. + +Маскується сам збіг, а не весь рядок: людині при погодженні треба +бачити, **що** змінюється («буде переписано пароль на vty»), і рядок із +самих зірочок цього не каже. + +Наслідок: справжній план у базі не зберігається взагалі. Він +**перебудовується** з зашифрованих тіл у мить відправки, а звіряється +підписом (`plan_hash`), знятим при погодженні. Побічний ефект виявився +головним запобіжником: перебудова йде від **поточного** стану хоста, тож +якщо між погодженням і відправкою конфіг на пристрої змінився — план +вийде інший, підпис не збіжиться, і відкат не поїде. «Погодили одне, +виконали інше» стає неможливим не за домовленістю, а механічно. + +### Після заливки — обов'язковий контрольний збір + +Відповідь CLI не є доказом. Пристрій відповідає рядком тексту, і +мовчазна згода означає «прочитав», а не «застосував»: половина платформ +мовчки ігнорує рядок, який не підходить до поточного контексту. Вірити +виводу — це той самий клас помилки, що колись дав «Next possible +completions» у ролі версії конфігу в архіві (0034, 0043). + +Тому після заливки система йде й **знімає конфіг заново**, будує з нього +той самий план ще раз і дивиться, чи лишилось що робити: + +* нуль команд → `applied`, відкат справді відбувся; +* не нуль → `mismatch`, і це число видно в інтерфейсі. + +Порівняння хешів для цього не годиться: свіжий конфіг ніколи не +збігається з архівним побайтово (інший час, uptime, лічильники), тож +хеш казав би «не збіглось» завжди, і стан `mismatch` за тиждень +перестали б читати. План же порівнює рівно те, що вміє залити — а рядки +під `scrub_patterns` профілю з порівняння викидаються з тієї ж причини. + +Контрольний збір ставиться **власним** завданням, без дедуплікації, на +відміну від звичайного `EnqueueConfigJob`. Збір, що вже виконується, міг +початись до заливки, і його результат відповів би на питання, якого ми +не ставили. + +### Обрив зв'язку посеред заливки + +Найгірший стан, у якому може опинитись вузол: половина команд на +пристрої, друга — ні, а результат не приїде ніколи. Найгірша реакція на +нього — записати `failed` і заспокоїтись, бо `failed` читається як +«нічого не сталось». + +Тому прибиральник переводить такий намір не у відмову, а в `verifying`: +ставить контрольний збір і дивиться, що реально стало на пристрої. +Відповідь буде або `applied` (устигло все), або `mismatch` із числом +недоробленого — і в обох випадках це знання, а не здогад. Пристрій +поруч, його можна спитати. + +Зонд зі свого боку робить три речі, які цьому допомагають: + +* зупиняється на **першій** помилці (доливати рядки в конфіг, який уже + пішов не тим шляхом, означає отримати стан, якого не описує жоден із + двох конфігів); +* віддає звіт **завжди**, навіть якщо обірвалось усе — у ньому видно, що + саме встигло виконатись; +* виконує команду збереження **лише після повного успіху**. Незбережений + напівзалитий конфіг має важливу властивість: його прибирає + перезавантаження вузла. Зберегти його означає відібрати цю останню + можливість. + +Окремий стан отримав і випадок «залито, але не збережено»: до +перезавантаження пристрій працює за новим конфігом, після — за старим. + +--- + +## Вбудовані профілі + +Заповнено те, що перевірено або однозначно випливає з синтаксису родини: + +| профіль | вхід | заперечення | збереження | +|---|---|---|---| +| `cisco-ios` | `configure terminal` | `no ` | `write memory` | +| `zte-zxr10` | `configure terminal` | `no ` | `write` | +| `zte-zxan` | `configure terminal` | `no ` | `write` | +| `dlink-me` | — (без режиму) | немає | `save` | + +**MikroTik RouterOS — свідомо без відкату.** Вивід `export` виглядає як +набір команд, але команди в ньому — `add`. Повторне виконання `add` не +повертає рядок на місце, а створює **другий** такий самий запис: другу +адресу на інтерфейсі, друге правило фаєрвола. Прибирання робиться через +`remove [find …]` — через пошук за критерієм, якого в рядку експорту +немає. Тобто механічний відкат тут не «поки не зроблений», а дав би +гарантовано хибний результат. + +**Juniper JUNOS — свідомо без відкату.** Профіль знімає `show +configuration | display omit`, тобто ієрархію у фігурних дужках. Це не +набір команд: віддати такий текст рядками в CLI неможливо. Альтернатива +існує — профіль зі збором `| display set` дав би рядки `set …` із +заперечником `delete `, — але це інший профіль збору й переливання +архіву, і робити це мовчки, підмінивши формат історії, не можна. + +Решта 140+ профілів лишається з `apply_supported = false`, і інтерфейс +про це чесно каже: «для цього профілю відкат не налаштований» плюс +причина з `apply_note`. Порожній профіль, який виглядає робочим, гірший +за відсутню кнопку: він обіцяє відкат рівно до того моменту, коли відкат +знадобиться. + +--- + +## Інтерфейс + +Кнопка «відкотити» стоїть на кожній версії, крім найсвіжішої. Діалог +показує три речі, і підтвердити не можна, доки не переглянуто першу: + +1. **Що зміниться** — різниця «зараз → стане», двома колонками. Не дата + версії й не її розмір, а самі рядки. Прапорець ставиться фактом + перегляду, а не галочкою «я подивився»: галочку ставлять не дивлячись. +2. **Команди на пристрій** — план у тому порядку, у якому його виконає + зонд, із нумерацією (коли заливка спиниться, у звіті буде номер). + Різниця показує намір, план показує дію, і це різні речі: «прибрати + vlan 200» у різниці виглядає як зникнення рядка, а на пристрій піде + `no vlan 200`. +3. **Що лишиться на пристрої** — перелік «прибрати вручну», з окремим + свідомим підтвердженням. З'являється не завжди й найважливіший, коли + з'являється. + +Наміри показуються там же, у картці хоста: погоджує їх інша людина, і +вона приходить на ту саму сторінку — щоб побачити ту саму різницю, яку +бачив автор, у тому ж місці. + +--- + +## Що змінилось + +**Міграція** +`server/migrations/0060_ncm_rollback.sql` — поля заливки в +`ncm.profiles`, два стани (`verifying`, `mismatch`) і десяток полів у +`ncm.rollbacks`, таблиця `ncm.rollback_policy`, команди заливки для +чотирьох родин і чесні пояснення для двох, яким її не дали. + +**Сервер** +`store/ncm_rollback_plan.go` (побудова плану, маскування), +`store/ncm_rollback.go` (намір, погодження, черга, перевірка), +`httpapi/ncm_rollback.go` (вісім ендпоїнтів, RBAC), +`grpcapi/ncm_rollback.go` (диспетчер і приймач результату). + +**Зонд** +`ncmx/apply.go` (заливка тим самим CLI, що й збір), +`session/apply_jobs.go` (звіт назад тим самим стрімом). + +**Веб** +`components/RollbackDialog.tsx`, панель намірів і кнопка в +`pages/ConfigsPage.tsx`. + +`.proto` **не змінювався**: `ConfigApplyJob`, `ConfigApplyResult` і +гілки в `ControlDown`/`ControlUp` лежали там від першого дня й чекали +рівно коду по обидва боки. + +## 2026-08-27 — Кнопки в Telegram нарешті щось роблять + +### Що було зламано + +Сповіщення в Telegram їхало з двома кнопками — «Підтвердити» й +«Заглушити 1 год». Кнопки малював `notify.go`, `callback_data` складався +як `ack:` і `mute:`. Приймача не існувало: сервер +не мав ані вебхука, ані опитування, і натискання не робило нічого. + +Не робило — це м'яко сказано. Telegram, надіславши `callback_query`, +малює на кнопці годинник і крутить його, доки бот не відповість на +`answerCallbackQuery`. Не відповідав ніхто, тож годинник висів до +таймауту клієнта. З телефона о третій ночі це читається не як «функції +немає», а як «зламалось» — причому в найпомітнішому місці продукту, у +повідомленні про аварію. + +Поруч лежала дрібніша поломка того ж роду: кнопка «Заглушити» малювалась +навіть тоді, коли алерт не має хоста (аварія самого зонда, черга не +встигає). У `callback_data` їхало голе `mute:`, і натиснути на неї можна +було рівно з тим самим результатом — тобто ні з яким. + +### Довге опитування, а не вебхук + +Вебхук вимагає, щоб Telegram МІГ ДО НАС ДОСТУКАТИСЬ: публічний порт із +переліку 443/80/88/8443 і сертифікат, якому довіряє їхній бік. +Самопідписаний приймається лише завантаженим у `setWebhook` файлом — і +навіть тоді потрібне ім'я, на яке його видано. + +Це розгортання — самопідписаний TLS на голій IP-адресі, домену немає. +Причому це не «поки не налаштували»: self-hosted інсталяція в мережі +оператора зазвичай узагалі не має входу ззовні. Вебхук там не запрацює +ніколи, і код під нього був би кодом, який не працює в жодній наявній +інсталяції. + +`getUpdates` не вимагає ані вхідного порту, ані імені, ані сертифіката: +з'єднання ініціює сервер, а TLS перевіряється в бік `api.telegram.org` — +тобто туди, де сертифікат справжній. Ціна — одне висяче HTTP-з'єднання +на бота й курсор у базі. + +Про `X-Telegram-Bot-Api-Secret-Token` окремо, бо його вимагають за +звичкою: цей заголовок захищає ВЕБХУК від сторонніх POST-ів на наш +відкритий шлях. У схемі з опитуванням відкритого шляху не існує взагалі +— приймати нема чого, ми самі ходимо по оновлення. Отвору, який той +заголовок затуляє, тут немає. Якщо колись з'явиться домен, вебхук стане +кращим, і перевірка натискання переїде в нього без змін: вона навмисно +не знає, звідки прийшло оновлення. + +### Порядок перевірок + +Головне питання приймача — не «що просять», а «хто просить». У +`callback_data` лежить рівно те, що ми туди поклали, але надіслати цей +рядок може будь-хто, хто раз побачив формат: він не підписаний і не +зашифрований. Тому кабінет і особа НЕ беруться з нього ніде. + +1. **Чий чат.** `matchChannel` шукає канал, у якого `chat_id` збігається + з чатом повідомлення з кнопкою. Чат прописав адміністратор кабінету + руками, тож збіг означає рівно потрібне. Чат, якого немає в жодному + каналі, кабінету не дає взагалі — і на цьому все зупиняється. +2. **Що просять.** `parseCallbackData` приймає лише `ack:`/`mute:` з + канонічним uuid. Кривий рядок відпадає до першого звернення до бази. +3. **Хто натиснув.** `core.telegram_accounts` за парою + (кабінет, `from.id`). Немає прив'язки — немає дії. +4. **Чи можна йому.** `UserPermissions` (потрібне `alerts:ack`) і + `LoadScope` (хост має бути в межах доступу). Ті самі дві перевірки, + що й у HTTP-обробників. +5. І лише тоді — дія. + +Порожній набір прав тут означає ще й відкликане членство: людину +прибрали з кабінету, а прив'язка лишилась. Відповідь однакова навмисно — +з боку Telegram це та сама відмова. + +### Та сама логіка, а не її копія + +`doAck` викликає `store.AckAlert`, `doMute` — `store.MuteDevice`. Ті +самі, що й `POST /api/v1/alerts/{id}/ack` і `POST /api/v1/mutes`: +підтвердження з телефона має лишати в базі такий самий слід, як +підтвердження з браузера, а власна копія розійшлася б із оригіналом на +першій же правці — і побачили б це не в коді, а у звіті. + +З тієї ж причини стеля заглушення переїхала з `httpapi` у +`store.MaxMute`: кнопку «Заглушити» тепер натискають із двох місць, і +політика в них одна. + +### Ідемпотентність — двома шарами + +Палець натискає двічі, а мережа вміє доставити те саме натискання +повторно. + +*Дія.* Перед `AckAlert` іде `AlertAckState`: якщо алерт уже +підтверджений, друге натискання відповідає «Уже підтверджено: <хто>» і +НЕ переписує автора — перший, хто взявся, лишається першим. Для +заглушення так само: `ActiveMute` не дає покласти другий ряд у +`alr.mutes`, бо інакше «на годину» мовчки перетворювалось би на дві. + +*Вигляд.* `withStatus` не дописує рядок, який у тексті вже є. Друге +натискання приводить повідомлення в той самий вигляд, що й перше, — а +саме це людина перевіряє очима. + +### Після дії повідомлення міняється + +`answerCallbackQuery` показує спливаючий рядок на кілька секунд і лише +тому, хто натиснув; у чат він не потрапляє, а чат читає вся зміна. Тому +слідом іде `editMessageText`: у текст дописується +«✅ Підтверджено: , 27.08 03:12», а `inline_keyboard` +замінюється на порожній — кнопки зникають. Без цього наступний черговий +натискає їх ще раз. + +`parse_mode` при правці навмисно не задається, хоч надсилали ми з HTML: +Telegram віддає в `message.text` уже готовий текст без розмітки, і +повторна відправка його як HTML або зламалася б на першому «<» у назві +інтерфейсу, або перетворила б частину тексту алерту на теги. + +### Прив'язка людини + +Питати в людини її числовий `telegram user_id` безглуздо — вона його не +знає, а дізнатись може лише через сторонніх ботів, тобто ми б відправляли +її віддати свою ідентичність невідомо кому заради нашої ж форми. Поле +вводу до того ж дозволяє вписати ЧУЖИЙ id. + +Тому напрям такий: NetPulse видає одноразовий код (профіль → Telegram), +людина шле боту `/link КОД`. Повідомлення в Telegram — доказ володіння +акаунтом, якого форма дати не може: його не надіслати за іншого. Код +народжується там, де людина вже увійшла паролем, живе 15 хвилин, згорає +при першому використанні й лежить у базі лише хешем. + +Код перевіряється ще й на кабінет: приймається лише той, що виданий +кабінету, якому належить бот, що прийняв повідомлення. Інакше чинний код +кабінету А, надісланий боту кабінету Б, прив'язав би людину туди, де її +бот навіть не стоїть. + +### Міграція 0061 + +* `core.telegram_accounts` — зіставлення `tg_user_id` → `core.users`. + Тенантне, а не глобальне: підрядник обслуговує кілька кабінетів і в + кожному є окремим користувачем. Два унікальні індекси — один telegram + на людину й одна людина на telegram у межах кабінету. +* `core.telegram_link_codes` — sha256 коду, строк, одноразовість. +* `alr.telegram_cursors` — місце в черзі `getUpdates`, ключ — хеш токена + бота. Без нього перезапуск процесу або губить натискання, або переграє + добову історію (Telegram тримає невибрані оновлення 24 години) і + глушить хост о десятій ранку за кнопкою, натиснутою вночі. Ключ саме + бот, а не канал: `getUpdates` ексклюзивний, і два канали одного бота + крали б оновлення один в одного. + +Чого в міграції НЕМАЄ навмисно — зв'язку «повідомлення Telegram → +алерт». Редагувати треба РІВНО те повідомлення, кнопку якого натиснули, +а його `chat_id` і `message_id` приходять у самому `callback_query`. +Довідка «яким повідомленням це поїхало» вже є: +`alr.notifications.external_id` зберігає `message_id` з 0007. + +### Один читач на бота + +`getUpdates` ексклюзивний, тож два процеси на одному боті ділили б +натискання навпіл. Приймач бере advisory-блокування на ВЕСЬ час роботи +(не на такт, як движок алертів) і тримає його разом зі з'єднанням: +падіння процесу звільняє блокування само, сусід підхоплює за пів +хвилини. Живе з'єднання перевіряється щотакту — мертве означає, що +Postgres блокування вже зняв. + +Курсор посувається незалежно від того, чи вдалася сама дія: оновлення, +на якому обробник спіткнувся, інакше приходило б знову й знову, і одна +крива кнопка глушила б усі наступні назавжди. Людина при цьому без +відповіді не лишається — невдача сказана їй текстом. + +### Файли + +``` +server/migrations/0061_telegram_callbacks.sql нове +server/internal/store/telegram.go нове +server/internal/alerting/telegram_callback.go нове (розбір і перевірки) +server/internal/alerting/telegram_bot.go нове (опитування, Bot API) +server/internal/alerting/telegram_callback_test.go нове (без мережі) +server/internal/httpapi/telegram_link.go нове (код прив'язки) +server/internal/alerting/notify.go кнопка mute лише за наявності хоста +server/internal/store/alerts_query.go MaxMute, ActiveMute, AlertAckState, + DeviceNameInTenant +server/internal/httpapi/alerts.go maxMute → store.MaxMute +server/internal/httpapi/server.go /api/v1/me/telegram{,/code} +server/internal/store/audit_actions.go core.telegram.link / .unlink +server/cmd/netpulse-api/main.go -telegram-callbacks +web/src/types.ts TelegramLink, TelegramLinkCode +web/src/api/client.ts три виклики +web/src/pages/ProfilePage.tsx картка «Telegram» +``` + +### Чого НЕ зроблено + +* **Вебхука немає** — і не буде, доки немає домену з дійсним + сертифікатом. Причина вище; перевірка натискання від транспорту не + залежить і переїде без змін. +* **Кнопки «Закрити алерт» немає.** `CloseAlert` каже «проблему + полагоджено», і казати це не подивившись — не те, що робиться однією + кнопкою з телефона. +* **Прибирання протухлих кодів** (`PruneTelegramLinkCodes`) написане, але + в жоден такт не всунуте: рядків там стільки, скільки натискань на + «Отримати код», і заводити під це фонову задачу зараз дорожче за сам + механізм. Один незужитий код на людину тримає унікальний індекс. +* **Живого прогону не було.** Жодного запиту до Telegram API не + надсилалось: тест перевіряє розбір і перевірки без мережі, решта — на + стенді з живим ботом. + +## 2026-08-27 — Журнал без назв і архів без виходу + +Дві різні поломки, але хвороба одна: дію додали, а місце, де про неї +дізнається людина, лишили без змін. У журналі це виглядало як сирий +ключ замість назви, в інвентарі — як хост, що зник назавжди після +кнопки «прибрати з переліку». + +--- + +### Частина перша: журнал аудиту не знав власних дій + +`store.audit_actions.go` перекладає ключ (`ncm.mirror.push`) у фразу +(«Примусовий пуш архіву на дзеркало»). Файл просив наступного дописати +рядок — і за півроку програв це прохання тричі: дзеркало Git, ролі й +прив'язка хоста до машини зонда писались у журнал, а назв не мали. +Адміністратор бачив у фільтрі сирі ключі. + +Помітити пропуск неможливо ніяк, крім як відкрити журнал і впізнати +відсутню назву: запис іде, сторінка відкривається, фільтр працює. + +#### Що знайшлось насправді + +Перелік виведено з коду, а не з пам'яті: знайдено кожен виклик +`WriteAudit` у `server/internal` і розібрано, що саме лягає в +`AuditEntry.Action` (два з них ховались за помічниками `auditRole` і +`auditSelfAgent`, де ключ їде параметром). + +Бракувало восьми дій: + +``` +inv.device.self_agent Прив'язка хоста до машини зонда +inv.device.self_agent_clear Зняття прив'язки хоста до машини зонда +ncm.mirror.update Налаштування дзеркала Git +ncm.mirror.push Примусовий пуш архіву на дзеркало +core.role.create Створення ролі +core.role.update Зміна прав ролі +core.role.delete Видалення ролі +inv.device.bulk_purge Повне видалення хостів разом з історією +``` + +…і двох типів об'єктів: `ncm.mirror` («Дзеркало Git») і `core.role` +(«Роль»). Плюс дві нові дії цієї ж роботи — +`inv.device.bulk_restore` («Відновлення хостів з архіву») і той самий +`inv.device.bulk_purge`. + +Останній — окрема історія. Повне видалення писалось під тим самим +ключем, що й архівне (`inv.device.bulk_delete`), а різниця лежала в +`meta.mode`. Для журналу це найгірше з можливих місць: фільтр за дією +не розрізняє «прибрано з переліку» й «немає більше ніде», позначка +«незворотна» стоїть однаково на обох, а прочитати `meta` можна лише +розгорнувши кожну подію окремо. Ключ, за яким шукають найстрашнішу дію +в системі, тепер власний. Назва старого лишилась навмисно широкою: +під ним у журналі вже лежать обидва режими, і перейменувати їх заднім +числом означало б підправити чужі події, яких ніхто не перевірить. +`meta.mode` теж лишився — за ним уже шукають. + +#### Головне: спосіб, у який назви губились, закрито + +Дописати вісім рядків — це наслідок, а не робота. Робота — зробити так, +щоб дев'ятий не загубився. + +Ключ тепер має рівно одне місце оголошення — константу в +`audit_actions.go`. Поруч стоїть тест +(`audit_actions_source_test.go`), який розбирає ВЕСЬ `server/` як текст +програми, знаходить кожне присвоєння `AuditEntry.Action` і +`AuditEntry.ObjectType` і падає на двох речах: + +- ключ написано рядком просто в місці виклику (два місця оголошення — + два джерела правди, що розходяться мовчки); +- у константи немає назви у словнику. + +Ключі, які їдуть у помічник параметром, тест доводить до кінця сам: +знаходить оголошення функції, номер параметра й перевіряє аргументи в +кожному її виклику. Тобто «сховати» ключ за проміжною функцією не +вийде. + +Окремий запобіжник — від самої перевірки: якщо розбір знайшов менше +десяти присвоєнь, тест падає з поясненням. Перейменують `AuditEntry` — +і мовчазний нуль знахідок виглядав би як успіх. + +Заодно прибрано другу таку саму пастку поруч: перелік типів об'єктів +тримався мапою, а порядок для фільтра — окремим списком `order`. +Новий тип, дописаний у мапу й забутий у списку, просто не з'являвся у +фільтрі. Тепер це один упорядкований перелік. + +Блок `AuditBlindSpots` (чесний перелік того, чого журнал не бачить) +виправлено: рядок «зміни складу команди й ролей не записуються» став +неправдою наполовину — самі ролі тепер у журналі є, а от хто кого в +яку роль посадив — і далі ні. Так і написано. + +--- + +### Частина друга: «прибрати з переліку» було пасткою + +Видалення хоста має два режими: архівне (`deleted_at`) і повне (0057). +Архівне задумувалось оборотним — «зібране лишається», — але оборотним +ніколи не було: переліку прибраних хостів у продукті не існувало, дії +«відновити» теж, і рядок із `deleted_at` був недосяжний нізвідки, крім +SQL. Хост не видалений, місце займає, ряди метрик лишає — і ні +повернути, ні добити. + +0057 дав спосіб добити. Тут з'явився спосіб повернути. + +#### Чому цього не зробити одним `deleted_at = NULL` + +Архівування вимикає не лише хост, а й усі його увімкнені перевірки +(інакше увімкнений чек мертвого хоста назавжди лишається «таким, що +пропустив свій такт» на сторінці «Черги»). Знявши саме лише +`deleted_at`, ми повернули б у перелік хост, який мовчить: він на +місці, графіки порожні, причини не видно ніде. + +Вгадати нічого не можна: у базі не записано, який чек вимкнула людина, +а який — архівування. Тому міграція 0062 дає архівуванню місце, куди +це записати: + +``` +inv.devices.archived_enabled яким був enabled на момент архівування +core.checks.archived_off цей чек вимкнуло архівування, не людина +``` + +Обидві живуть лише між архівуванням і поверненням; повернення вмикає +рівно те, що вимикало архівування, і забуває запис. Хости, прибрані до +0062, мають `archived_enabled = NULL` — такий повертається вимкненим, і +інтерфейс про це прямо каже. Це чесніше, ніж увімкнути навмання те, що +людина могла вимкнути свідомо ще до видалення. + +Там же — індекс під сам перелік архіву: усі наявні індекси на +`inv.devices` часткові `WHERE deleted_at IS NULL`, тобто заведені під +питання «що зараз працює» й на питання «що прибрано» не відповідають +узагалі. + +#### Три речі, які могли змінитись, поки хост лежав + +Архів — не сейф: у кабінеті працювали далі. Дві унікальні умови бази +стосуються саме живих рядків і зіткнулися б із поверненням мовчки: + +- `devices_tenant_name_uniq (tenant_id, lower(name))` — за місяць хост + із такою ж назвою могли завести заново. Ім'я незамінне: мовчки + додати « (2)» означало б повернути не той хост, який просили. Тому + це відмова саме по цьому хосту, зі словами, що робити далі; решта + набору повертається. +- `devices_self_agent_uniq (self_agent_id)` — прив'язку «цей хост і є + машина зонда» міг забрати інший живий хост. Тут навпаки: хост + повертається, прив'язка знімається, і про це прямо сказано у + відповіді. Прив'язку людина ставить назад двома кліками, а відмовити + через неї означало б лишити хост в архіві через налаштування, яке й + так треба переглянути. +- Зонд-опитувач (`agent_id`) чіпати не треба: якщо зонд видалили, ключ + уже поставив NULL (0003), і хост повертається без опитувача — це + видно в переліку окремою колонкою. + +Зіткнутись можна не лише з живим хостом, а й із сусідом по тому самому +набору: два прибрані хости з однаковою назвою чи однією машиною зонда +не бачили одне одного, поки обидва лежали в архіві. Зайняте +накопичується під час обходу набору. + +Стан (`status`) при поверненні скидається в `unknown`: у базі лежить +те, яким хост був перед видаленням, і показати тримісячної давнини +«недоступний» як поточний стан означало б збрехати ще до першого +опитування. `next_run_at` повернутих перевірок зсувається на «зараз» — +інакше кожна з них одразу виглядає як така, що запізнилась на три +тижні, і «Черги» чесно показують затор, якого немає. + +#### Вибірка стала параметром, і навмисно тільки в одному місці + +`deleted_at IS NULL` стоїть у півсотні запитів: план колектора, рушій +алертів, мапи, добір цілей команд. Жоден із них не має права почати +бачити прибрані хости через те, що хтось додав параметр із «зручним» +типовим значенням. + +Тому параметр звужений до `ListDevices` — типом `DeviceView`, у якого +нульове значення (`""`) і є колишня поведінка. Умова написана так, що +при `false` вона тотожна колишній: параметр не розширює вибірку, а +перемикає її на другу половину. «Живі плюс прибрані» третім станом не +передбачено — над прибраним хостом немає жодної дії, яка є над живим. + +`GET /api/v1/devices?view=archived` віддає ЛИШЕ архів. Невідоме +значення читається як «живі»: клієнт старішої збірки має отримати +колишню поведінку, а не відмову. + +#### В інтерфейсі + +Кнопка «Архів (N)» у переліку хостів — і її немає, поки архів +порожній: вхід у перелік, у якому нічого не буде, лише розповідає про +режим, якого людині не треба. Сам перелік архіву відрізняється трьома +способами одразу: смуга-пояснення вгорі, перекреслене приглушене ім'я +з позначкою «в архіві» в кожному рядку, а колонка «Стан» замінена на +«Прибрано» з датою — стан прибраного хоста не показується взагалі, бо +це не стан, а музейний експонат. + +Режим вибору там увімкнений завжди: картки в прибраного хоста немає +(сервер віддає лише живих), тож клік по рядку не має куди вести, крім +як відмітити. Дії — «Повернути» й «Видалити назавжди», у порядку ціни +помилки. Вибір скидається при кожному перемиканні «живі ↔ архів»: +відмічені id лишились би дійсними, але дія над ними в іншому переліку +інша. + +Вікно підтвердження — те саме, що й у видалення, з тим самим переліком +імен і чисел. У режимі архіву воно не пропонує «прибрати з переліку»: +архівувати вже заархівоване — дія без наслідку. Після повернення +вікно не закривається, а показує те, чого не видно більше ніде: +скільки перевірок ожило, хто лишився вимкненим і в кого довелося зняти +прив'язку до машини зонда. + +Заодно виправлено текст, який став неправдою: картка «Прибрати з +переліку» обіцяла, що «побачити чи повернути такий хост через +інтерфейс не вийде». + +#### Права + +`devices:write` — те саме, що й на архівування, і окремого права під +повернення немає свідомо. Якби повернення вимагало більшого, система +дозволяла б прибрати хост і не дозволяла б виправити це тому самому +інженерові. Права на кожен хост окремо перевіряє добір +(`Scope.Writable`). Повне видалення з архіву йде тим самим шляхом, що +й було, — з `ncm:delete`, якщо в хоста є збережені конфіги. + +--- + +### Перевірка + +`cd web && npx tsc --noEmit` — чисто; `npm run build` — зібралось +(271 модуль, попередження про розмір чанка було й до цього). + +Go-перевірки (`gofmt -l . && go vet ./... && go build ./... && go test +./...`) НЕ виконано: на машині немає інсталяції Go — ані в PATH, ані +в типових місцях, ані в WSL (його теж немає). Це треба прогнати перед +злиттям; окремо варто переконатись, що новий +`audit_actions_source_test.go` бачить дерево від `server/internal/store` +(він ходить по `../..`). + +## 2026-08-27 — RLS, який нарешті вмикається + +Ізоляція кабінетів у продукті трималась на одному: на тому, що кожен із +26 тисяч рядків `server/internal/store` не забуває дописати `tenant_id = +$1`. Другий рубіж — Row Level Security — був написаний ще в 0011, +стоїть на 68 таблицях, на 56 із них із `FORCE ROW LEVEL SECURITY`, і не +спрацював жодного разу. + +Причина в одному рядку `docker-compose.yml`: DSN збирається з ролі +`netpulse`, а її створює образ Postgres зі змінної `POSTGRES_USER`, +тобто bootstrap-суперкористувачем. Суперкористувач обходить RLS +беззастережно — ані `ENABLE`, ані `FORCE` на нього не діють. Захист був +написаний, увімкнений у схемі й вимкнений у житті. + +Що показово: у 0050 це вже було записано прямим текстом — «у цьому +розгортанні застосунок ходить у базу роллю-власником таблиці», і саме +тому заборона правити журнал зроблена тригером, а не GRANT-ами. Тобто +про діру знали, обійшли її в одному місці й пішли далі. + +### Спочатку — перелік того, що зламається + +Увімкнути RLS — це один `ALTER ROLE`. Уся робота тут в іншому: у +переліку місць, які ходять у базу повз тенантний контекст. `InTenantTx` +із `SET LOCAL app.tenant_id` — єдине місце, де контекст виставляється, а +повз нього ходять 64 запити. + +Розібрані по одному, вони діляться на чотири різні речі: + +| | скільки | що це | +|---|---|---| +| A | 20 | вхід і токени: тенант ще невідомий, його щойно й з'ясовують | +| B | 29 | фонові такти поверх усіх кабінетів | +| C | 7 | тенант відомий, контекст просто не виставили | +| D | 8 | гіпертаблиці, де RLS немає й бути не може | + +Найнебезпечніша тут не C, як здавалось на початку, а B — і не тим, що +її багато, а тим, як вона ламається. Усі 29 б'ють у звичайні таблиці під +політиками: `alr.rules`, `ncm.jobs`, `ncm.command_targets`, +`core.event_outbox`, `inv.devices`. З порожнім `app.tenant_id` вони +повертають нуль рядків **без помилки**. Тобто після перемикання ролі +колектор, рушій алертів, дзеркало, retention і подієвий канал у браузер +не падають — вони замовкають. У журналі при цьому порожньо. + +Перший, хто ламається насправді, — `AuthenticateAgent`: пошук агента за +`token_hash` у `core.agents`. Виняток для шляху входу в 0012 зробили для +`core.users`, `core.sessions` і `core.memberships` — і не зробили для +агентів, API-токенів, реєстрації зондів, публічних панелей і квитків на +завантаження. Тобто без цих п'яти політик перемикання ролі означає, що +жоден зонд не автентифікується взагалі. + +### Три діри, які знайшлись дорогою + +**`topo.link_live`.** Звичайний VIEW поверх `topo.links` без +`security_invoker`. Такий вигляд читає базову таблицю правами ВЛАСНИКА, +а не того, хто питає. Це єдине місце в схемі, де перехід на роль без +BYPASSRLS не змінює нічого: завантаження лінків на мапі й далі +показувало б лінки всіх кабінетів. Один рядок `ALTER VIEW`. + +**Шість зв'язкових таблиць без жодної політики**: +`core.role_permissions`, `inv.device_group_members`, `inv.device_tags`, +`inv.device_credentials`, `topo.map_shares`, `bill.invoice_lines`. Цикл +у 0011 шукав колонку `tenant_id`, а в зв'язках її немає — і правильно, +що немає: власна колонка тенанта у зв'язці може розійтися з батьком. +Але руками про них ніхто не згадав. Найгірша з шести — +`inv.device_credentials`: її рядок каже, яким доступом ходити на хост. +Чужий рядок там — це не «побачив зайве», а «зайшов на чужий комутатор +нашими руками». + +**Права за замовчуванням.** Сім таблиць, доданих після 0011, тримаються +виключно на `ALTER DEFAULT PRIVILEGES` — і тримаються доти, доки кожну +наступну міграцію котить та сама роль. Відновлення з дампа під іншим +користувачем лишає їх без прав. Поки застосунок — суперкористувач, +цього не видно взагалі. Заразом виявилось, що `ALTER DEFAULT PRIVILEGES` +ніде не роздано на послідовності: перший же `serial` у наступній +міграції дав би «permission denied for sequence» на проді. + +### Ролі: чому воркер лишається з BYPASSRLS + +Питання стояло так: або окрема роль з обходом політик для фонових +тактів, або перебір тенантів у циклі. Перебір безпечніший, і саме так уже +влаштована більша частина фонової роботи — `SweepRetention` і +`MirrorGit` беруть перелік кабінетів і далі кожен обробляють через +`InTenantTx`. + +Ламається не обробка, а ПЕРШИЙ запит — той, що каже, кого обробляти. +Його перебором не заміниш: щоб дізнатись перелік кабінетів, треба +прочитати `core.tenants` поверх кабінетів. + +Друга половина гірша за це. Видача завдань зондам — це одна інструкція +`UPDATE … FOR UPDATE SKIP LOCKED … RETURNING tenant_id`, яка одночасно +знаходить роботу, забирає її собі й каже, чия вона. Розкласти її по +кабінетах означає замінити один такт на N тактів кожні 5 секунд і +власноруч завести голодування: кабінет, який стоїть у циклі першим, +вибирає ліміт, а останній не отримує нічого. `SKIP LOCKED` існує рівно +проти цього. + +Тому перебір лишається там, де він уже є — і саме він робить справжню +роботу, — а BYPASSRLS дістається окремій ролі для запитів-шукачів. + +Обмежує таку роль не RLS (її вона за визначенням не бачить), а те, ким і +звідки вона використовується: окремий DSN, окремий пул `Store.bg`, +скінченний перелік методів. Код, який обслуговує запит користувача, +фізично не має доступу до цього пулу — у нього інший пароль. Не `SET +ROLE` на спільному з'єднанні: воно повертається в пул, і одна забута +команда `RESET` перетворює запит користувача на запит з BYPASSRLS — +рівно та помилка, проти якої вся ця робота. + +Звужувати GRANT-и воркера до переліку таблиць 0063 не береться, і це +свідомо: вузький перелік, складений із читання коду, а не з роботи +стенду, — спосіб зупинити бекапи через півтори доби на таблиці, про яку +забули. Це окремий крок після тижня спостережень. + +### Міграцію можна накотити, нічого не вмикаючи + +Найкорисніша властивість усієї зміни. 0063 заводить ролі без пароля, +роздає права, закриває зв'язки політиками, ставить `security_invoker` — +і не змінює поведінку стенду ані на йоту, бо застосунок і далі ходить +суперкористувачем, який усе це обходить. Перемикач — не міграція, а DSN. + +Те саме з боку коду: `NETPULSE_DSN_WORKER` порожній — і `Store.bg` +дорівнює основному пулу, тобто все поводиться як раніше. Тобто і схему, +і код можна викотити наперед, а роль перемкнути окремим кроком з власною +перевіркою й відкатом у 30 секунд (`deploy/RLS-CUTOVER.md`). + +Розділяти їх довелось не з обережності, а тому, що інакше не було б +кроку, на якому можна зупинитись. + +### Міграція, яка перевіряє сама себе + +0063 закінчується трьома перевірками, які падають ТУТ, а не в проді: +таблиця з `tenant_id` без RLS або без політики; таблиця, до якої +`netpulse_app` не має SELECT (під новою роллю це не порожній результат, +а «permission denied» на першому ж запиті клієнта); і довідковий перелік +таблиць без tenant_id і без політик — щоб наступний автор побачив його +очима, а не дізнався про нову таблицю в цьому переліку через півроку. + +Перевірка разова за побудовою: вона спрацювала на момент накочування. +Наступна таблиця з `tenant_id` без політики знову з'явиться мовчки. + +### Тест, без якого все це не має сенсу + +Різницю між «RLS увімкнено» і «RLS працює» неможливо побачити з +`pg_policies`: `ENABLE`, `FORCE` і сама політика виглядають однаково і +там, де вони діють, і там, де їх обходить `rolbypassrls`. Тому тест не +перевіряє наявність політик — він перевіряє результат запиту. + +Два кабінети з хостами, портами, мітками, доступами й лінком; `SET ROLE +netpulse_app`; запит **без предиката** `tenant_id` — саме той, від якого +RLS і страхує. Плюс адресне звернення за чужим id (так виглядає витік +через підставлений у URL uuid), плюс спроба записати чужий `tenant_id`, +плюс порожній контекст. + +Дві деталі, без яких тест був би декорацією. Перша: він починається з +перевірки, що `netpulse_app` не має BYPASSRLS — інакше все нижче почне +проходити з протилежної причини, і тест зеленітиме від зняття захисту. +Друга: контрольний замір роллю власника перед перемиканням — без нього +прогін, у якому дані мовчки не налились, показав би нуль чужих рядків і +блискуче пройшов. + +### Чого це не дає + +Телеметрія лишається поза RLS — гіпертаблиці й стиснення в TimescaleDB +не поєднуються з row level security, і це не наслідок цієї роботи, а +незмінна властивість. Для `ts.*`, `core.audit_log`, `alr.alerts_history` +і `alr.notifications` предикат у запиті й далі єдиний механізм. + +`CreateUser` довелось лишити поза політиками: додати в кабінет людину, +яка вже працює в чужому, під RLS неможливо — `ON CONFLICT (username) DO +UPDATE … RETURNING` не побачить чужого користувача й замість «підхопили +наявного» дасть помилку унікальності. Політикою це не лікується: щоб її +обійти, треба зробити `core.users` видимою наскрізь, тобто відкрити рівно +ту дірку, яку закриваємо. Дія рідка, адміністративна й крос-тенантна за +природою — ходить пулом воркера. + +І головне, що варто сказати прямо: сама роль воркера бачить усе. Перехід +прибирає ризик «забутий `WHERE` в API віддає чужі хости» — той, через +який витік доходить до екрана клієнта. Ризик «фоновий такт узяв не той +кабінет» він не прибирає, тільки звужує до 29 перелічених запитів. + +## 2026-08-27 — Строки зберігання даних і сторінка сховища + +База на стенді важить 120 МБ при шести хостах. Це не проблема — проблема +в тому, що жодна з цифр не має стелі. Порт хоста це рядок у +`ts.if_counters` на кожному такті; 500 хостів по 24 порти — 12 000 рядів, +сотні тисяч рядків на добу з самих лічильників. Сеанс збору конфігу лишає +транскрипт у `ncm.jobs`, прогін команд — транскрипт на КОЖЕН хост у +`ncm.command_targets`. Диск закінчується мовчки: Postgres працює нормально +до останнього вільного байта, а потім перестає приймати записи взагалі. +Перша ознака проблеми і є сама аварія, і настає вона одночасно для всього +продукту. + +### Аудит: що вже прибиралось, а що ні + +Перше, що з'ясувалось: твердження «немає жодного механізму» неправильне. +Механізм є, і половина роботи полягала в тому, щоб його не зламати. + +**Гіпертаблиць у схемі дванадцять.** Вісім у `ts` (`samples`, +`icmp_samples`, `if_counters`, `link_status`, `device_status_history`, +`syslog`, `snmp_traps`, `agent_health`), дві в `alr` (`alerts_history`, +`notifications`), дві в `core` (`audit_log`, `login_attempts`), плюс +`bill.license_checkins`. Безперервних агрегатів шість: 5-хвилинні й +годинні згортки для метрик, ICMP і лічильників портів. + +**Стиснення** (0005) стоїть на восьми: `ts.samples`, `icmp_samples`, +`if_counters` — з другої доби, `link_status` і `snmp_traps` — із сьомої, +`syslog` і `agent_health` — з третьої, `core.audit_log` — з року (0050 +свідомо відсунула його з місяця до року, бо стиснутий журнал перестає +шукатись за вмістом). + +**Видалення** (0005, 0007, 0009, 0012) стоїть на дев'ятьох: сирі метрики, +ICMP і лічильники — 35 діб, syslog і трапи — 90, самометрики зондів — 14, +5-хвилинні згортки — 400, сповіщення — 90, спроби входу — 180, перевірки +ліцензії — 400. + +**Дір три, і кожна росте назавжди:** `ts.link_status`, +`ts.device_status_history` і `alr.alerts_history` не мають політики +видалення взагалі. Це не дрібниці: історія алертів — рядок на кожну +аварію кожного хоста, а історія станів пише рядок на кожну зміну +«вгору/вниз», тобто на кожен блимок каналу. Обидві маленькі рівно доти, +доки хостів шість. + +**Звичайні таблиці, які ростуть і не прибираються ніяк.** За спаданням +небезпеки: + +| Таблиця | Що в ній | Швидкість | +|---|---|---| +| `ncm.command_targets` | вивід кожної команди на кожному хості + повна стенограма сесії | найтовщі рядки в базі; один прогін по дільниці — кілобайти тексту на кожен зі ста хостів | +| `ncm.jobs` | транскрипт кожного сеансу збору конфігу | 500 рядків із текстом щодня при добовому розкладі | +| `core.secrets` | зашифровані тіла версій конфігів | мегабайт на версію для ZTE C320; прибирається політикою 0037 | +| `topo.discovery_runs` | прогони пошуку сусідів | найменша: запускають руками | + +**Те, що росте, але вже обмежене** — і саме тому нової ручки не отримує: +`ncm.configs` (політика 0037), `ncm.diffs` (каскад від конфігів), +`ncm.compliance_results` (`UNIQUE (rule_id, device_id)` — переписується, а +не росте), `core.event_outbox` (`PruneEvents`), `core.download_tickets` +(строк квитка), `topo.map_revisions` (стеля ревізій на мапу), `alr.alerts` +(закриті переїжджають в історію фоном). + +### Головне рішення: строк — рівня інсталяції, а не кабінету + +Видалення старого в гіпертаблиці — це `drop_chunks`: чанк зноситься цілою +таблицею, разом із рядками ВСІХ кабінетів, що в нього потрапили. Чанк +ріжеться за часом і лише за часом; про кабінет він не знає нічого. Тобто +«тенант А тримає метрики рік, тенант Б — тиждень» реалізується тільки +власним `DELETE` по рядках — тобто відмовою від єдиного механізму, заради +якого TimescaleDB і взято. + +Різниця не теоретична, і це друге рішення тієї ж пари. `DELETE` у +Postgres не звільняє місця на диску: рядок позначається мертвим, сторінка +лишається файлу, і повернути її здатен лише `VACUUM FULL` — який блокує +таблицю цілком і вимагає стільки ж вільного місця, скільки вона займає. +Тобто на переповненому томі, саме тоді, коли це потрібно, він не спрацює. +`drop_chunks` зносить чанк як таблицю, і місце повертається негайно. + +Тому: гіпертаблиці — рідні політики TimescaleDB, звичайні таблиці — +пакетне видалення, і про його межу сказано людині прямо у формі («місце +звільниться всередині таблиці й буде перевикористане, але на диск не +повернеться»). + +### Міграція 0064 + +**`core.retention_settings`** — рядок на вид даних: ключ, таблиця, +механізм (`timescale`/`batch`), колонка часу, строк у добах. `NULL` +означає «не видаляти» — саме `NULL`, а не нуль: нуль читався б як «тримати +нуль днів», тобто як наказ знести все, і одна помилка в перетворенні +типів між формою й API коштувала б усієї телеметрії. + +**Рядки заводяться з ФАКТИЧНИМ станом бази, а не з бажаним.** Це головне +рішення міграції. Спокуса накотити перелік «правильних» строків велика, +але на чужій інсталяції наш правильний строк — це чиясь втрачена історія. +Міграція лише записує те, що вже діє, у місце, де це видно людині: не +видаляє жодного рядка й не змінює жодного наявного строку. Інсталяція, що +оновиться, вранці має рівно ті самі дані, що й учора. Наслідок, який +варто розуміти: одразу після накату екран виглядає нерівно — десь 35 діб, +десь порожньо. Так і має бути. Це знімок реальності, і саме він змушує +запитати, чому історія алертів не прибирається взагалі. + +**`core.retention_current(rel)`** — чинний строк політики. Окрема функція +через одне неочевидне місце: для безперервного агрегату політика висить не +на вигляді, а на матеріалізованій гіпертаблиці під ним +(`_timescaledb_internal._materialized_hypertable_N`). Шукати її за іменем +вигляду означає не знайти ніколи й доповісти людині, що згортки не +прибираються, — при тому що 0005 їм строк задала. + +**`core.retention_hypertable(rel)`** — те саме з іншого боку: під яким +іменем відношення лежить на диску. Запитати розмір у вигляду означає +отримати нуль. + +**`core.apply_retention_policies()`** — `SECURITY DEFINER`, і це +необхідність, а не зручність. `add_retention_policy` вимагає прав +ВЛАСНИКА гіпертаблиці; після 0063 застосунок ходить роллю `netpulse_app`, +яка власником не є й не має ним ставати. Без цієї функції екран міг би +зберегти число й не змогти його застосувати — рівно той стан, у якому +інтерфейс бреше. Функція навмисно вузька: не приймає ані імені таблиці, +ані строку, а лише приводить політики у відповідність до рядків таблиці +налаштувань. `search_path` прибитий цвяхами. + +**Індекси під пакетне видалення:** `command_runs_age_idx`, +`ncm_jobs_age_idx`, `discovery_runs_age_idx` — по `created_at`. Наявні +індекси не годяться: у прогонах вони по `(tenant_id, created_at)`, у +завданнях — по `(device_id, created_at)`, а прибирання йде поверх +кабінетів і поверх хостів, тобто читало б усю таблицю на кожну партію. +`ncm.command_targets` власного індексу не отримує: цілі зникають каскадом +від прогону, і `command_targets_run_idx` каскад уже обслуговує. + +**`core.storage_samples` і `core.storage_config`** — ряд спостережень за +розміром і ємність тому. + +**Стиснення НЕ додається**, хоч трьом гіпертаблицям його бракує, і це +свідома відмова з поясненням у міграції. Урок 0050: стиснення робить +недоступними індекси за всіма колонками, крім `segmentby`. Для +`alr.notifications` `segmentby alert_id` дав би сегменти по одному-два +рядки, тобто стиснення без стиснення. `alr.alerts_history` питають і за +`tenant_id` (список історії), і за `device_id` (видалення хоста, 0057) — +будь-який вибір лишає другий запит без індексу, тобто прискорює місце +ціною тихо померлої сторінки. Правильна відповідь для всіх трьох — строк +зберігання: рядок, якого немає, займає нуль і шукається миттєво. + +### Безперервні агрегати: найлегше місце все зіпсувати + +Якщо сирі дані видаляються, а згортки лишаються, згортки мають бути +пораховані ДО видалення сирих. Інакше в графіках за минулий місяць +лишиться діра, яку вже нічим не заповнити — сирих даних, з яких згортку +рахують, більше немає, і перерахувати її неможливо в принципі. + +Захист двома правилами в `ValidateRetention`, і набір перевіряється +ЦІЛКОМ, а не по полю: половина умов тут про стосунки між видами, і на +одному числі їх не перевірити. + +1. **Нижня межа на вид.** Сирі дані під згорткою — не менше 2 діб + (5-хвилинні згортки рахуються з відставанням у три години); + 5-хвилинні — не менше 7 (годинні рахуються з вікном у дві доби); + годинні — 30. Журнал аудиту — не менше 365: рік це межа, у яку + вміщається практично будь-який розбір (0050), а коротший строк знищує + докази раніше, ніж по них приходять. +2. **Згортка не зникає раніше за джерело.** `keep(5m) ≥ keep(raw)`, + `keep(1h) ≥ keep(5m)`; «не видаляти» вважається нескінченністю. + Відмова з поясненням, а не мовчазне виправлення. + +Обидва правила перевірені юніт-тестами без бази: якби вони жили в SQL, +перевірити їх можна було б лише на живому Postgres — тобто на практиці +ніколи. + +### Безпека + +**Типове значення — «не видаляти».** Строки, яких не було, лишаються +порожніми. Строки, які були, переносяться як є. + +**Попередній перегляд перед застосуванням**, тим самим порядком, що й +повне видалення хоста: спершу покажи, що зникне. Різниця в тому, що тут +зникає не об'єкт зі списку, а хвіст історії, і побачити його інакше ніяк. + +Для гіпертаблиць рахуються саме ЧАНКИ, а не «рядки, старші за строк»: це +різні множини. `drop_chunks` зносить чанк лише тоді, коли ВЕСЬ його +діапазон вийшов за строк, тож частина рядків, старших за строк, ще поживе +в чанку, який закриється завтра. Рахувати за рядками означало б обіцяти +більше, ніж станеться, — а людина, звіривши цифри після, вирішила б, що +прибирання не працює. Розмір чанків точний (`chunks_detailed_size`), +кількість рядків — точний `count(*)` зі стелею в 15 секунд; не встигли — +повертається `-1` і форма каже «не порахували». Саме `-1`, а не нуль: +«нічого немає» перед видаленням мільйонів рядків — брехня найгіршого +ґатунку. + +Для звичайних таблиць кількість рядків точна, місце — оцінка через +середній рядок, і так і підписано: рядок із транскриптом на 40 КБ і рядок +з помилкою на 30 байтів відрізняються на три порядки. + +**Зміна строку йде в аудит** — `core.retention.update`, і в `meta` не +«зберегли форму», а що саме змінилось: вид, мітка, «було → стало» і +окрема позначка `shortened`. Через рік питання буде не «хто відкривав +сторінку», а «хто скоротив історію алертів до тижня». Дію не позначено +`Destructive`: сама зміна нічого не стирає, стирає її наслідок, і +позначка «об'єкта більше немає» була б неправдою про мить події. + +**Версії конфігів не переїжджають сюди.** Їхня політика (0037) влаштована +не за віком, а як «останні N АБО молодші за M днів», із захистом +останньої версії хоста, версій під відкатом і версій, на які посилаються +результати перевірок. Звести це до однієї цифри в добах означало б +утратити рівно ті гарантії, заради яких воно написане: конфіг, який не +міняли три роки, — не сміття, а єдина копія. Друга ручка до тих самих +даних була б гіршою за відсутність ручки — той самий висновок, що й у +коментарі 0037 до `ncm.device_policies.retention_versions`. Сторінка +сховища показує розмір `ncm.configs` і `core.secrets` як довідку й +відправляє міняти політику туди, де вона живе. + +**Дзеркала Git очистка не чіпає взагалі, і чіпати не може.** Git +append-only за побудовою: прибрати з нього історичну версію означає +переписати всі наступні коміти гілки, тобто зламати `commit_sha` в усіх +уцілілих рядках `ncm.configs` і зробити неможливим fast-forward для +дзеркала. Це вже описано в `ncm_retention.go` і сказано людині у формі. +Ні `drop_chunks`, ні пакетне видалення до репозиторію не дотягуються — +телеметрії там немає взагалі. + +### Сторінка «Сховище» + +Показує не розмір, а ШВИДКІСТЬ і те, що з неї випливає. «120 МБ» не +означає нічого; «плюс 40 МБ за добу, вільного на 12 діб» означає все. + +Цифри з системних представлень Postgres і TimescaleDB +(`pg_database_size`, `hypertable_detailed_size`, +`hypertable_compression_stats`, `timescaledb_information.chunks`, +`pg_total_relation_size`), а не з формул за кількістю рядків: розмір +рядка залежить від стиснення, TOAST і роздутості після видалень, і будь-яка +формула розійшлася б із диском у той самий день, коли на неї почнуть +покладатись, — і розійшлася б у бік «усе гаразд». + +Приріст рахується з ряду спостережень (`core.storage_samples`, знімок +щогодини), а не з різниці «зараз проти нуля»: інсталяція, підняту з +дампа, інакше показала б добовий приріст, що дорівнює всій базі. Вікно — +30 діб, різниця країв, а не регресія: регресія згладила б сходинку від +щойно ввімкненого строку саме тоді, коли цю сходинку й треба побачити. + +**Ємності тому сторінка не знає й не вигадує.** Функції «скільки +вільного на диску» в Postgres немає, а процес застосунку живе в іншому +контейнері й може стояти взагалі на іншій машині — тобто `statfs` теж не +відповідь. Число вводить людина; поки не ввела, сторінка показує розмір і +швидкість, але не показує дати переповнення. Вигадана дата гірша за її +відсутність: на такі дати дивляться саме тоді, коли перевіряти вже пізно. + +Дві смуги попереджень нагорі: види даних, у яких строку немає взагалі, і +види, у яких строк у налаштуваннях розійшовся з тим, що справді стоїть у +базі. Друге буває після відновлення з дампа — `pg_dump` не везе фонових +задач TimescaleDB узагалі, тобто строки в таблиці є, а видалення не +працює. Мовчки. + +### Права + +Дивитись — `devices:read`, тобто кожен, хто взагалі бачить систему. Те +саме рішення, що й для дзеркала конфігів (0054): помітити, що диск +закінчується, має могти той, хто щодня дивиться на моніторинг. Міняти — +`settings:write`: строк незворотний за наслідками й діє на всю +інсталяцію, тобто це рівно те, що описує це право. Досі його не питав +жоден обробник — рядок про нього прибрано з `dormantPerms` тією ж +правкою, як і передбачав коментар до цього переліку. + +Попередній перегляд теж під `settings:write`, хоч нічого й не змінює: він +частина шляху зміни, а не самостійна довідка, і він дорогий — точний +підрахунок рядків по хвосту гіпертаблиці має право запускати той, хто +збирається натиснути «застосувати», а не будь-яка відкрита вкладка. + +Пункт меню поставлено в «Збір даних», поруч із «Чергами»: там питання «чи +все встигає», тут — «чи все вміщається». В «Адмініструванні» сховище +стояло б поруч із ролями й журналом, тобто там, куди мережевий інженер не +заходить, — а закінчується диск саме в нього. + +### Прибиральник + +Такт щогодини в колекторі (`netpulse-server`), поруч із очисткою +конфігів і з тих самих трьох міркувань: тут стоїть той, хто дані створює; +REST-процес масштабують копіями й він же обслуговує запити людини; ключ +шифрування тут не потрібен зовсім. Своє advisory-блокування, окреме від +очистки конфігів: спільне означало б, що довгий прохід по прогонах команд +заодно зупиняє прибирання версій. + +Три кроки: звірка політик TimescaleDB зі збереженими строками (для +випадку відновлення з дампа), пакетне видалення звичайних таблиць по 5000 +рядків із стелею 200 000 на вид за прохід, знімок розмірів — після +прибирання, а не до, інакше приріст рахувався б із піків перед кожним +проходом. + +**Окремо про тиху відмову.** Прохід рахує, скільки рядків підпадало під +строк, ДО видалення. Якщо підпадало більше нуля, а видалено нуль — у лог +іде попередження з ймовірною причиною: з'єднання відкрито роллю під RLS +без заданого кабінету, тобто DSN колектора не веде на `netpulse_worker` +(0063). Без цієї пари чисел такий збій виглядав би як повна тиша: помилки +немає, прибирання немає, диск росте. + +### Чого не зроблено + +**Не перевірено на живій базі.** На цій машині немає ані Docker, ані +WSL, ані локального Postgres — одноразовий контейнер +`timescale/timescaledb:2.17.2-pg16` підняти нічим, а бойовий стенд чіпати +заборонено. SQL написано лише на публічних API TimescaleDB 2.17 і +перевірено читанням; перед накатом його треба прогнати на одноразовій +базі. Найризикованіші місця, які варто перевірити першими: +`chunks_detailed_size` над матеріалізованою гіпертаблицею агрегату, +`hypertable_compression_stats` на гіпертаблиці без стиснення (запит +відокремлено, щоб її збій коштував одного стовпця, а не сторінки) і +виклик `add_retention_policy` усередині транзакції з-під +`SECURITY DEFINER`. + +**Алерт про вичерпання місця не додано.** Движок правил зараз править +інший агент, і його файли не чіпались. Що варто додати, коли він +звільниться: `alr.rule_source` — це джерела, прив'язані до хоста +(`metric`, `icmp`, `interface`), а «на диску лишилось 5 %» до хоста не +прив'язане взагалі. Найдешевший шлях, який НЕ вимагає правок движка, — +писати розмір бази й запас у добах звичайними метриками в `ts.series` +(наприклад `db.size.bytes` і `db.days_left` на хості, що позначений +машиною зонда), і тоді працює наявне метричне правило без жодної зміни +коду. Альтернатива — нове джерело `system` у движку — дорожча й дає те +саме. + +**Стиснення трьом гіпертаблицям не додано** — свідомо, з поясненням вище. +Це вимірювана задача (як у 0050), а не рішення з міркувань симетрії. + +**`bill.license_checkins` не заведено видом даних.** Строк у неї є (0009, +400 діб), а розділу «Тариф» у продукті ще немає — рядок у формі означав +би запрошення покрутити те, наслідків чого людині ніде не видно. + +**Розмір у попередньому перегляді для звичайних таблиць — оцінка**, і +точнішою її зробити нічим без повного обходу рядків. Позначено як оцінка +в API (`exact: false`) і у формі (знак ≈). + +## 2026-08-27 — Перехід на роль без BYPASSRLS: як зелений тест пропустив поломку + +Перемикання застосунку на `netpulse_app` зламало вхід у систему. Не +тихо — чесна 403 `no_membership`, «користувача не додано до жодної +організації», для власника з повними правами. + +Причина в одному рядку `userMemberships`: + +```sql +SELECT ... FROM core.memberships m +JOIN core.tenants t ON t.id = m.tenant_id +``` + +Політика на `core.tenants` вимагає `id = core.current_tenant()`. Під час +входу кабінет ще невідомий за визначенням — його якраз і з'ясовують цим +запитом. Курка з яйцем: щоб дізнатись свої кабінети, треба вже бути в +кабінеті. + +### Чому перевірка цього не спіймала + +Перед перемиканням прогнали тест ізоляції. Він був правильний, ретельний +і зелений: створили другий кабінет із хостом усередині транзакції, +переконались, що роль застосунку бачить шість своїх і нуль чужих, +відкотили. Жодного сліду в базі, жодного хибного припущення. + +І він дивився повз поломку за побудовою. Тест питав «чи не видно +ЧУЖОГО». Зламалось протилежне — не видно СВОГО, і не в даних, а на +шляху до них. + +Потік метрик теж нічого не показав: він іде під `netpulse_worker`, у +якого BYPASSRLS лишився. Обидві перевірки, зроблені перед перемиканням, +були слушні й обидві сліпі до того самого місця. + +Це головний висновок дня, і він не про RLS: **зелена перевірка доводить +тільки те, що вона перевіряє.** Тест на ізоляцію не є тестом на +працездатність, хоч і виглядає переконливо. + +### Що виявилось насправді + +Аналіз, зроблений при написанні 0063, був правильний: у ньому чорним по +білому стояв «клас A — 20 запитів, вхід і токени, тенант ще невідомий». +Не зробленою лишилась робота за цим аналізом — жоден із тих запитів на +воркерний пул не перевели. Правильний висновок, не доведений до коду, +рятує рівно настільки, наскільки його прочитали. + +Замір показав 30 звернень основним пулом поза тенантним контекстом. З +них під RLS ламався один — `core.tenants`, — але це збіг обставин, а не +підстава правити один рядок. + +### Принцип замість переліку правок + +RLS захищає доступ до даних кабінету. Він **за побудовою** не може +захистити запит, який питає «а який це кабінет?»: політика вимагає +відповіді на те саме питання, яке запит ставить. + +Тому на воркерний пул переведено весь до-кабінетний шар цілком: вхід і +сесії, машинні токени, автентифікація й реєстрація зонда, публічна +панель за токеном, квиток на завантаження, приймання телеметрії. Не як +виняток заради працездатності, а тому, що RLS їх ніколи й не захищав. +Їхній захист — предикат: хеш токена, `user_id`, термін дії квитка. Він +був єдиним і до 0063. + +Перелік дозволених видів запитів у коментарі до `Store.bg` доповнено +четвертим пунктом. Лишити його тризначним означало б зробити коментар +неправдою — тобто повторити ту саму помилку, яку весь цей день ловили в +схемі, довіднику й протоколі. + +### Телеметрія: поломка, яка чекала б наступної зміни в мережі + +`ts.series` — єдина таблиця під RLS серед цілей запису телеметрії, і +писалась вона основним пулом. Під час невдалого перемикання метрики +йшли: лічильники росли, всі шість хостів звітували, відставання дві +секунди. Бо ряди вже існували. + +Впав би НОВИЙ ряд — новий хост, новий порт, перший рядок прототипу. +Тобто поломка виявилась би не при перемиканні, а через тиждень, при +першій зміні в мережі, і зв'язати її з переходом на RLS було б уже +нічим. + +### Як перевіряли вдруге + +Написали перевірку прохідності: вхід, і потім двадцять вісім переліків, +з яких чотирнадцять **мають** бути непорожніми. Порожній перелік +рахується провалом, а не «даних немає» — саме так виглядає відмова RLS. + +Еталон зняли ДО перемикання, на суперкористувачі. Без нього «шість +хостів» нічого не доводить: цифру треба порівнювати, а не оцінювати. + +Після перемикання всі 28 збіглися з еталоном, вхід повернув «Основний +кабінет», ізоляція лишилась (шість своїх, нуль без контексту), новий ряд +`ts.series` створюється, у журналах жодної відмови доступу. + +### Ціна помилки виявилась низькою, і це не випадковість + +Між зламаним входом і відкатом минуло кілька хвилин: `.env` мав копію, +знімок бази був знятий перед міграціями, а сам перехід спроєктований +інертним — три рядки в конфігурації, тридцять секунд на відкат. Паролі +ролей пережили відкат, тож повторне перемикання не вимагало заводити їх +наново. + +Дорого коштує не помилка, а помилка без шляху назад. + +## 2026-08-27 — Друга сімка: те, що перевірки нарешті виконались + +Сім задач (0065–0068 плюс `plural`, тести вебу й виправлення знайденого) +робились так само паралельно, як перша сімка. Різниця в одному: цього +разу в кожне завдання було вписано вимогу — окремим абзацом сказати, +**чого перевірка НЕ покриває**. + +Вимога з'явилась не з методології, а з ранкової поразки: тест ізоляції +RLS був правильний і зелений, і пропустив зламаний вхід, бо перевіряв +«чи не видно чужого», коли зламалось «чи видно своє». + +### Що ця вимога дала + +Три агенти незалежно написали одне й те саме речення: **жоден рядок SQL +не виконувався**. Ані нові міграції, ані запити ескалацій, ані запис +трапів — усе перевірено компіляцією Go й прочитане очима. + +Це не було відомо до того. Тести були зелені, звіти бадьорі, і без +прямої вимоги назвати непокрите ця діра просто не потрапила б у поле +зору — рівно як не потрапила вранці. + +Агент трапів пішов далі й назвав найімовірнішу непомічену поломку у +власній роботі: v1-трап жодного разу не летів по мережі, нормалізація +перевірялась на зібраному руками пакеті, і якби gosnmp заповнював +`Enterprise`/`AgentAddress` інакше, переклад за RFC 3584 давав би +невірний OID мовчки при зелених тестах. + +### Пісочниця + +Тому перед розгортанням на стенді заведено окрему базу `netpulse_probe` +й накочено в неї 0001–0068 **з нуля**. Досі міграції перевірялись лише +як приріст до наявної схеми — на чистій базі не проганявся ніхто. + +60 із 60 пройшли. Разом із ними вперше в житті виконались: +`TestRLSTenantIsolation`, `TestEscalationAgainstDB` і всі інтеграційні +набори `store` / `httpapi` / `grpcapi`. + +**І одразу дали дві помилки.** Обидві — не в новому коді, а в тестах, +які описують світ до появи розпізнавання пристроїв: +`TestControlHandshake` вимагав рівно один чек у плані (тепер їх два, +`topology.identify` заводить сам сервер при підключенні зонда), а +`TestPlanHashSkipsResend` рахував хеш ДО того, як цей чек створювався, +і потім звинувачував сервер у пересиланні, яке спричинив сам. + +Півтора року вони мовчки пропускались без `NETPULSE_TEST_DSN`. Тест, що +не виконується, не є перевіркою — він є її виглядом. + +Виправлено тести, не поведінку. Перший — так, щоб наступний +автоматичний чек не завалив його знову: він шукає свій чек серед решти, +а не покладається на їхню кількість. + +### Міна із закінченнями рядків + +Генератор профілів писав `0014` через `write_text`, який на Windows +перекладає `\n` у `\r\n`. Контрольна сума при цьому змінюється, а +сторож міграцій звіряє саме її — тобто бінарник, зібраний на Windows +після запуску генератора, зупинив би накат на стенді. **Не одну +міграцію, а всі**: сторож падає на першій розбіжності. + +Знайшлось це не в бою лише тому, що агент каталогу порівняв текст, а не +повірив «генератор відпрацював без помилки». + +Заразом виявилось, що сам генератор був зламаний: `MIGRATION` вказував +на `db/migrations`, хоча міграції давно переїхали в `server/migrations`. +Тобто `--check` **завжди** звітував про розходження, і його червоний +стан став фоном, на який перестали дивитись. + +Наслідок довелось розплутувати на стенді: там лежала CRLF-версія 0014 з +відповідною сумою, у дереві — LF. Полагоджено виправленням записаної +суми, і не на віру: `стенд без CR == дерево`, `дерево з CR == стенд`, +побайтово. + +### Третя функція, що вбиває кирилицю + +`safeName` у вебі згортав усе не-ASCII у підкреслювач, бо `\w` у +JavaScript — це `[A-Za-z0-9_]`. `Київ-ядро` і `Львів-ядро` давали +ОДНАКОВЕ ім'я файла: вивантажив два конфіги — отримав один. + +Це третій випадок за дві сесії після `sanitizePath` у гілках Git і +шляхів дзеркала. Спільного коду в них немає — спільна лише звичка +писати класи символів як `\w`. + +### Що ще знайшли тести, яких не було + +Тести вебу писались із нуля (їх не існувало взагалі) і по дорозі +принесли одинадцять справжніх вад. Найдорожчі три: `IntervalInput` міг +надіслати на сервер інтервал 0 секунд (`Number('') === 0` при `min={5}` +у розмітці); мінікарта малювалась у 180 px усередині контейнера 68 px, +тобто стиснута в 2.6 раза; `fmtBytes` існував у трьох копіях, і третя +вже розійшлась — спинялась на мегабайтах. + +Окремо варте запису: агент, що виправляв знайдене, **спростував частину +переліку**, який йому передали. Порада «повісити `aria-hidden` на +приховану копію рядка» була неправильною — копії ховаються через +`display:none`, який читач з екрана й так пропускає, а `aria-hidden` над +фокусованим елементом є справжньою помилкою доступності. Виправив +натомість те, що справді зламане: клавіатуру й саме подвійне малювання. + +Кожен новий тест перевірено на «падає до виправлення»: усі виправлення +тимчасово відкочувались, упало рівно 18 очікуваних перевірок. Тест, який +ніколи не падав, не доводить нічого — це той самий урок, що й із +пропущеними інтеграційними. + +### Що лишилось вимкненим свідомо + +Приймач трапів розгорнуто, але модуль не увімкнено в переліку зонда, і +162/udp назовні не опубліковано. Це не забуто: порт без автентифікації +приймає будь-кого, хто знає адресу, і відкривати його на бойовій машині +— рішення власника, а не наслідок того, що код готовий. + +Напівзаходу теж немає. Увімкнути модуль на нестандартному порту +всередині контейнера означало б отримати «трапи працюють» без жодного +трапа — рівно той вигляд роботи без роботи, проти якого написано весь +цей розділ. + + +## 2026-08-27 — SNMP-трапи: приймач на зонді, словник і джерело алертів `trap` + +Дорога від пристрою до бази була прокладена вся, крім першого метра. +Таблиця `ts.snmp_traps` існує з 0005, поле `LogBatch.traps` — у контракті +з перших днів gRPC, серверний `WriteLogs` писав трапи в базу. Не було +рівно одного: на зонді ніхто не слухав 162/udp. Клієнт, який налаштував +на комутаторі `snmp-server host <зонд> traps`, отримував порожній журнал +і жодного способу дізнатися, що справа не в комутаторі. + +Друге, що лікує ця робота, — відмова 0058 від джерела `trap`. Її +аргумент («без словника MIB умова звелася б до порівняння сирих OID») +був правильний, а висновок — надто широкий: він припускав, що словник +буває або повний, або ніякий. + +### Приймач на зонді + +Новий модуль `agent/internal/modules/traps`. Слухає 162/udp через +`gosnmp.TrapListener`, приймає v1, v2c та inform. + +Inform — головна відмінність від приймача syslog. Трап відправник шле й +забуває; inform він шле, доки не отримає підтвердження. Не підтвердити +означає перетворити одну подію на нескінченний потік — і саме тоді, коли +зонду найважче. Тому підтвердження не залежить від того, чи взяли ми +подію в чергу: слухач відповідає завжди, а вже потім вирішується, чи є +місце. Обробник тримає читальну горутину, тому в ньому рівно розбір і +черга, жодних звернень назовні. + +v1-трапи перекладаються у v2-ідентифікатор за RFC 3584: generic 0–5 → +`1.3.6.1.6.3.1.1.5.N+1`, generic 6 → `.0.`. Завдяки +цьому «linkDown зі старого світча по v1» і «linkDown із нового по v2c» — +одна подія, а не дві, під які треба заводити два правила. Поля заголовка +v1, яких у v2 немає (sysUpTime, enterprise, agent-address), дописуються +varbind-ами — теж за RFC 3584. + +Обмеження, щоб шторм не поклав ані зонд, ані базу: двадцять трапів за +секунду з однієї адреси (відро токенів на джерело, а не спільне — інакше +один комутатор із битою оптикою витісняє всю решту мережі), десять тисяч +у черзі з викиданням найстарішого, тридцять два varbind-и на трап, +512 символів на значення. Кожне обрізання видиме: у varbind-ах з'являється +запис із порожнім OID і текстом «ще N відкинуто». Мовчазне обрізання +перетворює «ми показали не все» на «більше нічого не було». + +Модуль вмикається окремим ключем `traps` у `-modules`, а не разом із +`syslog`: це другий порт у фаєрволі клієнта, і відкривати його «заодно» +ми не маємо права. Адреса — `-traps-listen` / `NETPULSE_TRAPS_LISTEN`. + +Syslog і трапи їдуть ОДНИМ стрімом `StreamLogs`. За межами зонда це та +сама подія з мережі: сусідні таблиці, ті самі подієві правила, той самий +момент аварії. Другий стрім означав би другий комплект підтверджень, +лімітів і черг переповнення. + +### Питання MIB, вирішене чесно + +Повного словника не буде, і це рішення, а не відкладена робота: +компілятор ASN.1, сховище вендорських MIB на кабінет і підтримка +діалектів — окремий продукт. Але між «усі MIB світу» і «нічого» лежить +те, що працює вже: + +* шість трапів, які визначає сам протокол (RFC 1215 / snmpTraps з + RFC 3418): coldStart, warmStart, linkDown, linkUp, + authenticationFailure, egpNeighborLoss. Вони вшиті в код + (`store/traps_mib.go`), бо однакові в кожного вендора й не мають + налаштовуватись кабінетом. Плюс імена обов'язкових полів трапа й + чотирьох стовпців ifTable, без яких linkDown не відповідає на єдине + питання, яке до нього ставлять, — «який саме порт»; +* власний словник кабінету — `inv.trap_oids`, редагується на сторінці + «Трапи». Кілька рядків, а не тисячі: у живому кабінеті трапів, на які + хтось дивиться, менше десятка; +* усе інше показується сирим OID із написом «невідомий трап». Саме з + написом. Назва, вгадана за схожістю префікса, була б брехнею рівно + там, де написаному довіряють найбільше, — у заголовку, який читають, + вирішуючи, чи вставати. + +Словник живе на сервері, а не на зонді, і застосовується при ЧИТАННІ. +Тому назва, дописана сьогодні, підписує й учорашній журнал. + +### Джерело `trap` у подієвому движку + +Умова вміє три речі, і рівно ті, які до трапа ставлять: `trap_oid` (що +сталося), `source_ip` (звідки — адресою або підмережею) і пара +`varbind_oid` / `varbind_value` (з яким значенням). Varbind порівнюється +зі СТОВПЦЕМ: у трапі приходить `ifOperStatus.7`, а людина пише +`ifOperStatus`, бо номера порту наперед не знає. + +Чого умова не вміє — сказано вголос, а не проігноровано. Зразок (regex), +`severity_lte` і `tag` у правилі на трапи не приймаються з поясненням, +чому саме: трап — не рядок тексту, а набір типізованих полів. Це не +причіпка: людина, яка переключила джерело правила з «Журнал» на «Трапи» +й лишила в формі зразок, інакше отримала б правило, що ловить УСЕ. +Правило без OID і без адреси теж відхиляється — воно підпало б під кожен +трап у мережі. + +Трап БЕЗ хоста піднімає алерт лише тоді, коли правило явно назвало +адресу джерела. Алерт без хоста нікуди не маршрутизується, не глушиться +вікном обслуговування й майже нічого не каже черговому. Ключ дедуплікації +для такого алерту будується від адреси (`TrapDedupKey`), інакше всі +незнайомці злилися б в один рядок «щось у мережі шле трапи». + +### Трап від невідомої адреси + +Окремий стан, а не тиха втрата. Такі трапи лягають у журнал із порожнім +`device_id` — і додатково в `inv.trap_unknown_sources`: адреса, зонд, +перша й остання поява, лічильник, останній OID. У журналі їх не видно +(він за часом, і три трапи на добу тонуть між тисячею своїх), а це +найцінніші три: майже завжди вони означають, що в мережі з'явилось +кероване залізо, про яке моніторинг не знає. + +Таблиця має стелю в 500 рядків на кабінет (тригер на INSERT). Адресу +відправника UDP підробити нічого не варте, і без обмеження цей шлях був +би способом наростити таблицю клієнта з його ж мережі. + +### Сторінка «Трапи» + +`web/src/pages/TrapsPage.tsx`, у меню поруч з алертами. Фільтри: період, +трап (списком тих, чиї назви ми знаємо, або довільним OID), адреса чи +підмережа джерела, пошук по varbind-ах, «лише невідомі». Стан фільтра в +адресі — відфільтрований журнал пересилають колезі. Угорі — блок +невідомих відправників, унизу — словник і перелік того, чого приймач НЕ +бачить: порожній журнал без цього блоку читається як «трапів не було», +хоча найчастіше означає «їх нікому приймати». + +### Що знято, а що ні + +Заборону на збереження правила з джерелом `trap` знято в коді +(`store.UnsupportedSourceReason`), бо саме там вона й стояла — 0058 +лишила по собі відмову з поясненням, а не обмеження в схемі. + +Правила, вимкнені 0058-ю, міграція 0065 навмисно НЕ вмикає назад. Вони +писались тоді, коли перевірки умови не існувало, і в їхньому `condition` +лежить що завгодно. Увімкнути їх означало б отримати або тишу, або потоп +— тобто знову «увімкнено й не працює». Правило лишається сірим, людина +його відкриває, і форма або приймає умову, або каже, чого бракує. + +### Формат зберігання varbind-ів + +Був мапою «oid → значення», став масивом `{o,t,v}`. Мапа втрачала тип +(`2` в ifOperStatus — це «down», а не число два), порядок (RFC вимагає +sysUpTime і snmpTrapOID першими) і повтори (трап із двома ifIndex +перетворювався на трап з одним). Читач розуміє обидві форми: архів, +накопичений раніше, не мав зникнути того вечора, коли вперше з'явилась +сторінка, на якій його видно. + +# 0066 — Ескалація сповіщень + +## Що було + +Таблиця `alr.escalation_policies` стоїть у схемі з 0007. Порожня. Коду під +нею немає жодного рядка — ані читання, ані запису. Це та сама порожня +обіцянка, що й тригери на трапи до 0058, тільки непомітніша: там людина +бачила правило в переліку й могла помітити нуль спрацювань, тут сутність +існувала лише в схемі. + +Ціна відсутності рахується однією ситуацією. О 02:40 падає ядро, +сповіщення йде в Telegram черговому, черговий спить. Система вважає, що +повідомила: `notify_count = 1`, у журналі доставки `status = 'sent'`, на +дошці алерт червоний. Формально все спрацювало. Фактично про аварію +дізнаються о 09:00 з дзвінка клієнта. Моніторинг, який повідомив рівно +один раз і замовк, відрізняється від відсутнього лише тим, що в нього є +алібі. + +## Рішення + +### До чого прив'язана драбина + +**До правила** (`alr.rules.escalation_policy_id`), типово `NULL`. + +- *До серйозності* — одна драбина накрила б усі `high` у кабінеті. Але + `high` на тестовому комутаторі й `high` на ядрі — це різні люди й різна + година ночі, а серйозність у них однакова, бо її ставить той самий + тригер. Розвести їх можна було б лише збрехавши про гостроту. +- *До групи хостів* — той самий комутатор ескалював би однаково + «завантаження порту 91%» і «пристрій не відповідає». Перше може чекати + до ранку, друге — ні. +- *До правила* — правило єдине місце, де «що сталося» і «на яких хостах» + уже вирішені разом. Саме там у 0018 оселилось «куди слати» + (`channel_ids`) з тим самим міркуванням. + +Серйозність і група нікуди не діваються: вони вже є в самому правилі +(`severity` + `selector`). + +`alr.routes.policy_id` лишається невикористаним свідомо — про це +поставлено `COMMENT`. Дві точки, де задається та сама драбина, означають, +що на «чому мене розбудили» треба читати обидві. + +### Схема (0066) + +- `alr.alert_escalations` — стан драбини, рівно один рядок на алерт + (PK = `alert_id`). Взводиться через `ON CONFLICT DO NOTHING`, тому + повторна доставка драбину не подвоює. +- `alr.escalation_steps` — журнал сходинок. Окремо від + `alr.notifications`, бо той відповідає на «чи пішло», а тут потрібне + «не пішло, бо алерт підтвердили о 02:47». У ескалації половина роботи — + не будити, і саме її треба вміти показати. +- `alr.escalation_policies` — доведена до придатного стану: `CHECK` на + кількість сходинок і повторів, `description`, `created_at/updated_at`, + зафіксований формат `steps`. + +`after_min` рахується від **початку алерту**, а не від попередньої +сходинки: людина проектує чергування абсолютними числами, а відносні +проміжки мовчки зсуваються від будь-якої вставки посередині. + +### Як проходить драбина + +`store.PlanEscalation` — **чиста функція**, і це головне архітектурне +рішення. Причина проста: «сходинка спрацювала» перевіряється легко й +доводить мало; «сходинка не спрацювала, бо алерт підтвердили / закрили / +хост заглушено / драбина протухла» — п'ять гілок, кожна з яких коштує +чийогось сну, і кожну треба вміти перевірити без бази. + +Правила: + +1. **Стан алерту читається перед КОЖНОЮ сходинкою.** Драбина, взведена о + 02:40, не має о 03:10 будити керівника через алерт, закритий о 02:45. + Перевірка «лише на початку» проходила б усі очевидні тести й ламалась + би рівно там, де дорого. +2. **Підтвердження й закриття зупиняють драбину.** `AckAlert` і + `CloseAlert` зупиняють її ще й негайно, у тій самій транзакції, — але + це косметика для картки алерту, а не механізм безпеки. Механізм — + пункт 1: він накриває і ті шляхи закриття, до яких код ack не + дотягується (гасіння прострочених подієвих, `ResolveMissing`, + вимкнення правила). +3. **Заглушення й вікно обслуговування сходинку не витрачають, а + відкладають.** Списана сходинка означала б, що півгодинне вікно тихо + роззброює драбину до кінця життя алерту — тобто дає рівно ту мовчазну + відмову, від якої ескалація рятує. Відкладання обмежене стелею життя + драбини (`deadline`), інакше воно ходило б по колу місяцями. +4. **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.** + Немає каналів (тиха година правила, поріг серйозності, вимкнений + канал) — немає драбини: інакше о 15-й хвилині пішло б те, що на + нульовій свідомо не надіслали. +5. **Подієві алерти (0058) проходять драбину один раз, без повторів.** + Повтор — це ставка на те, що проблема триває, і зробити її можна лише + там, де існування алерту саме по собі є доказом. Метричний алерт живий + рівно доти, доки виконується умова. Подієвий такого доказу не дає: + рядок журналу стався один раз і «перестати ставатись» не може. + Повторювати за ним драбину означало б будити зміну по колу через + давно припинений нічний блимок порту. Сходинки в межах одного проходу + подієвий алерт проходить як усі — «конфіг змінився на ядрі, і ніхто не + глянув» має піднімати наступного. + +### Перезапуск процесу + +Стан живе в базі. Такт: **взяти з орендою → вирішити → записати → +надіслати**. + +- Падіння між взяттям і записом — оренда спливає, сходинку беруть знову, + вона не втрачена й не подвоєна. +- Падіння між записом і надсиланням — коштує однієї сходинки. Це та сама + угода, що вже зроблена для `notify_pending` у 0058: «спробували» не + дорівнює «доставили», і краще не надіслати, ніж надіслати вдруге о + третій ночі. + +Оренда — другий рубіж поверх advisory-блокування тіку. + +### Інтерфейс + +- Нова сторінка «Ескалації» — редактор драбин. Окремо від тригерів: одна + драбина накриває десяток тригерів і переписується раз на квартал, коли + міняється склад зміни, а тригер правлять щотижня. +- У формі тригера — вибір драбини, типово «без ескалації». +- **У картці алерту** — на якій сходинці він зараз, скільки їх усього, + коли буде наступна, а для зупиненої — чому саме. Без цього ескалація + перетворюється на невидиму магію, і перше ж «чому мене розбудили» не + має відповіді. +- Шапка самого повідомлення: `🔺 ЕСКАЛАЦІЯ 2/2 (Нічне чергування): не + підтверджено 45 хв`. Без неї третє повідомлення про ту саму аварію + читається як зациклена розсилка, і канал вимикають. + +## Чого не зроблено + +- **Тригери шаблонів драбину отримати не можуть.** Правила, породжені + `tpl.triggers`, правляться в шаблоні, а в тригері шаблону поля драбини + немає. Додавати його означало б чіпати схему шаблонів; драбина при + цьому тенант-специфічна, а шаблон — ні. +- **`alr.routes` лишились без інтерфейсу** (їх і не було). Ескалація до + них не прив'язана навмисно. +- **Ескалація не переходить на людину** (`user_id`), лише на канал. Черги + чергувань і календарів у продукті ще немає — драбина оперує тим, що є. + +## Перевірка + +`gofmt -l .`, `go vet ./...`, `go build ./...`, `go test ./...` — чисто. +`npx tsc --noEmit`, `npm run build` — чисто. + +Чого тести **не** покривають — окремо в звіті; коротко: SQL взяття з +орендою, атомарність і RLS перевіряються лише тестом проти бази +(`TestEscalationAgainstDB`), який мовчки пропускається без +`NETPULSE_TEST_DSN`, а бази в цьому оточенні немає. + +## 2026-08-27 — Диск закінчується мовчки: попередження, якого бракувало 0064 + +0064 навчила систему МІРЯТИ місце: розмір бази, приріст за добу, +«вистачить ще на N діб». Але міряти й попереджати — різні дієслова. +Цифри лежать на сторінці, куди заходять раз на квартал, а том +заповнюється в ніч із суботи на неділю. Перша ознака проблеми — Postgres +перестав приймати записи, тобто впав увесь продукт одночасно. + +Сторінка без сповіщення — це прилад без сигналізації. Він чесний, він +показує правду, і на нього ніхто не дивиться саме в ту годину, коли на +нього треба подивитись. + +### Пропозиція автора 0064 перевірена й не працює + +У розділі «Що лишається поза» 0064 записано найдешевший на вигляд шлях: + +> писати розмір бази й запас у добах звичайними метриками в `ts.series` +> (наприклад `db.size.bytes` і `db.days_left` на хості, що позначений +> машиною зонда), і тоді працює наявне метричне правило без жодної зміни +> коду. + +Це слушна за духом ідея — не чіпати движок правил, — і вона не працює +з чотирьох незалежних причин. Кожної окремо вистачило б. + +**Перша: метричне правило вимагає ХОСТА.** `store/alerts.go`, +`evalSeries` будує запит із + +```sql +JOIN inv.devices d ON d.id = se.device_id AND d.deleted_at IS NULL AND d.enabled +``` + +Ряд без хоста не дасть кандидата ніколи. «Хост машини зонда» — це +`core.agents`, а не `inv.devices`; рядка в інвентарі для нього не існує. +Тобто його довелося б завести. + +**Друга: заводити його не можна.** На `INSERT` в `inv.devices` висить +`bill.assert_device_limit` (0009). Штучний хост займає слот тарифу, а на +інсталяції, яка вже вперлась у стелю плану, `INSERT` просто впаде — +попередження про диск не встановиться саме там, де щільніше за все. І це +ще не найгірше: такий хост поповз би в інвентар, на мапи, у масові +операції й під наявні правила «даних немає взагалі» з порожнім +селектором, який за 0007 означає «до всього». Попередження про диск +почало б із того, що підняло б хибний алерт про самого себе. + +**Третя: правила живуть у кабінеті, том — в інсталяції.** `evalSeries` +фільтрує `se.tenant_id`, `alr.rules` має `tenant_id NOT NULL`. Це та сама +асиметрія, яку 0064 доводить про строки зберігання: чанк TimescaleDB не +знає кабінету. Щоб правило спрацювало в кожного, довелося б множити ряд і +кожен семпл на кількість кабінетів. + +**Четверта, і головна: `db.days_left` — не вимір, а частка.** Її +знаменник буває нулем (база не росте) і від'ємним (базу почистили), а +чисельник невідомий, поки людина не вказала ємність. `ts.samples.value` — +`double precision NOT NULL`: записати туди «немає відповіді» ніяк. +Записати нескінченність можна, і тоді першим зламається не прогноз, а +`encoding/json` — весь список алертів почне віддавати 500. Прогноз +мусить уміти мовчати; ряд вимірів такого не вміє. + +Висновок збережено: **не чіпати движок правил** — вимога слушна, і вона +виконана. Просто виконана інакше. + +### Як зроблено + +Перевірка живе там, де живе сам факт: у такті прибиральника даних, одразу +після знімка розмірів (`grpcapi/storage_retention.go`). Алерт піднімається +без правила (`rule_id IS NULL`) і без хоста (`device_id IS NULL`) — +обидві колонки необов'язкові з 0007, а всі читання алертів ходять туди +через `LEFT JOIN`. Розсилку робить наявний движок: алерт позначається +`notify_pending`, і `TakeNotifyPending` забирає його разом із подієвими +(0058). Жодного рядка в `alerting/**` і в движку правил не змінено. + +Перевірено й те, що алерт не зникне сам: `ExpireEventAlerts` працює через +`FROM alr.rules r WHERE r.id = a.rule_id`, тобто внутрішнім з'єднанням, і +рядок із NULL не чіпає. `ResolveMissing` шукає за `rule_id = $2` — так +само повз. Закриває алерт той самий такт, який його підняв. + +### До якого об'єкта чіпляти — ні до якого, і це рішення + +Спокуса привʼязати том до «хоста, на якому стоїть база», сильна: людина +шукає алерти на об'єктах. Але чесного об'єкта тут немає. Сервер бази — +не хост моніторингу; зонд — не база; штучний хост коштує слота тарифу й +ламає чужі правила (див. вище). + +Тому об'єкт — сама інсталяція, а місце, куди людина приходить, — сторінка +сховища. Саме там і показано стан: скільки лишилось, до якої дати +вистачить і чи налаштоване попередження. Алерт у дошці має текст, у якому +все це є словами, бо в Telegram посилання на сторінку нічого не варте +о третій ночі. + +### Типово УВІМКНЕНЕ + +Це навмисне порушення принципу 0064. Там нічого не вмикається само — +«строки не вмикаються самі, щоб оновлення не забрало нічиєї історії», — і +це правильно РІВНО ТОМУ, що строк видаляє. Помилкове типове значення там +знищує дані незворотно. + +Попередження не видаляє нічого. Найгірше, що коштує помилкове +спрацювання, — хвилина уваги чергового. Найгірше, що коштує помилкове +мовчання, — база, тобто весь продукт, без натяку заздалегідь. Ціни +несиметричні на кілька порядків. + +Друга причина практична: вимкнене типово попередження вмикає лише той, +хто вже думає про диск, а думає про диск той, у кого він уже +закінчувався. Тобто вимкнене типово воно рятує рівно тих, кого рятувати +пізно. + +### Пороги: головний — час, не рівень + +**21 доба до попередження.** Не «приблизно три тижні», а час, за який на +місце можна щось зробити в організації, а не в терміналі: помітити, +узгодити, замовити диск або вікно обслуговування, дочекатись його. Поріг, +коротший за цикл узгодження, повідомляє про те, чого вже не встигнути. +Чому не 30, які підсвічує сама сторінка: сторінка — довідка, її читає +той, хто вже прийшов; алерт будить. Якби він спрацьовував там само, де +фарбується сторінка, він спрацьовував би на кожній рівномірно зростаючій +інсталяції й був би вимкнений першого ж місяця. + +**4 доби до аварії.** П'ятниця, вечір → вівторок, ранок. Прогноз, знятий +у п'ятницю ввечері, має пережити вихідні й лишити ще один робочий день на +дію. Три доби з'їдають вихідні повністю; п'ять — це вже те саме +попередження іншими словами. Нижче цієї межі єдина дія, що встигає, — +скоротити строк зберігання: `drop_chunks` повертає місце негайно, а +купівля диска вже ні. Саме тому серйозність інша. + +**Рівень зайнятого лишається, але як другий поріг** — і не «на всяк +випадок». Прогноз мовчить рівно тоді, коли швидкості немає: спостережень +ще не набралось, приріст нульовий, або від'ємний після прибирання чи +видалення хоста. Останнє найгірше: одне видалення робить приріст +від'ємним на все вікно спостережень, і прогноз сліпне на місяць, поки +база тим часом росте як росла. Рівень бачить це без будь-якої історії. + +**Третій поріг — вільного менше за `max_wal_size`.** Підлога, нижче якої +відсотки безглузді: Postgres між контрольними точками має право написати +до `max_wal_size` журналу, і якщо стільки не влазить, він зупиняється +незалежно від того, 90 це відсотків чи 99. Число береться з +`current_setting('max_wal_size')`, а не з нашого уявлення про запас. + +### Вільне місце тепер можна міряти + +0064 сказала правду: у Postgres функції «скільки вільного на томі» немає, +а сервер застосунку живе в іншому контейнері. У штатному +`docker-compose` так і є. Але «не видно за замовчуванням» — не те саме, +що «не видно ніколи»: той, хто змонтує том бази в контейнер збірника хоч +тільки для читання й укаже шлях, отримує справжнє вільне місце замість +введеного числа. + +Різниця не косметична. Введене число застаріває мовчки — том розширили, +поруч поклали дамп, журнал попереднього запису роздувся від застряглого +слота реплікації. Виміряне бачить усе це, бо міряє ТОМ, а не суму відомих +нам таблиць. `statfs` рахує `Bavail`, а не `Bfree`: різниця — резерв +суперкористувача (типово 5 % на ext4), до якого Postgres не дотягнеться. + +Шлях перевіряється перед тим, як йому повірити: том, менший за самі бази, +— це не том бази, а кореневий розділ контейнера, у який показали +помилково. Такий шлях відхиляється з поясненням на сторінці, а не мовчки: +помилка тут дає не «немає числа», а «є число, і воно втішне». + +### Чого перевірки НЕ покривають + +Написано вісім наборів перевірок, і всі вони дивляться на чистий вирок за +числами. Що лишилось за їхніми межами: + +**Живої бази не торкається жодна.** Не перевірено: чи справді +`pg_ls_waldir()` доступний ролі застосунку (найімовірніше ні — потрібне +членство в `pg_monitor`, і тоді журнал просто не показується); чи +`pg_database_size` по всіх базах кластера не впаде на базі без права +`CONNECT`; чи `ON CONFLICT ... WHERE state IN (...)` справді потрапляє в +частковий унікальний індекс `alerts_active_dedup_uniq`. Три запити, які +або працюють, або не працюють, і дізнатись це можна лише на живому +Postgres. + +**`diskFree` не перевірено взагалі.** Це `syscall.Statfs` під тегом +`!windows`; на машині розробки він навіть не компілюється (перевірено +крос-складанням `GOOS=linux`). Чи правильно перемножуються `Bavail` і +`Bsize` на конкретній файловій системі, чи не переповнюється int64 на +дуже великому томі — не перевірено нічим. + +**Порядок доставки не перевірено.** Що алерт із `notify_pending` справді +підхопить `TakeNotifyPending` і що маршрут доставки з фільтром за +серйозністю його пропустить — прочитано в коді, не виконано. + +**Сліпа зона прогнозу після видалення хоста лишається сліпою.** Приріст +рахується різницею країв за 30 діб (так вирішила 0064, і два числа на +одній сторінці були б гіршими за одну сліпу зону). Одне велике видалення +робить цю різницю від'ємною — і прогноз мовчить до тридцяти діб. Покриває +це лише поріг за рівнем, тобто грубіший з двох. Перевірка на цей випадок +є, і вона перевіряє саме те, що спрацьовує рівень, — а не те, що прогноз +відновився. Він не відновлюється. + +**Не перевірено нічого про кілька кабінетів.** Алерт піднімається в +кожному чинному, і скільки їх, ніхто на перевірці не рахував. + +**Формулювання не перевірені людиною.** Тексти алерту й сторінки писались +із думкою про третю ночі, але о третій ночі їх ніхто не читав. + +# Поля заливки конфігу переїхали в каталог профілів + +## Що було + +Міграція 0060 додала `ncm.profiles` сім колонок про те, ЯК заливати +конфіг на пристрій (`apply_enter`, `apply_exit`, `apply_commit`, +`apply_negate`, `apply_block_exit`, `apply_supported`, `apply_note`) і +заповнила їх для шести профілів разовими `UPDATE`. + +`db/profiles/catalog.json` про ці поля не знав. Тобто джерело істини +роздвоїлось: команди ЗНЯТТЯ конфігу описані в каталозі, команди +ЗАЛИВКИ — у разовій міграції. Наступна платформа, додана в каталог, +з'явилась би без полів заливки, і відкат для неї мовчки не працював би +— рівно до того моменту, коли відкат знадобиться. + +## Що зроблено + +**Каталог отримав блок `apply`.** Він живе в самому профілі, поруч із +командою збору: `enter`, `exit`, `commit`, `negate`, `block_exit`, +`supported`, `note` і `comment` (причина рішення, їде коментарем у +SQL). Профілі, заведені не каталогом, а руками (`zte-zxan` — 0028, +`dlink-me` — 0034/0043), описані в новій секції `apply_external`: +тримати частину відповідей у каталозі, а частину в разовій міграції +означало б знову два джерела істини. + +Заливка задається профілем, а не родиною, і це не дрібниця. Родина +описує поведінку консолі (пейджер, запрошення), а не синтаксис +конфігурації: `zte-zxr10` стоїть у родині `huawei` через запрошення, а +конфігурується як Cisco. Успадкування від родини відправило б на +пристрій чужі команди. + +**Значення перенесено з 0060 без жодної зміни** — включно з причинами +відмови там, де заливки немає: + +* `mikrotik-routeros` — «RouterOS: рядки експорту — це add, і повторне + виконання не повертає запис, а створює дубль…»; +* `juniper-junos` — «JUNOS зберігається ієрархією у фігурних дужках — + це не набір команд…». + +Порожній `note` перетворив би свідоме рішення на схоже на недогляд, +тому причина переїхала разом зі значенням. + +**Генератор навчився другого виходу.** `build.py` тепер пише два файли: + +* `server/migrations/0014_ncm_profiles.sql` — самі профілі (як і був); +* `db/profiles/apply.generated.sql` — `UPDATE` з полями заливки. + +## Чому заливка НЕ поїхала в 0014 + +Дві незалежні причини, кожної досить. + +1. **Порядок.** Колонки `apply_*` заводить 0060, тобто ПІЗНІШЕ за 0014. + Вставка в 0014 не може згадати колонку, якої на той момент ще немає + — на чистій базі це впало б одразу. +2. **Сторож контрольних сум.** `netpulse-migrate` тримає sha256 + кожного застосованого файлу і зупиняє накат на розбіжності + («міграцію 0014 змінено після застосування»). 0014 уже накочена на + стенді, тож будь-яка зміна її тексту зупинила б оновлення всім. + +Тому 0014 лишилась байт-у-байт тією самою: sha256 +`0a50e0e9…` до й після, `git diff` по файлу порожній. + +`apply.generated.sql` свідомо НЕ є міграцією: номер призначає людина. +Над одним каталогом номерів паралельно працюють інші гілки, і вигаданий +генератором номер зіткнувся б із чужим. + +## Знайдено дорогою + +**Генератор писав не туди.** `MIGRATION` вказував на `db/migrations`, +хоч міграції переїхали в `server/migrations` ще тоді, коли їх почали +вшивати через `//go:embed` (див. `db/README-migrations.md`, де переїзд +описаний, а шлях у генераторі лишився старий). Наслідок: `--check` +завжди звітував «розійшлася з каталогом», а звичайний запуск падав на +записі в неіснуючий каталог. Виправлено; після виправлення каталог і +0014 збіглися байт-у-байт — розходження не було, був зламаний шлях. + +**Генератор ламав контрольну суму на Windows.** `write_text` у +текстовому режимі перекладає `\n` у `\r\n`, а `.gitattributes` вимагає +LF. CRLF-версія 0014 дає sha256 `501cb391…` замість `0a50e0e9…` — тобто +бінарник, зібраний на Windows після запуску генератора, зупинив би +накат на стенді. Тепер файли пишуться байтами, без перекладу. + +## Перевірено + +* `python build.py` — відпрацював, 147 платформ, 6 профілів із заливкою; +* `python build.py --check` — «згенероване актуальне»; +* `diff` згенерованої 0014 з наявною — **порожній**, sha256 не змінилась; +* окремим скриптом розібрано `UPDATE`-и з 0060 і звірено ЗНАЧЕННЯ всіх + семи полів для всіх шести профілів (незадані в 0060 поля добрано з + `DEFAULT` колонок) — **збіг по всіх**; +* `go build ./...`, `go test ./...` — зелено. + +Окремо про перевірку: «генератор відпрацював без помилки» тут нічого не +доводило б. Значуще саме те, що ТЕКСТ згенерованої 0014 не змінився й +що значення в каталозі дорівнюють значенням у вже накоченій 0060. + +## Що НЕ зроблено + +* **Міграції не створено.** `apply.generated.sql` чекає на номер, який + призначить людина. +* **Нових родин із заливкою не додано.** Спокуса була (`huawei`: + `system-view` / `return` / `undo `), але `save` на VRP перепитує + `[Y/N]`, а модель «команда → запрошення → наступна команда» такого не + переживає. Вигадана команда заливки пише на живе залізо — гірша за + її відсутність. +* **CI не звіряє каталог зі згенерованим.** `python db/profiles/build.py + --check` у `.forgejo/workflows/ci.yml` зловив би розходження, але + ci.yml поза цією зоною. + +## 2026-08-27 — Відмінювання: `plural()` більше не друкує число + +### Що було не так + +`plural(n, one, few, many)` у `web/src/components/ui.tsx` повертала рядок +**разом із числом** (`` `${n} ${форма}` ``). Тринадцять місць виклику з +шістдесяти дев'яти ставили число ще раз — на екрані виходило «5 5 хостів», +«Видалити назавжди 22 22 хости», «1 000 1000 рядків» (у StoragePage число +ще й друкувалось двічі в різному вигляді: з розділювачами тисяч ззовні й +голим зсередини). + +Три форми української множини стара функція вміла: 11–14 бралися за +двома останніми цифрами, 21 і 22 — за останньою. Тобто серйознішої вади, +ніж подвоєне число, у самій функції не було — вона була **навколо неї**: +у двох десятках місць число й іменник склеювались взагалі повз `plural()` +(«1 учасників», «21 активних алертів», «1 перевірок»), а в QueuesPage +множина була на дві форми через тернарник — «21 групах». + +### Рішення про підпис + +`plural(n, ['хост', 'хости', 'хостів'])` повертає **лише слово**; число +друкує місце виклику. + +Другий варіант — лишити «число + слово» і повиправляти місця виклику — +відкидає причину, а не наслідок: поки функція друкує число, поставити +його поруч ще раз може будь-хто, і саме так вада й з'явилась. Побічний +виграш: місце виклику лишає собі своє форматування числа — `num()` з +розділювачами тисяч і `toLocaleString('uk-UA')` більше не витісняються +голим `${n}` зсередини функції. + +Форми передаються **трійкою**, а не трьома аргументами, теж навмисно: +якби змінився лише зміст рядка, `tsc` лишився б зеленим, а числа тихо +зникли б з екрана. З тюплом кожен старий виклик перестає збиратись, і +компілятор сам показує все, що треба перевести — це те, чого перевірка +типів у цій задачі сама по собі не доводить. + +Додано `Math.abs()`: `-1 % 10` дорівнює `-1`, жоден `case` не збігається, +і від'ємний лічильник («не рахували») діставав би форму «багато». + +### Обсяг правки + +- 69 місць виклику `plural()` переведено на новий підпис (13 із них саме + ті, що друкували число двічі — там число вже стояло, тож зникло + дублювання). +- ~24 місця, де число й іменник були склеєні намертво, зведено до + `plural()`: AppShell, AlertsPage, AuditPage, BulkDeviceForm (переліки + «що зникне разом із хостом»), CommandsPage, CompliancePage, ConfigsPage, + GroupsPage, MapCanvas, MapPage, NodeInspector, ProfilesPage, QueuesPage, + RolesPage, TeamPage. +- `MapSettings`: рахунок груп винесено в `const allowed` — двічі писати + той самий `filter` у рядку означало б напрошуватись на розбіжність. + +### Перевірка + +`npx tsc --noEmit`, `npm run build`, `npx vitest run` (71 тест) — зелені. +Але зелений `tsc` тут нічого не доводив би: подвоєне число — не помилка +типів, збиралось воно й раніше. Тому результат прогнано **рядками**: +справжня стара реалізація і справжня нова, обидві через esbuild, на +справжніх шаблонах з коду, для 0/1/2/5/11/21/22: + +``` +BulkDeviceForm:833 до: «5 5 хостів» після: «5 хостів» +BulkDeviceForm:805 до: «Видалити назавжди 22 22 хости» після: «… 22 хости» +StoragePage:472 до: «1 000 1000 рядків» після: «1 000 рядків» +MirrorPage:312 до: «У ньому 21 21 версія з 7 7 хостів» після: «У ньому 21 версія з 7 хостів» +TeamPage:69 до: «1 учасників» після: «1 учасник» +AppShell:241 до: «21 активних алертів» після: «21 активний алерт» +QueuesPage:409 до: «99 у 21 групах» після: «99 у 21 групі» +``` + +Окремо перевірено механічно: біля кожного з 69 викликів число друкується +рівно один раз і саме те, за яким обрано форму. + +### Що лишилось не зробленим (свідомо) + +- `StoragePage` і `RulesPage` правлять інші агенти — там зачеплено лише + рядки з `plural()`. Незакриті склейки в них: «діб» (`keep_days`, + `min_days`, `from_days`, `to_days`, `days_left`) і «с» у RulesPage + (одиниця, не відмінюється). +- Одиниці вимірювання (`с`, `хв`, `МБ`, `Гбіт/с`) навмисно лишились без + `plural()` — вони не відмінюються. +- `DevicePassport`: «{checks_auto} заводить система» — це узгодження + дієслова, а не іменника; переписувати текст заради нього не стали. + +# Тести вебу й робочий локальний прогін перевірок + +**Стан до:** у вебі не було жодного тестового прогону — ні vitest, ні jest. +Перевірялось типами й живим браузером. `.forgejo/workflows/ci.yml` написано, але +раннера немає, тож CI не запускався жодного разу. + +**Стан після:** 103 тести в 9 файлах, `npm test` зелений, `sh scripts/check.sh` +проганяє всі перевірки обох світів однією командою. + +--- + +## Чому vitest, а не jest + +Проєкт на Vite. `vitest.config.ts` зроблено через +`mergeConfig(viteConfig, …)` — тести проходять ТУ САМУ трансформацію, що й +збірка (плагін React, tailwind, ті самі налаштування розвʼязання модулів). З +jest вийшло б два різні конвеєри, і розбіжність між ними знаходили б не тестом, +а на стенді. Окремий файл конфігурації, а не секція в `vite.config.ts`, — щоб не +чіпати файл, у якому паралельно працюють інші. + +Vitest бере `vite.config.ts` лише коли власного конфіга немає; щойно він +зʼявляється — вітовий ігнорується цілком. Саме тому `mergeConfig`, а не порожній +`defineConfig`: інакше JSX не трансформувався б узагалі. + +## Що обрано покривати — і чому саме це + +Приводом був сьогоднішній випадок із ізоляцією БД: зелений тест пропустив +поломку, бо перевіряв «чи не видно чужого», а зламалось «чи видно своє». +Висновок — **зелена перевірка доводить рівно те, що вона перевіряє** — визначив +відбір: не число покриття, а місця, де помилка ТИХА й дорога. + +1. **Порівняння конфігів (Myers, `linediff.ts`).** Найдорожче місце в усьому + вебі: людина дивиться на дві колонки й вирішує, чи котити відкат на живий + комутатор. Diff, який загубив рядок або зсунув нумерацію на одиницю, не падає + й не пише в журнал — він показує неправдиву картину, і рішення за нею + приймають як за правдивою. + + Тому перевіряються не приклади, а **інваріанти на 500 випадкових парах** + (детермінований LCG, не `Math.random` — тест, що падає раз на сто прогонів і + не відтворюється, гірший за відсутність тесту): із ділянок точно + відновлюються обидві версії; рядки, оголошені однаковими, справді однакові; + нумерація обох колонок іде без пропусків і повторів; текст у рядку відповідає + своєму номеру. Окремо — розрахунковий випадок Myers: один змінений рядок + серед 30 000 (якби відкидання спільного початку й кінця зламалось, тест не + впав би — він би завис). + +2. **Числа, одиниці, час.** `plural`, `ago`, `humanInterval`, `fmtBytes`, + `formatBps`, `fmtValue`, `fmtBps`. Перевіряються МЕЖІ (1023/1024, + 999e6/1e9, 59/60 с), знак і дата з майбутнього — саме там і живуть тихі + помилки. + +3. **Геометрія мапи.** `autoSides`, `autoLabelPositions`, `labelCandidates`, + `estimateLabelBox`, жадібна розкладка підписів у `labelLayout.ts`. Помилка + тут не падає: лінія йде в обхід через півсхеми або підписи лягають один на + одного — і те, й те виглядає як «мапу малювали абияк», а не як помилку + програми. + +4. **Дві поведінки, а не два вигляди.** `Modal` — єдине місце, де самовільне + закриття зʼїдає набране в довгій формі (тест на виділення тексту, доведене + мишею за край вікна). `ConfirmDialog` — останній екран перед незворотною + дією; його дві тихі поломки коштують найдорожче: другий клік = другий запит + на видалення, а закриття після невдачі читається як «виконано». + +## Правило, яке трималось під час написання + +**Не закріплювати ваду зеленим тестом.** Знайдене під час роботи пішло в звіт і +в `web/TESTING.md`, а не в `expect`. Виняток — коли поточне правило треба +зафіксувати свідомо (сортування `uniq` кодами символів; згортання кирилиці в +`safeName`); тоді причина сказана в коментарі, щоб зміна була свідомим кроком, а +не побічним ефектом. + +## Про `plural` — випадок, вартий запису + +Задача передбачала ЧЕРВОНИЙ тест на відому ваду: функція повертала «число + +слово», а вісім місць виклику ставили число ще раз, і на екрані виходило +«5 5 хостів». Поки писались тести, паралельний агент цю ваду полагодив — +змінив підпис на `plural(n, ['хост','хости','хостів'])`, що повертає ЛИШЕ слово, +і перевів усі ~60 місць виклику (я застав проміжний стан, коли `tsc` давав 60 +помилок «Expected 2 arguments, but got 4»). + +Тому червоного тесту немає, а є зелений інваріант, який тримає виправлення: + +```ts +it('не друкує число — його ставить місце виклику', () => { + for (const n of [0, 1, 2, 5, 11, 21, 100, 1234]) { + expect(plural(n, HOSTS)).not.toMatch(/\d/) + expect(HOSTS).toContain(plural(n, HOSTS)) + } +}) +``` + +Перевірка стоїть на САМІЙ функції, а не на місцях виклику: їх шістдесят, і +завтра буде сімдесят. Поки функція не друкує числа, подвоїти його нема чому. + +## CI без раннера, і що з цим робити + +`.forgejo/workflows/ci.yml` доповнено кроком тестів вебу (до збірки: збірка +коштує хвилини, а падіння тесту видно за секунди) і переведено на +`upload-artifact@v4`. + +Але раннера як не було, так і немає, а перевірка, яку нікому виконати, не ловить +нічого. Тому додано `scripts/check.sh` — та сама послідовність локально: + +``` +gofmt + vet + тести server → gofmt + vet + тести agent → крос-збірка зонда +під 5 платформ → tsc → vitest → vite build +``` + +Три рішення в скрипті варті пояснення: + +- **Не зупиняється на першій невдачі.** Інакше, щоб побачити три поламані місця, + довелось би прогнати його тричі. Ненульовий код виходу все одно повертається — + для гачка pre-push. +- **Не піднімає Postgres.** Тести проти бази самі пропускаються без + `NETPULSE_TEST_DSN`. Це єдине місце, де скрипт свідомо ВУЖЧИЙ за CI, і в + ci.yml про це сказано вголос. +- **`npm ci` лише коли `node_modules` немає.** На кожному прогоні він викидає + залежності й ставить наново — хвилини замість секунд. + +Скрипт відпрацював із першого разу й одразу знайшов те, чого ніхто не бачив: сім +файлів `server/` лежать у робочій копії з CRLF, хоча `.gitattributes` вимагає LF +(`git ls-files --eol` показує `i/lf w/crlf`). На Linux-раннері вони були б +зелені, локально — червоні. Скрипт про цю пастку каже прямо, щоб її не «полагодили» +через `gofmt -w`. + +## Найважливіший артефакт — `web/TESTING.md` + +Половина файла — перелік того, чого тести НЕ покривають, і він стоїть ПЕРЕД +переліком покритого. Це не формальність: доки цей перелік тут і чесний, зелений +прогін не можна прийняти за доказ справності вебу. Головні дірки: перетягування +вузлів на мапі (d3-drag не запускається синтетичними подіями — jsdom не рахує ні +`PointerEvent`, ні матрицю SVG), усе полотняне в `Minimap` (у jsdom +`getContext('2d')` віддає `null`), `pointsOnPath` (немає `getTotalLength`), +`api/*` і `hooks/*` цілком, усі сторінки з `src/pages/`. + +# Вади вебу, знайдені при написанні тестів, — виправлення + +**Стан до:** 103 тести, усі зелені. Частина з них закріплювала ПОМИЛКОВУ +поведінку («кирилиця згортається в підкреслювач», «кожен рядок є в DOM двічі») — +свідомо, щоб перехід був помітним кроком, а не побічним ефектом. + +**Стан після:** 137 тестів у 12 файлах, `tsc --noEmit`, `npm run build` і +`vitest run` зелені. Одинадцять пунктів переліку перевірено кожен окремо: десять +підтверджено й виправлено, один (пункт 10) підтверджено наполовину — про це +нижче. + +**Головне, що варто винести:** жодна з одинадцяти вад не є помилкою типів. +`tsc` і `vite build` проходили з усіма ними. Тому на кожну є тест, а те, що +тестом не ловиться (розміри, фокус, розкладка), перевірено живим браузером — +не читанням. + +--- + +## Як перевірялось, що тест справді щось ловить + +Написати тест ПІСЛЯ виправлення легко так, що він був би зелений і до нього. +Тому всі виправлення тимчасово відкочувались назад (копія файлів, точкові +зворотні заміни) і прогонявся весь набір. Впало рівно 13 + 5 перевірок — тих +самих, що й мали: + +``` +FAIL datatable > рядок із дією отримує фокус і відкривається з клавіатури +FAIL datatable > картка телефона теж відкривається з клавіатури +FAIL format > ago > округлення переносить у більшу одиницю, а не вигадує 60 хв +FAIL format > fmtValue > ненульове не показує нулем +FAIL format > formatBps > рахує межу за модулем: мінус не має ламати одиницю +FAIL interval > очищене поле НЕ перетворюється на нуль +FAIL interval > число нижче межі назовні не йде, поки з поля не пішли +FAIL interval > порожнє поле на виході повертається до чинного значення +FAIL interval > зміна значення ззовні на непресетне вмикає ручний режим +FAIL minimap > контейнер завширшки рівно MAP_W — одне число, а не два +FAIL minimap > ширина не задається класом +FAIL minimap > рядок із самих пробілів не малюється зовсім +FAIL minimap > порожній рядок не бере навіть колір збігу пошуку +FAIL modal > переносить фокус у перше поле вікна +FAIL modal > вікно без полів бере фокус на себе +FAIL modal > повертає фокус туди, звідки вікно відкрили +FAIL modal > Tab із останнього поля повертається на перше +FAIL modal > тло стає inert, поки вікно відкрите +``` + +Після відновлення — 137 зелених. + +--- + +## 1. `safeName` згортав кирилицю (`components/ConfigViewer.tsx`) + +Підтверджено. `\w` у JavaScript — рівно ASCII, тому «Київ-ядро» і «Львів-ядро» +давали ОДНАКОВЕ ім'я файла `_-_`. Людина вивантажувала два конфіги й отримувала +один: другий тихо затирав перший, а помічають це тоді, коли по втраченому вже +щось відновлюють. + +Це третя така функція в проєкті. `sanitizePath` у `server/internal/store/ncm.go` +хворів тим самим і вилікуваний через `unicode.IsLetter`; тут прямий відповідник — +`\p{L}` із прапорцем `u`. Звідти ж узято згортання `..`: ім'я потрапляє в атрибут +`download` як є, і ланцюжок крапок — це спроба вийти за теку завантажень. Крапки +всередині лишаються («10.0.0.1» має бути собою). + +Тест `configview.test.ts` переписано, а не обійдено: замість «згортається в +підкреслювач» тепер «лишає кирилицю», «різні імена лишаються різними» і +«згортає «..», але не чіпає звичайні крапки». + +## 2. `IntervalInput` слав нуль секунд (`components/ui.tsx`) + +Підтверджено обидві частини. + +Перша: у розмітці `min={5}`, у коді `Math.max(0, Number(e.target.value))`, а +`Number('') === 0`. Досить було стерти вміст поля, щоб надрукувати нове число, — +і між двома натисканнями клавіш перевірка вже мала інтервал «0 секунд». Тепер +ручний ввід тримає ВЛАСНИЙ рядок, а назовні йде лише придатне число; межі +притискаються один раз, на виході з поля (`onBlur`), щоб «4» на шляху до «45» не +перетворювалось на 5 і не їхало на сервер. + +Друга: `manual` виводився з `value` лише при монтуванні. Місце, де це справді +трапляється, знайдено: `PrototypeEditor.tsx:173` і `TemplatesPage.tsx:466,618` +мапляться з `key={i}` — прибрали сусідній рядок, і той самий екземпляр поля +дістає чуже значення. При 45 секундах список показував порожній вибір замість +числа. Синхронізація зроблена узаконеним у React способом «правка стану під час +рендера при зміні пропа», і лише в один бік: непресетне значення вмикає ручний +режим, зворотно не вимикає — інакше поле забирали б з-під рук того, хто друкує. + +Новий файл `src/test/interval.test.tsx` (8 перевірок). + +## 3. Мінікарта була стиснута ~2.6× (`components/Minimap.tsx`) + +Підтверджено: полотно малювалось у `MAP_W = 180`, а контейнер мав клас +`w-[68px]` від попередньої ширини. Коментар «Було 68 — і цього замало» лишився, +клас — ні. На екрані це виглядало не як помилка, а як «щось дрібне й +нерозбірливе». + +Джерело тепер одне: ширина йде в `style={{ width: MAP_W }}`, класу немає взагалі. +Саме так, а не «полагодити число в класі»: Tailwind збирає довільні значення +статично, тож клас не може слідувати за константою — і наступна зміна `MAP_W` +розійшлася б із ним так само мовчки. Тест закріплює обидва боки: ширина дорівнює +`MAP_W` і клас `w-[…]` відсутній. + +Виміряно в живому Chrome: контейнер 180 CSS px, `canvas.width` 225 при +`devicePixelRatio` 1.25 — тобто рівно 1:1, стиснення немає. + +## 4. Чотири форматувальники швидкості → один + +Підтверджено: `edgeState.formatBps` (`1.0 Гбіт/с`), `DeviceCollected.fmtBps` +(`1.00 Gbps`), `InterfaceFilterEditor.speedLabel` (`1G`), `MetricChart.fmtValue` +(`1.0G`). Одна й та сама лінія в різних місцях називалась по-різному. + +Зведено до `formatBps(bps, 'full' | 'short')` у `ui.tsx` — не до чотирьох +функцій і не до однієї без параметра. Короткі форми справді потрібні: у колонці +фільтра інтерфейсів і на осі графіка назва одиниці не влазить. Але це РЕЖИМ +одного форматувальника зі спільними порогами, а не окрема реалізація; коротка +форма — це той самий `fmtValue`, тобто число на осі й число в колонці тепер +збігаються. + +`fmtValue` при цьому лишився окремою функцією (теж у `ui.tsx`) і навмисно: він +підписує не лише біти, а й відсотки, градуси та байти, і дописувати йому +«Гбіт/с» означало б поставити біти на вісь температури. + +Точність повної форми: гігабіти завжди з десятою (так було й лишається), +мегабіти й кілобіти — з десятою до 10 одиниць і без неї далі. Це не косметика: +трафік на порту доступу живе саме в діапазоні 1–10 Мбіт/с, і `1 Мбіт/с` замість +`1.5 Мбіт/с` — півтора раза похибки на око. + +## 5. `fmtBytes` було ТРИ, а не дві + +Підтверджено дві названі копії — `BackupDefaultsForm.tsx` (експортована) і +`BulkDeviceForm.tsx` (дослівна локальна). Третю знайшов новий тест: у +`pages/AuditPage.tsx` лежала ще одна, і вона вже РОЗІЙШЛАСЬ — спинялась на +мегабайтах, тож вкладення понад гігабайт показувалось як «1048576.0 МБ». Прибрані +обидві копії. + +`src/test/formatters.test.ts` — тест не поведінки, а тексту джерел: скільки в +дереві оголошень із таким іменем. Вада, яку він стереже, поведінкою не ловиться +взагалі: дві дослівні копії працюють однаково рівно до дня, коли одну правлять, а +другу — ні. Джерела читаються через `import.meta.glob` з `?raw`, а не через +`node:fs`: у проєкті немає `@types/node`, і тягнути їх заради одного тесту +дорожче. + +## 6. `formatBps` не брав модуль + +Підтверджено: `-1e9` давало «-1000000000 біт/с». Тепер межі порівнюються з +модулем — як у `fmtBps`, який саме тому й був другою функцією. + +## 7. `fmtValue` показував нуль для ненульового + +Підтверджено: `0.0001` → `"0.000"`. На осі графіка нуль означає «даних немає», а +не «мало»; на графіку втрат пакетів це різниця між «канал чистий» і «канал +сипле». Тепер: якщо три знаки після коми дають нуль, а значення не нульове — +рахуються значущі цифри (`0.0001`, `0.00025`), а зовсім дрібне йде показником +степеня. Нуль лишається нулем лише коли значення справді нульове. + +## 8. `ago()` перескакував одиницю + +Підтверджено: 3599 с → «60 хв», 86399 с → «24 год». Причина — межа перевірялась +за секундами, а число рахувалось окремо. Тепер одиниця обирається ПІСЛЯ +округлення: 3599 → «1 год», 86399 → «1 дн». «60 хв» поруч зі справжніми «59 хв» і +«1 год» читається як третя одиниця виміру, якої немає, — а дивляться на цей підпис +саме тоді, коли хост щойно перестав відповідати. + +## 9. `Modal` не керував фокусом + +Підтверджено все чотири: не переносив фокус усередину, не тримав його, не +повертав на місце виклику, тло не позначалось. + +Зроблено: +- фокус переїжджає в ПЕРШЕ поле тіла вікна (у підтвердженні видалення це + «Скасувати» — і це правильно, бо Enter одразу після відкриття не має нічого + стирати); вікно без полів бере фокус на себе через `tabIndex={-1}`; +- Tab-кільце по вмісту вікна в обидва боки; +- `inert` на СУСІДІВ кожного предка вікна аж до `body`, а не на один контейнер: + вікно живе всередині сторінки, тож «усе, крім нього» — це саме сусіди по + дорозі нагору. Позначки, які вже стояли, не чіпаються — інакше два відкриті + вікна знімали б їх одне одному; +- фокус повертається туди, звідки вікно відкрили. + +Порядок ефектів має значення й закріплений коментарем: `inert` оголошено ПЕРШИМ, +бо React прибирає ефекти в порядку оголошення — тло встигає перестати бути inert +до того, як фокус повертається на кнопку, яка в ньому лежить. Інакше браузер +відмовив би у фокусі, і вийшла б рівно та вада, яку це лікує. + +Перевірено в живому Chrome, а не лише в jsdom (jsdom `inert` не виконує): +фокус після відкриття стоїть у першому полі, кнопка тла НЕ фокусується, саме +вікно не inert, після закриття фокус на кнопці-відкривачі, `[inert]` у документі +не лишилось жодного. + +## 10. `DataTable`: подвійне малювання і недоступність із клавіатури + +**Спростовано частину діагнозу.** Дві копії рядка ховались через `hidden md:table` +і `md:hidden`, тобто `display: none`. Читач з екрана `display: none` пропускає — +обидві копії він НЕ читає, у дереві доступності в будь-який момент рівно одна. +З тієї ж причини `aria-hidden` на прихованій копії був би не найдешевшим +рішенням, а неправильним: у ній тепер є елементи з `tabIndex`, а фокусований +елемент усередині `aria-hidden` — це вже справжня помилка доступності. + +**Підтверджено решту, і виправлено обидві.** Клавіатура: `onClick` на `` і +`
  • ` не дає ні фокусу, ні Enter — людина без миші просто не могла відкрити хост +зі списку, при тому що клік по рядку там єдиний шлях. Додано `tabIndex` і +обробку Enter/Пробілу (з `preventDefault`, інакше пробіл прокрутив би сторінку). +Ролей `role="button"` на `` навмисно не ставилось: усередині таблиці це +ламає її ж семантику. + +Подвійне малювання прибрано насправді, а не заклеєно: подачу обирає +`useSyncExternalStore` над `matchMedia('(min-width: 768px)')` — той самий поріг, +що й `md:` у Tailwind, тож на око нічого не змінилось. Без `matchMedia` (jsdom, +старі рушії) показується таблиця: дані видно всі, просто густіше. Підписка йде +ще й на `resize` як страхувальна сітка — подія медіазапиту приходить не в кожному +середовищі, а помилка тут тиха: людина повертає телефон і лишається з таблицею на +375 px. + +Перевірено в живому Chrome на обох ширинах: 1280 — таблиця, списку немає, +`tabindex="0"` на ``; 375 — шість карток, таблиці немає, колонка +`hideOnMobile` у картку не потрапила, горизонтального переповнення немає, +мінікарта прихована. Перемикання без перезавантаження перевірено подією `resize`. + +## 11. `Minimap.buildShape`: порожній рядок малювався звичайним + +Підтверджено. Рядок із самих пробілів отримує `KIND_BLANK`, але `len > 0` — тож +доходив до малювання; `KIND_COLOR[0]` не існує, спрацьовував запасний +`KIND_BODY`. А саме порожні рядки й розділяють розділи конфігу: намальовані як +текст, вони стирають межі, по яких людина на мінікарту й дивиться. + +Рішення «яким кольором» винесено в чисту `rowColor(shape, i, hit)`, яка віддає +`null` для рядка, що не малюється. Це єдине місце мінікарти, яке можна перевірити +без полотна (у jsdom 2D-контексту немає взагалі), — і саме там вада й сиділа. + +--- + +## Що перевірено ЛИШЕ читанням коду + +- Ширина мінікарти в режимі порівняння (`ConfigDiff.tsx`): `Minimap` там + вживається двічі з тими самими пропсами й без власних класів ширини — отже, + та сама виправлена ширина. Живим браузером дивився лише `ConfigViewer`. +- Ширші підписи швидкості в картці хоста (`1.0 Гбіт/с` замість `1.00 Gbps`, + на два символи довше): комірки `DataTable` без фіксованих ширин, тож розкладка + не залежить від довжини. Не міряно. +- Поведінка `inert` у вікні поверх вікна (два `Modal` одночасно): логіка + «не чіпати вже позначене» прочитана, але живим сценарієм не відтворювалась. +- Форматування швидкості на самій мапі (`TrafficEdge`, `EdgeInspector`): форма + там не змінювалась (та сама повна українська), тому оком не звірялось. + +## Що НЕ виправлено — і чому + +- `fmtValue` не влито у `formatBps` (пункт 4 просив «звести до одного»). Він + підписує будь-яку метрику, не лише швидкість; спільним у них зроблено те, що + справді спільне — саме скорочення числа. Коротка форма `formatBps` — це виклик + `fmtValue`, тобто реалізація одна. +- `aria-hidden` на прихованій копії `DataTable` (пункт 10) — копії більше немає, + а на `display: none` він і не був потрібен. Причина розписана в пункті 10. +- Попередження збірки про чанк >500 kB — було до цієї роботи, до переліку не + входить, і код-спліт мапи це окрема задача. diff --git a/ROADMAP.md b/ROADMAP.md index 952aba5..de0af33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,8 +24,8 @@ API віддає готове полотно з живими статусами, | Мапа, топологія, телеметрія | ✅ | ✅ | | Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ | | **Користувачі, ролі, вхід** | ✅ | ✅ | -| **Шаблони опитування** | ✅ | ⚠️ є, з тригерами й автопризначенням; немає snmp.walk і прототипів | -| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git, syslog і compliance є; немає відкату | +| **Шаблони опитування** | ✅ | ✅ з тригерами, автопризначенням, `snmp.walk` і прототипами (0059) | +| **NCM (збір конфігів)** | ✅ | ✅ збір, розклад, Git, syslog, compliance і відкат (0060) | | **Керування зондом із UI** | ✅ | ✅ | | **Алерти й сповіщення** | ✅ | ✅ | | **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає | @@ -113,6 +113,11 @@ POST /api/v1/roles кастомна роль із набором > **Додано 2026-08-25:** тригери описуються в шаблоні й розгортаються в > правила сповіщень; автопризначення за `sysObjectID` — пристрій сам > каже, що він таке, і шаблон чіпляється без жодного натискання. +> За тим самим `sysObjectID` тепер підбирається й профіль збору конфігів +> (`ncm.profile_auto_assign`, уточнення за `sysDescr` для випадків, коли +> один OID покриває різні типи заліза). Щоб було з чого підбирати, хост +> зі SNMP-доступом сам отримує чек розпізнавання: полегшений +> `topology.discover` — три OID, без сусідів і без обходу `ifTable`. > > **Лишилось:** прототипи шаблонів і `snmp.walk` як тип елемента > (таблиці з динамічним індексом). @@ -311,6 +316,99 @@ Diff* — усе з ТЗ. --- +## Етап 11. Сім задач одним заходом — 2026-08-27 + +> Міграції 0058–0064, зроблено паралельно. Розбір спільного знаменника — +> в [HISTORY.md](HISTORY.md), розділ «Сім задач одним заходом». +> +> - **0058 подієві алерти** — `syslog`, `ncm`, `compliance` спрацьовують +> у мить надходження події; правило з нереалізованим джерелом більше +> не зберігається мовчки. +> - **0059 `snmp.walk` і прототипи** — таблиці з динамічним індексом +> описуються шаблоном, а не Go. +> - **0060 відкат конфігу** — план як різниця, маскування паролів із +> підписом плану, обов'язковий контрольний збір, `verifying` при +> обриві. MikroTik і Juniper відмовлені з поясненням. +> - **0061 кнопки Telegram** — довге опитування (домену немає й не +> передбачається), авторизація не з `callback_data`, прив'язка +> акаунта одноразовим кодом. +> - **0062 аудит і архів хостів** — вісім відсутніх назв дій; тест на +> AST, що падає на ключі без назви; «відновити» повертає хост робочим, +> а не мовчазним. +> - **0063 RLS** — три ролі, окремий пул для фонових тактів. Інертна до +> перемикання DSN. +> - **0064 строки зберігання** — три гіпертаблиці й дві звичайні +> таблиці, що росли назавжди; сторінка сховища з прогнозом. + +### Лишилось із цього етапу + +- ~~**Тест ізоляції RLS не прогнано.**~~ ✅ 2026-08-27: прогнано на + бойовій базі, перемикання зроблено. Ізоляція діє, вхідні шляхи + переведено на воркерний пул. Подробиці — [HISTORY.md](HISTORY.md), + розділ «Перехід на роль без BYPASSRLS». **Лишилось:** телеметрія, + аудит та історія алертів під RLS не підпадають і не підпадуть — + TimescaleDB не поєднує стиснення з row level security. Їхню ізоляцію + далі тримає предикат у запиті. +- **0064 не прогнано на живій БД.** Перевірити першими: + `chunks_detailed_size` над матеріалізованою гіпертаблицею, + `hypertable_compression_stats` на нестиснутій, `add_retention_policy` + всередині транзакції під `SECURITY DEFINER`. +- **Алерт про вичерпання диска** — найдешевший шлях без правок движка: + писати `db.size.bytes` і `db.days_left` звичайними метриками на хості + машини зонда, тоді наявне метричне правило працює як є. +- **`apply_*` у генераторі профілів.** Поля заливки задані міграцією + через `UPDATE`; `db/profiles/catalog.json` про них не знає. +- **`plural()` повертає рядок разом із числом**, а частина місць виклику + додає число ще раз — на екрані «5 5 хостів». Стара вада, не з цього + етапу. + +## Етап 12. Друга сімка — 2026-08-27 + +> Міграції 0065–0068 плюс роботи без міграцій. Розбір спільного — у +> [HISTORY.md](HISTORY.md), розділ «Друга сімка». +> +> - **0065 трапи** — приймач 162/udp на зонді, словник із шести +> протокольних трапів плюс словник кабінету, джерело алертів `trap`. +> - **0066 ескалації** — драбина сходинок, стан у базі, зупинка при +> підтвердженні, заглушення відкладає сходинку, а не витрачає. +> - **0067 алерт про диск** — пороги за часом (21 доба / 4 доби), а не +> за відсотками; вільне місце міряється `statfs` по `Bavail`. +> - **0068 каталог профілів** — поля заливки переїхали з разової +> міграції в `catalog.json`. +> - **`plural`** — 69 місць виклику, 13 друкували число двічі; підпис +> змінено так, щоб помилка стала неможливою. +> - **Тести вебу** — з нуля до 137; знайшли 11 справжніх вад, +> усі виправлені. + +### Увімкнути трапи — рішення власника + +Код розгорнуто, модуль **не увімкнено**. Щоб запрацював, потрібні три +речі, і третя виходить за межі технічної: + +1. `traps` у `-modules` зонда; +2. `NET_BIND_SERVICE` — процес не root, а 162 привілейований; +3. **публікація 162/udp на хост** — порт без автентифікації приймає + будь-кого, хто знає адресу. + +Обмеження в модулі є (20 трапів/с з адреси, стеля черги, окремий облік +невідомих джерел), але вони зменшують шкоду, а не прибирають рішення. + +### Лишилось із цього етапу + +- **`db/profiles/build.py --check` не в CI.** Один рядок у кроці «Схема» + ловив би розходження каталогу зі згенерованим — саме те, що цього разу + знайшлось випадково. +- **CI без раннера.** `scripts/check.sh` робить те саме однією командою + вже сьогодні; сам workflow чекає на раннера. +- **Перетягування вузлів на мапі не покрите й не буде** — d3-drag не + запускається синтетичними подіями. Наслідок: вузол візуально стає на + місце, запит не йде, розкладка «сама відкочується» після + перезавантаження, і всі тести при цьому зелені. +- **Тригери шаблонів не можуть отримати драбину ескалації** — поля в + тригері шаблону немає, а драбина ще й тенант-специфічна. +- **SNMPv3-трапи не перевіряються** — розбираються й зберігаються, підпис + і шифрування не звіряються. + ## Порядок і чому саме такий 1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅ @@ -341,8 +439,6 @@ Diff* — усе з ТЗ. | Що | Де схема | Чого бракує | |---|---|---| | **Білінг і ліцензії** | `0009_billing_licensing.sql` | усього: тарифи, ліміти, Stripe, ключі. Для Micro-SaaS це те, через що продають | -| **Відкат конфігу** | `ncm.rollbacks` | API, UI і — головне — запис на пристрій. Двоетапне погодження в схемі є, виконавця немає | -| **Ескалації сповіщень** | `alr.escalation_policies` | «не підтвердили за 15 хв — буди наступного» | | **Web Push** | `alr.push_subscriptions` | підписки й доставка. Потрібне для PWA | | **Звіти SLA** | `core.sla_targets`, `core.sla_periods` | розрахунок доступності за період і вивантаження | | **Збережені подання** | `core.saved_views` | фільтри інвентарю, які можна назвати й повернутись | @@ -352,28 +448,21 @@ Diff* — усе з ТЗ. | Що | Стан | Чому не зроблено | |---|---|---| -| **`snmp.walk`** | тип чека є в `core.check_types`, модуля немає | таблиці з динамічним індексом: потрібне виявлення рядків і породження метрик на кожен. Найбільша з відсутніх можливостей опитування | | **Modbus-TCP** | плагін у сіді, `is_core = false` | немає жодного інвертора чи UPS під рукою. Неперевірений промисловий протокол у мережі з живим обладнанням — гірше, ніж його відсутність | | **NetFlow / sFlow** | плагін у сіді, `is_core = false` | окремий приймач потоків, за обсягом — власний етап | -| **Приймач SNMP-трапів** | `ts.snmp_traps` і `WriteLogs` готові | на зонді немає слухача 162/udp. Syslog зроблено, трапи — ні | Обидва плагіни позначені `is_core = false`, тож у переліку перевірок система показує їх недоступними — обіцянки користувачу немає. ### Зроблено наполовину -**Сповіщення за подіями не спрацьовують.** Движок правил рахує тільки -джерела `metric`, `icmp` і `interface`. Для `syslog`, `trap`, `ncm` і -`compliance` він свідомо повертає порожньо — їх треба обробляти подією в -момент надходження, а не опитуванням. Тобто правило з такими джерелами -можна створити, і воно ніколи не спрацює. Дані при цьому збираються: -журнал пишеться, знахідки відповідності рахуються — не вистачає ланки -між ними й алертом. - -**Кнопки в Telegram нікуди не ведуть.** Сповіщення йде з кнопками -«Підтвердити» й «Заглушити 1 год» (`callback_data` у `notify.go`), але -приймача натискань немає: сервер не має вебхука Telegram. Кнопки видно, -натиснути можна, нічого не станеться. +**Сповіщення за подіями — лишився `trap`.** `syslog`, `ncm` і +`compliance` зроблено подієво (0058): правило перевіряється в мить +надходження події. `trap` свідомо не реалізовано — без словника MIB +умова звелась би до порівняння сирих OID, тобто до другої мовчазної +обіцянки замість першої. Джерела `link` і `agent` не подієві за +природою. Правило з нереалізованим джерелом тепер не зберігається, а не +мовчить. **Підкладки-плани приміщень.** `topo.map_backgrounds` віддається в `GET /maps/{id}`, полотно їх не малює. Потрібен прийом і роздача файлів @@ -385,14 +474,20 @@ S3. Приблизно пів дня. явний режим редагування, інакше кожна спроба посунути карту рухатиме вузол. -**Прототипи шаблонів.** Шаблон описує сталий перелік метрик. Для -інтерфейсів і дисків потрібні прототипи: «на кожен знайдений рядок -таблиці — свій набір метрик». Без цього кожен порт заводиться руками. +**Інтерфейси досі захардкоджені.** Прототипи шаблонів зроблено (0059), +але `snmp.if` через них не виражається: зонд тримає попередній замір, +рахує швидкість за фактичним інтервалом і ловить перевертання +лічильника, а лічильники лягають у `ts.if_counters` за `interface_id`, а +не в `ts.samples` за міткою. На цьому `interface_id` тримаються анімація +трафіку на мапі, інспектор лінка й тригери з джерелом `interface`. +Виграш — мінус ~200 рядків Go; ризик — обірвані графіки на живих хостах. +Свідомо відкладено. ### Перевірки, яких немає -**У вебі немає тестового прогону взагалі** — ні vitest, ні jest. -Перевіряється типами й живим браузером. Найпомітніший наслідок: +**Тести вебу з'явились 2026-08-27** — vitest, 137 перевірок, і +`scripts/check.sh` проганяє обидва світи однією командою. Що покрито і, +головне, що НІ — у `web/TESTING.md`. Найбільша діра лишається та сама: перетягування вузла на мапі не покрите, бо d3-drag не запускається синтетичними подіями; серверний бік цієї дії тестами покритий. diff --git a/agent/cmd/netpulse-agent/main.go b/agent/cmd/netpulse-agent/main.go index 91b4e91..4bf3e67 100644 --- a/agent/cmd/netpulse-agent/main.go +++ b/agent/cmd/netpulse-agent/main.go @@ -20,11 +20,13 @@ import ( "github.com/netpulse/netpulse/agent/internal/config" "github.com/netpulse/netpulse/agent/internal/module" + "github.com/netpulse/netpulse/agent/internal/modules/filecfg" "github.com/netpulse/netpulse/agent/internal/modules/httpx" "github.com/netpulse/netpulse/agent/internal/modules/icmp" "github.com/netpulse/netpulse/agent/internal/modules/snmp" "github.com/netpulse/netpulse/agent/internal/modules/syslog" "github.com/netpulse/netpulse/agent/internal/modules/topology" + "github.com/netpulse/netpulse/agent/internal/modules/traps" "github.com/netpulse/netpulse/agent/internal/scheduler" "github.com/netpulse/netpulse/agent/internal/session" "github.com/netpulse/netpulse/agent/internal/telemetry" @@ -136,16 +138,36 @@ func run() error { if err := reg.Register(httpx.New()); err != nil { return err } + // Модуль конфіг-файлів реєструється завжди, а не за переліком + // -modules: він не виконує чеків і нічого не споживає, доки його не + // попросили зібрати. Реєстрація тут лише для того, щоб він + // потрапив у compiled_modules — оголошення «цей зонд уміє читати + // локальні файли». Чи буде щось прочитано, вирішує не це й не + // сервер, а локальний перелік файлів на самій машині. + fileCfg := filecfg.New(cfg.FileCfgPolicy) + if err := reg.Register(fileCfg); err != nil { + return err + } reg.EnsureDefaults(cfg.DefaultModules...) // Приймач syslog не модуль реєстру: у нього немає задач і розкладу, // він просто слухає порт. Вмикається тим самим переліком -modules, // щоб людині не треба було знати про цю різницю. - var syslogRecv *syslog.Receiver + // + // Приймач трапів — те саме: слухає 162/udp і теж не має ані задач, + // ані розкладу. Вмикається окремим модулем, а не разом із syslog: + // це другий порт, який має бути відкритий у фаєрволі клієнта, і + // відкривати його «заодно» ми не маємо права. + var ( + syslogRecv *syslog.Receiver + trapRecv *traps.Receiver + ) for _, m := range cfg.DefaultModules { - if m == "syslog" { + switch m { + case "syslog": syslogRecv = syslog.New(cfg.SyslogListen, log) - break + case "traps": + trapRecv = traps.New(cfg.TrapsListen, log) } } @@ -180,6 +202,8 @@ func run() error { MaxBackoff: cfg.MaxBackoff, DefaultModules: cfg.DefaultModules, Syslog: syslogRecv, + Traps: trapRecv, + FileCfg: fileCfg, }) sched := scheduler.New(scheduler.Config{ @@ -224,6 +248,21 @@ func run() error { } }() } + if trapRecv != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := trapRecv.Run(ctx); err != nil { + // Найчастіша причина саме тут: 162 нижче 1024, і без + // CAP_NET_BIND_SERVICE зонд його не займе. Помилка в + // журналі, а не мовчазна відсутність трапів, — бо + // відсутність трапів виглядає точнісінько як спокійна + // мережа. + log.Error("приймач трапів не запустився", + "адреса", cfg.TrapsListen, "err", err) + } + }() + } runErr := sess.Run(ctx) diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index 71adadf..80b05cf 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -15,6 +15,8 @@ import ( "strconv" "strings" "time" + + "github.com/netpulse/netpulse/agent/internal/localfiles" ) type Config struct { @@ -51,6 +53,10 @@ type Config struct { // фаєрвола можна скрізь. SyslogListen string + // Адреса приймача SNMP-трапів. Той самий випадок із + // CAP_NET_BIND_SERVICE, що й у syslog: 162 — привілейований порт. + TrapsListen string + // Одноразовий токен запрошення. Якщо заданий — зонд спершу // реєструється, зберігає посвідчення й лише потім працює. EnrollToken string @@ -58,6 +64,19 @@ type Config struct { EnrollName string // Де лежить посвідчення між запусками. IdentityPath string + + // Перелік конфіг-файлів, які вільно читати з ЦІЄЇ машини. + // + // Єдине налаштування зонда, яке принципово не може приходити з + // сервера. Зонд читає файли правами машини, і поле «які саме», + // кероване з кабінету, дало б будь-кому з правом ncm:write читання + // довільного файлу на всіх серверах клієнта одразу. Тому перелік + // лежить у файлі на самій машині — там, де його редагує той, хто + // вже має на ній права, — а сервер про його вміст не знає нічого. + // + // Прапорець задає лише ШЛЯХ до переліку, і сам по собі нічого не + // дозволяє: без файлу збір локальних конфігів не робиться взагалі. + FileCfgPolicy string } func envOr(key, def string) string { @@ -113,6 +132,9 @@ func Parse(args []string) (*Config, error) { fs.StringVar(&c.SyslogListen, "syslog-listen", envOr("NETPULSE_SYSLOG_LISTEN", ":514"), "адреса приймача syslog; діє лише з модулем syslog") + fs.StringVar(&c.TrapsListen, "traps-listen", envOr("NETPULSE_TRAPS_LISTEN", ":162"), + "адреса приймача SNMP-трапів; діє лише з модулем traps") + fs.StringVar(&c.EnrollToken, "enroll", envOr("NETPULSE_ENROLL", ""), "одноразовий токен запрошення з UI") fs.StringVar(&c.EnrollName, "name", envOr("NETPULSE_NAME", ""), @@ -120,6 +142,10 @@ func Parse(args []string) (*Config, error) { fs.StringVar(&c.IdentityPath, "identity", envOr("NETPULSE_IDENTITY", DefaultIdentityPath()), "файл посвідчення зонда") + fs.StringVar(&c.FileCfgPolicy, "files-policy", + envOr("NETPULSE_FILES_POLICY", localfiles.DefaultPolicyPath()), + "перелік конфіг-файлів цієї машини, дозволених до збору") + if err := fs.Parse(args); err != nil { return nil, err } diff --git a/agent/internal/localfiles/bundle.go b/agent/internal/localfiles/bundle.go new file mode 100644 index 0000000..5c688d7 --- /dev/null +++ b/agent/internal/localfiles/bundle.go @@ -0,0 +1,92 @@ +package localfiles + +import ( + "fmt" + "strings" +) + +// Склейка набору в один текст. +// +// # Чому один текст, а не окрема версія на кожен файл +// +// Питання, з яким приходять до архіву сервера, — «що на ньому змінилось +// із учора», а не «що змінилось у nginx.conf». Один набір відповідає на +// нього одним diff; десять окремих історій довелося б спершу зводити +// докупи очима. +// +// Практичніша половина причини — розкладка Git. Шлях у репозиторії +// виводиться з пари (ім'я хоста, config_type) у ДВОХ місцях: коли конфіг +// приймають (store.StoreConfig) і коли репозиторій відновлюють із бази +// (store.SyncGit). Версія на файл вимагала б переписати формулу шляху в +// обох і вигадати другу розкладку поряд із наявною. Набір лягає в неї як +// є: <хост>/files.cfg, гілка device/, — і дзеркало, ретеншен, +// перегляд і порівняння працюють без жодної правки. +// +// Ціна, яку платимо чесно: `git log -- etc/nginx/nginx.conf` тут не +// працює. Історія одного файлу читається як його ділянка в спільному +// diff — заголовки нижче для того й потрібні, щоб ділянку було видно. +// +// # Чому в тексті немає жодної мітки часу +// +// Зміну ловлять порівнянням хеша тіла. Дата збору, тривалість або час +// зміни файлу зробили б КОЖЕН збір відмінним від попереднього: щоночі +// новий коміт, і за місяць тридцять однакових записів, серед яких треба +// шукати одну справжню правку. Тому в набір іде лише те, що змінюється +// разом зі змістом: шлях, права, власник, розмір. + +// bundle складає набір у текст. +func bundle(machine string, files []File, skips []Skip) []byte { + var b strings.Builder + + b.WriteString("# NetPulse: конфіг-файли сервера\n") + // Машина — частина ВМІСТУ, а не метаданих. Якщо зонд переставлять + // на інший сервер, це буде видно другим рядком diff, а не через + // півроку по дивних змінах у конфігу. + b.WriteString("# машина: " + machine + "\n") + b.WriteString(fmt.Sprintf("# файлів: %d", len(files))) + if len(skips) > 0 { + b.WriteString(fmt.Sprintf(", пропущено: %d", len(skips))) + } + b.WriteString("\n") + + for _, f := range files { + b.WriteString("\n===== " + f.Path + " =====\n") + b.WriteString(fmt.Sprintf("# %v %s %d Б\n", f.Mode, f.Owner, f.Size)) + b.Write(f.Body) + // Рівно один перевід рядка в кінці: інакше файл без кінцевого + // «\n» злипався б із заголовком наступного, а файл із двома + // давав би зайвий порожній рядок у diff. + if !strings.HasSuffix(string(f.Body), "\n") { + b.WriteString("\n") + } + } + + if len(skips) > 0 { + // Пропуски — теж вміст: файл, який зник, має з'явитись у diff + // як зміна, а не тихо випасти з набору. + b.WriteString("\n===== пропущено =====\n") + for _, s := range skips { + b.WriteString("# " + s.Path + " — " + s.Reason + "\n") + } + } + + return []byte(b.String()) +} + +// report — протокол збору для транскрипту завдання. +// +// Окремо від набору саме тому, що набір мусить бути стабільним: сюди +// вільно писати все, що допомагає розібратись, не платячи за це зайвим +// комітом. +func report(machine string, files []File, skips []Skip) string { + var b strings.Builder + b.WriteString("машина: " + machine + "\n") + b.WriteString(fmt.Sprintf("зібрано файлів: %d, пропущено: %d\n\n", len(files), len(skips))) + for _, f := range files { + b.WriteString(fmt.Sprintf(" + %s (%d Б, %v, %s)\n", f.Path, f.Size, f.Mode, f.Owner)) + } + for _, s := range skips { + b.WriteString(fmt.Sprintf(" - %s: %s\n", s.Path, s.Reason)) + } + return b.String() +} diff --git a/agent/internal/localfiles/collect.go b/agent/internal/localfiles/collect.go new file mode 100644 index 0000000..f5116a7 --- /dev/null +++ b/agent/internal/localfiles/collect.go @@ -0,0 +1,282 @@ +package localfiles + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" +) + +// File — один зібраний файл. +type File struct { + Path string + Mode fs.FileMode + Owner string + Size int64 + Body []byte +} + +// Skip — файл, який зібрати не вдалося, і чому. +// +// Пропуск — це результат, а не збій: файл міг зникнути разом із +// демоном, який його ставив, і це саме те, що людина має побачити. +// Обірвати через нього все завдання означало б втратити ще дев'ять +// файлів, які цілком читаються. +type Skip struct { + Path string + Reason string +} + +// Result — те, що піде в архів. +type Result struct { + Body []byte + SHA256 []byte + LineCount int + Files []File + Skips []Skip + Duration time.Duration + // Report — людський протокол збору: що взято, що пропущено й чому. + // Їде транскриптом завдання, бо потрібен рівно тоді, коли щось + // пішло не так. + Report string +} + +// Limits — стелі, з якими виконується збір. +type Limits struct { + MaxFileBytes int64 + MaxFiles int + MaxTotalBytes int64 +} + +// Narrow звужує стелі політики серверними, ніколи не розширюючи. +// +// Напрямок односторонній навмисно: сервер може попросити взяти менше +// (наприклад, на вузькому каналі), але не може випросити більше, ніж +// дозволила машина. Нуль із сервера означає «не звужую». +func (l Limits) Narrow(o Limits) Limits { + if o.MaxFileBytes > 0 && o.MaxFileBytes < l.MaxFileBytes { + l.MaxFileBytes = o.MaxFileBytes + } + if o.MaxFiles > 0 && o.MaxFiles < l.MaxFiles { + l.MaxFiles = o.MaxFiles + } + if o.MaxTotalBytes > 0 && o.MaxTotalBytes < l.MaxTotalBytes { + l.MaxTotalBytes = o.MaxTotalBytes + } + return l +} + +// Collect читає дозволені файли машини й складає з них один набір. +func Collect(ctx context.Context, p *Policy, srv Limits, machine string) (Result, error) { + start := time.Now() + lim := Limits{ + MaxFileBytes: p.MaxFileBytes, + MaxFiles: p.MaxFiles, + MaxTotalBytes: p.MaxTotalBytes, + }.Narrow(srv) + + var ( + files []File + skips []Skip + total int64 + ) + + for _, path := range expand(p) { + if err := ctx.Err(); err != nil { + return Result{}, err + } + if len(files) >= lim.MaxFiles { + skips = append(skips, Skip{path, fmt.Sprintf("перевищено ліміт у %d файлів", lim.MaxFiles)}) + continue + } + f, reason := read(p, path, lim.MaxFileBytes) + if reason != "" { + skips = append(skips, Skip{path, reason}) + continue + } + if total+f.Size > lim.MaxTotalBytes { + skips = append(skips, Skip{path, fmt.Sprintf("набір перевищив би %d Б", lim.MaxTotalBytes)}) + continue + } + total += f.Size + files = append(files, *f) + } + + // Порожній набір у сховище не йде. + // + // Той самий висновок, що й у ncmx.looksLikeConfig: зібране, що не + // схоже на конфіг, не має ставати версією конфігу. Нуль прочитаних + // файлів означає, що політика вказує в порожнечу або все зникло; + // покласти це в архів — значить записати «конфіг сервера порожній» + // і показати цю версію в diff як видалення всього. + if len(files) == 0 { + reason := "перелік дозволених файлів нічого не знайшов" + if len(skips) > 0 { + reason = fmt.Sprintf("усі %d шляхів пропущено; перший: %s — %s", + len(skips), skips[0].Path, skips[0].Reason) + } + return Result{Duration: time.Since(start), Skips: skips, Report: report(machine, nil, skips)}, + fmt.Errorf("жодного конфіг-файлу не зібрано: %s", reason) + } + + body := bundle(machine, files, skips) + sum := sha256.Sum256(body) + + return Result{ + Body: body, + SHA256: sum[:], + LineCount: strings.Count(string(body), "\n") + 1, + Files: files, + Skips: skips, + Duration: time.Since(start), + Report: report(machine, files, skips), + }, nil +} + +// expand розкриває маски політики в конкретні шляхи. +// +// filepath.Glob навмисно: його «*» не переходить через розділювач, тож +// «/etc/nginx/*.conf» не може дотягнутись до /etc/nginx/ssl/server.key. +// Рекурсивних масок немає взагалі — рівні пишуться явно. +func expand(p *Policy) []string { + seen := map[string]bool{} + var out []string + for _, pat := range p.Allow { + matches, err := filepath.Glob(pat) + if err != nil { + continue + } + if len(matches) == 0 && !strings.ContainsAny(pat, "*?[") { + // Файл, названий буквально й відсутній, має дати чесний + // пропуск із причиною, а не тихо зникнути з набору: саме + // зникнення конфігу найцікавіше в цій задачі. + matches = []string{pat} + } + for _, m := range matches { + if !seen[m] { + seen[m] = true + out = append(out, m) + } + } + } + // Сталий порядок: інакше той самий набір давав би різний вміст і + // кожен збір виглядав би зміною. + sort.Strings(out) + return out +} + +// read перевіряє один файл і читає його. +// +// Порядок перевірок — від найдешевших і найважливіших до змістовних. +// Перевіряється і початковий шлях, і той, у який він розкрився: інакше +// посилання з дозволеного каталогу читало б що завгодно. +func read(p *Policy, path string, maxBytes int64) (*File, string) { + if reason := Denied(path); reason != "" { + return nil, reason + } + + real, err := filepath.EvalSymlinks(path) + if err != nil { + if os.IsNotExist(err) { + return nil, "файл зник" + } + return nil, "не вдалося розкрити шлях: " + err.Error() + } + if normPath(real) != normPath(path) { + if reason := Denied(real); reason != "" { + return nil, "посилання веде на " + real + ": " + reason + } + if !p.Allows(real) { + // Найтонше місце всієї перевірки. /etc/resolv.conf на + // systemd — посилання в /run; дозволити перехід «бо початок + // шляху дозволений» означало б дозволити й посилання на + // /etc/shadow, покладене поруч. Тому ціль мусить бути + // дозволена сама по собі, а відмова називає її явно, щоб + // адміністратор міг дописати рядок свідомо. + return nil, "посилання веде за межі дозволеного: " + real + } + } + + st, err := os.Stat(real) + if err != nil { + if os.IsNotExist(err) { + return nil, "файл зник" + } + return nil, err.Error() + } + if st.IsDir() { + return nil, "це каталог" + } + if !st.Mode().IsRegular() { + return nil, "це не звичайний файл (" + st.Mode().String() + ")" + } + if !p.AllowNonPublic && st.Mode().Perm()&0o004 == 0 { + // Права 0640 має /etc/shadow, 0600 — приватні ключі, 0644 — + // nginx.conf, interfaces і main.cf. Одне правило відрізає рівно + // перший клас, не зачепивши другого. + return nil, fmt.Sprintf("файл закритий для читання всіма (%v) — "+ + "конфігом такий не вважаємо; дозволяється директивою !allow-nonpublic", st.Mode().Perm()) + } + if st.Size() > maxBytes { + return nil, fmt.Sprintf("завеликий: %d Б при межі %d Б", st.Size(), maxBytes) + } + + f, err := os.Open(real) + if err != nil { + return nil, err.Error() + } + defer f.Close() + + // Читаємо через LimitReader попри перевірку розміру: між Stat і + // Open файл могли переписати, і саме на це розраховує той, хто + // хотів би згодувати зонду гігабайт. + body, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) + if err != nil { + return nil, err.Error() + } + if int64(len(body)) > maxBytes { + return nil, fmt.Sprintf("завеликий: понад %d Б", maxBytes) + } + + if reason := notText(body); reason != "" { + return nil, reason + } + + return &File{ + Path: filepath.ToSlash(path), + Mode: st.Mode().Perm(), + Owner: owner(st), + Size: int64(len(body)), + Body: body, + }, "" +} + +// notText — та сама думка, що й у ncmx.looksLikeConfig, перекладена на +// файли. +// +// Порогу «щонайменше п'ять рядків» звідти тут НЕМАЄ, і це свідомо: +// /etc/resolv.conf має два рядки й лишається конфігом. Що переноситься — +// це головне: зібране, що не є текстом, не має ставати версією конфігу. +// Двійковий файл, покладений у Git, робить історію нечитабельною, а diff +// безглуздим, і помічають це через півроку. +func notText(body []byte) string { + if len(body) == 0 { + return "файл порожній" + } + for _, b := range body { + if b == 0 { + return "двійковий файл (нульовий байт)" + } + } + if !utf8.Valid(body) { + return "не текст у UTF-8" + } + return "" +} diff --git a/agent/internal/localfiles/deny.go b/agent/internal/localfiles/deny.go new file mode 100644 index 0000000..58a928c --- /dev/null +++ b/agent/internal/localfiles/deny.go @@ -0,0 +1,116 @@ +package localfiles + +import ( + "path/filepath" + "strings" +) + +// Чорний перелік — другий рубіж, а не перший. +// +// Першим і головним лишається те, що сервер узагалі не називає шляхів: +// читається тільки те, що дозволив адміністратор машини. Але дозволити +// зайве можна й помилково — «/etc/ssh/*» пишеться так само легко, як +// «/etc/ssh/sshd_config», — а ціна такої описки надто висока, щоб +// покладатись на уважність. +// +// Тому цей перелік вкомпільований, перевіряється ПІСЛЯ дозволу й +// перекриває його завжди. Вимкнути його не можна ні з сервера, ні +// директивою в локальному файлі: усе, що сюди потрапило, не є конфігом +// ні за яких обставин, а є або секретом, або не файлом узагалі. +// +// Звіряється і початковий шлях, і той, у який він розкрився після +// символьних посилань. + +// denyDirs — каталоги, з яких не читається нічого. +var denyDirs = []string{ + // Не файли: віртуальні файлові системи ядра. Читання /proc/self/mem + // або /dev/mem — це не збір конфігу, а знімок чужої пам'яті. + "/proc/", "/sys/", "/dev/", + // Секрети за домовленістю: сюди їх кладуть Docker і Kubernetes. + "/run/secrets/", + // Приватні частини PKI. + "/etc/ssl/private/", "/etc/pki/tls/private/", + // Власне господарство зонда: там лежить його токен до сервера. + // Дозволити зонду вивантажити на сервер власне посвідчення означало + // б зробити з нього інструмент захоплення сусідніх зондів. + "/etc/netpulse/", "/var/lib/netpulse/", + "c:/programdata/netpulse/", +} + +// denyExact — окремі файли, які ніколи не конфіг. +var denyExact = []string{ + "/etc/shadow", "/etc/shadow-", "/etc/gshadow", "/etc/gshadow-", + "/etc/security/opasswd", "/etc/krb5.keytab", +} + +// denySegments — будь-який рівень шляху з таким іменем. +var denySegments = []string{ + ".ssh", // ключі доступу, свої й чужі + ".gnupg", // + ".git", // історія разом з усім, що в неї колись поклали + "private", // /etc/*/private/ у кількох дистрибутивів + "passwords", // +} + +// denyNames — маски імені файлу на будь-якому рівні. +// +// Ключі й «файли оточення» — найдорожче, що взагалі буває в /etc, і +// саме вони найчастіше лежать поруч зі справжніми конфігами: nginx.conf +// і ssl.key в одному каталозі — звичайна розкладка. +var denyNames = []string{ + "id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*", + "*.key", "*.pem", "*.pfx", "*.p12", "*.jks", "*.keystore", "*.kdbx", + ".env", ".env.*", "*.env", + ".netrc", ".pgpass", ".my.cnf", "*.htpasswd", + "*_key", "*-key", "*.secret", "*secrets.y*ml", + // Файли паролів за ІМЕНЕМ, а не лише за повним шляхом. + // + // denyExact вище звіряє «/etc/shadow» буквально й промахується, щойно + // корінь інший: контейнер із примонтованим /hostetc, chroot, образ, + // розпакований у /srv/backup. Живою перевіркою на стенді саме це й + // знайшлось: «/hostetc/shadow» повного збігу не дав і тримався лише + // на правилі про права — тобто впав би від однієї директиви + // !allow-nonpublic. + "shadow", "shadow-", "gshadow", "gshadow-", +} + +// Denied повертає причину відмови або порожній рядок. +func Denied(path string) string { + np := normPath(path) + + for _, d := range denyDirs { + if strings.HasPrefix(np, d) { + return "каталог " + strings.TrimSuffix(d, "/") + " закритий назавжди" + } + } + for _, f := range denyExact { + if np == f { + return "файл із паролями" + } + } + + segs := splitSegments(np) + for i, s := range segs { + // Останній сегмент — ім'я файлу; його перевіряють маски нижче, + // а як «каталог» він рахуватись не повинен: файл із іменем + // «private» — це просто файл. + if i == len(segs)-1 { + break + } + for _, d := range denySegments { + if s == d { + return "шлях проходить через «" + d + "»" + } + } + } + + if len(segs) > 0 { + name := segs[len(segs)-1] + for _, pat := range denyNames { + if ok, err := filepath.Match(pat, name); err == nil && ok { + return "ім'я підпадає під заборонену маску «" + pat + "»" + } + } + } + return "" +} diff --git a/agent/internal/localfiles/localfiles_test.go b/agent/internal/localfiles/localfiles_test.go new file mode 100644 index 0000000..64b014a --- /dev/null +++ b/agent/internal/localfiles/localfiles_test.go @@ -0,0 +1,254 @@ +package localfiles + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestParsePolicyDirectives(t *testing.T) { + p, err := ParsePolicy(strings.NewReader(` +# перелік +/etc/nginx/nginx.conf +/etc/nginx/conf.d/*.conf + +!max-files 3 +!max-file-bytes 1024 +!allow-nonpublic +`)) + if err != nil { + t.Fatal(err) + } + if len(p.Allow) != 2 { + t.Fatalf("очікували 2 зразки, маємо %d", len(p.Allow)) + } + if p.MaxFiles != 3 || p.MaxFileBytes != 1024 || !p.AllowNonPublic { + t.Fatalf("директиви не застосувались: %+v", p) + } +} + +// Головна перевірка політики: широкий зразок не має проходити взагалі. +// +// «/etc/*» пишеться так само легко, як «/etc/nginx/nginx.conf», а +// коштує на кілька порядків більше — саме на такій описці й з'являється +// віддалене читання всього /etc. +func TestParsePolicyRejectsBroad(t *testing.T) { + for _, bad := range []string{"/etc/*", "/*", "/*/*.conf", "etc/nginx.conf", "/etc/../etc/shadow"} { + if _, err := ParsePolicy(strings.NewReader(bad)); err == nil { + t.Errorf("зразок %q мав бути відхилений", bad) + } + } + for _, good := range []string{"/etc/nginx/nginx.conf", "/etc/nginx/conf.d/*.conf", "/etc/hosts"} { + if _, err := ParsePolicy(strings.NewReader(good)); err != nil { + t.Errorf("зразок %q мав пройти: %v", good, err) + } + } +} + +// Чорний перелік перекриває дозвіл завжди. +func TestDenied(t *testing.T) { + deny := []string{ + "/etc/shadow", + "/etc/gshadow", + // Не лише за повним шляхом: у контейнері з примонтованим /etc + // корінь інший, а файл той самий. + "/hostetc/shadow", + "/srv/backup/rootfs/etc/shadow", + "/proc/self/mem", + "/dev/mem", + "/home/ops/.ssh/authorized_keys", + "/etc/ssh/ssh_host_ed25519_key", + "/etc/ssl/private/site.pem", + "/srv/app/.env", + "/var/lib/netpulse/identity.json", + "/run/secrets/db_password", + } + for _, p := range deny { + if Denied(p) == "" { + t.Errorf("%s мав бути заборонений", p) + } + } + allow := []string{ + "/etc/nginx/nginx.conf", + "/etc/network/interfaces", + "/etc/postfix/main.cf", + "/etc/hosts", + "/etc/ssh/sshd_config", + } + for _, p := range allow { + if r := Denied(p); r != "" { + t.Errorf("%s заборонений даремно: %s", p, r) + } + } +} + +func TestLimitsNarrowOnly(t *testing.T) { + base := Limits{MaxFileBytes: 100, MaxFiles: 10, MaxTotalBytes: 1000} + + got := base.Narrow(Limits{MaxFileBytes: 50, MaxFiles: 0, MaxTotalBytes: 9999}) + if got.MaxFileBytes != 50 { + t.Errorf("звуження не спрацювало: %d", got.MaxFileBytes) + } + if got.MaxFiles != 10 { + t.Errorf("нуль мав означати «не звужую», маємо %d", got.MaxFiles) + } + if got.MaxTotalBytes != 1000 { + t.Errorf("сервер розширив стелю до %d — цього не мало статись", got.MaxTotalBytes) + } +} + +func TestNotText(t *testing.T) { + if notText([]byte("server {\n}\n")) != "" { + t.Error("текст визнано не текстом") + } + if notText(nil) == "" { + t.Error("порожній файл мав бути відхилений") + } + if notText([]byte{'a', 0, 'b'}) == "" { + t.Error("двійковий файл мав бути відхилений") + } + if notText([]byte{0xff, 0xfe, 0xfd}) == "" { + t.Error("не-UTF8 мав бути відхилений") + } +} + +// Збір цілком: справжні файли на диску, справжні права, справжні +// посилання. Саме тут ловиться те, чого не видно в юніт-перевірках. +func TestCollect(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("права доступу перевіряються лише на unix") + } + // Каталог розкриваємо одразу: на деяких системах /tmp сам є + // посиланням, і тоді перевірка «ціль посилання теж дозволена» + // відхилила б геть усе — не через помилку в коді, а через оточення. + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + etc := filepath.Join(dir, "etc", "app") + if err := os.MkdirAll(etc, 0o755); err != nil { + t.Fatal(err) + } + + write := func(name, body string, mode os.FileMode) string { + p := filepath.Join(etc, name) + if err := os.WriteFile(p, []byte(body), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatal(err) + } + return p + } + + write("main.conf", "listen 80;\n", 0o644) + write("extra.conf", "gzip on;\n", 0o644) + // Закритий для «інших» — має бути пропущений, як /etc/shadow. + write("private.conf", "password = hunter2\n", 0o600) + // Двійковий — теж пропуск. + write("blob.conf", "a\x00b\n", 0o644) + + pol := &Policy{ + Allow: []string{filepath.Join(etc, "*.conf")}, + MaxFileBytes: 1 << 20, + MaxFiles: 10, + MaxTotalBytes: 1 << 20, + } + + res, err := Collect(context.Background(), pol, Limits{}, "test-host") + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 2 { + t.Fatalf("очікували 2 файли, зібрано %d (%s)", len(res.Files), res.Report) + } + if len(res.Skips) != 2 { + t.Fatalf("очікували 2 пропуски, маємо %d (%s)", len(res.Skips), res.Report) + } + body := string(res.Body) + if strings.Contains(body, "hunter2") { + t.Fatal("файл, закритий для читання всіма, потрапив у набір") + } + if !strings.Contains(body, "listen 80;") || !strings.Contains(body, "test-host") { + t.Fatalf("у наборі бракує вмісту або відбитка машини:\n%s", body) + } + + // Другий збір без змін на диску мусить дати той самий байт-у-байт + // набір: інакше кожен нічний бекап створював би коміт «змін немає». + again, err := Collect(context.Background(), pol, Limits{}, "test-host") + if err != nil { + t.Fatal(err) + } + if string(again.Body) != body { + t.Fatal("повторний збір дав інший вміст — кожен бекап виглядав би зміною") + } +} + +// Посилання за межі дозволеного не читається. +// +// Найтонше місце всієї перевірки: без нього рядок «/etc/app/*.conf» у +// політиці означав би «будь-що, на що з цього каталогу вкаже посилання». +func TestCollectRefusesSymlinkEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("символьні посилання на windows потребують окремих прав") + } + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + etc := filepath.Join(dir, "etc") + secret := filepath.Join(dir, "secret") + for _, d := range []string{etc, secret} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + // Ім'я цілі навмисно «нешкідливе»: назви на кшталт shadow ловить + // чорний перелік, і перевірка мовчки перестала б перевіряти саме те, + // заради чого написана, — заборону виходу за межі дозволеного. + if err := os.WriteFile(filepath.Join(secret, "db.conf"), []byte("password=1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(secret, "db.conf"), filepath.Join(etc, "sneaky.conf")); err != nil { + t.Fatal(err) + } + + pol := &Policy{ + Allow: []string{filepath.Join(etc, "*.conf")}, + MaxFileBytes: 1 << 20, + MaxFiles: 10, + MaxTotalBytes: 1 << 20, + } + res, cerr := Collect(context.Background(), pol, Limits{}, "test-host") + if cerr == nil && len(res.Files) > 0 { + t.Fatal("посилання за межі дозволеного прочиталось") + } + if len(res.Skips) != 1 || !strings.Contains(res.Skips[0].Reason, "за межі") { + t.Fatalf("очікували пропуск із поясненням, маємо %+v", res.Skips) + } +} + +// Порожній набір у сховище не йде — та сама думка, що й у +// ncmx.looksLikeConfig: зібране, що не схоже на конфіг, не зберігається. +func TestCollectRefusesEmptySet(t *testing.T) { + dir := t.TempDir() + pol := &Policy{ + Allow: []string{filepath.Join(dir, "etc", "nothing.conf")}, + MaxFileBytes: 1 << 20, + MaxFiles: 10, + MaxTotalBytes: 1 << 20, + } + if _, err := Collect(context.Background(), pol, Limits{}, "h"); err == nil { + t.Fatal("порожній набір мав дати відмову") + } +} + +func TestLoadPolicyMissingFile(t *testing.T) { + _, err := LoadPolicy(filepath.Join(t.TempDir(), "files.conf")) + if err == nil || !strings.Contains(err.Error(), "не дозволено") { + t.Fatalf("відсутній перелік мав дати ErrNoPolicy, маємо %v", err) + } +} diff --git a/agent/internal/localfiles/machine.go b/agent/internal/localfiles/machine.go new file mode 100644 index 0000000..edb6f89 --- /dev/null +++ b/agent/internal/localfiles/machine.go @@ -0,0 +1,66 @@ +package localfiles + +import ( + "os" + "strings" +) + +// Machine — відбиток машини, з якої знято набір. +// +// # Навіщо взагалі +// +// inv.devices.agent_id досі означав «який зонд ОПИТУЄ цей хост» — між +// зондом і хостом лежала мережа, і переставити зонд означало лише +// змінити маршрут до незмінного пристрою. Тут інакше: хост — це і є +// машина зонда, і перенесення зонда змінює не маршрут, а сам предмет +// збору. +// +// А посвідчення зонда — звичайний файл. Його копіюють разом з образом +// контейнера, переносять при міграції, відновлюють із бекапу на іншому +// залізі. У всіх цих випадках agent_id лишається тим самим, і без +// відбитка історія сервера А мовчки продовжилась би файлами сервера Б у +// тій самій гілці Git. Це найгірший різновид помилки в архіві: він +// виглядає справним рівно до того дня, коли з нього треба відновитись. +// +// # З чого складається +// +// Ім'я хоста плюс machine-id. Кожна половина сама по собі ненадійна: +// ім'я хоста міняють, machine-id на Linux іноді копіюють разом з +// образом. Разом вони дають достатньо: щоб збіглись обидві, це має бути +// та сама машина або її точна копія — а точна копія в ролі «того самого +// сервера» і є те, чого архів не мусить розрізняти. +// +// Відбиток їде В ТІЛІ набору (перші рядки) І в заголовку вивантаження. +// Перше робить переїзд видимим у diff, друге дозволяє серверу відмовити +// в прийомі до того, як чужі файли ляжуть у чужу історію. +func Machine() string { + host, err := os.Hostname() + if err != nil || strings.TrimSpace(host) == "" { + host = "?" + } + id := machineID() + if id == "" { + return host + } + return host + " · " + id +} + +// machineID шукає стабільний ідентифікатор машини. +// +// Windows тут свідомо не покритий: його MachineGuid лежить у реєстрі, а +// читання реєстру тягне golang.org/x/sys/windows у бінарник, який має +// лишатись єдиним і маленьким. Наслідок чесно названий: під Windows +// відбиток складається з самого лише імені хоста, тобто переїзд зонда +// на машину з таким самим іменем пройде непоміченим. +func machineID() string { + for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} { + b, err := os.ReadFile(p) + if err != nil { + continue + } + if s := strings.TrimSpace(string(b)); s != "" { + return s + } + } + return "" +} diff --git a/agent/internal/localfiles/owner_unix.go b/agent/internal/localfiles/owner_unix.go new file mode 100644 index 0000000..4c0ac31 --- /dev/null +++ b/agent/internal/localfiles/owner_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package localfiles + +import ( + "io/fs" + "strconv" + "syscall" +) + +// owner — власник файлу числами, без os/user. +// +// Числа, а не імена: розкладка імен читається з /etc/passwd, а це ще +// один файл, який довелося б відкривати з тими самими питаннями про +// права. Для «що змінилось» пари чисел досить: зміна власника конфігу +// видно як зміну рядка в diff, а хто такий 33 — знає той, хто цей +// сервер адмініструє. +func owner(fi fs.FileInfo) string { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return "?" + } + return strconv.FormatUint(uint64(st.Uid), 10) + ":" + strconv.FormatUint(uint64(st.Gid), 10) +} diff --git a/agent/internal/localfiles/owner_windows.go b/agent/internal/localfiles/owner_windows.go new file mode 100644 index 0000000..6bb7c7e --- /dev/null +++ b/agent/internal/localfiles/owner_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package localfiles + +import "io/fs" + +// owner на Windows не визначається. +// +// Там власник — це SID у списку контролю доступу, а не пара чисел у +// inode; дістати його без залежності від golang.org/x/sys/windows не +// вийде, а прикидатись, що визначили, гірше, ніж чесно не визначати. +// Це одна з причин, чому збір під Windows у цій версії неповний +// (див. коментар у machine.go). +func owner(fi fs.FileInfo) string { return "-" } diff --git a/agent/internal/localfiles/policy.go b/agent/internal/localfiles/policy.go new file mode 100644 index 0000000..07a454a --- /dev/null +++ b/agent/internal/localfiles/policy.go @@ -0,0 +1,260 @@ +// Package localfiles — збір конфіг-файлів САМОЇ машини, на якій працює +// зонд, у той самий архів версій, що й конфіги комутаторів. +// +// # Чому перелік файлів живе тут, а не на сервері +// +// Зонд стоїть на сервері клієнта й читає файли його правами — зазвичай +// root. Якби перелік «що прочитати» приходив із сервера, будь-хто з +// правом ncm:write отримав би віддалене читання довільного файлу на +// КОЖНІЙ машині, де стоїть зонд: /etc/shadow, приватні ключі, .env із +// паролем бази — з усіх серверів одним запитом. Це не гіпотетична +// загроза, а типова й найдорожча помилка систем цього класу. +// +// Тому в протоколі (LocalFilesJob) немає й не буде поля зі шляхом. +// Перелік лежить у текстовому файлі на самій машині; редагувати його +// може лише той, хто вже має на ній права. Сервер може попросити +// зібрати — і не може сказати, що саме. +// +// Конфіг-файл тут з'являється попри те, що агент його свідомо не має +// (див. config.Config): саме в цьому й сенс. Налаштування, яке мусить +// бути недосяжним для сервера, не можна передати ані прапорцем із +// unit-файла (він теж локальний, але туди не влізе тридцять шляхів), +// ані тим паче з кабінету. Формат навмисно рядковий, без YAML: жодної +// нової залежності й жодного зайвого кілобайта в бінарнику. +package localfiles + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +// Типові стелі. Свідомо тісні: конфіг-файл сервера — це кілобайти, а не +// мегабайти, і те, що в них не вміщується, майже напевно не конфіг. +const ( + DefaultMaxFileBytes = 1 << 20 // 1 МіБ на файл + DefaultMaxFiles = 64 + DefaultMaxTotalBytes = 8 << 20 // 8 МіБ на весь набір +) + +// Policy — рішення машини про те, що з неї вільно читати. +type Policy struct { + // Path — звідки прочитано; порожній, якщо політику склали в пам'яті. + Path string + + // Allow — зразки дозволених шляхів у порядку з файлу. + Allow []string + + MaxFileBytes int64 + MaxFiles int + MaxTotalBytes int64 + + // AllowNonPublic знімає вимогу «файл читається всіма». + // + // Вимкнено типово, і це один із найдешевших запобіжників у всій + // задачі: /etc/shadow має права 0640, приватні ключі — 0600, а + // nginx.conf, interfaces і main.cf — 0644. Тобто одне правило + // відрізає рівно той клас файлів, заради якого цю задачу й + // перевіряють, не зачепивши жодного справжнього конфігу. + // + // Вмикається лише директивою в локальному файлі: це рішення + // адміністратора САМОЇ машини, і з кабінету його не видно й не + // змінити. + AllowNonPublic bool +} + +// ErrNoPolicy — політики немає, збирати нічого. +// +// Окрема помилка, бо це найчастіший і цілком штатний стан: зонд +// поставили заради пінгів, а конфіг-файли з нього ніхто не просив. +// Відмова має звучати як інструкція, а не як поломка. +var ErrNoPolicy = errors.New("на цій машині не дозволено збір конфіг-файлів") + +// DefaultPolicyPath — де зонд шукає перелік. +func DefaultPolicyPath() string { + if runtime.GOOS == "windows" { + dir := os.Getenv("ProgramData") + if dir == "" { + dir = `C:\ProgramData` + } + return filepath.Join(dir, "NetPulse", "files.conf") + } + return "/etc/netpulse/files.conf" +} + +// LoadPolicy читає перелік дозволених файлів. +// +// Відсутній файл — це ErrNoPolicy, а не помилка читання: типовий зонд +// його не має, і сипати в журнал «немає файлу» на кожному завданні +// означало б навчити на цей журнал не дивитись. +func LoadPolicy(path string) (*Policy, error) { + if strings.TrimSpace(path) == "" { + return nil, ErrNoPolicy + } + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("%w: немає %s", ErrNoPolicy, path) + } + if err != nil { + return nil, err + } + defer f.Close() + + p, err := ParsePolicy(f) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + p.Path = path + if len(p.Allow) == 0 { + return nil, fmt.Errorf("%w: у %s немає жодного дозволеного шляху", ErrNoPolicy, path) + } + return p, nil +} + +// ParsePolicy розбирає перелік. +// +// Формат: один зразок шляху на рядок, «#» — коментар, рядок із «!» — +// директива. Порожній результат означає «нічого не дозволено»: типове +// значення тут — заборона, і жодного «якщо не задано, беремо все» +// не існує навмисно. +func ParsePolicy(r io.Reader) (*Policy, error) { + p := &Policy{ + MaxFileBytes: DefaultMaxFileBytes, + MaxFiles: DefaultMaxFiles, + MaxTotalBytes: DefaultMaxTotalBytes, + } + + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 4096), 1<<20) + line := 0 + for sc.Scan() { + line++ + s := strings.TrimSpace(sc.Text()) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + if strings.HasPrefix(s, "!") { + if err := p.directive(strings.TrimPrefix(s, "!")); err != nil { + return nil, fmt.Errorf("рядок %d: %w", line, err) + } + continue + } + if err := validPattern(s); err != nil { + return nil, fmt.Errorf("рядок %d: %w", line, err) + } + p.Allow = append(p.Allow, s) + } + if err := sc.Err(); err != nil { + return nil, err + } + return p, nil +} + +func (p *Policy) directive(s string) error { + name, arg, _ := strings.Cut(s, " ") + arg = strings.TrimSpace(arg) + + switch name { + case "allow-nonpublic": + p.AllowNonPublic = true + return nil + case "max-file-bytes", "max-total-bytes", "max-files": + n, err := strconv.ParseInt(arg, 10, 64) + if err != nil || n <= 0 { + return fmt.Errorf("директива %q хоче додатне число, а не %q", name, arg) + } + switch name { + case "max-file-bytes": + p.MaxFileBytes = n + case "max-total-bytes": + p.MaxTotalBytes = n + case "max-files": + p.MaxFiles = int(n) + } + return nil + default: + return fmt.Errorf("невідома директива %q", name) + } +} + +// validPattern відсіює зразки, які дозволяють забагато. +// +// Три вимоги, і кожна закриває свій спосіб випадково відкрити всю +// файлову систему рядком, що виглядає нешкідливо. +func validPattern(pat string) error { + if !filepath.IsAbs(pat) && !strings.HasPrefix(pat, "/") { + return fmt.Errorf("шлях %q не абсолютний", pat) + } + if strings.Contains(pat, "..") { + return fmt.Errorf("шлях %q містить «..»", pat) + } + + segs := splitSegments(pat) + if len(segs) == 0 { + return fmt.Errorf("порожній шлях") + } + // Зразок мусить назвати щонайменше два рівні буквально: «/*/*.conf» + // і «/etc/*» відкривають надто багато, щоб це можна було написати + // випадково й не помітити. + fixed := 0 + for _, s := range segs { + if strings.ContainsAny(s, "*?[") { + break + } + fixed++ + } + if fixed == 0 { + return fmt.Errorf("шлях %q починається з маски", pat) + } + if fixed < 2 && len(segs) > 1 { + return fmt.Errorf("шлях %q надто широкий: перші два рівні мають бути без масок", pat) + } + if _, err := filepath.Match(pat, pat); err != nil { + return fmt.Errorf("некоректна маска %q: %w", pat, err) + } + return nil +} + +// Allows каже, чи потрапляє готовий шлях під дозволені зразки. +// +// Потрібне окремо від розкриття масок: після переходу за символьним +// посиланням ми маємо ІНШИЙ шлях, і його треба звірити з переліком ще +// раз — інакше посилання з дозволеного каталогу читало б будь-що. +func (p *Policy) Allows(path string) bool { + np := normPath(path) + for _, pat := range p.Allow { + if ok, err := filepath.Match(normPath(pat), np); err == nil && ok { + return true + } + } + return false +} + +// normPath зводить шлях до однієї форми для порівняння. +// +// Windows: розділювачі й регістр. Без цього C:\ProgramData\... і +// c:/programdata/... були б різними шляхами, і чорний перелік обходився +// б зміною регістру. +func normPath(p string) string { + p = strings.ReplaceAll(p, `\`, "/") + if runtime.GOOS == "windows" { + p = strings.ToLower(p) + } + return p +} + +func splitSegments(p string) []string { + var out []string + for _, s := range strings.Split(normPath(p), "/") { + if s != "" { + out = append(out, s) + } + } + return out +} diff --git a/agent/internal/modules/filecfg/filecfg.go b/agent/internal/modules/filecfg/filecfg.go new file mode 100644 index 0000000..be8499f --- /dev/null +++ b/agent/internal/modules/filecfg/filecfg.go @@ -0,0 +1,76 @@ +// Package filecfg — модуль зонда «конфіг-файли власної машини». +// +// Модуль без жодного типу чека, і це не недогляд. Реєстр модулів +// вирішує два різні питання: «кому віддати задачу такого типу» +// (CheckTypes) і «що взагалі вміє цей бінарник» (Compiled). Тут потрібне +// лише друге: збір конфіг-файлів їде не розкладом чеків, а завданням +// ConfigJob — тим самим шляхом, що й бекап комутатора. +// +// Реєстрація потрібна заради одного рядка в Hello.build.compiled_modules: +// «цей зонд уміє читати локальні файли». Сервер сьогодні цього поля не +// читає взагалі, тож зараз воно нікуди не впливає — але саме звідти +// кабінет колись дізнаватиметься, кому пропонувати прив'язку «сервер = +// машина зонда», а кому ні. Доки не читає, зонд без модуля відповідає +// на завдання зрозумілою відмовою, і це видно в стані хоста. +// +// Активація сервером (ModuleControl) на збір НЕ впливає, і це навмисно. +// Вимикач, яким керує сервер, не є захистом від сервера. Єдине, що +// вирішує, чи буде щось прочитано, — локальна політика машини +// (agent/internal/localfiles). Тримати поруч другий вимикач із кабінету +// означало б натякати, що захист саме в ньому. +package filecfg + +import ( + "context" + "errors" + "sync" + + "github.com/netpulse/netpulse/agent/internal/localfiles" + "github.com/netpulse/netpulse/agent/internal/module" +) + +// Module читає конфіг-файли машини, на якій працює зонд. +type Module struct { + policyPath string + + // Політика перечитується з диска перед кожним збором, а не + // кешується назавжди: адміністратор машини правит перелік і має + // побачити наслідок із наступним бекапом, а не після перезапуску + // зонда. Мьютекс — бо завдання приходять паралельно. + mu sync.Mutex +} + +func New(policyPath string) *Module { + if policyPath == "" { + policyPath = localfiles.DefaultPolicyPath() + } + return &Module{policyPath: policyPath} +} + +func (m *Module) Key() string { return "filecfg" } +func (m *Module) CheckTypes() []string { return nil } +func (m *Module) Close() error { return nil } + +// Run ніколи не викликається: без типів чеків реєстр не має чого сюди +// маршрутизувати. Помилка замість тиші — щоб помилку маршрутизації було +// видно одразу, а не як задачу, що «виконалась» без результату. +func (m *Module) Run(context.Context, module.Task) (module.Result, error) { + return module.Result{}, errors.New("filecfg не виконує чеків: збір іде завданням ConfigJob") +} + +// PolicyPath — де лежить локальний перелік дозволених файлів. +func (m *Module) PolicyPath() string { return m.policyPath } + +// Collect виконує збір за локальною політикою. +// +// srv — стелі з завдання; вони вміють лише звузити дозволене машиною. +func (m *Module) Collect(ctx context.Context, srv localfiles.Limits) (localfiles.Result, error) { + m.mu.Lock() + defer m.mu.Unlock() + + pol, err := localfiles.LoadPolicy(m.policyPath) + if err != nil { + return localfiles.Result{}, err + } + return localfiles.Collect(ctx, pol, srv, localfiles.Machine()) +} diff --git a/agent/internal/modules/snmp/snmp.go b/agent/internal/modules/snmp/snmp.go index 3f2c560..94e5756 100644 --- a/agent/internal/modules/snmp/snmp.go +++ b/agent/internal/modules/snmp/snmp.go @@ -2,8 +2,9 @@ // // Два типи чеків: // -// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку) -// snmp.get — довільні OID → узагальнені метрики +// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку) +// snmp.get — довільні OID → узагальнені метрики +// snmp.walk — обхід піддерева: які рядки має таблиця (див. walk.go) // // Свідоме рішення: агент НЕ ходить по ifTable, щоб з'ясувати, які // інтерфейси існують. Перелік (ifIndex → interface_id → speed_bps) @@ -104,7 +105,7 @@ func New() *Module { } func (m *Module) Key() string { return "snmp" } -func (m *Module) CheckTypes() []string { return []string{"snmp.if", "snmp.get"} } +func (m *Module) CheckTypes() []string { return []string{"snmp.if", "snmp.get", "snmp.walk"} } func (m *Module) Close() error { m.mu.Lock() @@ -125,6 +126,8 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro return m.runInterfaces(ctx, client, task) case "get": return m.runGet(ctx, client, task) + case "walk": + return m.runWalk(ctx, client, task) default: return module.Result{}, fmt.Errorf("snmp: невідомий чек %q", task.CheckType) } diff --git a/agent/internal/modules/snmp/walk.go b/agent/internal/modules/snmp/walk.go new file mode 100644 index 0000000..12b8e95 --- /dev/null +++ b/agent/internal/modules/snmp/walk.go @@ -0,0 +1,242 @@ +package snmp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/gosnmp/gosnmp" + "github.com/netpulse/netpulse/agent/internal/module" + "github.com/netpulse/netpulse/agent/internal/snmpx" +) + +// snmp.walk — виявлення рядків таблиці, а не збір метрик. +// +// Тип був оголошений у довіднику core.check_types від самого початку, а +// модуля не існувало: система обіцяла те, чого не вміла. Тепер він є, і +// робить рівно одне — каже, ЯКІ рядки має ця таблиця на цьому пристрої +// зараз. +// +// Чому не збирає заразом і значення. Прототип шаблону описує метрики на +// рядок; щойно перелік рядків відомий, підставити індекс і зібрати +// числа вміє звичайний snmp.get — однією пачкою OID, як і решта метрик +// шаблону. Зібрати все тут означало б завести другий, паралельний шлях +// метрик із власним розкладом, власним інтервалом і власними багами +// поруч із наявним. Натомість тут — та сама роль, що в модуля topology +// для портів: знайти й доповісти, а що з цим робити, вирішує сервер. +// +// Наслідок, який видно в налаштуваннях: у прототипа ДВА інтервали. +// Обхід таблиці рідкий (диски й сенсори не з'являються щохвилини), +// збір значень частий. Один інтервал на обидві дії означав би або +// щохвилинний walk усього піддерева, або графік, що з'являється лише +// через годину після встановлення модуля. + +// WalkParams — params_json для snmp.walk. +// +// Дзеркалить те, що складає сервер (server/internal/store/prototypes.go, +// walkParams). Розбіжність між цією структурою й тим, що пише сервер, — +// це чек, який мовчки нічого не знаходить: рівно та помилка, яку +// виправляла міграція 0046. Тому обидві сторони й params_schema в +// довіднику описують ОДНУ форму. +type WalkParams struct { + Prototypes []WalkPrototype `json:"prototypes"` +} + +// WalkPrototype — одне правило виявлення рядків. +type WalkPrototype struct { + // Ключ прототипу в шаблоні. Агент його не тлумачить — лише повертає + // назад, бо інакше сервер не знав би, чий це перелік рядків. + Key string `json:"key"` + // Піддерево, обхід якого дає перелік індексів. Класично це колонка + // з людською назвою рядка: hrStorageDescr, entPhysicalName, ifName. + OID string `json:"oid"` + // Звідки брати назву рядка: "value" (значення колонки) або "index" + // (сам індекс). Друге потрібне для таблиць, де людської назви немає + // взагалі, — тоді підписом рядка стає його номер. + NameFrom string `json:"name_from"` + // Стеля кількості рядків для цього прототипу. 0 — узяти типову. + MaxRows int `json:"max_rows"` +} + +// WalkResult — те, що їде назад у CheckResult.payload_json. +// +// Через payload, а не через метрики: перелік рядків — це не вимір у +// момент часу, і класти його в ts.samples не було б куди. Через +// payload, а не через окремий ReportDiscovery: той звіт описує сусідів +// і порти, має власний життєвий цикл run'ів і чистку застарілих +// записів — приліпити до нього рядки чужих таблиць означало б зробити +// його третьою сутністю замість двох. +type WalkResult struct { + Prototypes []WalkPrototypeRows `json:"prototypes"` +} + +// WalkPrototypeRows — знайдене за одним прототипом. +type WalkPrototypeRows struct { + Key string `json:"key"` + Rows []WalkRow `json:"rows"` + // Обхід уперся в стелю: перелік неповний, і сервер не сміє вважати + // зниклими ті рядки, до яких просто не дійшли. + Truncated bool `json:"truncated,omitempty"` + // Обхід не вдався. Порожній перелік і невдалий обхід — це різні + // події: перше означає «таблиця порожня», друге — «ми не знаємо». + Error string `json:"error,omitempty"` +} + +// WalkRow — один рядок таблиці. +type WalkRow struct { + // Індекс — суфікс OID після кореня, як є: "1", "65536", "1.3.6" + // для складених індексів. Рядком, а не числом, саме через складені: + // entPhysicalIndex поміщається в int64, а індекс IP-адреси — ні. + Index string `json:"index"` + Name string `json:"name"` +} + +// Стеля рядків на один прототип. +// +// 256 — не про пам'ять, а про те, що з цих рядків буде складено +// snmp.get-чек. Кожен рядок дає стільки OID, скільки метрик у +// прототипі, а пачка ходить по 24 змінні за PDU: 256 рядків по чотири +// метрики — це вже 43 PDU за такт. Пристрій, у якого таблиця більша, +// майже завжди означає, що прототип описали надто широко (walk по +// ifName замість walk по потрібній колонці), і мовчки опитувати все — +// не послуга. +const DefaultWalkMaxRows = 256 + +// Абсолютна стеля, вище за яку не підіймає навіть явне прохання. +// +// Обхід — це не Get: він триває стільки, скільки в таблиці рядків, і +// не має жодної природної точки зупинки. Пристрій із розбитою таблицею +// (а такі бувають: агент віддає піддерево, що не закінчується) інакше +// тримав би задачу до самого таймаута щоразу. +const hardWalkMaxRows = 4096 + +// errRowLimit зупиняє обхід зсередини колбека. +// +// gosnmp не має способу сказати «досить» інакше, ніж помилкою; ловимо +// її на виході й перетворюємо на прапорець truncated. +var errRowLimit = errors.New("досягнуто стелі рядків") + +func (m *Module) runWalk(ctx context.Context, client *gosnmp.GoSNMP, task module.Task) (module.Result, error) { + var p WalkParams + if len(task.Params) > 0 { + if err := json.Unmarshal(task.Params, &p); err != nil { + return module.Result{}, fmt.Errorf("невалідні params для snmp.walk: %w", err) + } + } + if len(p.Prototypes) == 0 { + return module.Result{}, fmt.Errorf("snmp.walk без жодного прототипу: сервер має передати правила виявлення рядків") + } + + out := WalkResult{Prototypes: make([]WalkPrototypeRows, 0, len(p.Prototypes))} + failed := 0 + + for _, proto := range p.Prototypes { + // Дедлайн задачі перевіряємо між прототипами, а не лише + // всередині обходу: чек із трьома таблицями має віддати те, що + // встиг, а не втратити все через третю. + if err := ctx.Err(); err != nil { + break + } + + res := WalkPrototypeRows{Key: proto.Key, Rows: []WalkRow{}} + rows, truncated, err := walkRows(ctx, client, proto) + switch { + case err != nil: + res.Error = err.Error() + failed++ + default: + res.Rows = rows + res.Truncated = truncated + } + out.Prototypes = append(out.Prototypes, res) + } + + // Один невдалий прототип із трьох — це не невдалий чек: сервер + // побачить помилку в його власному полі й лишить рядки цієї + // таблиці як були. А от коли не вдалося нічого, чек мусить впасти + // по-справжньому: інакше «пристрій не відповідає» виглядало б як + // «таблиці порожні», і всі метрики прототипів тихо зникли б. + if failed == len(out.Prototypes) && failed > 0 { + return module.Result{}, fmt.Errorf("жоден з %d прототипів не обійшовся: %s", + failed, out.Prototypes[0].Error) + } + + payload, err := json.Marshal(out) + if err != nil { + return module.Result{}, err + } + return module.Result{Payload: payload}, nil +} + +// walkRows обходить піддерево одного прототипу. +func walkRows(ctx context.Context, client *gosnmp.GoSNMP, proto WalkPrototype) ([]WalkRow, bool, error) { + root := snmpx.Normalize(proto.OID) + if strings.Trim(root, ".") == "" { + return nil, false, fmt.Errorf("прототип %q без OID виявлення", proto.Key) + } + + limit := proto.MaxRows + if limit <= 0 { + limit = DefaultWalkMaxRows + } + if limit > hardWalkMaxRows { + limit = hardWalkMaxRows + } + + byIndex := proto.NameFrom == "index" + + var ( + rows []WalkRow + truncated bool + // Індекси повторюються, коли пристрій віддає піддерево з + // перекриттям (буває на агентах із кількома контекстами). + // Другий такий рядок дав би дубль метрики й другу серію з тим + // самим підписом. + seen = make(map[string]bool) + ) + + err := snmpx.Walk(ctx, client, root, func(pdu gosnmp.SnmpPDU) error { + if err := ctx.Err(); err != nil { + return err + } + idx := indexOf(root, snmpx.Normalize(pdu.Name)) + if idx == "" || seen[idx] { + return nil + } + seen[idx] = true + + name := idx + if !byIndex { + if v := snmpx.AsString(pdu); v != "" { + name = v + } + } + rows = append(rows, WalkRow{Index: idx, Name: name}) + + if len(rows) >= limit { + truncated = true + return errRowLimit + } + return nil + }) + if err != nil && !errors.Is(err, errRowLimit) { + return nil, false, fmt.Errorf("обхід %s: %w", root, err) + } + return rows, truncated, nil +} + +// indexOf вирізає індекс рядка з повного OID. +// +// Порожній рядок означає «це не з нашого піддерева». Таке приходить +// від агентів, які на кінці обходу віддають перший OID за межами +// піддерева замість endOfMibView; порахувати його рядком означало б +// завести метрику з чужим індексом. +func indexOf(root, full string) string { + prefix := strings.TrimRight(root, ".") + "." + if !strings.HasPrefix(full, prefix) { + return "" + } + return strings.Trim(full[len(prefix):], ".") +} diff --git a/agent/internal/modules/snmp/walk_test.go b/agent/internal/modules/snmp/walk_test.go new file mode 100644 index 0000000..2580188 --- /dev/null +++ b/agent/internal/modules/snmp/walk_test.go @@ -0,0 +1,95 @@ +package snmp + +import ( + "encoding/json" + "testing" +) + +// Індекс рядка — це те, що відрізняє «диск /» від «диска /var», і +// помилка тут не падає, а тихо змішує два рядки в один. +func TestIndexOf(t *testing.T) { + cases := []struct { + root, full, want string + }{ + // Звичайний рядок таблиці. + {".1.3.6.1.2.1.25.2.3.1.3", ".1.3.6.1.2.1.25.2.3.1.3.1", "1"}, + // Корінь із зайвою крапкою на кінці — так його інколи пишуть у + // документації вендора, і відмовляти через це немає підстав. + {".1.3.6.1.2.1.25.2.3.1.3.", ".1.3.6.1.2.1.25.2.3.1.3.31", "31"}, + // Складений індекс: беремо цілком, бо саме він адресує рядок. + {".1.3.6.1.2.1.4.20.1.1", ".1.3.6.1.2.1.4.20.1.1.10.0.0.1", "10.0.0.1"}, + // Кінець обходу: агент віддав перший OID за межами піддерева. + // Порахувати його рядком означало б завести метрику з чужим + // індексом — саме те, від чого ця перевірка. + {".1.3.6.1.2.1.25.2.3.1.3", ".1.3.6.1.2.1.25.2.3.1.4.1", ""}, + // Сам корінь без індексу рядком не є. + {".1.3.6.1.2.1.25.2.3.1.3", ".1.3.6.1.2.1.25.2.3.1.3", ""}, + // Сусіднє піддерево з тим самим початком цифр. + {".1.3.6.1.4.1.9.1", ".1.3.6.1.4.1.9.10.1", ""}, + } + for _, c := range cases { + if got := indexOf(c.root, c.full); got != c.want { + t.Errorf("indexOf(%q, %q) = %q, хотіли %q", c.root, c.full, got, c.want) + } + } +} + +// Форма params — це контракт із сервером і з params_schema в довіднику. +// Розходження між ними вже коштувало проєкту міграції 0046: схема +// описувала не той тип, ніж читає агент, і чек мовчки нічого не збирав. +func TestWalkParamsShape(t *testing.T) { + // Рівно те, що складає server/internal/store/prototypes.go. + raw := `{"prototypes":[ + {"key":"storage","oid":".1.3.6.1.2.1.25.2.3.1.3","name_from":"value","max_rows":64}, + {"key":"sensors","oid":".1.3.6.1.2.1.99.1.1.1.1","name_from":"index"} + ]}` + + var p WalkParams + if err := json.Unmarshal([]byte(raw), &p); err != nil { + t.Fatalf("params не читаються: %v", err) + } + if len(p.Prototypes) != 2 { + t.Fatalf("прототипів %d, хотіли 2", len(p.Prototypes)) + } + if p.Prototypes[0].Key != "storage" || p.Prototypes[0].MaxRows != 64 { + t.Errorf("перший прототип розібрано не так: %+v", p.Prototypes[0]) + } + // Відсутній max_rows має означати «типова стеля», а не «жодного + // рядка»: нуль тут — це порожній чек без жодного повідомлення. + if p.Prototypes[1].MaxRows != 0 { + t.Errorf("max_rows без значення має лишитись нулем, а не %d", p.Prototypes[1].MaxRows) + } + if p.Prototypes[1].NameFrom != "index" { + t.Errorf("name_from розібрано як %q", p.Prototypes[1].NameFrom) + } +} + +// Відповідь агента читає сервер, і порожній перелік рядків мусить +// відрізнятись від невдалого обходу: перше означає «таблиця порожня», +// друге — «ми не знаємо». Сплутати їх означає стерти всі метрики +// прототипу на першому ж таймауті. +func TestWalkResultDistinguishesEmptyFromFailure(t *testing.T) { + res := WalkResult{Prototypes: []WalkPrototypeRows{ + {Key: "empty", Rows: []WalkRow{}}, + {Key: "broken", Rows: []WalkRow{}, Error: "обхід .1.3: таймаут"}, + {Key: "big", Rows: []WalkRow{{Index: "1", Name: "/"}}, Truncated: true}, + }} + b, err := json.Marshal(res) + if err != nil { + t.Fatalf("payload не серіалізується: %v", err) + } + + var back WalkResult + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("payload не читається назад: %v", err) + } + if back.Prototypes[0].Error != "" || back.Prototypes[0].Rows == nil { + t.Errorf("порожня таблиця має їхати як порожній перелік без помилки: %+v", back.Prototypes[0]) + } + if back.Prototypes[1].Error == "" { + t.Errorf("невдалий обхід втратив причину: %+v", back.Prototypes[1]) + } + if !back.Prototypes[2].Truncated { + t.Errorf("обрізаний перелік втратив прапорець: %+v", back.Prototypes[2]) + } +} diff --git a/agent/internal/modules/topology/topology.go b/agent/internal/modules/topology/topology.go index df55cdb..39755ea 100644 --- a/agent/internal/modules/topology/topology.go +++ b/agent/internal/modules/topology/topology.go @@ -67,8 +67,12 @@ const ( // Params — вміст params_json для topology.discover. type Params struct { - // lldp | cdp | arp | fdb. Порожньо — lldp + cdp. - Protos []string `json:"protos"` + // lldp | cdp | arp | fdb. + // + // Вказівник, а не зріз, бо «поля немає» і «порожній список» тут + // різні речі: перше означає типове lldp+cdp, друге — свідоме + // «жодного протоколу», коли потрібен самий інвентар портів. + Protos *[]string `json:"protos"` // Збирати інвентар портів разом із сусідами. Обидва беруться з // одного SNMP-обходу, тому окремий чек був би зайвим трафіком. CollectInterfaces *bool `json:"collect_interfaces"` @@ -78,9 +82,19 @@ type Module struct{} func New() *Module { return &Module{} } -func (m *Module) Key() string { return "topology" } -func (m *Module) CheckTypes() []string { return []string{"topology.discover"} } -func (m *Module) Close() error { return nil } +// CheckTypeIdentify — розпізнавання пристрою: сама лише системна група. +// +// Окремий тип чека, а не набір параметрів `topology.discover`, хоч +// робить його той самий код. Причина не технічна: у списку перевірок +// хоста рядок мусить називати те, що робить, а «Neighbor Discovery», +// який не шукає жодного сусіда, — підказка, що бреше. +const CheckTypeIdentify = "topology.identify" + +func (m *Module) Key() string { return "topology" } +func (m *Module) CheckTypes() []string { + return []string{"topology.discover", CheckTypeIdentify} +} +func (m *Module) Close() error { return nil } func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, error) { var p Params @@ -89,11 +103,18 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro return module.Result{}, fmt.Errorf("невалідні params для topology.discover: %w", err) } } - if len(p.Protos) == 0 { - p.Protos = []string{"lldp", "cdp"} + protos := []string{"lldp", "cdp"} + if p.Protos != nil { + protos = *p.Protos } collectIfaces := p.CollectInterfaces == nil || *p.CollectInterfaces + // Тип чека вирішує, а не параметри: розпізнавання не має способу + // випадково перетворитись на повний обхід через недогляд у формі. + if task.CheckType == CheckTypeIdentify { + protos, collectIfaces = nil, false + } + client, err := snmpx.Dial(ctx, task.Target.Address, task.Credentials, task.Timeout) if err != nil { return module.Result{}, err @@ -113,11 +134,19 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro // Інвентар портів потрібен першим: LLDP оперує власною нумерацією // портів, і без ifName/ifDescr її нема на що відобразити. - ifaces, err := collectInterfaces(ctx, client, task.DeviceID) - if err != nil { - errs = append(errs, "інтерфейси: "+err.Error()) - } else if collectIfaces { - res.InterfaceRecords = ifaces + // + // Коли не просять ні портів, ні сусідів, обхід пропускаємо. Це не + // мікрооптимізація: ifTable на комутаторі з 400 портами — тисячі + // PDU, а «розпізнати пристрій» коштує три OID, які вже зняті вище. + var ifaces []*npv1.InterfaceRecord + if collectIfaces || len(protos) > 0 { + var err error + ifaces, err = collectInterfaces(ctx, client, task.DeviceID) + if err != nil { + errs = append(errs, "інтерфейси: "+err.Error()) + } else if collectIfaces { + res.InterfaceRecords = ifaces + } } byIndex := make(map[int64]*npv1.InterfaceRecord, len(ifaces)) @@ -125,7 +154,7 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro byIndex[r.IfIndex] = r } - for _, proto := range p.Protos { + for _, proto := range protos { var ( found []*npv1.NeighborRecord perr error @@ -151,7 +180,8 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro res.Neighbors = append(res.Neighbors, found...) } - if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 && len(errs) > 0 { + if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 && + len(res.Devices) == 0 && len(errs) > 0 { return module.Result{}, fmt.Errorf("автовиявлення не дало результату: %s", strings.Join(errs, "; ")) } diff --git a/agent/internal/modules/traps/mib.go b/agent/internal/modules/traps/mib.go new file mode 100644 index 0000000..9d613be --- /dev/null +++ b/agent/internal/modules/traps/mib.go @@ -0,0 +1,92 @@ +package traps + +import "strconv" + +// Словник трапів: чому він мінімальний і чому взагалі є. +// +// Трап приїжджає числом: `1.3.6.1.6.3.1.1.5.3` і десяток varbind-ів, +// теж числами. Повний словник MIB — це тисячі файлів, окремий +// компілятор ASN.1 і сховище під них; продукт, який обіцяє «завантажте +// свої MIB», обіцяє все це разом із підтримкою вендорських діалектів. +// Ми такого не обіцяємо. +// +// Але з протилежного боку — екран, на якому самі числа. Людина, що +// відкриває журнал трапів о третій ночі, не знає напам'ять жодного OID, +// і сторінка з колонкою `1.3.6.1.6.3.1.1.5.3` не відповідає на питання, +// заради якого її відкрили. +// +// Тому тут рівно та частина словника, яка НЕ залежить від вендора. +// Шість типових трапів (RFC 1215, він же snmpTraps з RFC 3418) однакові +// в Cisco, Huawei, MikroTik і в найдешевшому некерованому світчі, який +// узагалі вміє трапи. Це саме те, заради чого трапи вмикають у 90% +// випадків: порт упав, порт піднявся, коробка перезавантажилась, хтось +// підбирає community. +// +// Решта лишається сирим OID із чесною позначкою «невідомий трап». Це +// принципово: підставити сюди назву, вигадану за схожістю префікса, +// означало б збрехати рівно в тому місці, де людина найбільше довіряє +// написаному. +// +// Свої відповідності «OID → назва» дописуються НЕ тут, а на сервері +// (inv.trap_oids). Причина в тому, що словник на зонді довелося б +// доставляти кожному зонду й синхронізувати; словник на сервері +// застосовується до вже прийнятих трапів заднім числом — тобто назва, +// додана сьогодні, підписує й учорашній журнал. + +// Стандартні OID, з яких складається сам протокол сповіщень. +// +// Це не «трапи», а службові varbind-и, у яких лежить, ЩО саме сталося. +// Вони потрібні коду, а не людині, тому й винесені константами. +const ( + // snmpTrapOID.0 — у v2c/v3 саме тут лежить ідентифікатор трапа. + // Сам PDU його не містить: v2 навмисно зробили трап звичайним + // набором varbind-ів. + oidSnmpTrapOID = "1.3.6.1.6.3.1.1.4.1.0" + // sysUpTime.0 — обов'язковий перший varbind v2-трапа. + oidSysUpTime = "1.3.6.1.2.1.1.3.0" + // snmpTrapEnterprise.0 і snmpTrapAddress.0 — куди RFC 3584 велить + // покласти поля заголовка v1-трапа, яких у v2 немає. + oidSnmpTrapEnterprise = "1.3.6.1.6.3.1.1.4.3.0" + oidSnmpTrapAddress = "1.3.6.1.6.3.18.1.3.0" + // Префікс шести типових трапів: snmpTraps з RFC 3418. + oidGenericTrapPrefix = "1.3.6.1.6.3.1.1.5." +) + +// genericTrapOID перекладає v1-трап у v2-ідентифікатор за RFC 3584. +// +// Це не наша вигадка й не зручність: RFC 3584 (Coexistence between +// SNMP versions) описує рівно це перетворення, і всі NMS роблять його +// однаково. Завдяки йому «linkDown від старого світча по v1» і +// «linkDown від нового по v2c» — це один і той самий трап, а не два +// різні рядки, під які треба заводити два правила. +// +// generic 6 означає «enterprise-specific»: ідентифікатор складається з +// enterprise-OID виробника й номера трапа. Крапка-нуль посередині — теж +// вимога RFC 3584, а не описка: v1 дозволяв enterprise, що вже +// закінчується на цифру, і без розділювача два різні трапи склеїлись +// би в один OID. +func genericTrapOID(generic, specific int, enterprise string) string { + if generic == 6 { + if enterprise == "" { + return "" + } + return trimOID(enterprise) + ".0." + strconv.Itoa(specific) + } + if generic < 0 || generic > 5 { + return "" + } + return oidGenericTrapPrefix + strconv.Itoa(generic+1) +} + +// trimOID знімає провідну крапку. +// +// gosnmp віддає OID то з нею, то без — залежно від того, з якого поля +// пакета він узявся. Різниця в один символ перетворила б умову правила +// «цей OID» на лотерею, тож форма зводиться до однієї в найранішій +// точці: одразу після розбору. +func trimOID(s string) string { + for len(s) > 0 && s[0] == '.' { + s = s[1:] + } + return s +} diff --git a/agent/internal/modules/traps/parse.go b/agent/internal/modules/traps/parse.go new file mode 100644 index 0000000..37c1904 --- /dev/null +++ b/agent/internal/modules/traps/parse.go @@ -0,0 +1,285 @@ +package traps + +import ( + "encoding/hex" + "fmt" + "net" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gosnmp/gosnmp" + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// maxVarbinds — скільки varbind-ів зберігаємо з одного трапа. +// +// Стеля потрібна не через пам'ять зонда, а через базу: varbind-и лежать +// у jsonb, і трап на тисячу рядків таблиці маршрутизації важить більше, +// ніж година метрик з усього кабінету. Тридцять два — це вдвічі більше +// за найбагатослівніший трап, який трапляється на практиці (BGP-сесія +// з повним набором лічильників), тож обрізання означає не «нам шкода +// місця», а «щось пішло не так». +const maxVarbinds = 32 + +// maxValueRunes — стеля на одне значення. +// +// OCTET STRING у трапі буває довжиною в конфіг: деякі вендори пхають +// туди повний текст події. У колонці таблиці з цього видно перші три +// слова, а решта коштує місця в кожному рядку журналу. +const maxValueRunes = 512 + +// truncationMark — синтетичний varbind про обрізання. +// +// Порожній OID навмисно: жоден справжній varbind його мати не може, +// тож рядок неможливо сплутати з даними пристрою. Мовчазне обрізання +// було б гіршим за будь-яку втрату — воно перетворює «ми показали не +// все» на «більше нічого не було». +const truncationMark = "" + +// Normalize зводить пакет будь-якої версії до одного вигляду. +// +// Уся різниця між v1 і v2c/v3 з'їдається тут, і далі по системі їде +// один тип події. Це не косметика: правило «linkDown» має спрацьовувати +// незалежно від того, якою версією протоколу його прислали, а на +// одному майданчику зазвичай живуть обидві — новий комутатор шле v2c, +// а той, що стоїть із 2011-го, вміє лише v1. +// +// srcIP — адреса, з якої прийшла датаграма, а не та, що написана +// всередині пакета. Всередині вона теж є (v1 має agent-address), але +// довіряти їй не можна: це просто поле, і воно навмисно неправдиве в +// кожному релеї трапів. Зіставлення з хостом іде за адресою відправника +// саме тому; заявлену адресу зберігаємо окремим varbind-ом, щоб +// розбіжність було видно, а не щоб на неї покладатись. +func Normalize(pkt *gosnmp.SnmpPacket, srcIP string, now time.Time) *npv1.SnmpTrap { + if pkt == nil { + return nil + } + + out := &npv1.SnmpTrap{ + Ts: timestamppb.New(now), + SourceIp: srcIP, + AuthContext: authContext(pkt), + } + + // Копія, а не зріз пакета. Слухач після нашого обробника ще + // повертається до цього ж пакета, щоб відповісти на inform, і + // дописувати щось у його масив varbind-ів (а append із запасом + // місткості робить саме це) означало б правити те, що зараз + // поїде назад відправнику. + vbs := make([]gosnmp.SnmpPDU, len(pkt.Variables)) + copy(vbs, pkt.Variables) + + if pkt.PDUType == gosnmp.Trap { + // v1: ідентифікатор трапа лежить не у varbind-ах, а в заголовку + // PDU, і його треба зібрати з трьох полів за RFC 3584. + out.TrapOid = genericTrapOID(pkt.GenericTrap, pkt.SpecificTrap, pkt.Enterprise) + // Поля заголовка, яких у v2 немає, RFC 3584 велить дописати + // varbind-ами. Робимо саме так, а не заводимо власні колонки: + // інакше сторінка трапів мала б дві порожні клітинки для всього, + // що приїхало по v2c, і одну заповнену для музейних експонатів. + vbs = append(vbs, + gosnmp.SnmpPDU{Name: oidSysUpTime, Type: gosnmp.TimeTicks, Value: pkt.Timestamp}, + gosnmp.SnmpPDU{Name: oidSnmpTrapEnterprise, Type: gosnmp.ObjectIdentifier, Value: pkt.Enterprise}, + ) + if pkt.AgentAddress != "" { + vbs = append(vbs, + gosnmp.SnmpPDU{Name: oidSnmpTrapAddress, Type: gosnmp.IPAddress, Value: pkt.AgentAddress}) + } + } else { + out.TrapOid = trapOIDFromVarbinds(vbs) + } + + out.Varbinds = convertVarbinds(vbs) + return out +} + +// trapOIDFromVarbinds дістає snmpTrapOID.0 з набору varbind-ів. +// +// Порожній результат — не помилка розбору, а окремий стан: буває, що +// пристрій шле v2-трап без обов'язкового snmpTrapOID.0 (так поводяться +// деякі дешеві PDU і кілька прошивок ONU). Викидати таку подію не можна +// — вона все одно означає, що щось сталося, — тому вона доїжджає з +// порожнім OID і на екрані виглядає як «трап без ідентифікатора». Це +// чесно: ми справді не знаємо, що це було. +func trapOIDFromVarbinds(vbs []gosnmp.SnmpPDU) string { + for _, v := range vbs { + if trimOID(v.Name) != oidSnmpTrapOID { + continue + } + switch x := v.Value.(type) { + case string: + return trimOID(x) + case []byte: + return trimOID(string(x)) + } + } + return "" +} + +// authContext — те, чим відправник себе назвав. +// +// Не автентифікація: community їде відкритим текстом, і зонд його не +// перевіряє. Зберігаємо, бо це єдине, що відрізняє «трапи від нашого +// обладнання» від «трапи, які хтось шле нам у порт»; для v3 тут +// security name, у якого доказова сила вже є. +func authContext(pkt *gosnmp.SnmpPacket) string { + if pkt.Version == gosnmp.Version3 { + if usm, ok := pkt.SecurityParameters.(*gosnmp.UsmSecurityParameters); ok && usm != nil { + return usm.UserName + } + return "" + } + return pkt.Community +} + +// convertVarbinds перекладає varbind-и в те, що поїде на сервер. +func convertVarbinds(vbs []gosnmp.SnmpPDU) []*npv1.VarBind { + if len(vbs) == 0 { + return nil + } + out := make([]*npv1.VarBind, 0, min(len(vbs), maxVarbinds+1)) + for i, v := range vbs { + if i >= maxVarbinds { + out = append(out, &npv1.VarBind{ + Oid: truncationMark, + Type: "truncated", + Value: fmt.Sprintf("ще %d varbind-ів відкинуто зондом", len(vbs)-maxVarbinds), + }) + break + } + typ, val := formatValue(v) + out = append(out, &npv1.VarBind{Oid: trimOID(v.Name), Type: typ, Value: val}) + } + return out +} + +// formatValue перетворює значення varbind-а на текст і назву типу. +// +// Тип зберігається окремо, а не вгадується зі значення при читанні, і +// це важливо саме для трапів: `2` у ifOperStatus — це стан «down», а +// не число два, і різницю між INTEGER і Counter32 не відновити з +// рядка «2» ніяк. Умова правила «varbind дорівнює цьому значенню» +// порівнює текст, тому текст мусить бути передбачуваним: одне й те +// саме число з двох різних трапів має дати однаковий рядок. +func formatValue(v gosnmp.SnmpPDU) (string, string) { + switch v.Type { + case gosnmp.Integer: + return "INTEGER", strconv.FormatInt(gosnmp.ToBigInt(v.Value).Int64(), 10) + case gosnmp.Counter32: + return "Counter32", gosnmp.ToBigInt(v.Value).String() + case gosnmp.Gauge32: + return "Gauge32", gosnmp.ToBigInt(v.Value).String() + case gosnmp.Counter64: + return "Counter64", gosnmp.ToBigInt(v.Value).String() + case gosnmp.Uinteger32: + return "Unsigned32", gosnmp.ToBigInt(v.Value).String() + case gosnmp.TimeTicks: + // Сотні секунд, як їх визначає SMI. Не перетворюємо на «3 дні + // 4 години» тут: людське подання — робота екрана, а порівняння + // в умові правила має мати справу з числом, яке прислав + // пристрій. + return "TimeTicks", gosnmp.ToBigInt(v.Value).String() + case gosnmp.ObjectIdentifier: + return "OID", trimOID(asString(v.Value)) + case gosnmp.IPAddress: + return "IpAddress", asString(v.Value) + case gosnmp.OctetString: + return "OCTET STRING", octetString(v.Value) + case gosnmp.Null: + return "NULL", "" + case gosnmp.NoSuchObject: + return "noSuchObject", "" + case gosnmp.NoSuchInstance: + return "noSuchInstance", "" + case gosnmp.EndOfMibView: + return "endOfMibView", "" + case gosnmp.Opaque, gosnmp.OpaqueFloat, gosnmp.OpaqueDouble: + return "Opaque", octetString(v.Value) + default: + // Тип, якого ми не знаємо, — не привід втратити значення. + // Числом типу видно, що саме приїхало, і за ним можна дописати + // гілку, коли з'ясується, який вендор так робить. + return "type-" + strconv.Itoa(int(v.Type)), octetString(v.Value) + } +} + +// octetString подає рядок байтів так, щоб його можна було прочитати. +// +// OCTET STRING — це байти, а не текст: там буває і опис події +// українською в UTF-8, і MAC-адреса, і бінарний блок стану. Друковане +// лишаємо як є, недруковане показуємо шістнадцятковим — інакше в +// журналі виявиться сміття, яке ще й ламає JSON на шляху до бази. +func octetString(v any) string { + var b []byte + switch x := v.(type) { + case []byte: + b = x + case string: + b = []byte(x) + default: + return truncate(asString(v)) + } + if utf8.Valid(b) && printable(b) { + return truncate(string(b)) + } + return truncate(hexPairs(b)) +} + +// printable відсіює керівні байти. Табуляція й перенос — теж керівні, +// але їх лишаємо: багатослівні трапи Juniper приходять саме так, і +// перетворювати їх на hex означало б зробити нечитабельним рівно те, +// що читабельне. +func printable(b []byte) bool { + for _, c := range b { + if c < 0x20 && c != '\t' && c != '\n' && c != '\r' { + return false + } + } + return true +} + +// hexPairs — "00:1b:21:3c:4d:5e". Двокрапки саме тому, що найчастіший +// бінарний OCTET STRING у трапі — це MAC-адреса, і в такому вигляді її +// впізнають, а суцільний рядок цифр — ні. +func hexPairs(b []byte) string { + if len(b) > maxValueRunes/3 { + b = b[:maxValueRunes/3] + } + s := hex.EncodeToString(b) + var sb strings.Builder + for i := 0; i < len(s); i += 2 { + if i > 0 { + sb.WriteByte(':') + } + sb.WriteString(s[i : i+2]) + } + return sb.String() +} + +func asString(v any) string { + switch x := v.(type) { + case string: + return x + case []byte: + return string(x) + case net.IP: + return x.String() + case nil: + return "" + default: + return fmt.Sprint(x) + } +} + +// truncate обрізає значення, лишаючи видимим сам факт обрізання. +func truncate(s string) string { + s = strings.TrimRight(s, "\x00") + if utf8.RuneCountInString(s) <= maxValueRunes { + return s + } + r := []rune(s) + return string(r[:maxValueRunes]) + "…" +} diff --git a/agent/internal/modules/traps/receiver.go b/agent/internal/modules/traps/receiver.go new file mode 100644 index 0000000..f777430 --- /dev/null +++ b/agent/internal/modules/traps/receiver.go @@ -0,0 +1,329 @@ +// Package traps — приймач SNMP-трапів на зонді. +// +// Навіщо він, коли вже є syslog. Трап і рядок журналу відповідають на +// різні питання. Syslog розповідає, що пристрій ПРО СЕБЕ думає, і +// робить це тоді, коли встигає: якщо коробка перевантажена або +// перезавантажується, останні рядки не встигають вийти. Трап — це +// одна датаграма, підготовлена заздалегідь; її встигають вислати навіть +// ті прошивки, у яких syslog вимикається першим. Плюс сотні дешевих +// пристроїв (керовані PDU, ДБЖ, ONU, конвертери) syslog не вміють +// взагалі, а трапи шлють — це для них єдиний спосіб сказати «мене +// вимикають». +// +// Приймач саме на зонді, а не на сервері, з тієї ж причини, що й +// syslog: комутатор у закритій мережі до сервера не достукається, а +// зонд уже має вихідний канал. +// +// Головна відмінність від syslog-приймача — inform. Трап відправник +// шле й забуває; inform він шле, доки не отримає підтвердження. Не +// підтвердити означає перетворити одну подію на нескінченний потік з +// того самого джерела — і це відбувається саме тоді, коли зонду +// найважче. Тому підтвердження тут не залежить від того, чи взяли ми +// подію в чергу: ми відповідаємо ЗАВЖДИ, а вже потім вирішуємо, чи є в +// нас місце. +package traps + +import ( + "context" + "log/slog" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/gosnmp/gosnmp" + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" +) + +// DefaultAddr — стандартний порт трапів. +// +// Нижче 1024, тож у Linux потрібна CAP_NET_BIND_SERVICE. Налаштовується +// з тієї самої причини, що й порт syslog: віддати зонду право на +// привілейований порт можна не всюди, а перекинути 162 на 1162 +// правилом фаєрвола можна скрізь. +const DefaultAddr = ":162" + +// maxDatagram — стеля на одну датаграму. +// +// RFC 3416 гарантує прийом лише 484 байтів; практика — до 1500 (MTU). +// Вісім кілобайтів беремо з запасом на трапи, у які вендор запхав +// текст події, і не більше: буфер виділяється на кожен прийом, а +// датаграма, більша за нього, все одно обрізається ядром. +const maxDatagram = 8192 + +// maxQueue — скільки трапів чекає відправки. +// +// Трап-шторм — це не гіпотеза, а звичайний вівторок: порт, що фліпає +// через биту оптику, дає linkDown/linkUp по кілька разів на секунду +// роками. Черга без стелі означає, що зонд з'їдає пам'ять машини, на +// якій живе, — тобто ламає не себе, а сервер клієнта. +const maxQueue = 10_000 + +// defaultPerSource — стеля трапів за секунду з однієї адреси. +// +// На відміну від syslog, тут ліміт стоїть ЗАВЖДИ, а не лише коли сервер +// його попросив. Різниця в тому, що трапи дозволяють підтвердження: +// відправник inform-ів, який не отримав відповіді, шле повторно, і +// перші секунди після старту зонда — це рівно той момент, коли на нього +// вивалюється все, що накопичилось. Нуль тут означав би «без обмежень» +// саме тоді, коли вони найпотрібніші. +// +// Двадцять на секунду — це вчетверо більше, ніж дає порт, що фліпає +// найшвидше з бачених. Сервер може підняти або опустити цю стелю +// (LogAck.rate_limit_per_source): він бачить картину по всіх зондах. +const defaultPerSource = 20 + +// Receiver приймає трапи й накопичує їх до відправки на сервер. +type Receiver struct { + addr string + log *slog.Logger + + mu sync.Mutex + queue []*npv1.SnmpTrap + dropped uint64 + buckets map[string]*bucket + + perSource atomic.Int64 + + resolve atomic.Pointer[func(ip string) string] + + // Скільки трапів прийшло з адрес, які зонд не знайшов серед своїх + // хостів. Лічильник, а не прапорець: одна подія від незнайомої + // адреси — це, найімовірніше, сусід у спільній підмережі, а тисяча + // — це те, що в мережі стоїть кероване залізо, про яке моніторинг + // не знає. + unknownSource atomic.Uint64 + + notify chan struct{} +} + +// New створює приймач. Порожня адреса означає DefaultAddr. +func New(addr string, log *slog.Logger) *Receiver { + if addr == "" { + addr = DefaultAddr + } + r := &Receiver{ + addr: addr, + log: log, + buckets: map[string]*bucket{}, + notify: make(chan struct{}, 1), + } + r.perSource.Store(defaultPerSource) + return r +} + +// SetResolver задає спосіб знайти хост за адресою відправника. +func (r *Receiver) SetResolver(f func(ip string) string) { + r.resolve.Store(&f) +} + +// ApplyAck застосовує ліміт, надісланий сервером. +// +// Нуль не знімає обмеження, а лишає типове. Це відрізняється від +// syslog навмисно: там нуль означає «бери все», бо втрачений рядок +// журналу нічого не ламає. Тут відсутність стелі означає, що зонд +// лягає під штормом — а зонд, який ліг, не бачить уже нічого. +func (r *Receiver) ApplyAck(perSourcePerSec uint32) { + if perSourcePerSec > 0 { + r.perSource.Store(int64(perSourcePerSec)) + } +} + +// Ready повідомляє про появу подій у черзі. +func (r *Receiver) Ready() <-chan struct{} { return r.notify } + +// UnknownSources — скільки трапів прийшло від нерозпізнаних адрес. +func (r *Receiver) UnknownSources() uint64 { return r.unknownSource.Load() } + +// Drain забирає з черги до limit подій разом із лічильником відкинутих. +func (r *Receiver) Drain(limit int) ([]*npv1.SnmpTrap, uint64) { + r.mu.Lock() + defer r.mu.Unlock() + + if limit <= 0 || limit > len(r.queue) { + limit = len(r.queue) + } + if limit == 0 { + return nil, 0 + } + + out := r.queue[:limit] + r.queue = append([]*npv1.SnmpTrap(nil), r.queue[limit:]...) + dropped := r.dropped + r.dropped = 0 + return out, dropped +} + +// Requeue повертає невідправлені трапи на початок черги. +// +// Порядок має значення так само, як у журналі: linkDown і linkUp того +// самого порту відрізняються лише часом, і пачка, що повернулась у +// хвіст, показала б порт піднятим до того, як він упав. +func (r *Receiver) Requeue(entries []*npv1.SnmpTrap) { + if len(entries) == 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + room := maxQueue - len(r.queue) + if room <= 0 { + r.dropped += uint64(len(entries)) + return + } + if len(entries) > room { + r.dropped += uint64(len(entries) - room) + entries = entries[len(entries)-room:] + } + r.queue = append(entries, r.queue...) +} + +// Run слухає порт, доки живий контекст. +func (r *Receiver) Run(ctx context.Context) error { + tl := gosnmp.NewTrapListener() + // Params потрібні розбирачу, а не нам: він читає з них версію за + // замовчуванням і логер. Версію пакета він однаково бере з самого + // пакета, тож жодного відсіювання за версією тут немає — і не має + // бути. Зонд, який приймає лише v2c, мовчки губив би все, що шлють + // старі коробки, а саме вони найчастіше й уміють тільки трапи. + tl.Params = &gosnmp.GoSNMP{Version: gosnmp.Version2c} + tl.WithBufferSize(maxDatagram) + tl.OnNewTrap = func(pkt *gosnmp.SnmpPacket, addr *net.UDPAddr) { + // Обробник тримає читальну горутину слухача: доки він не + // завершився, наступна датаграма не читається, а підтвердження + // inform-а не відсилається. Тому тут рівно розбір і черга — + // жодних звернень назовні. + r.Handle(pkt, addrIP(addr), time.Now()) + } + + go func() { + <-ctx.Done() + // Close() закриває сокет: іншого способу перервати ReadFromUDP, + // який не знає про контекст, немає. + tl.Close() + }() + + r.log.Info("приймач трапів слухає", "адреса", r.addr) + if err := tl.Listen(r.addr); err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + return nil +} + +// Handle розбирає й кладе в чергу один трап. +// +// Окремо від Run, щоб приймач можна було перевірити без сокета. +func (r *Receiver) Handle(pkt *gosnmp.SnmpPacket, srcIP string, now time.Time) { + if pkt == nil { + return + } + // Ліміт перевіряється ДО розбору: сенс обмежувача в тому, щоб + // шторм коштував дешево, а розбір varbind-ів — найдорожча частина + // шляху. + if !r.allow(srcIP, now) { + r.drop() + return + } + + t := Normalize(pkt, srcIP, now) + if t == nil { + return + } + if f := r.resolve.Load(); f != nil { + t.DeviceId = (*f)(srcIP) + } + if t.DeviceId == "" { + // Трап від невідомої адреси не викидається. + // + // Спокуса викинути є: подія, не прив'язана до хоста, не + // потрапить у жодне правило й нікого не розбудить. Але саме + // вона найчастіше є першим сигналом, що в мережі з'явилось + // залізо, якого моніторинг не знає, — новий комутатор, чужий + // маршрутизатор у спільному VLAN, ДБЖ, який хтось увімкнув «на + // часок». Тиха втрата тут означала б, що продукт бачить рівно + // те, що йому вже показали. + r.unknownSource.Add(1) + } + r.push(t) +} + +func (r *Receiver) push(t *npv1.SnmpTrap) { + r.mu.Lock() + if len(r.queue) >= maxQueue { + // Викидаємо найстаріше. Під час шторму свіжі трапи пояснюють, + // що відбувається зараз, а найстаріші — те, що вже видно з + // наступних десяти тисяч. + r.queue = r.queue[1:] + r.dropped++ + } + r.queue = append(r.queue, t) + r.mu.Unlock() + + select { + case r.notify <- struct{}{}: + default: + } +} + +func (r *Receiver) drop() { + r.mu.Lock() + r.dropped++ + r.mu.Unlock() +} + +// --- обмеження частоти ------------------------------------------------ + +type bucket struct { + tokens float64 + last time.Time +} + +// allow пропускає трап, якщо джерело не перевищило ліміт. +// +// Ліміт на джерело, а не спільний: один комутатор із битою оптикою +// інакше витіснив би з черги всю решту мережі — тобто рівно те, що +// потрібно бачити під час аварії. +func (r *Receiver) allow(ip string, now time.Time) bool { + rate := float64(r.perSource.Load()) + if rate <= 0 { + return true + } + + r.mu.Lock() + defer r.mu.Unlock() + + b, ok := r.buckets[ip] + if !ok { + // Прибирання разом зі створенням: окрема горутина заради мапи, + // яка росте на одне джерело, — зайва ціна. Стеля потрібна ще й + // тому, що адресу відправника UDP підробити нічого не варте: + // без неї мапа стає способом з'їсти пам'ять зонда з мережі. + if len(r.buckets) > 4096 { + r.buckets = map[string]*bucket{} + } + r.buckets[ip] = &bucket{tokens: rate - 1, last: now} + return true + } + + b.tokens += now.Sub(b.last).Seconds() * rate + if b.tokens > rate { + b.tokens = rate + } + b.last = now + + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +func addrIP(a *net.UDPAddr) string { + if a == nil { + return "" + } + return a.IP.String() +} diff --git a/agent/internal/modules/traps/traps_test.go b/agent/internal/modules/traps/traps_test.go new file mode 100644 index 0000000..2ad24eb --- /dev/null +++ b/agent/internal/modules/traps/traps_test.go @@ -0,0 +1,438 @@ +package traps + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "strings" + "testing" + "time" + + "github.com/gosnmp/gosnmp" +) + +var now = time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + +func quiet() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// v2c-трап: ідентифікатор події лежить не в заголовку, а у varbind-і +// snmpTrapOID.0. Помилка тут означала б, що жодне правило «цей OID» +// ніколи не спрацює. +func TestNormalizeV2cTrap(t *testing.T) { + pkt := &gosnmp.SnmpPacket{ + Version: gosnmp.Version2c, + Community: "public", + PDUType: gosnmp.SNMPv2Trap, + Variables: []gosnmp.SnmpPDU{ + {Name: ".1.3.6.1.2.1.1.3.0", Type: gosnmp.TimeTicks, Value: uint(12345)}, + {Name: ".1.3.6.1.6.3.1.1.4.1.0", Type: gosnmp.ObjectIdentifier, Value: ".1.3.6.1.6.3.1.1.5.3"}, + {Name: ".1.3.6.1.2.1.2.2.1.1.7", Type: gosnmp.Integer, Value: 7}, + {Name: ".1.3.6.1.2.1.2.2.1.2.7", Type: gosnmp.OctetString, Value: []byte("GigabitEthernet0/7")}, + }, + } + + tr := Normalize(pkt, "10.10.0.5", now) + + if tr.GetTrapOid() != "1.3.6.1.6.3.1.1.5.3" { + t.Fatalf("OID трапа: %q", tr.GetTrapOid()) + } + if tr.GetAuthContext() != "public" { + t.Fatalf("community: %q", tr.GetAuthContext()) + } + if tr.GetSourceIp() != "10.10.0.5" { + t.Fatalf("адреса: %q", tr.GetSourceIp()) + } + if len(tr.GetVarbinds()) != 4 { + t.Fatalf("varbind-ів: %d", len(tr.GetVarbinds())) + } + // Провідна крапка знімається скрізь: інакше умова правила «цей OID» + // залежала б від того, з якого поля пакета OID узявся. + if got := tr.GetVarbinds()[1].GetValue(); got != "1.3.6.1.6.3.1.1.5.3" { + t.Fatalf("OID у значенні лишився з крапкою: %q", got) + } + if got := tr.GetVarbinds()[2]; got.GetType() != "INTEGER" || got.GetValue() != "7" { + t.Fatalf("ifIndex: %s=%s", got.GetType(), got.GetValue()) + } + if got := tr.GetVarbinds()[3].GetValue(); got != "GigabitEthernet0/7" { + t.Fatalf("ifDescr: %q", got) + } +} + +// v1-трап несе ідентифікатор трьома полями заголовка. Без перекладу за +// RFC 3584 «linkDown зі старого світча» і «linkDown із нового» були б +// різними подіями, під які треба заводити два правила. +func TestNormalizeV1GenericTrap(t *testing.T) { + pkt := &gosnmp.SnmpPacket{ + Version: gosnmp.Version1, + Community: "public", + PDUType: gosnmp.Trap, + SnmpTrap: gosnmp.SnmpTrap{ + Enterprise: ".1.3.6.1.4.1.9", + AgentAddress: "10.10.0.9", + GenericTrap: 2, // linkDown + SpecificTrap: 0, + Timestamp: 999, + }, + Variables: []gosnmp.SnmpPDU{ + {Name: ".1.3.6.1.2.1.2.2.1.1.3", Type: gosnmp.Integer, Value: 3}, + }, + } + + tr := Normalize(pkt, "10.10.0.9", now) + + if tr.GetTrapOid() != "1.3.6.1.6.3.1.1.5.3" { + t.Fatalf("linkDown не перекладено в OID v2: %q", tr.GetTrapOid()) + } + // Поля заголовка v1 мають доїхати varbind-ами, інакше вони + // зникають безслідно. + byOID := map[string]string{} + for _, v := range tr.GetVarbinds() { + byOID[v.GetOid()] = v.GetValue() + } + if byOID[oidSysUpTime] != "999" { + t.Fatalf("sysUpTime: %q", byOID[oidSysUpTime]) + } + if byOID[oidSnmpTrapEnterprise] != "1.3.6.1.4.1.9" { + t.Fatalf("enterprise: %q", byOID[oidSnmpTrapEnterprise]) + } + if byOID[oidSnmpTrapAddress] != "10.10.0.9" { + t.Fatalf("agent-address: %q", byOID[oidSnmpTrapAddress]) + } +} + +// generic 6 — «своє», і ідентифікатор складається з enterprise і номера +// через ".0.". Без цього роздільника два різні трапи склеїлись би в +// один OID. +func TestNormalizeV1EnterpriseTrap(t *testing.T) { + pkt := &gosnmp.SnmpPacket{ + Version: gosnmp.Version1, + PDUType: gosnmp.Trap, + SnmpTrap: gosnmp.SnmpTrap{ + Enterprise: ".1.3.6.1.4.1.2011.5.25.219", + GenericTrap: 6, + SpecificTrap: 4, + }, + } + if got := Normalize(pkt, "10.0.0.1", now).GetTrapOid(); got != "1.3.6.1.4.1.2011.5.25.219.0.4" { + t.Fatalf("enterprise-specific: %q", got) + } +} + +// Трап без snmpTrapOID.0 існує (дешеві PDU, окремі прошивки ONU). Це не +// привід його загубити: подія все одно означає, що щось сталося. +func TestNormalizeV2cWithoutTrapOID(t *testing.T) { + pkt := &gosnmp.SnmpPacket{ + Version: gosnmp.Version2c, + PDUType: gosnmp.SNMPv2Trap, + Variables: []gosnmp.SnmpPDU{ + {Name: ".1.3.6.1.2.1.1.3.0", Type: gosnmp.TimeTicks, Value: uint(1)}, + }, + } + tr := Normalize(pkt, "10.0.0.7", now) + if tr == nil { + t.Fatal("трап без OID викинуто") + } + if tr.GetTrapOid() != "" { + t.Fatalf("вигаданий OID: %q", tr.GetTrapOid()) + } + if len(tr.GetVarbinds()) != 1 { + t.Fatalf("varbind-и втрачено: %d", len(tr.GetVarbinds())) + } +} + +// Бінарне значення не має потрапити в журнал сирими байтами: у +// найкращому разі це нечитабельно, у гіршому — ламає JSON дорогою в базу. +func TestOctetStringBinaryBecomesHex(t *testing.T) { + pkt := &gosnmp.SnmpPacket{ + PDUType: gosnmp.SNMPv2Trap, + Variables: []gosnmp.SnmpPDU{ + {Name: "1.3.6.1.2.1.2.2.1.6.1", Type: gosnmp.OctetString, + Value: []byte{0x00, 0x1b, 0x21, 0x3c, 0x4d, 0x5e}}, + }, + } + if got := Normalize(pkt, "10.0.0.1", now).GetVarbinds()[0].GetValue(); got != "00:1b:21:3c:4d:5e" { + t.Fatalf("MAC-адреса: %q", got) + } +} + +// Обрізання має бути видно. Мовчазне перетворює «ми показали не все» на +// «більше нічого не було». +func TestVarbindsAreCappedVisibly(t *testing.T) { + pkt := &gosnmp.SnmpPacket{PDUType: gosnmp.SNMPv2Trap} + for i := 0; i < maxVarbinds+10; i++ { + pkt.Variables = append(pkt.Variables, gosnmp.SnmpPDU{ + Name: fmt.Sprintf("1.3.6.1.4.1.1.%d", i), Type: gosnmp.Integer, Value: i, + }) + } + vbs := Normalize(pkt, "10.0.0.1", now).GetVarbinds() + if len(vbs) != maxVarbinds+1 { + t.Fatalf("varbind-ів після обрізання: %d", len(vbs)) + } + last := vbs[len(vbs)-1] + if last.GetType() != "truncated" || !strings.Contains(last.GetValue(), "10") { + t.Fatalf("позначки про обрізання немає: %+v", last) + } +} + +func TestLongValueTruncated(t *testing.T) { + long := strings.Repeat("я", maxValueRunes*2) + pkt := &gosnmp.SnmpPacket{ + PDUType: gosnmp.SNMPv2Trap, + Variables: []gosnmp.SnmpPDU{ + {Name: "1.3.6.1.4.1.1.1", Type: gosnmp.OctetString, Value: []byte(long)}, + }, + } + got := Normalize(pkt, "10.0.0.1", now).GetVarbinds()[0].GetValue() + if !strings.HasSuffix(got, "…") { + t.Fatalf("обрізання непомітне: %q", got[:20]) + } + if n := len([]rune(got)); n != maxValueRunes+1 { + t.Fatalf("довжина після обрізання: %d", n) + } +} + +// --------------------------------------------------------------------- +// Черга й ліміти +// --------------------------------------------------------------------- + +func trapPacket() *gosnmp.SnmpPacket { + return &gosnmp.SnmpPacket{ + Version: gosnmp.Version2c, + PDUType: gosnmp.SNMPv2Trap, + Variables: []gosnmp.SnmpPDU{ + {Name: oidSnmpTrapOID, Type: gosnmp.ObjectIdentifier, Value: "1.3.6.1.6.3.1.1.5.3"}, + }, + } +} + +// Шторм із однієї адреси не має витіснити решту мережі — саме її й +// потрібно бачити під час аварії. +func TestPerSourceRateLimit(t *testing.T) { + r := New(":0", quiet()) + r.perSource.Store(5) + + for i := 0; i < 20; i++ { + r.Handle(trapPacket(), "10.0.0.1", now) + } + // Друге джерело в ту саму мілісекунду має пройти повністю: ліміт на + // джерело, а не спільний. + for i := 0; i < 3; i++ { + r.Handle(trapPacket(), "10.0.0.2", now) + } + + got, dropped := r.Drain(0) + if dropped == 0 { + t.Fatal("шторм пройшов без жодної відмови") + } + var fromSecond int + for _, tr := range got { + if tr.GetSourceIp() == "10.0.0.2" { + fromSecond++ + } + } + if fromSecond != 3 { + t.Fatalf("сусіда витіснив шторм: %d із 3", fromSecond) + } +} + +// Відро поповнюється часом: перерва має повертати джерелу право слати. +func TestRateLimitRefills(t *testing.T) { + r := New(":0", quiet()) + r.perSource.Store(2) + + r.Handle(trapPacket(), "10.0.0.1", now) + r.Handle(trapPacket(), "10.0.0.1", now) + r.Handle(trapPacket(), "10.0.0.1", now) // третій за ту саму мить — повз + + r.Handle(trapPacket(), "10.0.0.1", now.Add(time.Second)) + + got, dropped := r.Drain(0) + if len(got) != 3 || dropped != 1 { + t.Fatalf("прийнято %d, відкинуто %d", len(got), dropped) + } +} + +// Черга без стелі означає, що зонд з'їдає пам'ять машини клієнта. +func TestQueueOverflowDropsOldest(t *testing.T) { + r := New(":0", quiet()) + r.perSource.Store(0) // ліміт частоти тут не перевіряємо + + for i := 0; i < maxQueue+50; i++ { + pkt := trapPacket() + pkt.Variables = append(pkt.Variables, gosnmp.SnmpPDU{ + Name: "1.3.6.1.4.1.1.1", Type: gosnmp.Integer, Value: i, + }) + r.Handle(pkt, "10.0.0.1", now) + } + + got, dropped := r.Drain(0) + if len(got) != maxQueue { + t.Fatalf("черга виросла над стелю: %d", len(got)) + } + if dropped != 50 { + t.Fatalf("відкинуто %d замість 50", dropped) + } + // Лишитись мають СВІЖІ: під час шторму саме вони пояснюють, що + // відбувається зараз. + if v := got[0].GetVarbinds()[1].GetValue(); v != "50" { + t.Fatalf("викинуто не найстаріше: перший у черзі %s", v) + } +} + +// Трап від адреси, якої немає серед хостів, — окремий стан, а не тиха +// втрата: часто це перший сигнал, що в мережі з'явилось незнайоме залізо. +func TestUnknownSourceIsKeptAndCounted(t *testing.T) { + r := New(":0", quiet()) + r.SetResolver(func(ip string) string { + if ip == "10.0.0.1" { + return "11111111-1111-1111-1111-111111111111" + } + return "" + }) + + r.Handle(trapPacket(), "10.0.0.1", now) + r.Handle(trapPacket(), "10.0.0.250", now) + + got, _ := r.Drain(0) + if len(got) != 2 { + t.Fatalf("трап від невідомої адреси загубився: %d", len(got)) + } + if got[1].GetDeviceId() != "" { + t.Fatalf("невідомій адресі приписано хост: %q", got[1].GetDeviceId()) + } + if r.UnknownSources() != 1 { + t.Fatalf("лічильник невідомих джерел: %d", r.UnknownSources()) + } +} + +// Requeue кладе невідправлене НА ПОЧАТОК: linkDown і linkUp того самого +// порту відрізняються лише часом, і переставлені місцями вони описують +// протилежну подію. +func TestRequeueKeepsOrder(t *testing.T) { + r := New(":0", quiet()) + r.perSource.Store(0) + + first := trapPacket() + first.Variables[0].Value = "1.3.6.1.6.3.1.1.5.3" // linkDown + second := trapPacket() + second.Variables[0].Value = "1.3.6.1.6.3.1.1.5.4" // linkUp + + r.Handle(first, "10.0.0.1", now) + sent, _ := r.Drain(0) + r.Handle(second, "10.0.0.1", now) + r.Requeue(sent) + + got, _ := r.Drain(0) + if len(got) != 2 || got[0].GetTrapOid() != "1.3.6.1.6.3.1.1.5.3" { + t.Fatalf("порядок після Requeue: %+v", got) + } +} + +// --------------------------------------------------------------------- +// Мережа +// --------------------------------------------------------------------- + +// Найдорожча помилка приймача трапів: не підтвердити inform. +// +// Відправник inform-а вважає подію недоставленою, доки не отримає +// відповідь, і шле її знову й знову. Тобто мовчазний приймач +// перетворює одну подію на нескінченний потік — рівно тоді, коли +// мережа й так у біді. Перевіряємо справжнім обміном по UDP, бо +// перевірити це «логікою» неможливо: відповідь формує сам слухач. +func TestInformIsAcknowledged(t *testing.T) { + addr := freeUDPPort(t) + + r := New(addr, quiet()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- r.Run(ctx) }() + + sender := &gosnmp.GoSNMP{ + Target: "127.0.0.1", + Port: uint16(portOf(t, addr)), + Version: gosnmp.Version2c, + Community: "public", + Timeout: time.Second, + Retries: 5, + Transport: "udp", + } + if err := sender.Connect(); err != nil { + t.Fatalf("з'єднання: %v", err) + } + defer sender.Conn.Close() + + // Відповідь на inform приходить у той самий сокет; без неї виклик + // поверне таймаут після всіх повторів. + resp, err := sender.SendTrap(gosnmp.SnmpTrap{ + IsInform: true, + Variables: []gosnmp.SnmpPDU{ + {Name: oidSysUpTime, Type: gosnmp.TimeTicks, Value: uint32(1)}, + {Name: oidSnmpTrapOID, Type: gosnmp.ObjectIdentifier, Value: "1.3.6.1.6.3.1.1.5.4"}, + }, + }) + if err != nil { + t.Fatalf("inform не підтверджено: %v", err) + } + if resp == nil { + t.Fatal("порожня відповідь на inform") + } + + // І сама подія має лежати в черзі: підтвердити й загубити — гірше, + // ніж не підтвердити, бо відправник більше не повторить. + deadline := time.Now().Add(2 * time.Second) + for { + got, _ := r.Drain(0) + if len(got) > 0 { + if got[0].GetTrapOid() != "1.3.6.1.6.3.1.1.5.4" { + t.Fatalf("не той трап: %q", got[0].GetTrapOid()) + } + break + } + if time.Now().After(deadline) { + t.Fatal("inform підтверджено, але подія в чергу не потрапила") + } + time.Sleep(20 * time.Millisecond) + } + + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("приймач завершився з помилкою: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("приймач не зупинився за контекстом") + } +} + +// freeUDPPort знаходить вільний порт: слухач приймає адресу рядком і +// свого фактичного порту назовні не показує. +func freeUDPPort(t *testing.T) string { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("порт: %v", err) + } + addr := pc.LocalAddr().String() + _ = pc.Close() + return addr +} + +func portOf(t *testing.T, addr string) int { + t.Helper() + _, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("адреса %q: %v", addr, err) + } + var n int + if _, err := fmt.Sscanf(port, "%d", &n); err != nil { + t.Fatalf("порт %q: %v", port, err) + } + return n +} diff --git a/agent/internal/ncmx/apply.go b/agent/internal/ncmx/apply.go new file mode 100644 index 0000000..06ecb3e --- /dev/null +++ b/agent/internal/ncmx/apply.go @@ -0,0 +1,242 @@ +package ncmx + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strings" + "time" + + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" +) + +// Заливка конфігу на пристрій — єдине місце, де зонд ПИШЕ на залізо. +// +// Транспорт, вхід, пошук запрошення тут ті самі, що в збору конфігу й +// виконанні команд: Dial, WaitPrompt, enable, CLI.Run. Другого шляху до +// пристрою в зонда немає й не має бути — сесія, відкрита іншим кодом, +// означала б другий набір помилок входу, другий спосіб зіпсувати +// стенограму й другу відповідь на питання «чому не під'єднались». +// +// Відрізняється не механіка, а поводження з помилкою. +// +// Збір конфігу, який упав, — це відсутність результату: нічого не +// сталось, спробуємо ще раз уночі. Заливка, яка впала на третій команді +// з двадцяти, — це ДВІ виконані команди на живому вузлі. Тому: +// +// * зупиняємось на першій же помилці (сервер шле continue_on_error = +// false), бо доливати рядки в конфіг, який уже пішов не тим шляхом, +// означає отримати стан, якого не описує жоден із двох конфігів; +// * звіт віддається ЗАВЖДИ, навіть якщо обірвалось усе, — у ньому +// видно, що саме встигло виконатись, а це половина відповіді на +// питання, у якому стані зараз вузол; +// * команда збереження виконується лише після повного успіху: +// зберегти напівзалитий конфіг означає зробити його постійним. +// +// ПАРОЛЬ У СТЕНОГРАМУ НЕ ПОТРАПЛЯЄ. Правило проєкту лишається чинним і +// тут: у буфер стенограми пишуться лише байти, які надіслав САМ +// пристрій (див. session.go і transport.go). Але заливка додає ризик, +// якого не було в збору: пристрій відлунює те, що ми йому шлемо, а +// шлемо ми рядки конфігу — зокрема з паролями. Ті байти прийшли від +// пристрою, тож у стенограму вони лягають законно, і прибрати їх тут +// нічим: зонд не знає, які саме рядки в цієї родини секретні. Тому +// маскування зроблено на сервері, за redact_patterns профілю, до +// запису в базу (див. store.FinishApply). + +// ApplyResult — те, що зонд повідомляє про заливку. +type ApplyResult struct { + Outcomes []*npv1.CommandOutcome + Committed bool + Transcript string + Duration time.Duration + // Err — причина, з якої послідовність не дійшла до кінця. + Err error +} + +// applyRejects — як залізо каже «я не зрозумів цей рядок». +// +// Окремий перелік, а не спільний із cliRejects зі збору, і різниця +// принципова. Там перелік застосовується до КОРОТКОГО виводу й слугує +// відповіді на питання «це взагалі конфіг?»; тут — до відповіді на +// кожен рядок, і ціна помилки інша в обидва боки. +// +// Пропустити відмову — найгірше: заливка піде далі, лишивши по собі +// напівзастосований конфіг, який виглядає застосованим. Тому сюди +// додано те, чого в збірному переліку немає: відмови прав доступу й +// скарги на контекст («command is not supported in current mode»). +// +// Прийняти за відмову звичайний рядок — теж погано, але дешевше: ми +// зупинимось раніше, ніж треба, і людина побачить, на чому саме, разом +// із самим виводом. Тому зразки прив'язані до початку рядка або до +// характерних маркерів («%», «Error:»), а не шукаються будь-де. +var applyRejects = regexp.MustCompile(`(?im)^\s*(%|\^|Error:|ERROR:)|` + + `invalid input|unknown command|invalid command|incomplete command|` + + `syntax error|command not found|next possible completions|` + + `not supported|permission denied|access denied|authorization failed|` + + `fail(ed)? to (set|apply|create|delete)`) + +// Apply виконує завдання заливки конфігу. +// +// Помилку в ApplyResult.Err ставить і тоді, коли до пристрою взагалі не +// дійшли: звіт із порожнім переліком команд і зрозумілою причиною — +// теж відповідь, і саме вона потрібна серверу, щоб не вирішити, що +// зонд просто зник. +func Apply(ctx context.Context, job *npv1.ConfigApplyJob) ApplyResult { + start := time.Now() + res := ApplyResult{Outcomes: make([]*npv1.CommandOutcome, 0, len(job.GetCommands()))} + + if len(job.GetCommands()) == 0 { + res.Err = fmt.Errorf("завдання заливки без жодної команди") + return res + } + + promptRe, err := compilePrompt(job.GetPromptRegex()) + if err != nil { + res.Err = err + return res + } + + timeout := job.GetTimeout().AsDuration() + if timeout <= 0 { + timeout = 10 * time.Minute + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Стенограма пишеться завжди, без прапорця в завданні. Заливка — + // незворотна дія: питання «а що саме пристрій на це відповів» + // ставлять уже після того, як усе сталося, і другого шансу зняти + // ту саму сесію не буде. + transcript := &bytes.Buffer{} + defer func() { + res.Transcript = transcript.String() + res.Duration = time.Since(start) + }() + + cred := job.GetCredential() + conn, err := Dial(ctx, transportName(job.GetTransport()), + job.GetDevice().GetAddress(), int(cred.GetPort()), + cred.GetUsername(), password(cred), privateKey(cred), + connectTimeout(timeout), transcript) + if err != nil { + res.Err = fmt.Errorf("підключення: %w", err) + return res + } + defer conn.Close() + + cli := NewCLI(conn, Options{PromptRe: promptRe, Transcript: transcript}) + + if err := cli.WaitPrompt(ctx); err != nil { + res.Err = fmt.Errorf("привітання пристрою: %w", err) + return res + } + if job.GetEnableRequired() { + if err := enable(ctx, cli, cred.GetEnablePassword()); err != nil { + res.Err = err + return res + } + } + + res.Outcomes, res.Committed, res.Err = applyLoop(ctx, cli, job) + return res +} + +// applyLoop проганяє рядки конфігу по черзі. +// +// Окремо від Apply, щоб перевірятись без мережі: усе, що варте +// перевірки, відбувається саме тут, а Dial із живим сокетом у тест не +// затягнеш. Так само зроблено для масового виконання команд +// (runCommandLoop) — і з тієї ж причини. +func applyLoop(ctx context.Context, cli *CLI, job *npv1.ConfigApplyJob) ( + []*npv1.CommandOutcome, bool, error) { + + outcomes := make([]*npv1.CommandOutcome, 0, len(job.GetCommands())+1) + + for i, cmd := range job.GetCommands() { + out, err := cli.Run(ctx, cmd) + oc := &npv1.CommandOutcome{ + Index: uint32(i), + Command: cmd, + Output: strings.TrimRight(out, "\n"), + Success: true, + } + + if err != nil { + // Не дочекались запрошення або обірвався канал. Найгірший + // різновид: ми не знаємо навіть, чи прочитав пристрій цей + // рядок. Далі не йдемо в жодному разі. + oc.Success = false + oc.ErrorLine = err.Error() + outcomes = append(outcomes, oc) + return outcomes, false, fmt.Errorf("рядок %d (%q): %w", i+1, cmd, err) + } + + if line := rejectLine(out); line != "" { + oc.Success = false + oc.ErrorLine = line + outcomes = append(outcomes, oc) + if !job.GetContinueOnError() { + return outcomes, false, fmt.Errorf("пристрій відхилив рядок %d (%q): %s", + i+1, cmd, line) + } + continue + } + + outcomes = append(outcomes, oc) + } + + // Збереження — лише після повного успіху. + // + // Незбережений напівзалитий конфіг має одну важливу властивість: + // його прибирає перезавантаження вузла. Зберегти його означає + // відібрати в людини цю останню можливість — і зробити помилку + // постійною. + commit := strings.TrimSpace(job.GetCommitCommand()) + if commit == "" { + return outcomes, false, nil + } + + out, err := cli.Run(ctx, commit) + oc := &npv1.CommandOutcome{ + Index: uint32(len(outcomes)), + Command: commit, + Output: strings.TrimRight(out, "\n"), + Success: err == nil && rejectLine(out) == "", + } + switch { + case err != nil: + oc.ErrorLine = err.Error() + case !oc.Success: + oc.ErrorLine = rejectLine(out) + } + outcomes = append(outcomes, oc) + + if !oc.Success { + // Конфіг застосований, але не збережений. Це НЕ повний провал і + // не повний успіх: до перезавантаження пристрій працює за новим + // конфігом, після — за старим. Такий стан має бути видно + // окремо, тому committed лишається false, а причина йде в + // помилку. + return outcomes, false, fmt.Errorf("конфіг залито, але зберегти не вдалося (%q): %s", + commit, oc.ErrorLine) + } + return outcomes, true, nil +} + +// rejectLine повертає перший рядок виводу, схожий на відмову. +// +// Саме рядок, а не «так/ні»: людині в звіті потрібно бачити, ЧИМ +// пристрій відповів. «Пристрій відхилив рядок 7» без його власних слів +// — це запрошення відкривати стенограму щоразу. +func rejectLine(out string) string { + if strings.TrimSpace(out) == "" { + return "" + } + for _, ln := range strings.Split(out, "\n") { + if applyRejects.MatchString(ln) { + return strings.TrimSpace(ln) + } + } + return "" +} diff --git a/agent/internal/ncmx/apply_test.go b/agent/internal/ncmx/apply_test.go new file mode 100644 index 0000000..cbc8688 --- /dev/null +++ b/agent/internal/ncmx/apply_test.go @@ -0,0 +1,147 @@ +package ncmx + +import ( + "context" + "strings" + "testing" + + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" +) + +// Перевірки заливки конфігу. +// +// Головне, що тут перевіряється, — поводження з ВІДМОВОЮ пристрою. +// Заливка, яка не помітила відмови й пішла далі, лишає по собі +// напівзастосований конфіг, який виглядає застосованим: жодна помилка +// нікуди не записалась, звіт зелений, а вузол не той. Це найдорожчий +// різновид збою в усій системі, і ловиться він рівно тут. + +func applyJob(commands []string, commit string) *npv1.ConfigApplyJob { + return &npv1.ConfigApplyJob{ + RollbackId: "rb-1", + Commands: commands, + CommitCommand: commit, + } +} + +func TestApplyRunsAllLinesAndCommits(t *testing.T) { + d := newDevice("\nsw1(config)#", "", map[string]string{ + "configure terminal": "", + "vlan 10": "", + "end": "", + "write memory": "Building configuration...\r\nOK\r\n", + }) + cli := cliFor(d, `[>#]\s*$`) + ctx := context.Background() + _ = cli.WaitPrompt(ctx) + + outcomes, committed, err := applyLoop(ctx, cli, + applyJob([]string{"configure terminal", "vlan 10", "end"}, "write memory")) + + if err != nil { + t.Fatalf("несподівана помилка: %v", err) + } + if !committed { + t.Error("команда збереження відпрацювала, а committed лишився false") + } + if len(outcomes) != 4 { + t.Fatalf("очікували 4 результати (3 рядки + збереження), отримали %d", len(outcomes)) + } + for _, oc := range outcomes { + if !oc.GetSuccess() { + t.Errorf("рядок %q позначено невдалим: %s", oc.GetCommand(), oc.GetErrorLine()) + } + } +} + +// Відмову пристрою видно лише у виводі: коду помилки в CLI немає. +// Пропустити її означає долити решту конфігу поверх того, що не +// застосувалось. +func TestApplyStopsOnDeviceRejection(t *testing.T) { + d := newDevice("\nsw1(config)#", "", map[string]string{ + "configure terminal": "", + "vlan 10": "% Invalid input detected at '^' marker.\r\n", + "name office": "", + }) + cli := cliFor(d, `[>#]\s*$`) + ctx := context.Background() + _ = cli.WaitPrompt(ctx) + + outcomes, committed, err := applyLoop(ctx, cli, + applyJob([]string{"configure terminal", "vlan 10", "name office"}, "write memory")) + + if err == nil { + t.Fatal("відмову пристрою не помічено") + } + if committed { + t.Error("напівзалитий конфіг не має зберігатись") + } + if len(outcomes) != 2 { + t.Fatalf("послідовність мала спинитись на другому рядку, отримали %d", len(outcomes)) + } + if outcomes[1].GetSuccess() { + t.Error("відхилений рядок позначено успішним") + } + if !strings.Contains(outcomes[1].GetErrorLine(), "Invalid input") { + t.Errorf("у звіті немає слів самого пристрою: %q", outcomes[1].GetErrorLine()) + } +} + +// Невдале збереження — це НЕ провал заливки: конфіг уже на пристрої й +// працює. Але й не успіх: перезавантаження його прибере. Стан має бути +// видно окремо, тому committed лишається false, а помилка — непорожня. +func TestApplyReportsFailedCommitSeparately(t *testing.T) { + d := newDevice("\nsw1(config)#", "", map[string]string{ + "configure terminal": "", + "vlan 10": "", + "write memory": "%% Error: startup-config is locked\r\n", + }) + cli := cliFor(d, `[>#]\s*$`) + ctx := context.Background() + _ = cli.WaitPrompt(ctx) + + outcomes, committed, err := applyLoop(ctx, cli, + applyJob([]string{"configure terminal", "vlan 10"}, "write memory")) + + if err == nil { + t.Fatal("невдале збереження мало дати помилку") + } + if committed { + t.Error("committed має лишитись false") + } + // Рядки конфігу при цьому успішні: вони справді виконались. + if !outcomes[0].GetSuccess() || !outcomes[1].GetSuccess() { + t.Error("рядки конфігу помилково позначено невдалими") + } + if outcomes[len(outcomes)-1].GetSuccess() { + t.Error("невдале збереження позначено успішним") + } +} + +// Порожній вивід — нормальна відповідь на конфігураційний рядок: +// більшість платформ на успішний `vlan 10` не пише нічого. +func TestApplyAcceptsSilentSuccess(t *testing.T) { + d := newDevice("\nsw1(config)#", "", map[string]string{"vlan 10": ""}) + cli := cliFor(d, `[>#]\s*$`) + ctx := context.Background() + _ = cli.WaitPrompt(ctx) + + outcomes, _, err := applyLoop(ctx, cli, applyJob([]string{"vlan 10"}, "")) + if err != nil { + t.Fatalf("мовчазний успіх прийнято за помилку: %v", err) + } + if len(outcomes) != 1 || !outcomes[0].GetSuccess() { + t.Fatalf("отримали %+v", outcomes) + } +} + +// Слово «invalid» усередині законного рядка не має зупиняти заливку: +// зразки відмов прив'язані до маркерів, а не шукаються будь-де в тексті. +func TestApplyDoesNotMistakeConfigTextForRejection(t *testing.T) { + if line := rejectLine("ip access-list extended INVALID-SRC"); line != "" { + t.Errorf("законний рядок прийнято за відмову: %q", line) + } + if line := rejectLine("% Invalid input detected"); line == "" { + t.Error("справжню відмову не помічено") + } +} diff --git a/agent/internal/ncmx/collect.go b/agent/internal/ncmx/collect.go index 04ddf53..bc43352 100644 --- a/agent/internal/ncmx/collect.go +++ b/agent/internal/ncmx/collect.go @@ -57,7 +57,8 @@ func Collect(ctx context.Context, job *npv1.ConfigJob) (Result, error) { conn, err := Dial(ctx, transportName(job.GetTransport()), job.GetDevice().GetAddress(), int(job.GetPort()), - cred.GetUsername(), password(cred), connectTimeout(timeout), transcript) + cred.GetUsername(), password(cred), privateKey(cred), + connectTimeout(timeout), transcript) if err != nil { return withTranscript(Result{}, transcript), err } @@ -96,9 +97,8 @@ func Collect(ctx context.Context, job *npv1.ConfigJob) (Result, error) { if err != nil { return withTranscript(Result{}, transcript), fmt.Errorf("команда %q: %w", last, err) } - if strings.TrimSpace(body) == "" { - return withTranscript(Result{}, transcript), - fmt.Errorf("команда %q повернула порожній вивід", last) + if err := looksLikeConfig(last, body); err != nil { + return withTranscript(Result{}, transcript), err } b := []byte(body) @@ -187,6 +187,17 @@ func password(c *npv1.Credential) string { return "" } +// privateKey — SSH-ключ, якщо доступ заведений із ключем, а не паролем. +func privateKey(c *npv1.Credential) []byte { + if c == nil { + return nil + } + if k, ok := c.GetSecret().(*npv1.Credential_PrivateKey); ok { + return k.PrivateKey + } + return nil +} + func transportName(t npv1.Transport) string { switch t { case npv1.Transport_TRANSPORT_TELNET: @@ -217,3 +228,63 @@ func withTranscript(r Result, b *bytes.Buffer) Result { } return r } + +// Скільки змістовних рядків мусить мати те, що ми називаємо конфігом. +// +// Порогу вистачає одного, і він грубий навмисне: конфігурація будь-якої +// коробки — це десятки рядків щонайменше, а все, що коротше, — це +// пристрій відповідає щось інше. Тонше налаштування тут було б +// самообманом: точного числа не існує, а видимість точності заважала б +// помітити, що поріг узагалі спрацював. +const minConfigLines = 5 + +// cliRejects — як залізо каже «я не зрозумів команду». +// +// Кожен виробник каже по-своєму, і жоден не робить це кодом помилки: +// для CLI відмова — це просто ще один рядок виводу. Зразки шукаються +// лише в короткому виводі (див. looksLikeConfig): у справжньому конфізі +// на тридцять тисяч рядків слова «invalid» чи «unknown» трапляються в +// іменах ACL і в банерах, і глушити збір через них не можна. +var cliRejects = regexp.MustCompile(`(?i)next possible completions|` + + `invalid input|unknown command|invalid command|incomplete command|` + + `available commands|command not found|syntax error|% *bad|permission denied`) + +// looksLikeConfig відсіює відповіді, які конфігом не є. +// +// Навіщо окрема перевірка, якщо порожній вивід ми й так ловили. Бо +// пристрій рідко мовчить: на невідому команду D-Link відповідає +// «Next possible completions», Cisco — «% Invalid input», і кожна з цих +// відповідей непорожня. Далі вона лягала в сховище як версія конфігу, +// завдання позначалось успішним, і людина дізнавалась про це через +// півроку — коли конфіг знадобився. +// +// Гучна помилка тут коштує одного дзвінка. Тиха — усього архіву. +func looksLikeConfig(cmd, body string) error { + trimmed := strings.TrimSpace(body) + if trimmed == "" { + return fmt.Errorf("команда %q повернула порожній вивід", cmd) + } + + var lines int + for _, ln := range strings.Split(trimmed, "\n") { + if strings.TrimSpace(ln) != "" { + lines++ + } + } + if lines >= minConfigLines && !cliRejects.MatchString(trimmed) { + return nil + } + // Зразок відмови в довгому виводі нічого не означає — там це просто + // слово всередині конфігу. + if lines >= minConfigLines { + return nil + } + + // Показуємо, що саме сказав пристрій: без цього людині лишається + // здогадуватись, чим команда з профілю не підійшла її залізу. + said := trimmed + if len(said) > 300 { + said = said[:300] + "…" + } + return fmt.Errorf("команда %q не дала конфігу — пристрій відповів: %s", cmd, said) +} diff --git a/agent/internal/ncmx/commands.go b/agent/internal/ncmx/commands.go new file mode 100644 index 0000000..f56be34 --- /dev/null +++ b/agent/internal/ncmx/commands.go @@ -0,0 +1,144 @@ +package ncmx + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "strings" + "time" + + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" +) + +// Виконання довільних команд — той самий сеанс, що й збір конфігу, з +// однією відмінністю: тут цікавий вивід КОЖНОЇ команди, а не лише +// останньої. +// +// Через це Collect тут не годиться, хоч і робить майже те саме: у ньому +// вивід підготовчих команд свідомо відкидається, а результат проходить +// перевірку «схоже на конфіг». Обидва рішення правильні для бекапу й +// обидва хибні тут: `display cpu` не схожий на конфіг і ніколи не +// пройде looksLikeConfig, а вивід підготовчої команди — єдине місце, де +// видно, що консоль так і лишилась посторінковою. + +// Outcome — результат однієї команди. +type Outcome struct { + Command string `json:"command"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} + +// CommandReport — тіло, яке їде на сервер замість тексту конфігу. +// +// JSON, а не суцільний текст із роздільниками: питання до результату +// завжди адресне («що відповіла ОЦЯ команда»), і різати спільний потік +// назад на команди означало б удруге робити роботу, яку ми вже зробили +// тут — до того ж навгад, бо роздільник може трапитись і у виводі. +type CommandReport struct { + Outcomes []Outcome `json:"outcomes"` + // Чому послідовність обірвалась. Порожнє — усі команди відпрацювали. + Error string `json:"error,omitempty"` +} + +// RunCommands виконує всі команди завдання й повертає вивід кожної. +// +// Помилку повертає лише тоді, коли розповідати нічого: не під'єднались, +// не дочекались привітання. Усе, що сталося після входу, — це вміст +// звіту, а не відмова: половина виводу теж відповідь, і саме в ній +// зазвичай видно, чому впала друга половина. +func RunCommands(ctx context.Context, job *npv1.ConfigJob) (Result, error) { + start := time.Now() + + if len(job.GetCommands()) == 0 { + return Result{}, fmt.Errorf("завдання без жодної команди") + } + + promptRe, err := compilePrompt(job.GetPromptRegex()) + if err != nil { + return Result{}, err + } + + timeout := job.GetTimeout().AsDuration() + if timeout <= 0 { + timeout = 2 * time.Minute + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cred := job.GetCredential() + + var transcript *bytes.Buffer + if job.GetCaptureTranscript() { + transcript = &bytes.Buffer{} + } + + conn, err := Dial(ctx, transportName(job.GetTransport()), + job.GetDevice().GetAddress(), int(job.GetPort()), + cred.GetUsername(), password(cred), privateKey(cred), + connectTimeout(timeout), transcript) + if err != nil { + return withTranscript(Result{}, transcript), err + } + defer conn.Close() + + cli := NewCLI(conn, Options{ + PromptRe: promptRe, + MaxBytes: int(job.GetMaxBytes()), + Transcript: transcript, + }) + + if err := cli.WaitPrompt(ctx); err != nil { + return withTranscript(Result{}, transcript), + fmt.Errorf("привітання пристрою: %w", err) + } + + if job.GetEnableRequired() { + if err := enable(ctx, cli, cred.GetEnablePassword()); err != nil { + return withTranscript(Result{}, transcript), err + } + } + + rep := runCommandLoop(ctx, cli, job.GetCommands()) + + body, err := json.Marshal(rep) + if err != nil { + return withTranscript(Result{}, transcript), err + } + sum := sha256.Sum256(body) + + res := Result{ + Body: body, + SHA256: sum[:], + LineCount: len(rep.Outcomes), + Duration: time.Since(start), + } + return withTranscript(res, transcript), nil +} + +// runCommandLoop проганяє команди по черзі й збирає вивід кожної. +// +// Окремо від RunCommands, щоб перевірятись без мережі: усе, що варте +// перевірки, відбувається саме тут, а Dial із живим сокетом у тест не +// затягнеш. +func runCommandLoop(ctx context.Context, cli *CLI, commands []string) CommandReport { + rep := CommandReport{Outcomes: make([]Outcome, 0, len(commands))} + + for _, cmd := range commands { + out, err := cli.Run(ctx, cmd) + oc := Outcome{Command: cmd, Output: strings.TrimRight(out, "\n")} + if err != nil { + oc.Error = err.Error() + rep.Outcomes = append(rep.Outcomes, oc) + // Далі не йдемо. Не дочекались запрошення — означає, що + // пристрій ще щось пише або чекає на щось інше; наступна + // команда пішла б у невідомо який контекст, а її вивід + // приписався б не тій команді. + rep.Error = fmt.Sprintf("команда %q: %v", cmd, err) + break + } + rep.Outcomes = append(rep.Outcomes, oc) + } + return rep +} diff --git a/agent/internal/ncmx/commands_test.go b/agent/internal/ncmx/commands_test.go new file mode 100644 index 0000000..70681d1 --- /dev/null +++ b/agent/internal/ncmx/commands_test.go @@ -0,0 +1,107 @@ +package ncmx + +import ( + "context" + "regexp" + "strings" + "testing" + "time" +) + +// Вивід кожної команди має лишатись при своїй команді. +// +// Це і є вся суть масового виконання: збір конфігу вивід попередніх +// команд відкидає, і якби тут використали його, оператор отримав би +// відповідь лише на останню. +func TestCommandLoopKeepsOutputPerCommand(t *testing.T) { + d := newDevice("\nsw1#", "Welcome\r\n", map[string]string{ + "display version": "VRP (R) software, Version 5.170\r\n", + "display cpu": "CPU Usage : 7%\r\n", + }) + cli := cliFor(d, `[>#]\s*$`) + ctx := context.Background() + if err := cli.WaitPrompt(ctx); err != nil { + t.Fatalf("привітання: %v", err) + } + + rep := runCommandLoop(ctx, cli, []string{"display version", "display cpu"}) + + if rep.Error != "" { + t.Fatalf("несподівана помилка: %s", rep.Error) + } + if len(rep.Outcomes) != 2 { + t.Fatalf("очікували 2 результати, отримали %d", len(rep.Outcomes)) + } + if rep.Outcomes[0].Command != "display version" || + !strings.Contains(rep.Outcomes[0].Output, "Version 5.170") { + t.Fatalf("перша команда: %+v", rep.Outcomes[0]) + } + if rep.Outcomes[1].Command != "display cpu" || + !strings.Contains(rep.Outcomes[1].Output, "CPU Usage") { + t.Fatalf("друга команда: %+v", rep.Outcomes[1]) + } +} + +// Порожня відповідь — це відповідь. +// +// На відміну від збору конфігу, де порожній вивід означає провал: +// «undo terminal monitor» нічого не друкує й відпрацьовує правильно. +func TestCommandLoopAcceptsEmptyOutput(t *testing.T) { + d := newDevice("\nsw1#", "", map[string]string{ + "screen-length 0 temporary": "", + }) + cli := cliFor(d, `[>#]\s*$`) + ctx := context.Background() + _ = cli.WaitPrompt(ctx) + + rep := runCommandLoop(ctx, cli, []string{"screen-length 0 temporary"}) + + if rep.Error != "" { + t.Fatalf("порожній вивід не мав бути помилкою: %s", rep.Error) + } + if len(rep.Outcomes) != 1 || rep.Outcomes[0].Output != "" { + t.Fatalf("отримали %+v", rep.Outcomes) + } +} + +// Збій посеред послідовності зупиняє її, але вже зібране лишається. +// +// Половина виводу — половина відповіді, і саме в ній зазвичай видно, +// чому не стало другої половини. Продовжувати не можна: сесія лишилась +// у невідомому стані, і вивід наступної команди приписався б не їй. +func TestCommandLoopStopsAtFirstFailureAndKeepsWhatItGot(t *testing.T) { + // Друга команда впирається в ліміт розміру: пристрій сипле більше, + // ніж дозволено, і сесія лишається на середині чужого виводу. + d := newDevice("\nsw1#", "", map[string]string{ + "display version": "5.170\r\n", + "display cpu": strings.Repeat("x", 500) + "\r\n", + }) + d.chunkLen = 3 // дрібними шматками, щоб ліміт спрацював до промпту + + cli := NewCLI(d, Options{ + PromptRe: promptFor(`[>#]\s*$`), + MaxBytes: 64, + IdleTimeout: time.Second, + }) + ctx := context.Background() + _ = cli.WaitPrompt(ctx) + + rep := runCommandLoop(ctx, cli, []string{"display version", "display cpu"}) + + if rep.Error == "" { + t.Fatal("збій мав потрапити в звіт прогону") + } + if len(rep.Outcomes) != 2 { + t.Fatalf("зібране до збою мало лишитись, отримали %d", len(rep.Outcomes)) + } + if rep.Outcomes[0].Error != "" { + t.Fatalf("перша команда мала відпрацювати: %+v", rep.Outcomes[0]) + } + if rep.Outcomes[1].Error == "" { + t.Fatalf("друга команда мала нести помилку: %+v", rep.Outcomes[1]) + } +} + +func promptFor(pattern string) *regexp.Regexp { + return regexp.MustCompile(`(?m)` + pattern) +} diff --git a/agent/internal/ncmx/transport.go b/agent/internal/ncmx/transport.go index deaa6e7..32614b6 100644 --- a/agent/internal/ncmx/transport.go +++ b/agent/internal/ncmx/transport.go @@ -21,14 +21,14 @@ import ( // запису від пристрою лишається тільки «i/o timeout» без жодної підказки, // чого він чекав. func Dial(ctx context.Context, transport, host string, port int, - user, password string, timeout time.Duration, tr *bytes.Buffer) (Conn, error) { + user, password string, key []byte, timeout time.Duration, tr *bytes.Buffer) (Conn, error) { switch transport { case "ssh", "": if port == 0 { port = 22 } - return dialSSH(ctx, host, port, user, password, timeout) + return dialSSH(ctx, host, port, user, password, key, timeout) case "telnet": if port == 0 { port = 23 @@ -51,11 +51,33 @@ type sshConn struct { } func dialSSH(ctx context.Context, host string, port int, - user, password string, timeout time.Duration) (Conn, error) { + user, password string, key []byte, timeout time.Duration) (Conn, error) { - cfg := &ssh.ClientConfig{ - User: user, - Auth: []ssh.AuthMethod{ + // Ключ першим, якщо він є. + // + // На магістральному залізі вхід за паролем часто просто вимкнений — + // і тоді сервер відхиляє і password, і keyboard-interactive, а + // go-ssh каже «no supported methods remain», не пояснюючи, що саме + // від нас хотіли. Поле private_key було в протоколі від початку, + // але його ніхто не читав: транспорт брав лише пароль. + var auth []ssh.AuthMethod + if len(key) > 0 { + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + // Пароль може бути парольною фразою до ключа — пробуємо і + // так, перш ніж здаватись. + if password == "" { + return nil, fmt.Errorf("розбір SSH-ключа: %w", err) + } + signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(password)) + if err != nil { + return nil, fmt.Errorf("розбір SSH-ключа: %w", err) + } + } + auth = append(auth, ssh.PublicKeys(signer)) + } + if password != "" { + auth = append(auth, ssh.Password(password), // Частина старих платформ не вміє «password», лише // keyboard-interactive з єдиним запитом. @@ -66,7 +88,15 @@ func dialSSH(ctx context.Context, host string, port int, } return ans, nil }), - }, + ) + } + if len(auth) == 0 { + return nil, fmt.Errorf("для SSH не задано ні пароля, ні ключа") + } + + cfg := &ssh.ClientConfig{ + User: user, + Auth: auth, // Ключі мережевого обладнання не звіряються. // // Це свідоме рішення, а не недогляд. Зонд стоїть усередині diff --git a/agent/internal/scheduler/scheduler.go b/agent/internal/scheduler/scheduler.go index 4588ad3..2952c25 100644 --- a/agent/internal/scheduler/scheduler.go +++ b/agent/internal/scheduler/scheduler.go @@ -463,7 +463,10 @@ func (s *Scheduler) execute(ctx context.Context, e *entry) { s.sink.Add(task.DeviceID, module.ModuleKey(task.CheckType), res) s.sink.AddCheckResult(cr) - if len(res.Neighbors) > 0 || len(res.InterfaceRecords) > 0 { + // Devices теж рахуються: у режимі самого лише розпізнавання звіт + // не містить ні сусідів, ні портів — тільки системну групу, заради + // якої чек і заведено. Без цієї умови вона нікуди не їхала. + if len(res.Neighbors) > 0 || len(res.InterfaceRecords) > 0 || len(res.Devices) > 0 { s.onDisco(res) } s.onStatus(&npv1.TaskStatusUpdate{ diff --git a/agent/internal/session/apply_jobs.go b/agent/internal/session/apply_jobs.go new file mode 100644 index 0000000..fe4ad4a --- /dev/null +++ b/agent/internal/session/apply_jobs.go @@ -0,0 +1,90 @@ +package session + +import ( + "context" + "time" + + "github.com/netpulse/netpulse/agent/internal/ncmx" + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "google.golang.org/protobuf/types/known/durationpb" +) + +// runApplyJob заливає конфіг на пристрій і звітує про результат. +// +// Виконується в окремій горутині — з тієї ж причини, що й збір конфігу: +// заливка триває хвилини, і тримати на ній контрольний цикл означало б +// не відповідати на ping і бути визнаним мертвим саме тоді, коли зонд +// робить найнебезпечнішу роботу. +// +// Жодної власної логіки погодження тут немає й не буде. Завдання, що +// приїхало в межах живої автентифікованої сесії, виконується — усе +// рішення про те, чи можна його виконувати, ухвалено на сервері +// (ncm.rollbacks: awaiting_approval → approved). Зонд, який намагався б +// вирішувати це вдруге, мав би власну копію політики — і вона розійшлася +// б із серверною першого ж дня. +func (s *Session) runApplyJob(ctx context.Context, out chan<- *npv1.ControlUp, + job *npv1.ConfigApplyJob) { + + log := s.log.With("rollback", job.GetRollbackId(), + "device", job.GetDevice().GetName(), + "рядків", len(job.GetCommands())) + + // Warn, а не Info, і до початку роботи. Запис на живе залізо — те, + // що шукають у журналі зонда після аварії, і рядок «почали» має + // стояти навіть тоді, коли після нього не буде жодного іншого. + log.Warn("заливка конфігу почалась") + + res := ncmx.Apply(ctx, job) + + result := &npv1.ConfigApplyResult{ + RollbackId: job.GetRollbackId(), + Success: res.Err == nil, + Outcomes: res.Outcomes, + Committed: res.Committed, + Transcript: res.Transcript, + Duration: durationpb.New(res.Duration), + } + if res.Err != nil { + result.Error = &npv1.Error{Code: "apply_failed", Message: res.Err.Error()} + log.Error("заливка конфігу не завершилась", + "помилка", res.Err, "виконано_рядків", len(res.Outcomes)) + } else { + log.Warn("заливка конфігу завершена", + "рядків", len(res.Outcomes), "збережено", res.Committed, + "тривалість", res.Duration.Round(time.Millisecond)) + } + + s.sendApplyResult(ctx, out, result) +} + +// sendApplyResult віддає звіт, чекаючи місця в черзі. +// +// Тут свідомо НЕ enqueue. Той кидає повідомлення, коли черга забита, і +// для heartbeat це правильно: сервер помітить пропажу за таймаутом. +// Звіт про заливку загубити не можна за жодних умов — сервер, який його +// не отримав, знає лише, що на пристрій щось пішло, і не знає, чим +// скінчилось. +// +// Якщо ж канал усе-таки помер разом із сесією, звіт справді зникне — і +// саме на цей випадок на сервері стоїть прибиральник, який переводить +// таке завдання не у відмову, а в контрольний збір: піти й подивитись, +// що реально стало на пристрої. Здогадуватись тут нема про що, пристрій +// поруч. +func (s *Session) sendApplyResult(ctx context.Context, out chan<- *npv1.ControlUp, + result *npv1.ConfigApplyResult) { + + msg := &npv1.ControlUp{ + Seq: s.seq.Add(1), + Payload: &npv1.ControlUp_ConfigApplyResult{ConfigApplyResult: result}, + } + + select { + case out <- msg: + case <-ctx.Done(): + s.log.Error("звіт про заливку не поїхав: сесія обірвалась", + "rollback", result.GetRollbackId()) + case <-time.After(30 * time.Second): + s.log.Error("звіт про заливку не поїхав: контрольний канал забитий", + "rollback", result.GetRollbackId()) + } +} diff --git a/agent/internal/session/command_jobs.go b/agent/internal/session/command_jobs.go new file mode 100644 index 0000000..7d2a0e2 --- /dev/null +++ b/agent/internal/session/command_jobs.go @@ -0,0 +1,53 @@ +package session + +import ( + "context" + "time" + + "github.com/netpulse/netpulse/agent/internal/ncmx" + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" +) + +// commandConfigType — значення config_type, яким сервер позначає +// «виконати команди», а не «зняти конфіг». +// +// Поле вже було й уже возило вид зрізу ('running', 'startup'), тож нове +// значення обійшлося без змін у .proto. Той самий рядок знає сервер +// (store.CommandConfigType); спільної залежності між ними немає навмисно +// — агент і сервер оновлюються нарізно, і константа в кожного своя +// рівно тому, що змінювати її не можна жодному з двох. +const commandConfigType = "command" + +// runCommandJob виконує довільні команди й віддає вивід тим самим +// стрімом, що й конфіг. +// +// Окрема функція, а не гілка всередині runConfigJob, бо відрізняється +// поводження з невдачею. Збір конфігу без конфігу — порожній результат, +// і його треба відправити як відмову. Прогін команд без частини виводу +// — це все ще результат: половина відповіді краще за нічого, і саме в +// ній зазвичай видно, чому не стало другої половини. +func (s *Session) runCommandJob(ctx context.Context, job *npv1.ConfigJob) { + log := s.log.With("job_id", job.GetJobId(), + "device", job.GetDevice().GetName(), + "команд", len(job.GetCommands())) + + log.Info("виконання команд почалось") + + res, err := ncmx.RunCommands(ctx, job) + if err != nil { + // Сюди доходить лише те, після чого немає що показувати: не + // під'єднались, не дочекались привітання. Стенограма при цьому + // найцінніша — у ній видно, на чому саме стало. + log.Error("виконання команд", "помилка", err) + s.uploadFailure(ctx, job, err, res.Transcript) + return + } + + log.Info("команди виконано", + "байтів", len(res.Body), + "тривалість", res.Duration.Round(time.Millisecond)) + + if err := s.uploadConfig(ctx, job, res); err != nil { + log.Error("вивантаження результату команд", "помилка", err) + } +} diff --git a/agent/internal/session/config_jobs.go b/agent/internal/session/config_jobs.go index b717115..014f1b0 100644 --- a/agent/internal/session/config_jobs.go +++ b/agent/internal/session/config_jobs.go @@ -34,6 +34,29 @@ func (s *Session) runConfigJob(ctx context.Context, job *npv1.ConfigJob) { "device", job.GetDevice().GetName(), "config_type", job.GetConfigType()) + // Масове виконання команд їде тим самим завданням і тим самим + // стрімом — інакше довелося б заводити другий шлях сервер→зонд + // заради того, що вже працює. Різниця лише в тому, що робити з + // виводом, і про неї каже config_type. + // + // Гілка тут, а не в ncmx: транспорт, вхід і пошук запрошення в обох + // випадках однакові, і розводити їх нижче означало б розводити й це. + if job.GetConfigType() == commandConfigType { + s.runCommandJob(ctx, job) + return + } + + // Конфіг-файли самої машини — теж це саме завдання й те саме + // вивантаження, лише замість сесії до пристрою читається власний + // диск. Розвилка за наявністю local_files, а не за config_type: + // config_type тут означає назву зрізу в архіві, і навантажувати + // його ще й способом збору означало б, що перейменування зрізу + // тихо змінює те, звідки беруться дані. + if job.GetLocalFiles() != nil { + s.runLocalFilesJob(ctx, job) + return + } + log.Info("збір конфігу почався") res, err := ncmx.Collect(ctx, job) @@ -53,6 +76,17 @@ func (s *Session) runConfigJob(ctx context.Context, job *npv1.ConfigJob) { } func (s *Session) uploadConfig(ctx context.Context, job *npv1.ConfigJob, res ncmx.Result) error { + return s.uploadConfigAs(ctx, job, res, "") +} + +// uploadConfigAs — те саме вивантаження, але з відбитком машини. +// +// Відбиток заповнює лише збір локальних файлів: для комутатора питання +// «з якої машини це знято» не має сенсу — знято з пристрою, а зонд лише +// набирав. Для сервера воно головне, бо зонд і є той сервер. +func (s *Session) uploadConfigAs(ctx context.Context, job *npv1.ConfigJob, + res ncmx.Result, machineID string) error { + conn := s.client.Load() if conn == nil { return errNoConnection @@ -84,6 +118,7 @@ func (s *Session) uploadConfig(ctx context.Context, job *npv1.ConfigJob, res ncm ConfigType: job.GetConfigType(), CollectedAt: timestamppb.Now(), Encoding: "gzip", + MachineId: machineID, }}, }); err != nil { return err diff --git a/agent/internal/session/localfiles_jobs.go b/agent/internal/session/localfiles_jobs.go new file mode 100644 index 0000000..4538066 --- /dev/null +++ b/agent/internal/session/localfiles_jobs.go @@ -0,0 +1,73 @@ +package session + +import ( + "context" + "errors" + "time" + + "github.com/netpulse/netpulse/agent/internal/localfiles" + "github.com/netpulse/netpulse/agent/internal/ncmx" + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" +) + +// runLocalFilesJob збирає конфіг-файли машини, на якій працює зонд. +// +// Гілка стоїть поруч із бекапом комутатора й закінчується тим самим +// вивантаженням навмисно. Розклад (ncm.device_policies), кнопка «зібрати +// зараз», черга ncm.jobs, звірка з попередньою версією, коміт у Git, +// ретеншен і дзеркало — усе це вже є й працює. Другий шлях для файлів +// сервера означав би другий розклад, другу чергу й другу історію +// помилок; різниця між «зайти по SSH» і «прочитати власний диск» цього +// не варта. +func (s *Session) runLocalFilesJob(ctx context.Context, job *npv1.ConfigJob) { + log := s.log.With("job_id", job.GetJobId(), + "device", job.GetDevice().GetName(), "config_type", job.GetConfigType()) + + mod := s.cfg.FileCfg + if mod == nil { + // Зонд без модуля не мовчить: сервер запропонував збір, бо + // вважав його можливим, і має дізнатись, що помилився. + s.uploadFailure(ctx, job, + errors.New("цей зонд зібраний без модуля filecfg"), "") + return + } + + log.Info("збір конфіг-файлів машини почався", "політика", mod.PolicyPath()) + + res, err := mod.Collect(ctx, localfiles.Limits{ + MaxFileBytes: int64(job.GetLocalFiles().GetMaxFileBytes()), + MaxFiles: int(job.GetLocalFiles().GetMaxFiles()), + MaxTotalBytes: int64(job.GetLocalFiles().GetMaxTotalBytes()), + }) + if err != nil { + // Відсутня політика — не поломка, а стан: цю машину ніхто не + // дозволяв читати. Тому в журнал попередженням, а не помилкою, + // але на сервер — усе одно відмовою з поясненням: інакше + // завдання висітиме «в роботі» до прибиральника. + if errors.Is(err, localfiles.ErrNoPolicy) { + log.Warn("збір конфіг-файлів не дозволено на цій машині", "причина", err) + } else { + log.Error("збір конфіг-файлів", "помилка", err) + } + s.uploadFailure(ctx, job, err, res.Report) + return + } + + log.Info("конфіг-файли зібрано", + "файлів", len(res.Files), "пропущено", len(res.Skips), + "байтів", len(res.Body), + "тривалість", res.Duration.Round(time.Millisecond)) + + if err := s.uploadConfigAs(ctx, job, ncmx.Result{ + Body: res.Body, + SHA256: res.SHA256, + LineCount: res.LineCount, + Duration: res.Duration, + // Протокол збору їде транскриптом: він потрібен рівно тоді, + // коли зібралось не те, що очікували, — а перелік пропусків із + // причинами і є відповідь на «чому». + Transcript: res.Report, + }, localfiles.Machine()); err != nil { + log.Error("вивантаження конфіг-файлів", "помилка", err) + } +} diff --git a/agent/internal/session/logs.go b/agent/internal/session/logs.go index 3430efb..18ede69 100644 --- a/agent/internal/session/logs.go +++ b/agent/internal/session/logs.go @@ -28,9 +28,18 @@ const logFlush = 500 * time.Millisecond // Окремий стрім, а не контрольний канал: сплеск логів під час аварії не // має заважати heartbeat і командам. Саме тому в контракті StreamLogs // існує окремо від Control. +// Syslog і трапи їдуть ОДНИМ стрімом, а не двома. +// +// Спокуса завести другий є: приймачі різні, порти різні, розбір різний. +// Але за межами зонда це та сама подія з мережі — вона лягає в сусідні +// таблиці, звіряється з тими самими подієвими правилами й приїжджає в +// той самий момент аварії. Другий стрім означав би другий комплект +// підтверджень, лімітів і черг переповнення — і два різні місця, у +// яких по-різному вирішено, що робити з пачкою, яку не вдалося +// відправити. func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient) error { - rec := s.cfg.Syslog - if rec == nil { + sys, trp := s.cfg.Syslog, s.cfg.Traps + if sys == nil && trp == nil { return nil } @@ -55,11 +64,29 @@ func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient) continue } // Ліміти задає сервер: він бачить картину по всіх зондах і - // краще знає, що вважати шумом. - rec.ApplyAck(ack.GetMinSeverity(), ack.GetRateLimitPerSource()) + // краще знає, що вважати шумом. Стеля на джерело спільна + // для обох приймачів — шумить не протокол, а пристрій. + if sys != nil { + sys.ApplyAck(ack.GetMinSeverity(), ack.GetRateLimitPerSource()) + } + if trp != nil { + trp.ApplyAck(ack.GetRateLimitPerSource()) + } } }() + // Один із приймачів може бути вимкнений, тому канали готовності + // беремо через nil-заглушку: читання з nil-каналу блокується + // назавжди, і саме це в select потрібно — гілка, яка ніколи не + // спрацює, замість гілки, якої немає. + var sysReady, trapReady <-chan struct{} + if sys != nil { + sysReady = sys.Ready() + } + if trp != nil { + trapReady = trp.Ready() + } + ticker := time.NewTicker(logFlush) defer ticker.Stop() @@ -69,14 +96,33 @@ func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient) return nil case err := <-ackErr: return err - case <-rec.Ready(): + case <-sysReady: // Подія з'явилась — але не летимо одразу: даємо тіку // зібрати сусідів у ту саму пачку. + case <-trapReady: case <-ticker.C: } - entries, dropped := rec.Drain(maxLogBatch) - if len(entries) == 0 && dropped == 0 { + var ( + entries []*npv1.SyslogEntry + traps []*npv1.SnmpTrap + dropped uint64 + ) + if sys != nil { + var d uint64 + entries, d = sys.Drain(maxLogBatch) + dropped += d + } + if trp != nil { + // Половина пачки на трапи — не арифметика, а пріоритет: + // трапів за секунду на порядок менше, ніж рядків журналу, + // і стеля тут потрібна лише на випадок шторму. Витіснити + // журнал вони не мають. + var d uint64 + traps, d = trp.Drain(maxLogBatch / 2) + dropped += d + } + if len(entries) == 0 && len(traps) == 0 && dropped == 0 { continue } @@ -84,13 +130,19 @@ func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient) BatchId: s.nextBatch.Add(1), AgentId: s.cfg.AgentID, Syslog: entries, + Traps: traps, Dropped: dropped, } if err := stream.Send(batch); err != nil { // Невідправлене повертаємо в чергу: наступна сесія // доставить. Порядок зберігається — журнал читають // хронологічно. - rec.Requeue(entries) + if sys != nil { + sys.Requeue(entries) + } + if trp != nil { + trp.Requeue(traps) + } return err } } diff --git a/agent/internal/session/session.go b/agent/internal/session/session.go index e417dd6..d0ba1a5 100644 --- a/agent/internal/session/session.go +++ b/agent/internal/session/session.go @@ -19,7 +19,9 @@ import ( "time" "github.com/netpulse/netpulse/agent/internal/module" + "github.com/netpulse/netpulse/agent/internal/modules/filecfg" "github.com/netpulse/netpulse/agent/internal/modules/syslog" + "github.com/netpulse/netpulse/agent/internal/modules/traps" "github.com/netpulse/netpulse/agent/internal/scheduler" "github.com/netpulse/netpulse/agent/internal/telemetry" npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" @@ -58,6 +60,16 @@ type Config struct { // сервера не відкривається взагалі: тримати порожній канал заради // вимкненої можливості немає сенсу. Syslog *syslog.Receiver + + // Приймач SNMP-трапів. Порожній — зонд трапи не приймає. Окремо від + // Syslog, бо це окремий порт і окремий дозвіл у фаєрволі клієнта: + // вмикати обидва там, де просили лише один, означало б відкрити + // порт, про який ніхто не домовлявся. + Traps *traps.Receiver + + // Збір конфіг-файлів власної машини. Порожній — зонд такі завдання + // відхиляє з поясненням. + FileCfg *filecfg.Module } type Session struct { @@ -124,6 +136,9 @@ func New(cfg Config) *Session { if cfg.Syslog != nil { cfg.Syslog.SetResolver(s.resolveDeviceByIP) } + if cfg.Traps != nil { + cfg.Traps.SetResolver(s.resolveDeviceByIP) + } return s } @@ -304,7 +319,7 @@ func (s *Session) runOnce(ctx context.Context) error { spawn("status", func() error { return s.statusLoop(sctx, out) }) spawn("telemetry", func() error { return s.telemetryLoop(sctx, client, welcome) }) spawn("discovery", func() error { return s.discoveryLoop(sctx, client) }) - if s.cfg.Syslog != nil { + if s.cfg.Syslog != nil || s.cfg.Traps != nil { spawn("logs", func() error { return s.logsLoop(sctx, client) }) } @@ -484,7 +499,11 @@ func (s *Session) controlLoop(ctx context.Context, ctrl npv1.AgentService_Contro if s.cfg.Scheduler == nil { continue } - n := s.cfg.Scheduler.TriggerNow(p.DiscoveryRequest.GetDeviceIds(), "topo.") + // Префікс — ключ модуля з крапкою, а не «topo.»: рядок + // «topology.identify» на «topo.» не починається, і поштовх + // не зрушував нічого. Помилку не помічали, бо до появи + // кнопки «Розпізнати зараз» DiscoveryRequest не слав ніхто. + n := s.cfg.Scheduler.TriggerNow(p.DiscoveryRequest.GetDeviceIds(), "topology.") s.log.Info("сервер попросив запустити автовиявлення", "run_id", p.DiscoveryRequest.GetRunId(), "задач_зрушено", n) @@ -496,6 +515,19 @@ func (s *Session) controlLoop(ctx context.Context, ctrl npv1.AgentService_Contro s.runConfigJob(ctx, job) }() + case *npv1.ControlDown_ConfigApplyJob: + // Заливка конфігу на пристрій. Окрема гілка, а не ще одне + // значення config_type у ConfigJob, як зроблено для + // масових команд: там різниця була лише в тому, що робити + // з виводом, а тут інша сама природа завдання — ми пишемо + // на залізо, і звіт їде назад іншим повідомленням. + apply := p.ConfigApplyJob + s.jobs.Add(1) + go func() { + defer s.jobs.Done() + s.runApplyJob(ctx, out, apply) + }() + case *npv1.ControlDown_Directive: if stop := s.applyDirective(p.Directive); stop { return nil diff --git a/db/profiles/README.md b/db/profiles/README.md index 1720817..1cfb640 100644 --- a/db/profiles/README.md +++ b/db/profiles/README.md @@ -1,17 +1,35 @@ -# Каталог команд збору конфігу +# Каталог команд роботи з конфігом `catalog.json` — джерело істини про те, як зняти конфіг із кожної -підтримуваної платформи. 148 платформ, 67 вендорів. +підтримуваної платформи **і як залити його назад**. 148 платформ, +67 вендорів. -Міграція `server/migrations/0014_ncm_profiles.sql` **породжується** з -каталогу, а не правиться руками: два описи одного й того самого -розійшлися б із першою ж правкою, і невідомо було б, який справжній. +SQL із каталогу **породжується**, а не правиться руками: два описи +одного й того самого розійшлися б із першою ж правкою, і невідомо було +б, який справжній. ```bash -python build.py # перезібрати міграцію -python build.py --check # звірити, чи вона актуальна +python build.py # перезібрати згенероване +python build.py --check # звірити, чи воно актуальне ``` +Генератор пише два файли, і це не примха: + +| файл | що несе | +| --- | --- | +| `server/migrations/0014_ncm_profiles.sql` | самі профілі: команди **збору** | +| `apply.generated.sql` | поля **заливки** (`apply_*`) для наявних профілів | + +`apply.generated.sql` — **не міграція**. Колонки `apply_*` заводить +міграція 0060, тобто пізніше за 0014: вставка в 0014 не може згадати +колонку, якої на той момент ще немає. До того ж 0014 уже накочена на +стендах, а `netpulse-migrate` звіряє sha256 застосованих файлів і +зупиняє накат на розбіжності — зміна тексту 0014 зупинила б оновлення +всім. Тому поля заливки їдуть окремо: скопіюй `apply.generated.sql` у +нову міграцію під номером, який призначила людина. Номер генератор не +вигадує свідомо — над одним каталогом номерів працюють паралельні +гілки. + ## Будова ```jsonc @@ -36,9 +54,22 @@ python build.py --check # звірити, чи вона актуальна "startup": "show startup-config", // необовʼязково "prepare": ["undo terminal monitor"], // необовʼязково: підготовка консолі "strip_lines": 3, // скільки службових рядків відкинути - "alternatives": ["show config current_config"] // для інших моделей родини + "alternatives": ["show config current_config"], // для інших моделей родини + "apply": { // як ЗАЛИВАТИ конфіг; без блоку — заливка вимкнена + "comment": ["чому саме так"], // їде коментарем над UPDATE + "enter": ["configure terminal"], // чим увійти в режим конфігурації + "exit": ["end"], // чим із нього вийти + "commit": "write memory", // чим зберегти, щоб пережило перезавантаження + "negate": "no ", // префікс заперечення рядка; null — родина не вміє + "block_exit": "exit", // чим вийти з вкладеного контексту + "supported": true, // чи є заливка взагалі + "note": "…" // що сказати людині, коли заливки немає + } } - ] + ], + "apply_external": { // заливка для профілів, заведених + "zte-zxan": { "…": "…" } // не каталогом, а руками (0028, 0034/0043) + } } ``` @@ -47,9 +78,37 @@ python build.py --check # звірити, чи вона актуальна 1. Додай запис у `profiles`. Мінімум — `key`, `name`, `vendor`, `family`, `config`. 2. Якщо CLI не схожий на жодну наявну родину — заведи нову у `families`. -3. `python build.py`. -4. Накоти міграцію (`ON CONFLICT DO NOTHING`, тож повторний накат - безпечний). +3. Якщо заливка на цій платформі перевірена — додай блок `apply`. + Не перевірена — не додавай: див. «Про заливку». +4. `python build.py`. +5. Перенеси нові рядки у **нову** міграцію. 0014 чіпати не можна: вона + вже накочена, а runner звіряє sha256 і зупиниться на зміненому + файлі. Так уже робили для zte-zxan (0028) і dlink-me (0034, 0043). + `INSERT` іде з `ON CONFLICT DO NOTHING`, `UPDATE` задає всі поля + явно — повторний накат безпечний в обох випадках. + +## Про заливку + +Блок `apply` описує, як віддати конфіг пристрою назад: увійти в режим +конфігурації, прибрати зайвий рядок, зберегти. Живе він у каталозі +поруч із командами збору, а не в разовій міграції, з однієї причини: +профіль, доданий без полів заливки, виглядав би робочим, а відкат для +нього мовчки не спрацював би — і побачили б це рівно тоді, коли відкат +знадобився. + +Заливка задається **профілем, а не родиною**. Родина описує поведінку +консолі (пейджер, запрошення), і збігається вона не з синтаксисом +конфігурації: `zte-zxr10` стоїть у родині `huawei` через запрошення, а +конфігурується як Cisco. Успадкувати заливку від родини означало б +відправити на пристрій чужі команди. + +**Профіль без блоку `apply` лишається без заливки, і це нормально.** +`apply_supported = false` — чесна відповідь «не вміємо», з якої +інтерфейс робить вимкнену кнопку з поясненням. Вигадана команда гірша +за відсутню: вона пише на живе залізо. Тому коли заливки немає з +розібраної причини — заповнюй `note`: порожнє поле перетворює свідоме +рішення на схоже на недогляд (див. `mikrotik-routeros`, +`juniper-junos`). ## Родини diff --git a/db/profiles/apply.generated.sql b/db/profiles/apply.generated.sql new file mode 100644 index 0000000..5f34a8e --- /dev/null +++ b/db/profiles/apply.generated.sql @@ -0,0 +1,132 @@ +-- ===================================================================== +-- NetPulse :: поля заливки конфігу для вбудованих профілів +-- +-- ФАЙЛ ЗГЕНЕРОВАНО. Джерело — db/profiles/catalog.json (блоки +-- `apply` у профілях і секція `apply_external`). Правити тут +-- безглуздо: наступна збірка перезапише. +-- +-- ЦЕ НЕ МІГРАЦІЯ і сама вона нікуди не накочується. Номер міграції +-- призначає людина, а не генератор: над одним каталогом номерів +-- працюють паралельні гілки, і вигаданий номер зіткнувся б із чужим. +-- Щоб зміни доїхали до бази, скопіюй цей текст у нову міграцію. +-- +-- Чому не в 0014, звідки беруться самі профілі: колонки apply_* +-- заводить 0060, тобто ПІЗНІШЕ, — вставка в 0014 не може згадати +-- колонку, якої ще немає. І 0014 уже накочена на стендах, а runner +-- звіряє sha256 застосованих файлів: зміна її тексту зупинила б +-- накат усім. +-- +-- Повторний накат безпечний: UPDATE задає всі поля явно, тож +-- результат залежить лише від каталогу, а не від того, що вже +-- стояло в рядку. +-- ===================================================================== + +-- Cisco IOS. Класика, з якої списані всі інші: `configure terminal`, +-- заперечення через `no `, вихід із контексту `exit`, збереження +-- `write memory` (а не `copy run start`, який на частині версій +-- перепитує ім'я файлу й підвисає на очікуванні Enter). +UPDATE ncm.profiles SET + apply_enter = '["configure terminal"]'::jsonb, + apply_exit = '["end"]'::jsonb, + apply_commit = 'write memory', + apply_negate = 'no ', + apply_block_exit = 'exit', + apply_supported = true, + apply_note = NULL +WHERE tenant_id IS NULL AND key = 'cisco-ios'; + +-- Juniper JUNOS — теж свідомо без відкату, і теж через формат архіву. +-- +-- Профіль знімає `show configuration | display omit`, тобто ієрархію у +-- фігурних дужках. Це не набір команд: віддати такий текст рядками в +-- CLI неможливо. Правильний шлях на JUNOS — `load override terminal` із +-- вставкою всього файлу, а він не вкладається в модель «команда → +-- запрошення → наступна команда», на якій побудований увесь наш CLI. +-- +-- Альтернатива існує: профіль, що знімає `show configuration | +-- display set`, дав би рядки `set …` із заперечником `delete `. Це +-- окрема робота — інший профіль збору й переливання архіву, — і робити +-- її мовчки, підмінивши формат історії, не можна. +UPDATE ncm.profiles SET + apply_enter = '[]'::jsonb, + apply_exit = '[]'::jsonb, + apply_commit = NULL, + apply_negate = NULL, + apply_block_exit = 'exit', + apply_supported = false, + apply_note = 'JUNOS зберігається ієрархією у фігурних дужках — це не набір команд. Для відкату потрібен профіль зі збором «show configuration | display set» (рядки set …, заперечення delete …); наявний архів у такому вигляді немає.' +WHERE tenant_id IS NULL AND key = 'juniper-junos'; + +-- MikroTik RouterOS — свідомо БЕЗ відкату, і причина не в бракові часу. +-- +-- Вивід `export` виглядає як набір команд, але команди в ньому — +-- `add …`. Повторне виконання `add` не повертає рядок на місце, а +-- створює ДРУГИЙ такий самий запис: другу адресу на інтерфейсі, друге +-- правило фаєрвола. Прибирання ж робиться через `remove [find …]` — +-- тобто через пошук за критерієм, якого в рядку експорту немає. +-- +-- Тобто механічний відкат на RouterOS не «поки не зроблений», а дає +-- гарантовано хибний результат. Правильний шлях — `/system backup` або +-- `/import` файлом, і це інша функція з іншим транспортом. +UPDATE ncm.profiles SET + apply_enter = '[]'::jsonb, + apply_exit = '[]'::jsonb, + apply_commit = NULL, + apply_negate = NULL, + apply_block_exit = 'exit', + apply_supported = false, + apply_note = 'RouterOS: рядки експорту — це add, і повторне виконання не повертає запис, а створює дубль; прибирання потребує remove [find …]. Автоматичний відкат тут дав би гарантовано хибний результат — потрібне відновлення з /system backup або /import файлом.' +WHERE tenant_id IS NULL AND key = 'mikrotik-routeros'; + +-- ZTE ZXR10 — CLI родини Cisco з тим самим `configure terminal`/`no `. +-- Збереження коротше: `write`. +-- +-- Родина в каталозі стоїть huawei — але вона описує поведінку +-- консолі (пейджер, запрошення), а не синтаксис конфігурації. +-- Саме тому заливка задана профілем, а не родиною: успадкувати +-- її від huawei означало б відправити на пристрій чужі команди. +UPDATE ncm.profiles SET + apply_enter = '["configure terminal"]'::jsonb, + apply_exit = '["end"]'::jsonb, + apply_commit = 'write', + apply_negate = 'no ', + apply_block_exit = 'exit', + apply_supported = true, + apply_note = NULL +WHERE tenant_id IS NULL AND key = 'zte-zxr10'; + +-- ZTE ZXAN (OLT C300/C320/C600) — той самий CLI, що й ZXR10. Профіль +-- заведено окремо в 0028 через запрошення, а не через синтаксис. +UPDATE ncm.profiles SET + apply_enter = '["configure terminal"]'::jsonb, + apply_exit = '["end"]'::jsonb, + apply_commit = 'write', + apply_negate = 'no ', + apply_block_exit = 'exit', + apply_supported = true, + apply_note = NULL +WHERE tenant_id IS NULL AND key = 'zte-zxan'; + +-- D-Link DES/DGS (профіль dlink-me, спільний для xStack і Smart /ME — +-- див. 0043). Тут два свідомі відступи від класики. +-- +-- Режиму конфігурації немає: команди виконуються просто із запрошення, +-- тому apply_enter і apply_exit порожні, а вкладених контекстів не +-- буває — кожен рядок конфігу самодостатній (`create vlan v10 tag 10`). +-- +-- Заперечення немає ЗОВСІМ, і це не пропуск. У D-Link немає універсального +-- `no`: створене прибирається `delete`, налаштоване переписується +-- `config`, увімкнене вимикається `disable`. Вивести з рядка конфігу +-- потрібне дієслово автоматично неможливо — `create vlan v10 tag 10` +-- прибирається як `delete vlan v10`, і жодне механічне правило цього не +-- дасть. Тому рядки на прибирання йдуть людині переліком, а система +-- заливає лише те, що додає й переписує. +UPDATE ncm.profiles SET + apply_enter = '[]'::jsonb, + apply_exit = '[]'::jsonb, + apply_commit = 'save', + apply_negate = NULL, + apply_block_exit = '', + apply_supported = true, + apply_note = 'D-Link не має універсального заперечення рядка: створене прибирається delete, налаштоване переписується config. Тому зайві рядки система показує переліком «прибрати вручну», а заливає лише додане й змінене.' +WHERE tenant_id IS NULL AND key = 'dlink-me'; diff --git a/db/profiles/build.py b/db/profiles/build.py index 88824a8..350c4fc 100644 --- a/db/profiles/build.py +++ b/db/profiles/build.py @@ -7,10 +7,26 @@ самого розійшлися б із першою ж правкою, і невідомо було б, який із них справжній. +Каталог описує ДВІ речі про кожну платформу, і виходять вони в різні +файли: + + * як конфіг ЗНЯТИ (`config`, `prepare`, родина) — у міграцію + 0014_ncm_profiles.sql, тобто в самі рядки ncm.profiles; + * як конфіг ЗАЛИТИ (блок `apply`) — у apply.generated.sql, окремим + набором UPDATE. + +Розділені вони не з естетики. Колонки ncm.profiles.apply_* заводить +міграція 0060, яка йде ПІСЛЯ 0014: вставка в 0014 не може згадати +колонку, якої на той момент ще немає. До того ж 0014 уже накочена на +стендах, а runner міграцій звіряє sha256 застосованих файлів і зупиняє +накат на розбіжності — будь-яка зміна тексту 0014 зупинила б оновлення +всім. Тому 0014 лишається байт-у-байт таким, як є, а поля заливки +їдуть окремим файлом. + Використання: - python build.py # перезбирає ../migrations/0014_ncm_profiles.sql - python build.py --check # лише звіряє, чи міграція актуальна + python build.py # перезбирає обидва файли + python build.py --check # лише звіряє, чи вони актуальні """ import json import pathlib @@ -19,13 +35,48 @@ import sys HERE = pathlib.Path(__file__).parent CATALOG = HERE / "catalog.json" -MIGRATION = HERE.parent / "migrations" / "0014_ncm_profiles.sql" +# Міграції переїхали в server/migrations: netpulse-migrate вшиває їх +# через //go:embed, а embed не бачить нічого за межами свого модуля +# (див. db/README-migrations.md). Шлях на db/migrations, що лишався тут +# після переїзду, вів у неіснуючий каталог: --check завжди звітував +# «розійшлася», а звичайний запуск падав на записі. +MIGRATION = HERE.parent.parent / "server" / "migrations" / "0014_ncm_profiles.sql" +# Не міграція, а заготовка для неї: номер міграції призначає людина, +# генератор не має права його вигадувати — паралельні гілки розберуть +# ті самі номери й зіткнуться. +APPLY_SQL = HERE / "apply.generated.sql" + +# Значення за замовчуванням для полів заливки. +# +# Це не вибір генератора, а DEFAULT відповідних колонок із 0060: профіль +# без блоку `apply` має лишитись рівно таким, яким його зробив ALTER +# TABLE. Розбіжність тут означала б, що перезапуск генератора мовчки +# міняє поведінку вже налаштованих профілів. +APPLY_DEFAULTS = { + "enter": [], + "exit": [], + "commit": None, + "negate": None, + "block_exit": "exit", + "supported": False, + "note": None, +} def sql_str(v: str) -> str: return "'" + v.replace("'", "''") + "'" +def sql_opt(v) -> str: + """NULL, а не порожній рядок: для apply_negate це різні речі. + + Порожній рядок означав би «заперечувати рядок префіксом ''», тобто + відправити на пристрій команду без заперечника — рівно навпаки до + задуманого. NULL чесно означає «родина не вміє прибрати рядок». + """ + return "NULL" if v is None else sql_str(v) + + def sql_json(obj) -> str: return sql_str(json.dumps(obj, ensure_ascii=False)) @@ -100,21 +151,118 @@ def render(catalog: dict) -> str: return "\n".join(lines) +def apply_entries(catalog: dict) -> list: + """Пари (ключ профілю, блок заливки) в порядку каталогу. + + Два джерела, бо профілі теж із двох. Ті, що описані каталогом, + несуть `apply` поруч із командою збору — інакше нова платформа + з'явилась би без полів заливки, і відкат для неї мовчки не працював + би. Ті, що заведені окремими міграціями (zte-zxan — 0028, dlink-me — + 0034/0043), у каталозі профілю не мають, але їхня заливка описана + тут же, у `apply_external`: тримати частину відповідей у каталозі, а + частину — в разовій міграції означало б знову два джерела істини. + """ + out = [] + for p in catalog["profiles"]: + if "apply" in p: + out.append((p["key"], p["apply"])) + for key, ap in catalog.get("apply_external", {}).items(): + out.append((key, ap)) + return out + + +def render_apply(catalog: dict) -> str: + """UPDATE-и, що переносять блоки `apply` каталогу в ncm.profiles.""" + lines = [ + "-- =====================================================================", + "-- NetPulse :: поля заливки конфігу для вбудованих профілів", + "--", + "-- ФАЙЛ ЗГЕНЕРОВАНО. Джерело — db/profiles/catalog.json (блоки", + "-- `apply` у профілях і секція `apply_external`). Правити тут", + "-- безглуздо: наступна збірка перезапише.", + "--", + "-- ЦЕ НЕ МІГРАЦІЯ і сама вона нікуди не накочується. Номер міграції", + "-- призначає людина, а не генератор: над одним каталогом номерів", + "-- працюють паралельні гілки, і вигаданий номер зіткнувся б із чужим.", + "-- Щоб зміни доїхали до бази, скопіюй цей текст у нову міграцію.", + "--", + "-- Чому не в 0014, звідки беруться самі профілі: колонки apply_*", + "-- заводить 0060, тобто ПІЗНІШЕ, — вставка в 0014 не може згадати", + "-- колонку, якої ще немає. І 0014 уже накочена на стендах, а runner", + "-- звіряє sha256 застосованих файлів: зміна її тексту зупинила б", + "-- накат усім.", + "--", + "-- Повторний накат безпечний: UPDATE задає всі поля явно, тож", + "-- результат залежить лише від каталогу, а не від того, що вже", + "-- стояло в рядку.", + "-- =====================================================================", + "", + ] + + for key, ap in apply_entries(catalog): + # Друкарська помилка в назві поля інакше пройшла б мовчки: `.get` + # підставив би замовчування, і профіль поїхав би на пристрій із + # чужими командами замість описаних. + unknown = set(ap) - set(APPLY_DEFAULTS) - {"comment"} + if unknown: + raise SystemExit(f"{key}: невідомі поля заливки: {sorted(unknown)}") + + def field(name): + return ap.get(name, APPLY_DEFAULTS[name]) + + # Причина рішення їде поруч із рішенням. Порожнє місце над + # `apply_supported = false` читалось би як недогляд, хоча за ним + # стоїть розбір родини. + comment = ap.get("comment") or [] + for line in [comment] if isinstance(comment, str) else comment: + lines.append(("-- " + line).rstrip()) + lines += [ + "UPDATE ncm.profiles SET", + " apply_enter = {}::jsonb,".format(sql_json(field("enter"))), + " apply_exit = {}::jsonb,".format(sql_json(field("exit"))), + " apply_commit = {},".format(sql_opt(field("commit"))), + " apply_negate = {},".format(sql_opt(field("negate"))), + " apply_block_exit = {},".format(sql_str(field("block_exit"))), + " apply_supported = {},".format( + "true" if field("supported") else "false"), + " apply_note = {}".format(sql_opt(field("note"))), + "WHERE tenant_id IS NULL AND key = {};".format(sql_str(key)), + "", + ] + + return "\n".join(lines) + + def main(): catalog = json.loads(CATALOG.read_text(encoding="utf-8")) - sql = render(catalog) + outputs = [(MIGRATION, render(catalog)), (APPLY_SQL, render_apply(catalog))] if "--check" in sys.argv: - current = MIGRATION.read_text(encoding="utf-8") if MIGRATION.exists() else "" - if current != sql: - print("міграція розійшлася з каталогом — виконай build.py", file=sys.stderr) + stale = [] + for path, sql in outputs: + current = path.read_text(encoding="utf-8") if path.exists() else "" + if current != sql: + stale.append(path.name) + if stale: + print("розійшлося з каталогом: " + ", ".join(stale) + + " — виконай build.py", file=sys.stderr) return 1 - print("міграція актуальна") + print("згенероване актуальне") return 0 - MIGRATION.write_text(sql, encoding="utf-8") + for path, sql in outputs: + # Пишемо байтами, а не текстом: у текстовому режимі Python на + # Windows сам перекладає \n у \r\n. Для згенерованої 0014 це не + # косметика — runner міграцій звіряє sha256 файлу, вшитого в + # бінарник, а CRLF-версія дає ІНШУ суму, ніж LF-версія з + # репозиторію (.gitattributes: «у репозиторії — завжди LF»). + # Збірка на Windows після запуску генератора інакше зупиняла б + # накат на стенді з «міграцію 0014 змінено після застосування». + path.write_bytes(sql.encode("utf-8")) vendors = len({p["vendor"] for p in catalog["profiles"]}) print(f"{MIGRATION.name}: {len(catalog['profiles'])} платформ, {vendors} вендорів") + print(f"{APPLY_SQL.name}: профілів із описаною заливкою: " + f"{len(apply_entries(catalog))}") return 0 diff --git a/db/profiles/catalog.json b/db/profiles/catalog.json index 688b4a8..289d2b3 100644 --- a/db/profiles/catalog.json +++ b/db/profiles/catalog.json @@ -151,6 +151,44 @@ "vyatta": "juniper", "zte": "huawei" }, + "apply_external": { + "zte-zxan": { + "comment": [ + "ZTE ZXAN (OLT C300/C320/C600) — той самий CLI, що й ZXR10. Профіль", + "заведено окремо в 0028 через запрошення, а не через синтаксис." + ], + "enter": ["configure terminal"], + "exit": ["end"], + "commit": "write", + "negate": "no ", + "supported": true + }, + "dlink-me": { + "comment": [ + "D-Link DES/DGS (профіль dlink-me, спільний для xStack і Smart /ME —", + "див. 0043). Тут два свідомі відступи від класики.", + "", + "Режиму конфігурації немає: команди виконуються просто із запрошення,", + "тому apply_enter і apply_exit порожні, а вкладених контекстів не", + "буває — кожен рядок конфігу самодостатній (`create vlan v10 tag 10`).", + "", + "Заперечення немає ЗОВСІМ, і це не пропуск. У D-Link немає універсального", + "`no`: створене прибирається `delete`, налаштоване переписується", + "`config`, увімкнене вимикається `disable`. Вивести з рядка конфігу", + "потрібне дієслово автоматично неможливо — `create vlan v10 tag 10`", + "прибирається як `delete vlan v10`, і жодне механічне правило цього не", + "дасть. Тому рядки на прибирання йдуть людині переліком, а система", + "заливає лише те, що додає й переписує." + ], + "enter": [], + "exit": [], + "commit": "save", + "negate": null, + "block_exit": "", + "supported": true, + "note": "D-Link не має універсального заперечення рядка: створене прибирається delete, налаштоване переписується config. Тому зайві рядки система показує переліком «прибрати вручну», а заливає лише додане й змінене." + } + }, "profiles": [ { "key": "3com-4500", @@ -437,7 +475,20 @@ "family": "cisco", "config": "show running-config", "startup": "show startup-config", - "strip_lines": 3 + "strip_lines": 3, + "apply": { + "comment": [ + "Cisco IOS. Класика, з якої списані всі інші: `configure terminal`,", + "заперечення через `no `, вихід із контексту `exit`, збереження", + "`write memory` (а не `copy run start`, який на частині версій", + "перепитує ім'я файлу й підвисає на очікуванні Enter)." + ], + "enter": ["configure terminal"], + "exit": ["end"], + "commit": "write memory", + "negate": "no ", + "supported": true + } }, { "key": "cisco-iosxr", @@ -904,7 +955,25 @@ "name": "Juniper JUNOS", "vendor": "juniper", "family": "juniper", - "config": "show configuration | display omit" + "config": "show configuration | display omit", + "apply": { + "comment": [ + "Juniper JUNOS — теж свідомо без відкату, і теж через формат архіву.", + "", + "Профіль знімає `show configuration | display omit`, тобто ієрархію у", + "фігурних дужках. Це не набір команд: віддати такий текст рядками в", + "CLI неможливо. Правильний шлях на JUNOS — `load override terminal` із", + "вставкою всього файлу, а він не вкладається в модель «команда →", + "запрошення → наступна команда», на якій побудований увесь наш CLI.", + "", + "Альтернатива існує: профіль, що знімає `show configuration |", + "display set`, дав би рядки `set …` із заперечником `delete `. Це", + "окрема робота — інший профіль збору й переливання архіву, — і робити", + "її мовчки, підмінивши формат історії, не можна." + ], + "supported": false, + "note": "JUNOS зберігається ієрархією у фігурних дужках — це не набір команд. Для відкату потрібен профіль зі збором «show configuration | display set» (рядки set …, заперечення delete …); наявний архів у такому вигляді немає." + } }, { "key": "juniper-junose", @@ -967,7 +1036,24 @@ "name": "MikroTik RouterOS", "vendor": "mikrotik", "family": "mikrotik", - "config": "export" + "config": "export", + "apply": { + "comment": [ + "MikroTik RouterOS — свідомо БЕЗ відкату, і причина не в бракові часу.", + "", + "Вивід `export` виглядає як набір команд, але команди в ньому —", + "`add …`. Повторне виконання `add` не повертає рядок на місце, а", + "створює ДРУГИЙ такий самий запис: другу адресу на інтерфейсі, друге", + "правило фаєрвола. Прибирання ж робиться через `remove [find …]` —", + "тобто через пошук за критерієм, якого в рядку експорту немає.", + "", + "Тобто механічний відкат на RouterOS не «поки не зроблений», а дає", + "гарантовано хибний результат. Правильний шлях — `/system backup` або", + "`/import` файлом, і це інша функція з іншим транспортом." + ], + "supported": false, + "note": "RouterOS: рядки експорту — це add, і повторне виконання не повертає запис, а створює дубль; прибирання потребує remove [find …]. Автоматичний відкат тут дав би гарантовано хибний результат — потрібне відновлення з /system backup або /import файлом." + } }, { "key": "nag-snr", @@ -1295,7 +1381,23 @@ "vendor": "zte", "family": "huawei", "config": "show running-config", - "strip_lines": 1 + "strip_lines": 1, + "apply": { + "comment": [ + "ZTE ZXR10 — CLI родини Cisco з тим самим `configure terminal`/`no `.", + "Збереження коротше: `write`.", + "", + "Родина в каталозі стоїть huawei — але вона описує поведінку", + "консолі (пейджер, запрошення), а не синтаксис конфігурації.", + "Саме тому заливка задана профілем, а не родиною: успадкувати", + "її від huawei означало б відправити на пристрій чужі команди." + ], + "enter": ["configure terminal"], + "exit": ["end"], + "commit": "write", + "negate": "no ", + "supported": true + } }, { "key": "zyxel-dslam", diff --git a/deploy/.env.example b/deploy/.env.example index 4cedfb6..62549ba 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -8,10 +8,51 @@ NETPULSE_DOMAIN=netpulse.example.com # продовженням сертифіката. Порожньо — ACME без контакту. ACME_EMAIL=admin@example.com -# Пароль ролі netpulse у PostgreSQL. -# openssl rand -base64 24 +# Пароль ролі netpulse у PostgreSQL — власника схеми. Ним ходять +# міграції та утиліти netpulse-user і netpulse-secret. +# openssl rand -hex 24 POSTGRES_PASSWORD= +# --- ролі бази й Row Level Security ----------------------------------- +# +# Два паролі нижче — це вимикач другого рубежу ізоляції. +# +# Заповнені: API й колектор ходять у базу роллю netpulse_app БЕЗ +# BYPASSRLS, тобто під політиками RLS, а фонові такти — окремою роллю +# netpulse_worker. Забутий предикат tenant_id у коді дає порожній +# результат замість чужих даних. +# +# Порожні: застосунок ходить роллю власника (суперкористувач), і жодна +# з політик RLS не діє. Ізоляцію кабінетів тримає лише те, що кожен +# запит у коді фільтрує tenant_id руками. +# +# НОВА ІНСТАЛЯЦІЯ: заповнити обидва, більше нічого не робити. +# netpulse-migrate побачить порожню базу, накотить схему й сам видасть +# ці паролі ролям — RLS працює з першого запуску. Установник заповнює +# ці рядки за вас. +# +# ІНСТАЛЯЦІЯ, СТАРША ЗА 0063: не чіпати цих рядків, поки не пройдено +# deploy/RLS-EXISTING-INSTALL.md. На непорожній базі мігратор паролів не +# видає навмисно — там паролі видає людина, з перевіркою після кожного +# кроку й зі шляхом назад. +# +# Обидва або жодного. Заповнений NETPULSE_APP_PASSWORD без +# NETPULSE_WORKER_PASSWORD — найгірший стан із можливих: інтерфейс +# працює, а бекапи, алерти й події мовчки не відбуваються. Мігратор +# такої пари не приймає й зупиняє запуск. +# +# ТІЛЬКИ HEX, і це не стиль, а вимога. Пароль їде всередині DSN +# postgres://роль:пароль@db, тому не кожен символ лишається собою: +# `/` (звичайний символ base64) обриває пароль і перетворює його хвіст +# на ім'я бази, а `%` читається як початок %XX і мовчки робить пароль +# іншим рядком. У журналі це «database ... does not exist» або +# «authentication failed», а на вигляд — «RLS усе зламав». +# openssl rand -hex 24 +NETPULSE_APP_PASSWORD= + +# openssl rand -hex 24 +NETPULSE_WORKER_PASSWORD= + # Ключ шифрування секретів (паролі SSH, SNMP-community). # Формат: =<32 байти hex>. Id потрібен для зміни ключа: старий # лишається в списку, щоб розшифрувати вже записане. diff --git a/deploy/Dockerfile.server b/deploy/Dockerfile.server index 5eae444..b71d0df 100644 --- a/deploy/Dockerfile.server +++ b/deploy/Dockerfile.server @@ -60,7 +60,19 @@ FROM alpine:3.20 # бекапів і тиша сповіщень задаються в часовому поясі тенанта. wget не # ставимо: healthcheck обходиться тим, що вже є в busybox. RUN apk add --no-cache ca-certificates tzdata \ - && adduser -D -u 10001 netpulse + && adduser -D -u 10001 netpulse \n && mkdir -p /var/lib/netpulse \n && chown -R netpulse:netpulse /var/lib/netpulse + +# Каталог сховища версій створюється В ОБРАЗІ й одразу під тим +# користувачем, від якого працює процес. +# +# Інакше іменований том, змонтований сюди вперше, успадковує права +# каталогу з образу — а він був root:root. Процес під uid 10001 не +# міг створити в ньому навіть підкаталог, і кожен коміт конфігу в +# git тихо не відбувався: у сховищі лишався контентний хеш замість +# SHA коміту, а репозиторій не існував узагалі. +# +# Помітили аж тоді, коли вперше знадобилось дзеркалення на +# зовнішній Git: виявилось, що дзеркалити нема чого. COPY --from=build /out/ /usr/local/bin/ diff --git a/deploy/README.md b/deploy/README.md index 01a196e..784972d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -41,7 +41,7 @@ openssl rand -base64 48 # NETPULSE_JWT_SECRET ```sh docker compose up -d --build -docker compose run --rm api netpulse-user \ +docker compose run --rm --entrypoint netpulse-user cli \ -tenant default -login admin -role owner -name "Адміністратор" ``` @@ -168,6 +168,31 @@ docker compose up -d --build Відкат схеми не передбачений: зворотні міграції на даних телеметрії коштують дорожче, ніж відновлення з бекапу. +## Ізоляція кабінетів (RLS) + +**Нова інсталяція вже під політиками — робити нічого не треба.** +`netpulse-migrate` на чистій базі сам видає паролі ролям `netpulse_app` +і `netpulse_worker`, і застосунок з першої секунди ходить роллю без +`BYPASSRLS`. + +Ізоляція тримається на двох незалежних рубежах: політика RLS у базі й +предикат `tenant_id` у кожному запиті коду. Другий потрібен окремо, бо +на гіпертаблицях RLS не працює взагалі — TimescaleDB не поєднує його зі +стисненням, а туди йде вся телеметрія. + +**Інсталяціям, старшим за 0063,** застосунок і далі ходить роллю +`netpulse` — тобто суперкористувачем, який політики обходить, — і другий +рубіж вмикається окремою оборотною процедурою: +**deploy/RLS-EXISTING-INSTALL.md**. Вона не змінює даних. Робити її +разом з оновленням версії не варто: ламатись у них різне, і розбирати +доведеться одночасно. + +Свій випадок видно одним запитом: + +```sh +docker compose exec -T db psql -U netpulse -d netpulse -c "SELECT fresh FROM public.netpulse_install" +``` + ## Зміна ключа шифрування Ключі перелічуються через кому, новий — першим: diff --git a/deploy/RLS-EXISTING-INSTALL.md b/deploy/RLS-EXISTING-INSTALL.md new file mode 100644 index 0000000..017605a --- /dev/null +++ b/deploy/RLS-EXISTING-INSTALL.md @@ -0,0 +1,419 @@ +# Увімкнення RLS на інсталяції, старшій за 0063 + +> **Цей документ потрібен ЛИШЕ інсталяціям, зробленим до міграції 0063.** +> На системі, розгорнутій із цією версією, RLS увімкнено з першого +> запуску: `netpulse-migrate` бачить порожню базу, накочує схему й +> одразу видає ролям паролі з `.env`. Робити не треба нічого, читати це +> теж не треба. +> +> Як перевірити, що це саме ваш випадок: +> +> ```sh +> docker compose exec -T db psql -U netpulse -d netpulse -c \ +> "SELECT fresh, applied_was, decided_at FROM public.netpulse_install" +> ``` +> +> `fresh = t` — інсталяція народилась із RLS, далі не читайте. `fresh = f` +> — база вже працювала, коли її вперше побачив мігратор із підтримкою +> ролей; перехід на ній робиться руками, і саме про це документ нижче. +> Таблиці немає взагалі — стенд ще не оновлювався; оновіть образи й +> накотіть міграції (крок 1), рядок з'явиться. + +Як увімкнути справжню ізоляцію кабінетів на живому стенді, що перевіряти +після кожного кроку і як відкотитись, якщо застосунок перестане бачити +дані. + +## Чому це не робиться саме + +Мігратор уміє видавати ролям паролі й робить це на кожній новій +інсталяції. На вашій він цього навмисно не робить, і ознака, за якою він +розрізняє два випадки, — не здогад, а записаний факт: чи була +`public.schema_migrations` порожня в ту мить, коли мігратор уперше +побачив цю базу. Відповідь пишеться один раз у `public.netpulse_install` +і більше не переглядається. + +Причина проста. На порожній базі перемикати нічого: немає ані даних, ані +клієнтів, ані стану, у який можна повернутись. На вашій — є все три. +Перемикання роллю на живому стенді має вікно, у якому зонди можуть +замовкнути, і мусить мати крок, на якому можна зупинитись. Тому воно +лишається процедурою, а `docker compose up` його не запускає. + +## Що саме змінюється + +Політики Row Level Security написані в схемі з міграції 0011 і стоять на +68 таблицях. Жодна з них ніколи не спрацьовувала: `docker-compose.yml` +збирає DSN із ролі `netpulse`, а її створює образ Postgres зі змінної +`POSTGRES_USER`, тобто bootstrap-суперкористувачем. Суперкористувач +обходить RLS беззастережно. + +Тобто ізоляцію кабінетів у продукті тримає рівно одне: те, що кожен +запит у `server/internal/store` дописує `tenant_id = $1` руками. Один +забутий предикат — і клієнт бачить чужі хости. Другий рубіж написаний, +увімкнений у схемі й вимкнений у житті. + +Перехід дає три ролі: + +| роль | BYPASSRLS | хто ходить | +|---|---|---| +| `netpulse` | так (суперкористувач) | `migrate`, `netpulse-user`, `netpulse-secret` | +| `netpulse_app` | **ні** | `api`, `collector` | +| `netpulse_worker` | так | фонові такти всередині `api` і `collector`, `netpulse-gitsync` | + +`netpulse_worker` лишається з BYPASSRLS свідомо: запити-шукачі черг +(`ClaimConfigJobs`, `ClaimCommandJobs`, `DuePolicies`, `FetchEvents`) — +це одна інструкція `UPDATE … FOR UPDATE SKIP LOCKED … RETURNING +tenant_id` на всю інсталяцію. Розкласти її по кабінетах означає замінити +один такт на N тактів кожні 5 секунд і завести голодування. Обґрунтування +цілком — у коментарі до `0063_rls_enforce.sql`, розділ 1. + +## Чого цей перехід не робить + +- **Не змінює телеметрію.** Гіпертаблиці (`ts.*`, `core.audit_log`, + `alr.alerts_history`, `alr.notifications`) під RLS не підпадають — і не + можуть, поки ввімкнено стиснення. Їхню ізоляцію й далі тримає предикат + у запиті. Це не наслідок переходу, а незмінна властивість TimescaleDB. +- **Не рятує від BYPASSRLS у воркера.** Роль воркера бачить усе. Її + обмежує не RLS, а те, ким і звідки вона використовується: окремий пул + у `store.Store.bg`, окремий пароль, скінченний перелік методів + (`grep -rn 's\.bg\.' server/internal/store/`). +- **Не переносить володіння об'єктами.** `netpulse` лишається власником + усіх 100+ таблиць. Передавати ownership на живій базі — це `ALTER + TABLE … OWNER TO` на кожну гіпертаблицю з чанками, тобто довга + блокувальна дія на чужих даних заради нуля користі. + +## Передумови + +- Свіжий дамп бази (`## Бекап` у `deploy/README.md`) — знятий **сьогодні**, + не «десь був». +- Вікно, у якому допустимо, що зонди на кілька хвилин перестануть + доповідати. Дані за цей час не губляться: агент тримає їх у себе й + дошле, — але алерти в цей проміжок не рахуються. +- Доступ до `docker compose exec db psql`. + +Далі всюди мається на увазі, що ви в каталозі з `docker-compose.yml`. + +--- + +## Крок 1. Накотити 0063 + +```sh +git pull +docker compose build +docker compose run --rm migrate +``` + +**Це безпечно й нічого не вмикає.** 0063 заводить ролі без пароля +(підключитись ними ще не можна), роздає права, закриває політиками шість +зв'язкових таблиць і ставить `security_invoker` на два вигляди. Поки +застосунок ходить суперкористувачем, жодна з цих змін на нього не діє. + +Міграція сама себе перевіряє: якщо в схемі є таблиця з `tenant_id` без +RLS або без політики, або таблиця, до якої `netpulse_app` не має SELECT, +вона впаде з переліком таких таблиць. Падіння тут означає «переходити ще +рано», а не «щось зламалось». + +На цьому ж запуску мігратор запише `public.netpulse_install` — рядок, +який назавжди фіксує, що ця база НЕ була порожньою, коли він її вперше +побачив. Саме через нього все подальше лишається ручним: паролі ролям на +такій базі він не видасть ні зараз, ні через рік, скільки б рядків не +з'явилось у `.env`. + +**Перевірити:** + +```sh +docker compose exec -T db psql -U netpulse -d netpulse -c \ + "SELECT rolname, rolcanlogin, rolbypassrls FROM pg_roles + WHERE rolname LIKE 'netpulse%' ORDER BY 1" +``` + +Очікується рівно це: + +``` + rolname | rolcanlogin | rolbypassrls +-----------------+-------------+-------------- + netpulse | t | t + netpulse_app | t | f + netpulse_worker | t | t +``` + +`rolbypassrls = f` у `netpulse_app` — головний рядок цієї таблиці. Якщо +там `t`, далі йти немає сенсу: усе наступне пройде, і не змінить нічого. + +Застосунок на цьому кроці не чіпаємо. Можна зупинитись тут на добу. + +--- + +## Крок 2. Видати паролі + +Паролі не лежать у міграції навмисно: у git і в контрольній сумі +`public.schema_migrations` вони були б назавжди. + +**Тільки hex.** Пароль ролі їде всередині DSN +`postgres://роль:пароль@db:5432/netpulse`, і не кожен символ лишається +там собою. `openssl rand -base64` рано чи пізно видасть `/` — скісна +риска обриває пароль і перетворює його хвіст на ім'я бази: з'єднання не +встановлюється, у журналі стоїть «database … does not exist». Гірший +випадок — `%`: розбирач читає його як початок `%XX`, помилки немає, а +пароль мовчки стає іншим рядком. Виглядає це не як зіпсований рядок у +`.env`, а як «RLS усе зламав», і півдня цього проєкту коштувало саме +воно. + +```sh +APP_PW=$(openssl rand -hex 24) +WRK_PW=$(openssl rand -hex 24) + +docker compose exec -T db psql -U netpulse -d netpulse < 'ПЕРШИЙ-КАБІНЕТ-UUID'; +SELECT 'зв''язки: доступи' AS q, count(*) FROM inv.device_credentials; +SELECT 'вигляд лінків' AS q, count(*) FROM topo.link_live; + +SET LOCAL app.tenant_id = ''; +SELECT 'без контексту' AS q, count(*) FROM inv.devices; + +RESET ROLE; +ROLLBACK; +SQL +``` + +Очікується: «бачить своїх» — реальна кількість хостів кабінету, «бачить +чужих» — **0**, «без контексту» — **0**. Кількість у зв'язках і у вигляді +`topo.link_live` має відповідати цьому ж кабінету, а не всій інсталяції. + +Якщо «бачить чужих» більше нуля — зупиніться. Далі йти не можна: це +означає, що якась таблиця лишилась без політики, і перехід дасть хибне +відчуття захисту замість захисту. + +Той самий сценарій у вигляді тесту, який ганяється на одноразовій базі: +`server/internal/store/rls_isolation_test.go`. + +--- + +## Крок 4. Перемкнути застосунок + +У `.env` дописати два рядки (пароль ролі-власника лишається на місці — +ним ходять міграції й утиліти): + +```sh +NETPULSE_APP_PASSWORD= +NETPULSE_WORKER_PASSWORD= +``` + +Обидва — разом. `NETPULSE_APP_PASSWORD` без `NETPULSE_WORKER_PASSWORD` +дає найгірший з можливих станів: інтерфейс працює, а фонові такти +мовчки нічого не знаходять — бекапи не запускаються, алерти не +розсилаються, події не доходять до браузера, і жодної помилки в журналі +при цьому немає. Тепер цю пару перевіряє мігратор і зупиняє запуск, але +покладатись на це не варто: він рятує від забутого рядка, а не від +неправильного пароля. + +Окремої змінної `NETPULSE_APP_USER` більше немає. Раніше вона була, і +будь-яка з двох половин без другої давала DSN, який не встановлюється: +нова роль зі старим паролем або стара роль з новим. Тепер ім'я ролі +випливає з наявності пароля. Якщо `NETPULSE_APP_USER` лишився у вашому +`.env` — його просто ігнорують, видаляти не обов'язково. + +```sh +docker compose up -d api collector +``` + +`api` і `collector` залежать від `migrate`, тож перед ними ще раз +відпрацює мігратор. Схему він не змінить (вона актуальна), паролів +ролям не видасть (база непорожня), але зайде обома DSN і перевірить, що +`netpulse_app` заходить і не має BYPASSRLS, а `netpulse_worker` заходить +і має. Якщо крок 2 пропущено, ви побачите це тут, а не за годину в +журналі колектора. + +**Перевірити протягом перших п'яти хвилин:** + +1. **Інтерфейс.** Увійти й відкрити перелік хостів. Порожній перелік у + непорожньому кабінеті — ознака, що щось лишилось без політики. + + ```sh + curl -sf https://$NETPULSE_DOMAIN/healthz + ``` + +2. **Зонди.** Це ламається першим, якщо ламається: + + ```sh + docker compose exec -T db psql -U netpulse -d netpulse -c \ + "SELECT status, count(*), max(last_heartbeat_at) FROM core.agents GROUP BY 1" + ``` + + `max(last_heartbeat_at)` має бути свіжішим за хвилину. Якщо він + застиг на моменті перезапуску — агенти не автентифікуються, і це + видно ще й у журналі колектора: + + ```sh + docker compose logs --since 5m collector | grep -i unauth + ``` + +3. **Телеметрія доходить:** + + ```sh + docker compose exec -T db psql -U netpulse -d netpulse -c \ + "SELECT max(ts) FROM ts.icmp_samples" + ``` + +4. **Фонові такти живі.** Черга завдань не має рости монотонно: + + ```sh + docker compose exec -T db psql -U netpulse -d netpulse -c \ + "SELECT status, count(*) FROM ncm.jobs GROUP BY 1" + ``` + + Повторити через п'ять хвилин. Якщо `queued` росте, а `running` і + `done` стоять — воркер не бачить черги, тобто `NETPULSE_WORKER_PASSWORD` + не доїхало. + +5. **Події доходять у браузер.** Відкрита сторінка має оновлювати статуси + без перезавантаження. Непрямо: + + ```sh + docker compose exec -T db psql -U netpulse -d netpulse -c \ + "SELECT count(*) FROM core.event_outbox WHERE published_at IS NULL" + ``` + + Число має коливатись, а не тільки зростати. + +**Перевірити протягом першої доби:** + +6. **Бекап конфігів відпрацював за розкладом:** + + ```sh + docker compose exec -T db psql -U netpulse -d netpulse -c \ + "SELECT max(created_at) FROM ncm.configs" + ``` + +7. **Алерти рахуються.** Погасити тестовий хост і переконатись, що алерт + з'явився й прийшов у канал. + +8. **Журнал аудиту показує імена акторів, а не порожні клітинки.** Це + єдине місце, де помилка виглядає правдоподібно: перелік подій + лишається, а колонка «хто» стає порожньою. + +--- + +## Крок 5. Утиліти командного рядка + +`netpulse-user` (заводить кабінети й людей) і `netpulse-secret` (кладе +паролі в `core.secrets`) роблять рівно те, чого роль під RLS робити не +має. Після переходу їм потрібен DSN власника — для цього в +`docker-compose.yml` є окрема служба `cli`: + +```sh +docker compose run --rm --entrypoint netpulse-user cli \ + -tenant default -login admin -role owner +``` + +Без неї вони не впадуть з помилкою, а мовчки нічого не знайдуть. + +`netpulse-gitsync`, навпаки, ходить `NETPULSE_DSN_WORKER` і працює як є — +перелік кабінетів він бере крос-тенантним запитом. + +--- + +## Відкат + +Відкат — не міграція. 0063 нічого не ламає й лишається накоченим; назад +повертається тільки те, якою роллю ходить застосунок. + +**Швидкий (30 секунд, без втрати даних):** + +```sh +# У .env закоментувати або прибрати два рядки: +# NETPULSE_APP_PASSWORD, NETPULSE_WORKER_PASSWORD +docker compose up -d api collector +``` + +DSN згортається до `netpulse` — тобто до стану «до переходу», разом із +BYPASSRLS. Перевірка та сама, що на кроці 4, пункти 1–4. + +Прибирати треба обидва рядки, і саме тому їх лишилось два, а не три: +одна змінна вирішує і роль, і пароль, тому «прибрав половину» більше не +є станом, у який можна потрапити. + +Це працює, бо порожній `NETPULSE_APP_PASSWORD` збирає старий DSN, а +порожній `NETPULSE_WORKER_PASSWORD` лишає `NETPULSE_DSN_WORKER` +порожнім — і `store.UseWorkerDSN` тоді просто не відкриває другий пул, +а фонові запити йдуть основним. Жодного коду вимикати не треба. + +**Якщо відкат не допоміг** — значить справа не в ролях, і схема тут ні +до чого: 0063 не змінює жодної таблиці з даними. Дивіться, що ще +поїхало разом із цим релізом. + +**Схему назад не котять.** Зворотних міграцій у проєкті немає навмисно +(`deploy/README.md`, `## Оновлення`), і 0063 тут не виняток. Якщо треба +прибрати саме її наслідки — це три команди, і жодна не чіпає даних: + +```sql +ALTER ROLE netpulse_app NOLOGIN; +ALTER ROLE netpulse_worker NOLOGIN; +ALTER VIEW topo.link_live SET (security_invoker = false); +``` + +Політики на зв'язкових таблицях лишати можна: під суперкористувачем +вони не діють. + +--- + +## Що зробити потім + +1. **Звузити права `netpulse_worker`.** Зараз він має `SELECT, INSERT, + UPDATE, DELETE` на все — успадковано з 0011. Звужувати наосліп, за + читанням коду, — спосіб зупинити бекапи через півтори доби на + таблиці, про яку забули. Правильний порядок: дати стенду відпрацювати + тиждень, зняти фактичний перелік і звузити за ним. + + ```sql + -- увімкнути на добу, потім зняти перелік + ALTER SYSTEM SET pg_stat_statements.track = 'all'; + SELECT calls, query FROM pg_stat_statements + WHERE userid = 'netpulse_worker'::regrole ORDER BY calls DESC; + ``` + +2. **Прибрати другий рубіж там, де він більше не потрібен?** Ні. Явний + `tenant_id = $1` у запитах лишається: на гіпертаблицях він єдиний, а + на решті — те, що робить план запиту передбачуваним (політика додає + умову, індекс використовує предикат). + +3. **Стежити за новими таблицями.** Перевірка в 0063 разова — вона + спрацювала на момент накочування. Наступна таблиця з `tenant_id` без + політики знову з'явиться мовчки. Найдешевше — повторити ту саму + перевірку в наступній міграції, що додає таблиці. diff --git a/deploy/act-runner.config.yml b/deploy/act-runner.config.yml new file mode 100644 index 0000000..a6cafa6 --- /dev/null +++ b/deploy/act-runner.config.yml @@ -0,0 +1,104 @@ +# Налаштування раннера Forgejo Actions для NetPulse. +# +# Читається демоном як `forgejo-runner daemon --config`. Підключається +# у deploy/docker-compose.ci.yml. +# +# Головне, заради чого цей файл узагалі існує, — два рядки: +# `capacity: 1` і `valid_volumes`. Решта — замовчування з поясненням, +# чому їх не змінено. + +log: + level: info + +runner: + # Файл реєстрації. Лежить в іменованому томі, тобто переживає + # перестворення контейнера: реєструватись заново після кожного + # `docker compose pull` не треба. + file: /data/.runner + + # ОДНА робота за раз. Не «щоб було просто», а тому що на цій машині + # 4 ГБ памʼяті й на ній же живе бойовий стек. Дві паралельні роботи — + # це `go build` і `vite build` одночасно, тобто ядро вибирає, кого + # вбити, і вибір робить не на нашу користь. CI, який кладе продукт, + # гірший за відсутній CI. + capacity: 1 + + # Стеля на роботу. Довший прогін означає, що щось зависло (мережа до + # реєстру образів, база, яка не піднялась), а не що збірка велика: + # найдовша робота тут — server, і вона вкладається в десяток хвилин. + timeout: 1h + + # Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше + # немає сенсу, рідше — довше чекати відповіді на пуш. + fetch_timeout: 5s + fetch_interval: 2s + + # Мітки визначають, що можна писати в `runs-on`. Усі роботи в + # .forgejo/workflows/ci.yml пишуть `runs-on: docker` і задають свій + # образ через `container:`, тож образ у мітці — лише запасний варіант + # для робіт без `container`. node:22-alpine узято тому, що це єдиний + # із наших образів, у якому є node: якщо колись зʼявиться робота з + # JS-дією, вона хоча б стартує. + # + # УВАГА: мітки з цього файлу застосовуються при РЕЄСТРАЦІЇ. Якщо + # раннер уже зареєстровано, зміна цього списку нічого не змінить, + # доки не перереєструвати (див. deploy/docker-compose.ci.yml). + labels: + - "docker:docker://node:22-alpine" + +cache: + # Вбудований кеш-сервер вимкнено НАВМИСНО. + # + # Він потрібен лише для actions/cache, а її тут немає: кеші Go й npm + # їдуть іменованими томами (див. valid_volumes). Різниця не в смаку — + # у диску. Кеш-сервер тримає вміст ЩЕ РАЗ, у вигляді tar-архівів + # поруч, і пакує/розпаковує їх на кожному прогоні. На машині з ~7 ГБ + # вільного це друга копія того самого й зайві хвилини на прогін. + enabled: false + +container: + # Порожньо — act створює власну мережу під кожну роботу. Саме завдяки + # їй контейнер роботи бачить сервіс `db` за іменем `db`. + network: "" + + # Docker-in-Docker не потрібен: ми нічого не збираємо в образи. + privileged: false + + # Стеля памʼяті й ядер на КОНТЕЙНЕР РОБОТИ. + # + # Це свідомий вибір, кому вмирати першим. Без стелі найбільший + # споживач памʼяті на машині — компілятор Go — і ядро при нестачі + # вбиває не його, а те, що поруч, тобто API або базу стенду. Зі + # стелею перевищення вбиває саму роботу: CI червоніє, продукт живий. + # Червоний CI видно й можна полагодити; перезапущений уночі API — + # ні. + # + # --cpus=2 заразом задає GOMAXPROCS: Go 1.25 читає обмеження cgroup + # сам, окремо його виставляти не треба. + options: "--memory=1600m --cpus=2" + + # Батьківський каталог робочих каталогів. + workdir_parent: workspace + + # Томи, які роботам дозволено монтувати. Порожній список означає + # «жодних», і тоді .forgejo/workflows/ci.yml не запуститься взагалі: + # там кожна робота монтує свій кеш. + # + # Перелічено поіменно, а не '**': раннер ходить у докер із правами + # root, і дозвіл монтувати будь-що перетворює будь-який workflow із + # будь-якої гілки на доступ до всього диска машини — включно з + # даними бойової бази. + valid_volumes: + - netpulse-ci-gomod + - netpulse-ci-gobuild + - netpulse-ci-npm + + # Порожньо — береться DOCKER_HOST із оточення (сокет, прокинутий у + # compose). + docker_host: "" + + # Не тягнути образ, якщо він уже є. Образи тут прибиті до версій + # (alpine:3.21, golang:1.25-alpine, node:22-alpine, timescaledb з + # тим самим тегом, що в бойовому compose), тож перевіряти реєстр на + # кожному прогоні — зайвий трафік і зайва точка відмови. + force_pull: false diff --git a/deploy/docker-compose.ci.yml b/deploy/docker-compose.ci.yml new file mode 100644 index 0000000..08cef03 --- /dev/null +++ b/deploy/docker-compose.ci.yml @@ -0,0 +1,146 @@ +# Раннер Forgejo Actions для NetPulse — окремим стеком. +# +# Окремий файл, а не сервіс у docker-compose.yml, свідомо: CI не має +# ані підніматись, ані падати разом із продуктом. `docker compose down` +# на стенді не повинен забирати з собою раннер, а перезапуск раннера не +# повинен торкатись бази. +# +# ───────────────────────────────────────────────────────────────────── +# РЕЄСТРАЦІЯ (робиться один раз, руками) +# +# 1. У Forgejo: Налаштування → Actions → Runners → Create registration +# token. Рівень вибирає, що раннер бачитиме: +# репозиторій — лише netpulse (для цієї задачі досить); +# організація/інстанс — усі репозиторії, включно з майбутніми. +# Раннер має root-доступ до докера машини, тому ширший рівень +# означає, що будь-який workflow у будь-якому репозиторії отримує +# цей доступ. Беріть найвужчий, який покриває потребу. +# +# 2. Реєстрація (виконати В КАТАЛОЗІ repo/deploy): +# +# docker compose -f docker-compose.ci.yml run --rm runner \ +# forgejo-runner register --no-interactive \ +# --instance https://git.zotac.keenetic.link \ +# --token \ +# --name netpulse-ci \ +# --labels docker:docker://node:22-alpine +# +# Результат — файл .runner у томі runner-data. Він переживає +# перестворення контейнера; повторно реєструватись не треба. +# +# 3. Запуск: +# +# docker compose -f docker-compose.ci.yml up -d +# docker compose -f docker-compose.ci.yml logs -f runner +# +# У Forgejo раннер має зʼявитись у списку зі станом «idle». +# +# ЯКЩО FORGEJO НА ЦІЙ САМІЙ МАШИНІ. Публічне імʼя git.zotac.keenetic.link +# з контейнера може не резолвитись у себе (розворот NAT на роутері +# роблять не всі). Ознака — раннер не реєструється або мовчить після +# старту. Лікується одним рядком у сервісі runner: +# +# extra_hosts: +# - "git.zotac.keenetic.link:host-gateway" +# +# ───────────────────────────────────────────────────────────────────── +# ЧОГО ЦЕЙ ФАЙЛ КОШТУЄ +# +# Памʼять у спокої: раннер ~40 МБ + прибиральник ~5 МБ. +# Памʼять під час прогону: одна робота за раз (capacity: 1), стеля +# контейнера роботи — 1600 МБ (deploy/act-runner.config.yml), плюс +# контейнер Postgres у роботі server (~400 МБ із TS_TUNE_MEMORY). +# Тобто пік ~2 ГБ понад те, що вже їсть бойовий стек. +# Диск: образи ~1.1 ГБ (alpine 8 МБ, node:22-alpine ~180 МБ, +# golang:1.25-alpine ~350 МБ, timescaledb ~450 МБ, сам раннер +# ~120 МБ) + кеші зі стелями 1200/900/400 МБ, які тримають самі +# роботи наприкінці кожного прогону. +# Разом у найгіршому випадку ~3.6 ГБ диска. +# +# ───────────────────────────────────────────────────────────────────── + +name: netpulse-ci + +services: + runner: + # ВЕРСІЮ ЗВІРТЕ ЗІ СВОЇМ FORGEJO. Раннер і сервер сумісні не в + # будь-яких парах, а який саме Forgejo стоїть у вас — звідси не + # видно. Це єдиний рядок у цьому файлі, який неможливо перевірити + # без доступу до стенду. + image: ${FORGEJO_RUNNER_IMAGE:-code.forgejo.org/forgejo/runner:6} + container_name: netpulse-ci-runner + restart: unless-stopped + command: ["forgejo-runner", "daemon", "--config", "/etc/forgejo/runner.yml"] + working_dir: /data + environment: + DOCKER_HOST: unix:///var/run/docker.sock + TZ: ${TZ:-Europe/Kyiv} + volumes: + # Сокет докера, а не docker-in-docker. DinD означав би ще один + # демон, власний пул образів (тобто ДРУГУ копію golang, node і + # timescaledb) і власний шар зберігання — на машині з ~7 ГБ це + # не поміщається. Ціна вибору названа чесно: усе, що потрапить у + # workflow, виконується з правами root на цьому докері. + - /var/run/docker.sock:/var/run/docker.sock + - runner-data:/data + - ./act-runner.config.yml:/etc/forgejo/runner.yml:ro + # Сам демон нічого важкого не робить — уся робота в дочірніх + # контейнерах, і їхню стелю задає container.options у конфізі. + mem_limit: 256m + + # Прибиральник. Половина сенсу цього стеку. + # + # act лишає по собі зупинені контейнери робіт і сервісів, а зміна + # тега образу лишає «висячий» попередній. Саме по собі це не + # зникає, і на диску, якого ~7 ГБ, воно закінчується не роками, а + # тижнями. CI, який забив диск, зупинить продукт, а не покращить. + # + # Кешів Go й npm цей сервіс НЕ чіпає навмисно: їх підрізають самі + # роботи, останнім кроком. У той момент точно ніхто не компілює, тож + # чистка не може вирвати файл з-під `go build`. Прибиральник, який + # ходить за розкладом, такої гарантії не має. + gc: + image: docker:28-cli + container_name: netpulse-ci-gc + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Томи кешів підключені лише щоб їх БАЧИТИ: compose створює + # іменований том тоді, коли його хтось монтує, а розмір у логу + # прибиральника — єдине місце, де за ним можна стежити, не + # заходячи на машину. + - gomod:/caches/gomod:ro + - gobuild:/caches/gobuild:ro + - npm:/caches/npm:ro + entrypoint: ["/bin/sh", "-c"] + command: | + while :; do + # БЕЗ -a і БЕЗ --volumes. `prune -a` знесла б образи бойового + # стеку, які просто зараз не запущені (наприклад, під час + # оновлення), а `--volumes` — дані. Тут прибирається лише те, + # що вже нічим не тримається: зупинені контейнери, підвішені + # образи, порожні мережі, кеш складання. + docker system prune -f --filter 'until=72h' >/dev/null 2>&1 || true + free=$$(df -Pm /caches/gobuild 2>/dev/null | awk 'NR==2{print $$4}') + echo "$$(date -Iseconds) CI-gc: вільно $${free:-?} МБ; кеші:" \ + "$$(du -sh /caches/gomod /caches/gobuild /caches/npm 2>/dev/null | tr '\n' ' ')" + sleep 21600 + done + mem_limit: 64m + +volumes: + # Реєстрація раннера (.runner) — без неї доведеться реєструватись + # заново після кожного перестворення контейнера. + runner-data: + + # Кеші збірок. Імена задані явно, БЕЗ префікса проєкту: у + # .forgejo/workflows/ci.yml вони пишуться саме так, а контейнери робіт + # створює act напряму, а не compose, тож жодного префікса він не + # додасть. Ці ж імена мають бути в container.valid_volumes конфігу + # раннера, інакше роботи впадуть на монтуванні. + gomod: + name: netpulse-ci-gomod + gobuild: + name: netpulse-ci-gobuild + npm: + name: netpulse-ci-npm diff --git a/deploy/files.conf.example b/deploy/files.conf.example new file mode 100644 index 0000000..bcc24b6 --- /dev/null +++ b/deploy/files.conf.example @@ -0,0 +1,88 @@ +# NetPulse — які конфіг-файли цієї машини вільно збирати. +# +# Покласти в /etc/netpulse/files.conf (шлях змінюється прапорцем +# -files-policy або змінною NETPULSE_FILES_POLICY). +# +# ЦЕЙ ФАЙЛ — ЄДИНЕ, ЩО ВИРІШУЄ, ЩО БУДЕ ПРОЧИТАНО. +# +# Сервер NetPulse не може ані задати перелік, ані його прочитати: у +# протоколі немає поля зі шляхом до файлу. Інакше будь-хто з правом на +# збір конфігів отримав би читання довільного файлу на ВСІХ машинах, де +# стоїть зонд. Тому перелік редагує той, хто вже має права на цій +# машині, — і тільки він. +# +# Немає файлу — немає збору. Типове значення тут заборона; «якщо не +# задано, беремо все» не існує навмисно. + +# --------------------------------------------------------------------- +# Правила запису +# --------------------------------------------------------------------- +# +# * один зразок шляху на рядок, абсолютний; +# * маски «*», «?», «[...]» діють у МЕЖАХ одного рівня — «*» не +# переходить через «/», рекурсивних масок немає взагалі; +# * перші два рівні мають бути без масок: «/etc/*» відкриває надто +# багато, щоб це можна було написати випадково й не помітити; +# * «..» заборонені. + +/etc/hostname +/etc/hosts +/etc/resolv.conf +/etc/timezone + +/etc/network/interfaces +/etc/network/interfaces.d/*.cfg + +/etc/nginx/nginx.conf +/etc/nginx/conf.d/*.conf +/etc/nginx/sites-enabled/* + +/etc/postfix/main.cf +/etc/postfix/master.cf + +/etc/ssh/sshd_config +/etc/ssh/sshd_config.d/*.conf + +/etc/fstab +/etc/crontab +/etc/cron.d/* + +# --------------------------------------------------------------------- +# Директиви (необов'язкові) +# --------------------------------------------------------------------- + +# Стеля на один файл, у байтах. Типово 1 МіБ. +#!max-file-bytes 1048576 + +# Скільки файлів щонайбільше в наборі. Типово 64. +#!max-files 64 + +# Стеля на весь набір разом. Типово 8 МіБ. +#!max-total-bytes 8388608 + +# Дозволити файли, закриті для читання «іншими» (права без біта 0004). +# +# Типово вимкнено, і це один із найдешевших запобіжників: /etc/shadow +# має 0640, приватні ключі — 0600, а nginx.conf, interfaces і main.cf — +# 0644. Тобто одне правило відрізає рівно той клас файлів, який тут не +# потрібен, не зачепивши жодного справжнього конфігу. +# +# Вмикати лише свідомо й лише разом із вузьким переліком вище. +#!allow-nonpublic + +# --------------------------------------------------------------------- +# Чого не буде зібрано за жодних умов +# --------------------------------------------------------------------- +# +# Вкомпільований у зонд чорний перелік перекриває цей файл завжди: +# +# /etc/shadow, /etc/gshadow і будь-який файл із такими іменами; +# /proc, /sys, /dev, /run/secrets; +# усе під .ssh, .gnupg, .git, */private/; +# *.key, *.pem, *.p12, *_key, .env, .netrc, .pgpass, .my.cnf та подібні; +# /etc/netpulse і /var/lib/netpulse — власне посвідчення зонда. +# +# Крім того зонд не піде за символьним посиланням, ціль якого не +# дозволена цим файлом сама по собі, і не збереже двійковий чи порожній +# файл. Про кожен пропуск він пише причину — вона видно в стенограмі +# завдання збору. diff --git a/docker-compose.yml b/docker-compose.yml index b9e2edc..0178748 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,15 +4,62 @@ # Швидкий старт: # cp deploy/.env.example .env # і заповнити секрети # docker compose up -d -# docker compose run --rm api netpulse-user -tenant default -login admin -role owner +# docker compose run --rm --entrypoint netpulse-user cli \ +# -tenant default -login admin -role owner +# +# netpulse-user і netpulse-secret заводять кабінети, людей і секрети — +# тобто роблять рівно те, чого роль під RLS робити не має. Тому для них +# є окрема служба cli з DSN власника: під роллю застосунку вони не +# падають з помилкою, а мовчки нічого не знаходять. # # Тільки стенд для розробки (БД і кеш, решта — з go run): # docker compose up -d db cache name: netpulse +# --------------------------------------------------------------------- +# DSN трьох ролей +# --------------------------------------------------------------------- +# +# Три ролі, три різні відповіді на «що цій ролі вільно бачити» (0063). +# +# netpulse власник схеми. Ним ходять migrate і cli. +# netpulse_app API і колектор. Без BYPASSRLS — під політиками RLS. +# netpulse_worker фонові такти поверх усіх кабінетів. З BYPASSRLS. +# +# Заповнені NETPULSE_APP_PASSWORD і NETPULSE_WORKER_PASSWORD означають +# «застосунок ходить під RLS». На ЧИСТІЙ базі їх пише установник, а +# netpulse-migrate сам видає ці паролі ролям одразу після накочування +# схеми — тобто нова інсталяція працює під політиками з першої секунди, +# і слова «cutover» клієнт не бачить взагалі. +# +# Порожні означають «як до 0063»: DSN згортається до ролі власника, +# DSN воркера лишається порожнім, store.UseWorkerDSN другого пулу не +# відкриває. Саме на цьому тримається безпека оновлення наявного стенду: +# у його .env цих рядків немає, вирази нижче дають той самий DSN, що й +# раніше, а мігратор на непорожній базі паролів ролям не видає взагалі. +# Перехід для таких інсталяцій — deploy/RLS-EXISTING-INSTALL.md. +# +# Ім'я ролі більше НЕ окрема змінна, і це виправлення, а не спрощення. +# Раніше їх було дві — NETPULSE_APP_USER і NETPULSE_APP_PASSWORD, — і +# будь-яка половина без другої давала DSN, який не встановлюється: нова +# роль зі старим паролем або стара роль з новим. Виглядає це не як +# помилка конфігурації, а як «RLS усе зламав». Тепер прапорець один: є +# пароль — є й роль netpulse_app, немає пароля — немає й ролі. +# +# Вираз читається так. Перша частина додає «netpulse_app:» лише тоді, +# коли пароль заданий. Друга віддає або сам пароль (коли заданий), або +# цілу пару «netpulse:<пароль власника>» (коли ні). Обидві частини +# дивляться на ОДНУ змінну, тому проміжного стану не буває. +x-app-dsn: &app-dsn postgres://${NETPULSE_APP_PASSWORD:+netpulse_app:}${NETPULSE_APP_PASSWORD:-netpulse:$POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable + +x-worker-dsn: &worker-dsn ${NETPULSE_WORKER_PASSWORD:+postgres://netpulse_worker:${NETPULSE_WORKER_PASSWORD}@db:5432/netpulse?sslmode=disable} + +x-owner-dsn: &owner-dsn postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable + x-server-env: &server-env - NETPULSE_DSN: postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable + NETPULSE_DSN: *app-dsn + NETPULSE_DSN_WORKER: *worker-dsn NETPULSE_DEK: ${NETPULSE_DEK:?потрібен NETPULSE_DEK — див. deploy/README.md} NETPULSE_LOG_LEVEL: ${NETPULSE_LOG_LEVEL:-info} TZ: ${TZ:-Europe/Kyiv} @@ -40,7 +87,11 @@ services: - -c - shared_buffers=${PG_SHARED_BUFFERS:-512MB} - -c - - timescaledb.max_background_workers=8 + # Вісім фонових робітників TimescaleDB — розумно на чотирьох + # ядрах і забагато на двох: вони конкурують за ті самі ядра з + # самими запитами, і стиснення чанків починає заважати опитуванню. + # Установник підставляє число за фактичною кількістю ядер. + - timescaledb.max_background_workers=${PG_BG_WORKERS:-8} volumes: - db-data:/var/lib/postgresql/data healthcheck: @@ -56,6 +107,13 @@ services: # у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і # контейнер не стартує взагалі з «error setting rlimit type 8». # Швидкість того не варта — обмеження знято. + # + # Стеля пам'яті задається явно, бо без неї Dragonfly бере її з + # доступної на машині — і рахує ту саму пам'ять, яку вже порахував + # PG_SHARED_BUFFERS. На машині з 4 ГБ два незалежні розрахунки + # чверті одного пирога закінчуються тим, що ядро вбиває когось + # третього, найімовірніше API. Установник задає обидві цифри разом. + command: ["--maxmemory=${DRAGONFLY_MAXMEMORY:-512mb}"] volumes: - cache-data:/data restart: unless-stopped @@ -74,7 +132,57 @@ services: COMMIT: ${NETPULSE_COMMIT:-none} image: netpulse/server:${NETPULSE_VERSION:-dev} entrypoint: ["netpulse-migrate"] - environment: *server-env + environment: + <<: *server-env + # Схему котить власник, а не netpulse_app: міграції створюють + # об'єкти, правлять самі політики й іноді переливають дані поверх + # кабінетів. Роль під RLS не змогла б жодного з трьох, а 0063 ще й + # перевіряє права netpulse_app — тобто мусить їх бачити. + NETPULSE_DSN: *owner-dsn + # А цими двома мігратор схему не котить — він ними ЗАХОДИТЬ. + # + # По-перше, на чистій базі саме звідси беруться паролі ролей: + # мігратор витягує пароль із самого DSN, тому в базу лягає рівно + # той рядок, який потім надішле pgx. Розійтись вони не можуть за + # побудовою — це той самий рядок і той самий розбирач. + # + # По-друге, перед тим як пустити api й collector, мігратор кожним + # з цих DSN підключається й дивиться, під якою роллю опинився. + # Саме цієї перевірки бракувало 0063: там перевіряли, що не видно + # чужого, а зламався вхід — тобто «чи видно своє». + NETPULSE_DSN_APP: *app-dsn + NETPULSE_DSN_WORKER: *worker-dsn + depends_on: + db: + condition: service_healthy + restart: "no" + + # Утиліти командного рядка під роллю власника: netpulse-user (кабінети + # й люди), netpulse-secret (секрети в core.secrets). + # + # Окрема служба, а не `run --rm api`, бо DSN тут інший. Обидві утиліти + # роблять рівно те, чого роль під RLS робити не має: заводять кабінет + # (INSERT у core.tenants під політикою tenant_self неможливий за + # означенням) і додають людину в чужі за контекстом рядки. Під + # netpulse_app вони не падають, а мовчки нічого не знаходять — тобто + # на новій інсталяції перша ж команда «створити адміністратора» + # виглядала б як успіх, а адміністратора не було б. + # + # profiles: службу не піднімає ні `up`, ні `up -d`; `docker compose run` + # вмикає її профіль сам. + # + # docker compose run --rm --entrypoint netpulse-user cli \ + # -tenant default -login admin -role owner + cli: + build: *server-build + image: netpulse/server:${NETPULSE_VERSION:-dev} + profiles: ["cli"] + environment: + <<: *server-env + NETPULSE_DSN: *owner-dsn + NETPULSE_DSN_WORKER: "" + volumes: + - git-data:/var/lib/netpulse depends_on: db: condition: service_healthy @@ -171,13 +279,25 @@ services: command: - -server=collector:9443 - -insecure - - -modules=icmp,snmp,topology,ncm + - -modules=icmp,snmp,topology,ncm,traps + - -traps-listen=${NETPULSE_TRAPS_LISTEN:-:162} environment: NETPULSE_ENROLL: ${NETPULSE_ENROLL:-} NETPULSE_NAME: ${NETPULSE_AGENT_NAME:-локальний зонд} TZ: ${TZ:-Europe/Kyiv} + # Порт трапів назовні. Прив'язка задається змінною, а не зашита: + # 162/udp без автентифікації приймає будь-кого, хто знає адресу, і + # на машині з публічним інтерфейсом «усі інтерфейси» — це рішення, а + # не типове значення. Порожній NETPULSE_TRAPS_BIND лишає службу + # всередині мережі docker, і зовні вона недосяжна. + ports: + - "${NETPULSE_TRAPS_BIND:-127.0.0.1}:162:162/udp" cap_add: - NET_RAW + # 162 нижче 1024, а процес усередині — не root. Без цього зонд + # порт не займе; він про це кричить у журнал, але приймати трапи + # все одно не буде. + - NET_BIND_SERVICE volumes: - agent-identity:/var/lib/netpulse depends_on: diff --git a/gen/go/netpulse/v1/ncm.pb.go b/gen/go/netpulse/v1/ncm.pb.go index 38f8157..4e0d713 100644 --- a/gen/go/netpulse/v1/ncm.pb.go +++ b/gen/go/netpulse/v1/ncm.pb.go @@ -59,8 +59,18 @@ type ConfigJob struct { MaxBytes uint64 `protobuf:"varint,12,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` // Записувати повний транскрипт сесії (для діагностики prompt_regex). CaptureTranscript bool `protobuf:"varint,13,opt,name=capture_transcript,json=captureTranscript,proto3" json:"capture_transcript,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Заповнене поле перемикає завдання з «зайди по SSH на пристрій» на + // «прочитай конфіг-файли машини, де ти сам стоїш». Тоді transport, + // credential, commands і prompt_regex не діють: сесії до пристрою + // немає взагалі. + // + // Окреме поле, а не ще одне значення config_type: config_type + // потрапляє в архів як назва зрізу, і навантажувати його ще й + // роллю «яким способом збирати» означало б, що зміна назви зрізу + // тихо змінює спосіб збору. + LocalFiles *LocalFilesJob `protobuf:"bytes,14,opt,name=local_files,json=localFiles,proto3" json:"local_files,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ConfigJob) Reset() { @@ -184,6 +194,94 @@ func (x *ConfigJob) GetCaptureTranscript() bool { return false } +func (x *ConfigJob) GetLocalFiles() *LocalFilesJob { + if x != nil { + return x.LocalFiles + } + return nil +} + +// LocalFilesJob — прохання зібрати локальні конфіг-файли. +// +// НАЙВАЖЛИВІШЕ В ЦЬОМУ ПОВІДОМЛЕННІ — ЧОГО В НЬОМУ НЕМАЄ. +// +// Тут немає й не буде поля зі шляхом, маскою чи каталогом. Зонд стоїть +// на сервері й читає файли його правами; поле «які саме файли читати», +// кероване з кабінету, означало б віддалене читання довільного файлу на +// КОЖНІЙ машині клієнта для будь-кого з правом ncm:write — /etc/shadow, +// приватні ключі, .env із паролем бази, і одразу з усіх серверів. +// +// Тому перелік файлів живе ВИКЛЮЧНО в локальному конфізі зонда +// (agent/internal/localfiles), який редагує адміністратор тієї самої +// машини. Сервер може попросити зібрати — і не може сказати, що саме. +// Наслідок: щоб дістати цим шляхом /etc/shadow, треба вже мати root на +// тому сервері; а маючи його, NetPulse для цього не потрібен. +// +// Поля нижче вміють лише ЗВУЖУВАТИ дозволене машиною: зонд бере мінімум +// зі свого й серверного значення. Нуль означає «не звужую». +type LocalFilesJob struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Стеля на один файл. + MaxFileBytes uint64 `protobuf:"varint,1,opt,name=max_file_bytes,json=maxFileBytes,proto3" json:"max_file_bytes,omitempty"` + // Скільки файлів щонайбільше взяти в один набір. + MaxFiles uint32 `protobuf:"varint,2,opt,name=max_files,json=maxFiles,proto3" json:"max_files,omitempty"` + // Стеля на весь набір разом. + MaxTotalBytes uint64 `protobuf:"varint,3,opt,name=max_total_bytes,json=maxTotalBytes,proto3" json:"max_total_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalFilesJob) Reset() { + *x = LocalFilesJob{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalFilesJob) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalFilesJob) ProtoMessage() {} + +func (x *LocalFilesJob) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalFilesJob.ProtoReflect.Descriptor instead. +func (*LocalFilesJob) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{1} +} + +func (x *LocalFilesJob) GetMaxFileBytes() uint64 { + if x != nil { + return x.MaxFileBytes + } + return 0 +} + +func (x *LocalFilesJob) GetMaxFiles() uint32 { + if x != nil { + return x.MaxFiles + } + return 0 +} + +func (x *LocalFilesJob) GetMaxTotalBytes() uint64 { + if x != nil { + return x.MaxTotalBytes + } + return 0 +} + type ConfigUpload struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Part: @@ -198,7 +296,7 @@ type ConfigUpload struct { func (x *ConfigUpload) Reset() { *x = ConfigUpload{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[1] + mi := &file_netpulse_v1_ncm_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -210,7 +308,7 @@ func (x *ConfigUpload) String() string { func (*ConfigUpload) ProtoMessage() {} func (x *ConfigUpload) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[1] + mi := &file_netpulse_v1_ncm_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -223,7 +321,7 @@ func (x *ConfigUpload) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigUpload.ProtoReflect.Descriptor instead. func (*ConfigUpload) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{1} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{2} } func (x *ConfigUpload) GetPart() isConfigUpload_Part { @@ -291,14 +389,27 @@ type ConfigHeader struct { CollectedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=collected_at,json=collectedAt,proto3" json:"collected_at,omitempty"` // gzip | none — агент стискає, бо конфіги добре жмуться, // а канал може бути вузьким. - Encoding string `protobuf:"bytes,6,opt,name=encoding,proto3" json:"encoding,omitempty"` + Encoding string `protobuf:"bytes,6,opt,name=encoding,proto3" json:"encoding,omitempty"` + // Відбиток МАШИНИ, з якої знято набір локальних файлів: ім'я хоста + // плюс machine-id. Заповнюється лише збором локальних файлів. + // + // Потрібен через те, що зонд переносний, а картка хоста — ні. + // Посвідчення зонда — це файл; його копіюють разом із контейнером або + // переставляють на іншу машину. Без відбитка історія сервера А + // мовчки продовжилась би файлами сервера Б у тій самій гілці Git — + // найгірший різновид помилки в архіві: він виглядає справним. + // + // Сервер закріплює відбиток за карткою при першому зборі й далі + // відмовляє в прийомі, якщо він змінився, доки людина не підтвердить + // прив'язку заново. + MachineId string `protobuf:"bytes,7,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ConfigHeader) Reset() { *x = ConfigHeader{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[2] + mi := &file_netpulse_v1_ncm_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -310,7 +421,7 @@ func (x *ConfigHeader) String() string { func (*ConfigHeader) ProtoMessage() {} func (x *ConfigHeader) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[2] + mi := &file_netpulse_v1_ncm_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -323,7 +434,7 @@ func (x *ConfigHeader) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigHeader.ProtoReflect.Descriptor instead. func (*ConfigHeader) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{2} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{3} } func (x *ConfigHeader) GetJobId() string { @@ -368,6 +479,13 @@ func (x *ConfigHeader) GetEncoding() string { return "" } +func (x *ConfigHeader) GetMachineId() string { + if x != nil { + return x.MachineId + } + return "" +} + type ConfigChunk struct { state protoimpl.MessageState `protogen:"open.v1"` // Номер чанка з 0; сервер збирає в порядку зростання. @@ -379,7 +497,7 @@ type ConfigChunk struct { func (x *ConfigChunk) Reset() { *x = ConfigChunk{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[3] + mi := &file_netpulse_v1_ncm_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -391,7 +509,7 @@ func (x *ConfigChunk) String() string { func (*ConfigChunk) ProtoMessage() {} func (x *ConfigChunk) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[3] + mi := &file_netpulse_v1_ncm_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -404,7 +522,7 @@ func (x *ConfigChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigChunk.ProtoReflect.Descriptor instead. func (*ConfigChunk) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{3} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{4} } func (x *ConfigChunk) GetSequence() uint32 { @@ -439,7 +557,7 @@ type ConfigTrailer struct { func (x *ConfigTrailer) Reset() { *x = ConfigTrailer{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[4] + mi := &file_netpulse_v1_ncm_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -451,7 +569,7 @@ func (x *ConfigTrailer) String() string { func (*ConfigTrailer) ProtoMessage() {} func (x *ConfigTrailer) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[4] + mi := &file_netpulse_v1_ncm_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -464,7 +582,7 @@ func (x *ConfigTrailer) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigTrailer.ProtoReflect.Descriptor instead. func (*ConfigTrailer) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{4} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{5} } func (x *ConfigTrailer) GetSuccess() bool { @@ -540,7 +658,7 @@ type ConfigReceipt struct { func (x *ConfigReceipt) Reset() { *x = ConfigReceipt{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[5] + mi := &file_netpulse_v1_ncm_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -552,7 +670,7 @@ func (x *ConfigReceipt) String() string { func (*ConfigReceipt) ProtoMessage() {} func (x *ConfigReceipt) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[5] + mi := &file_netpulse_v1_ncm_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -565,7 +683,7 @@ func (x *ConfigReceipt) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigReceipt.ProtoReflect.Descriptor instead. func (*ConfigReceipt) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{5} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{6} } func (x *ConfigReceipt) GetJobId() string { @@ -635,7 +753,7 @@ type ConfigApplyJob struct { func (x *ConfigApplyJob) Reset() { *x = ConfigApplyJob{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[6] + mi := &file_netpulse_v1_ncm_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -647,7 +765,7 @@ func (x *ConfigApplyJob) String() string { func (*ConfigApplyJob) ProtoMessage() {} func (x *ConfigApplyJob) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[6] + mi := &file_netpulse_v1_ncm_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -660,7 +778,7 @@ func (x *ConfigApplyJob) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigApplyJob.ProtoReflect.Descriptor instead. func (*ConfigApplyJob) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{6} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{7} } func (x *ConfigApplyJob) GetRollbackId() string { @@ -756,7 +874,7 @@ type ConfigApplyResult struct { func (x *ConfigApplyResult) Reset() { *x = ConfigApplyResult{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[7] + mi := &file_netpulse_v1_ncm_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -768,7 +886,7 @@ func (x *ConfigApplyResult) String() string { func (*ConfigApplyResult) ProtoMessage() {} func (x *ConfigApplyResult) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[7] + mi := &file_netpulse_v1_ncm_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -781,7 +899,7 @@ func (x *ConfigApplyResult) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigApplyResult.ProtoReflect.Descriptor instead. func (*ConfigApplyResult) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{7} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{8} } func (x *ConfigApplyResult) GetRollbackId() string { @@ -846,7 +964,7 @@ type CommandOutcome struct { func (x *CommandOutcome) Reset() { *x = CommandOutcome{} - mi := &file_netpulse_v1_ncm_proto_msgTypes[8] + mi := &file_netpulse_v1_ncm_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -858,7 +976,7 @@ func (x *CommandOutcome) String() string { func (*CommandOutcome) ProtoMessage() {} func (x *CommandOutcome) ProtoReflect() protoreflect.Message { - mi := &file_netpulse_v1_ncm_proto_msgTypes[8] + mi := &file_netpulse_v1_ncm_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -871,7 +989,7 @@ func (x *CommandOutcome) ProtoReflect() protoreflect.Message { // Deprecated: Use CommandOutcome.ProtoReflect.Descriptor instead. func (*CommandOutcome) Descriptor() ([]byte, []int) { - return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{8} + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{9} } func (x *CommandOutcome) GetIndex() uint32 { @@ -913,7 +1031,7 @@ var File_netpulse_v1_ncm_proto protoreflect.FileDescriptor const file_netpulse_v1_ncm_proto_rawDesc = "" + "\n" + - "\x15netpulse/v1/ncm.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\x92\x04\n" + + "\x15netpulse/v1/ncm.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\xcf\x04\n" + "\tConfigJob\x12\x15\n" + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x121\n" + "\x06device\x18\x02 \x01(\v2\x19.netpulse.v1.DeviceTargetR\x06device\x127\n" + @@ -931,12 +1049,18 @@ const file_netpulse_v1_ncm_proto_rawDesc = "" + "configType\x123\n" + "\atimeout\x18\v \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12\x1b\n" + "\tmax_bytes\x18\f \x01(\x04R\bmaxBytes\x12-\n" + - "\x12capture_transcript\x18\r \x01(\bR\x11captureTranscript\"\xb5\x01\n" + + "\x12capture_transcript\x18\r \x01(\bR\x11captureTranscript\x12;\n" + + "\vlocal_files\x18\x0e \x01(\v2\x1a.netpulse.v1.LocalFilesJobR\n" + + "localFiles\"z\n" + + "\rLocalFilesJob\x12$\n" + + "\x0emax_file_bytes\x18\x01 \x01(\x04R\fmaxFileBytes\x12\x1b\n" + + "\tmax_files\x18\x02 \x01(\rR\bmaxFiles\x12&\n" + + "\x0fmax_total_bytes\x18\x03 \x01(\x04R\rmaxTotalBytes\"\xb5\x01\n" + "\fConfigUpload\x123\n" + "\x06header\x18\x01 \x01(\v2\x19.netpulse.v1.ConfigHeaderH\x00R\x06header\x120\n" + "\x05chunk\x18\x02 \x01(\v2\x18.netpulse.v1.ConfigChunkH\x00R\x05chunk\x126\n" + "\atrailer\x18\x03 \x01(\v2\x1a.netpulse.v1.ConfigTrailerH\x00R\atrailerB\x06\n" + - "\x04part\"\xd9\x01\n" + + "\x04part\"\xf8\x01\n" + "\fConfigHeader\x12\x15\n" + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x19\n" + "\bagent_id\x18\x02 \x01(\tR\aagentId\x12\x1b\n" + @@ -944,7 +1068,9 @@ const file_netpulse_v1_ncm_proto_rawDesc = "" + "\vconfig_type\x18\x04 \x01(\tR\n" + "configType\x12=\n" + "\fcollected_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\vcollectedAt\x12\x1a\n" + - "\bencoding\x18\x06 \x01(\tR\bencoding\"=\n" + + "\bencoding\x18\x06 \x01(\tR\bencoding\x12\x1d\n" + + "\n" + + "machine_id\x18\a \x01(\tR\tmachineId\"=\n" + "\vConfigChunk\x12\x1a\n" + "\bsequence\x18\x01 \x01(\rR\bsequence\x12\x12\n" + "\x04data\x18\x02 \x01(\fR\x04data\"\xb0\x02\n" + @@ -1018,49 +1144,51 @@ func file_netpulse_v1_ncm_proto_rawDescGZIP() []byte { return file_netpulse_v1_ncm_proto_rawDescData } -var file_netpulse_v1_ncm_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_netpulse_v1_ncm_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_netpulse_v1_ncm_proto_goTypes = []any{ (*ConfigJob)(nil), // 0: netpulse.v1.ConfigJob - (*ConfigUpload)(nil), // 1: netpulse.v1.ConfigUpload - (*ConfigHeader)(nil), // 2: netpulse.v1.ConfigHeader - (*ConfigChunk)(nil), // 3: netpulse.v1.ConfigChunk - (*ConfigTrailer)(nil), // 4: netpulse.v1.ConfigTrailer - (*ConfigReceipt)(nil), // 5: netpulse.v1.ConfigReceipt - (*ConfigApplyJob)(nil), // 6: netpulse.v1.ConfigApplyJob - (*ConfigApplyResult)(nil), // 7: netpulse.v1.ConfigApplyResult - (*CommandOutcome)(nil), // 8: netpulse.v1.CommandOutcome - (*DeviceTarget)(nil), // 9: netpulse.v1.DeviceTarget - (*Credential)(nil), // 10: netpulse.v1.Credential - (Transport)(0), // 11: netpulse.v1.Transport - (*durationpb.Duration)(nil), // 12: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp - (*Error)(nil), // 14: netpulse.v1.Error + (*LocalFilesJob)(nil), // 1: netpulse.v1.LocalFilesJob + (*ConfigUpload)(nil), // 2: netpulse.v1.ConfigUpload + (*ConfigHeader)(nil), // 3: netpulse.v1.ConfigHeader + (*ConfigChunk)(nil), // 4: netpulse.v1.ConfigChunk + (*ConfigTrailer)(nil), // 5: netpulse.v1.ConfigTrailer + (*ConfigReceipt)(nil), // 6: netpulse.v1.ConfigReceipt + (*ConfigApplyJob)(nil), // 7: netpulse.v1.ConfigApplyJob + (*ConfigApplyResult)(nil), // 8: netpulse.v1.ConfigApplyResult + (*CommandOutcome)(nil), // 9: netpulse.v1.CommandOutcome + (*DeviceTarget)(nil), // 10: netpulse.v1.DeviceTarget + (*Credential)(nil), // 11: netpulse.v1.Credential + (Transport)(0), // 12: netpulse.v1.Transport + (*durationpb.Duration)(nil), // 13: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp + (*Error)(nil), // 15: netpulse.v1.Error } var file_netpulse_v1_ncm_proto_depIdxs = []int32{ - 9, // 0: netpulse.v1.ConfigJob.device:type_name -> netpulse.v1.DeviceTarget - 10, // 1: netpulse.v1.ConfigJob.credential:type_name -> netpulse.v1.Credential - 11, // 2: netpulse.v1.ConfigJob.transport:type_name -> netpulse.v1.Transport - 12, // 3: netpulse.v1.ConfigJob.timeout:type_name -> google.protobuf.Duration - 2, // 4: netpulse.v1.ConfigUpload.header:type_name -> netpulse.v1.ConfigHeader - 3, // 5: netpulse.v1.ConfigUpload.chunk:type_name -> netpulse.v1.ConfigChunk - 4, // 6: netpulse.v1.ConfigUpload.trailer:type_name -> netpulse.v1.ConfigTrailer - 13, // 7: netpulse.v1.ConfigHeader.collected_at:type_name -> google.protobuf.Timestamp - 14, // 8: netpulse.v1.ConfigTrailer.error:type_name -> netpulse.v1.Error - 12, // 9: netpulse.v1.ConfigTrailer.duration:type_name -> google.protobuf.Duration - 14, // 10: netpulse.v1.ConfigReceipt.error:type_name -> netpulse.v1.Error - 9, // 11: netpulse.v1.ConfigApplyJob.device:type_name -> netpulse.v1.DeviceTarget - 10, // 12: netpulse.v1.ConfigApplyJob.credential:type_name -> netpulse.v1.Credential - 11, // 13: netpulse.v1.ConfigApplyJob.transport:type_name -> netpulse.v1.Transport - 12, // 14: netpulse.v1.ConfigApplyJob.confirm_timeout:type_name -> google.protobuf.Duration - 12, // 15: netpulse.v1.ConfigApplyJob.timeout:type_name -> google.protobuf.Duration - 8, // 16: netpulse.v1.ConfigApplyResult.outcomes:type_name -> netpulse.v1.CommandOutcome - 14, // 17: netpulse.v1.ConfigApplyResult.error:type_name -> netpulse.v1.Error - 12, // 18: netpulse.v1.ConfigApplyResult.duration:type_name -> google.protobuf.Duration - 19, // [19:19] is the sub-list for method output_type - 19, // [19:19] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 10, // 0: netpulse.v1.ConfigJob.device:type_name -> netpulse.v1.DeviceTarget + 11, // 1: netpulse.v1.ConfigJob.credential:type_name -> netpulse.v1.Credential + 12, // 2: netpulse.v1.ConfigJob.transport:type_name -> netpulse.v1.Transport + 13, // 3: netpulse.v1.ConfigJob.timeout:type_name -> google.protobuf.Duration + 1, // 4: netpulse.v1.ConfigJob.local_files:type_name -> netpulse.v1.LocalFilesJob + 3, // 5: netpulse.v1.ConfigUpload.header:type_name -> netpulse.v1.ConfigHeader + 4, // 6: netpulse.v1.ConfigUpload.chunk:type_name -> netpulse.v1.ConfigChunk + 5, // 7: netpulse.v1.ConfigUpload.trailer:type_name -> netpulse.v1.ConfigTrailer + 14, // 8: netpulse.v1.ConfigHeader.collected_at:type_name -> google.protobuf.Timestamp + 15, // 9: netpulse.v1.ConfigTrailer.error:type_name -> netpulse.v1.Error + 13, // 10: netpulse.v1.ConfigTrailer.duration:type_name -> google.protobuf.Duration + 15, // 11: netpulse.v1.ConfigReceipt.error:type_name -> netpulse.v1.Error + 10, // 12: netpulse.v1.ConfigApplyJob.device:type_name -> netpulse.v1.DeviceTarget + 11, // 13: netpulse.v1.ConfigApplyJob.credential:type_name -> netpulse.v1.Credential + 12, // 14: netpulse.v1.ConfigApplyJob.transport:type_name -> netpulse.v1.Transport + 13, // 15: netpulse.v1.ConfigApplyJob.confirm_timeout:type_name -> google.protobuf.Duration + 13, // 16: netpulse.v1.ConfigApplyJob.timeout:type_name -> google.protobuf.Duration + 9, // 17: netpulse.v1.ConfigApplyResult.outcomes:type_name -> netpulse.v1.CommandOutcome + 15, // 18: netpulse.v1.ConfigApplyResult.error:type_name -> netpulse.v1.Error + 13, // 19: netpulse.v1.ConfigApplyResult.duration:type_name -> google.protobuf.Duration + 20, // [20:20] is the sub-list for method output_type + 20, // [20:20] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_netpulse_v1_ncm_proto_init() } @@ -1069,7 +1197,7 @@ func file_netpulse_v1_ncm_proto_init() { return } file_netpulse_v1_common_proto_init() - file_netpulse_v1_ncm_proto_msgTypes[1].OneofWrappers = []any{ + file_netpulse_v1_ncm_proto_msgTypes[2].OneofWrappers = []any{ (*ConfigUpload_Header)(nil), (*ConfigUpload_Chunk)(nil), (*ConfigUpload_Trailer)(nil), @@ -1080,7 +1208,7 @@ func file_netpulse_v1_ncm_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_ncm_proto_rawDesc), len(file_netpulse_v1_ncm_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/netpulse b/netpulse new file mode 100644 index 0000000..77a384a --- /dev/null +++ b/netpulse @@ -0,0 +1,1821 @@ +#!/usr/bin/env sh +# +# NetPulse — установка й обслуговування однією командою. +# +# Чому це існує. Розгортання складалося з вісімнадцяти змінних оточення +# й шестисот рядків інструкції. Рішень людини серед тих вісімнадцяти — +# п'ять; решта це або секрети, які не можна давати вводити руками, або +# значення, у яких є одна правильна відповідь, або PG_SHARED_BUFFERS, +# який мусить рахуватись із пам'яті машини: помилка в ньому на хості з +# 4 ГБ — це не «повільніше», це «Postgres не піднявся». +# +# Процедуру, яку клієнт не може виконати правильно з першого разу, не +# рятує докладніша інструкція. Її рятує те, що виконувати нічого. +# +# ./netpulse install поставити (ідемпотентно: можна повторювати) +# ./netpulse check лікар: ті самі твердження на живій системі +# ./netpulse backup дамп бази + ключі, без яких дамп марний +# ./netpulse restore -f відновлення з рамкою TimescaleDB +# ./netpulse upgrade перезбирання, міграції, перевірка +# ./netpulse logs [service] +# +# Головне про install — останній крок. Установник НЕ МАЄ ПРАВА сказати +# «готово» на підставі того, що контейнери піднялись. Рівно так уже +# було: усі служби «Started», а вхід у систему повертав 403, бо під +# новою роллю база віддавала нуль кабінетів. Тому останнє, що робить +# install, — заходить у систему справжнім паролем через HTTP і звіряє, +# що кабінет назвався, а переліки, які наливають міграції, не порожні. +# Не пройшло — не «готово з попередженням», а зупинка з назвою кроку. +# +# POSIX sh, не bash: сервер клієнта може бути будь-яким, і dash тут +# зустрічається частіше, ніж здається. + +set -u + +# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений +# в оточенні (а на робочих машинах буває), cd мовчки піде не туди, і скрипт +# правитиме .env у чужому каталозі. +# shellcheck disable=SC1007 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE="$ROOT/.env" +CONF_FILE="$ROOT/netpulse.conf" + +# Порт API всередині мережі docker. Самоперевірка ходить саме сюди, а не +# крізь проксі: нас цікавить ланцюг «HTTP → автентифікація → база», і +# додавати до нього TLS і DNS означало б плутати дві різні поломки. +API_INTERNAL="http://127.0.0.1:8080" + +DRY=0 +ASSUME_YES=0 +OWNER_PASSWORD="" +RESTORE_FILE="" + +# Заповнюється кроками; підсумок друкується один раз у кінці. +OUT_URL="" +OUT_OWNER_PW="" +OUT_ENROLL="" +OUT_OWNER_EXISTED=0 + +# --------------------------------------------------------------------- +# Вивід +# --------------------------------------------------------------------- +# +# Кольори лише в терміналі: у `./netpulse check > лист.txt`, який +# клієнт надсилає в підтримку, escape-послідовності перетворюють +# зрозумілий звіт на кашу. + +if [ -t 1 ]; then + C_B=$(printf '\033[1m'); C_R=$(printf '\033[31m') + C_Y=$(printf '\033[33m'); C_G=$(printf '\033[32m'); C_0=$(printf '\033[0m') +else + C_B=''; C_R=''; C_Y=''; C_G=''; C_0='' +fi + +STEP_NAME="" +STEP_NO=0 + +say() { printf '%s\n' "$*"; } +ok() { printf ' %sok%s %s\n' "$C_G" "$C_0" "$*"; } +warn() { printf ' %sувага%s %s\n' "$C_Y" "$C_0" "$*"; } +bad() { printf ' %sні%s %s\n' "$C_R" "$C_0" "$*"; } + +step() { + STEP_NO=$((STEP_NO + 1)) + STEP_NAME=$1 + printf '\n%s== крок %d · %s%s\n' "$C_B" "$STEP_NO" "$STEP_NAME" "$C_0" +} + +# die зупиняє все й називає КРОК. Назва кроку тут не оздоба: людина +# читає її вголос у телефон, і від неї залежить, чи розбирається далі +# вона сама, чи надсилає вивід. +die() { + printf '\n%sЗУПИНКА на кроці «%s»%s\n' "$C_R" "$STEP_NAME" "$C_0" + for _l in "$@"; do printf ' %s\n' "$_l"; done + if [ "${CMD:-}" = install ]; then + printf '\n Нічого незворотного не сталося: install можна запускати повторно —\n' + printf ' наявні секрети він підхопить із .env, а не перевипустить.\n' + fi + exit 1 +} + +# --------------------------------------------------------------------- +# docker compose +# --------------------------------------------------------------------- +# +# Дві несумісні реалізації з однаковою назвою. Обгортка визначається +# один раз, щоб решта скрипта не знала, яка з них тут. + +DC_KIND="" + +pick_compose() { + if docker compose version >/dev/null 2>&1; then + DC_KIND="plugin" + elif command -v docker-compose >/dev/null 2>&1; then + DC_KIND="legacy" + else + DC_KIND="" + fi +} + +dc() { + case "$DC_KIND" in + plugin) ( cd "$ROOT" && docker compose "$@" ) ;; + legacy) ( cd "$ROOT" && docker-compose "$@" ) ;; + *) return 127 ;; + esac +} + +# --------------------------------------------------------------------- +# Дрібні помічники +# --------------------------------------------------------------------- + +have() { command -v "$1" >/dev/null 2>&1; } + +# Версія у вигляді числа, щоб порівнювати без sort -V (його немає в +# busybox-середовищах, а установник має працювати й там). +ver_num() { + printf '%s' "$1" | sed 's/^[vV]//; s/[^0-9.].*//' | + awk -F. '{ printf "%d%03d%03d", $1, $2, $3 }' +} + +ver_ge() { [ "$(ver_num "$1")" -ge "$(ver_num "$2")" ] 2>/dev/null; } + +# Значення з .env. Береться ОСТАННЄ входження: саме так читає docker +# compose, і розходження тут означало б, що установник і compose бачать +# різні паролі. +env_get() { + [ -f "$ENV_FILE" ] || return 1 + sed -n "s/^$1=//p" "$ENV_FILE" | tail -1 +} + +# Рядкове поле JSON. Ключ обов'язково після «{» або «,», інакше пошук +# "token" знаходить хвіст "access_token" і повертає не той рядок. +json_str() { + sed -n "s/.*[{,][[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | + head -1 +} + +# Чи є у відповіді хоч один елемент переліку. +# +# Рахувати елементи в sh дорого й крихко, а нам потрібне рівно одне +# твердження: «перелік не порожній». Після прибирання пробілів масив +# об'єктів завжди починається з «[{», а порожній — з «[]». Працює і для +# голого масиву, і для загорнутого в об'єкт, тобто не залежить від +# того, як саме назвали поле. +json_nonempty() { + printf '%s' "$1" | tr -d ' \n\r\t' | grep -q '\[{' +} + +# Секрети — ТІЛЬКИ hex. +# +# `openssl rand -base64 24` дає символи «/», «+» і «=». Пароль їде +# всередині DSN виду postgres://user:пароль@db, де «/» починає ім'я +# бази: пароль обривається мовчки, а помилка приходить як +# «password authentication failed» — тобто вказує не туди. Ця вада вже +# ловилась у цьому проєкті; hex не має жодного символу, який щось +# означає в URL, і коштує нам лише довжини рядка. +rand_hex() { + if have openssl; then + openssl rand -hex "$1" + elif [ -r /dev/urandom ]; then + od -An -tx1 -N "$1" /dev/urandom | tr -d ' \n' + else + return 1 + fi +} + +# Пароль для людини: його читають з екрана й набирають у браузері. +# Алфавіт без 0/O/1/l/I — не з ввічливості, а тому що переплутана +# літера тут виглядає як «пароль не підходить», і розбирають це годину. +rand_pass() { + if [ -r /dev/urandom ]; then + LC_ALL=C tr -dc 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' \ + < /dev/urandom 2>/dev/null | head -c 24 + printf '\n' + else + return 1 + fi +} + +# --------------------------------------------------------------------- +# Стан хоста +# --------------------------------------------------------------------- + +host_mem_mb() { + if [ -r /proc/meminfo ]; then + awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo + elif have sysctl && sysctl -n hw.memsize >/dev/null 2>&1; then + sysctl -n hw.memsize | awk '{ printf "%d", $1 / 1048576 }' + else + printf '0' + fi +} + +# Кількість ядер. nproc є не всюди (busybox його не має), тому далі +# /proc/cpuinfo і sysctl. Нуль означає «не знаємо» — і викликач має +# обрати обережне значення, а не вдавати, що ядро одне. +host_cpus() { + if have nproc; then + nproc 2>/dev/null || printf '0' + elif [ -r /proc/cpuinfo ]; then + awk '/^processor/ { c++ } END { printf "%d", c }' /proc/cpuinfo + elif have sysctl && sysctl -n hw.ncpu >/dev/null 2>&1; then + sysctl -n hw.ncpu + else + printf '0' + fi +} + +# Вільне місце в МБ для каталогу. Питаємо і про репозиторій, і про +# /var/lib/docker: томи бази лежать там, і на розрізаному диску це +# різні файлові системи. +free_mb() { + df -Pk "$1" 2>/dev/null | awk 'NR==2 { printf "%d", $4 / 1024 }' +} + +# Чи слухає хтось порт. Три способи, бо ss немає на старих системах, +# netstat викинули з нових, а /proc/net є на будь-якому Linux. +port_busy() { + _p=$1 + _proto=${2:-tcp} + if have ss; then + if [ "$_proto" = udp ]; then + ss -lunH 2>/dev/null | awk '{print $5}' | grep -q "[:.]$_p\$" && return 0 + else + ss -ltnH 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0 + fi + return 1 + fi + if have netstat; then + if [ "$_proto" = udp ]; then + netstat -lun 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0 + else + netstat -ltn 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0 + fi + return 1 + fi + _hex=$(printf ':%04X' "$_p") + for _f in "/proc/net/$_proto" "/proc/net/${_proto}6"; do + [ -r "$_f" ] || continue + if awk -v p="$_hex" -v pr="$_proto" ' + NR > 1 && substr($2, length($2) - 4) == p && + (pr == "udp" || $4 == "0A") { found = 1 } + END { exit !found }' "$_f"; then + return 0 + fi + done + return 1 +} + +host_tz() { + if [ -r /etc/timezone ]; then + tr -d ' \n\r' < /etc/timezone + elif [ -L /etc/localtime ]; then + readlink /etc/localtime | sed 's|.*/zoneinfo/||' + else + printf '' + fi +} + +# Адреса, якою цю машину видно ззовні. Не питаємо інтернет: установник +# не має права ходити в мережу за спиною того, хто його запустив, а на +# закритому контурі такий запит ще й повисне на хвилину. +host_ip() { + if have ip; then + _a=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1) + [ -n "$_a" ] && { printf '%s' "$_a"; return 0; } + fi + if have hostname; then + _a=$(hostname -I 2>/dev/null | awk '{print $1}') + [ -n "$_a" ] && { printf '%s' "$_a"; return 0; } + fi + if have ifconfig; then + ifconfig 2>/dev/null | + sed -n 's/.*inet \(addr:\)\{0,1\}\([0-9.]*\).*/\2/p' | + grep -v '^127\.' | head -1 + return 0 + fi + printf '' +} + +# --------------------------------------------------------------------- +# КРОК 1. Передпольотна перевірка +# --------------------------------------------------------------------- +# +# Усе, що тут перевіряється, ламається пізніше і дорожче: без місця на +# диску Postgres падає посеред міграцій, без пам'яті не піднімається +# зовсім, а зайнятий 443 виявляється тоді, коли все інше вже працює й +# треба розбирати, чому не відкривається сторінка. + +preflight() { + step "передпольотна перевірка" + + _fatal=0 + + if ! have docker; then + _fatal=1 + bad "docker не знайдено" + else + _dv=$(docker version --format '{{.Server.Version}}' 2>/dev/null) + if [ -z "$_dv" ]; then + _fatal=1 + bad "docker є, але демон не відповідає (docker info падає)" + elif ver_ge "$_dv" 20.10; then + ok "docker $_dv" + else + _fatal=1 + bad "docker $_dv — потрібен 20.10 або новіший" + fi + fi + + pick_compose + case "$DC_KIND" in + plugin) + _cv=$(docker compose version --short 2>/dev/null) + if ver_ge "$_cv" 2.0; then + ok "docker compose $_cv" + else + _fatal=1 + bad "docker compose $_cv — потрібен 2.0 або новіший" + fi + ;; + legacy) + _fatal=1 + bad "знайдено лише docker-compose v1; compose-файл проєкту використовує + можливості v2 (profiles, depends_on.condition). Потрібен пакет + docker-compose-plugin" + ;; + *) + _fatal=1 + bad "docker compose не знайдено" + ;; + esac + + _mem=$(host_mem_mb) + if [ "$_mem" -eq 0 ]; then + warn "не вдалося визначити обсяг пам'яті — розрахунок shared_buffers буде обережним" + elif [ "$_mem" -lt 2048 ]; then + _fatal=1 + bad "пам'яті $_mem МБ. Postgres, TimescaleDB, кеш, API і колектор на + такій машині не вміщуються: перший запис у базу впаде на OOM. + Мінімум — 2 ГБ, робоче значення — 4 ГБ" + elif [ "$_mem" -lt 4096 ]; then + warn "пам'яті $_mem МБ — вистачить на десятки хостів, не на сотні" + else + ok "пам'ять: $_mem МБ" + fi + + _free=$(free_mb "$ROOT") + [ -n "$_free" ] || _free=0 + if [ -d /var/lib/docker ]; then + _freed=$(free_mb /var/lib/docker) + [ -n "$_freed" ] || _freed=0 + [ "$_freed" -lt "$_free" ] && _free=$_freed + fi + if [ "$_free" -eq 0 ]; then + warn "не вдалося виміряти вільне місце" + elif [ "$_free" -lt 20480 ]; then + _fatal=1 + bad "вільно $_free МБ. Образи займають близько 2 ГБ, і це разова + витрата; решту з'їдає телеметрія, яка росте щодня. Нижче 20 ГБ + установка закінчиться переповненим томом за тиждень" + elif [ "$_free" -lt 51200 ]; then + warn "вільно $_free МБ — стартувати вистачить, але профіль зберігання + беріть economy й дивіться на сторінку «Сховище»" + else + ok "вільно на диску: $_free МБ" + fi + + # Порти, зайняті НАШИМИ ж контейнерами, — не конфлікт, а повторний + # запуск. Розрізняти обов'язково, інакше ідемпотентність втрачається + # на другому ж install. + if dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then + ok "порти 80/443/9443 тримає вже піднятий proxy цієї ж інсталяції" + else + _busy=0 + for _port in 80 443 9443; do + if port_busy "$_port" tcp; then + _fatal=1 + _busy=1 + bad "порт $_port/tcp уже зайнятий. Це майже завжди сторонній + веб-сервер (nginx, apache) — його треба зупинити або перенести, + бо проксі NetPulse займає 80 і 443 цілком" + fi + done + if [ "$_busy" -eq 0 ]; then + ok "порти 80, 443, 9443 вільні" + fi + fi + + if [ "$_fatal" -ne 0 ]; then + if [ "$DRY" -eq 1 ]; then + warn "у сухому прогоні це не зупиняє — на справжній установці зупинило б" + else + die "Перелічене вище треба виправити до установки." + fi + fi +} + +# --------------------------------------------------------------------- +# КРОК 2. Відповіді +# --------------------------------------------------------------------- + +CFG_DOMAIN="" +CFG_EMAIL="" +CFG_TRAPS="" +CFG_RETENTION="" +CFG_TZ="" + +read_conf() { + step "відповіді" + + if [ -f "$CONF_FILE" ]; then + CFG_DOMAIN=$(sed -n 's/^[[:space:]]*DOMAIN=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_EMAIL=$(sed -n 's/^[[:space:]]*ADMIN_EMAIL=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_TRAPS=$(sed -n 's/^[[:space:]]*TRAPS_FROM=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_RETENTION=$(sed -n 's/^[[:space:]]*RETENTION=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_TZ=$(sed -n 's/^[[:space:]]*TZ=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + ok "прочитано $CONF_FILE" + else + ok "netpulse.conf немає — беруться типові відповіді" + say " (зразок із поясненнями: cp netpulse.conf.example netpulse.conf)" + fi + + case "$CFG_RETENTION" in + "") CFG_RETENTION=normal ;; + economy|normal|archive) ;; + *) die "RETENTION=$CFG_RETENTION — такого профілю немає." \ + "Дозволені: economy, normal, archive." ;; + esac + + if [ -n "$CFG_EMAIL" ] && [ -z "$CFG_DOMAIN" ]; then + warn "ADMIN_EMAIL заданий без DOMAIN. Сертифікат буде самопідписаний, + Let's Encrypt не задіяний, пошта нікуди не піде — прибрано" + CFG_EMAIL="" + fi + + if [ -n "$CFG_DOMAIN" ]; then + case "$CFG_DOMAIN" in + *[!a-zA-Z0-9.-]*) die "DOMAIN=$CFG_DOMAIN містить неприпустимі символи." ;; + *.*) ok "домен: $CFG_DOMAIN" ;; + *) die "DOMAIN=$CFG_DOMAIN не схожий на доменне ім'я (немає крапки)." \ + "Якщо домену ще немає — лишіть порожнім, буде самопідписаний сертифікат." ;; + esac + if [ -n "$CFG_EMAIL" ]; then + ok "пошта для Let's Encrypt: $CFG_EMAIL" + else + warn "ADMIN_EMAIL порожній: про проблеми з продовженням сертифіката + ніхто не дізнається листом" + fi + else + ok "домену немає — самопідписаний сертифікат на IP" + fi + + ok "строки зберігання: $CFG_RETENTION" +} + +# --------------------------------------------------------------------- +# КРОК 3. Обчислені значення +# --------------------------------------------------------------------- + +VAL_DOMAIN="" +VAL_TZ="" +VAL_SHBUF="" +VAL_DFMEM="" +VAL_PGBGW="" +VAL_TRAPS_BIND="" +VAL_TRAPS_SRC="" + +compute() { + step "обчислені значення" + + if [ -n "$CFG_DOMAIN" ]; then + VAL_DOMAIN=$CFG_DOMAIN + else + VAL_DOMAIN=$(host_ip) + if [ -z "$VAL_DOMAIN" ]; then + if [ "$DRY" -eq 1 ]; then + VAL_DOMAIN="" + warn "IP визначити не вдалося; у сухому прогоні це не зупиняє" + else + die "Не вдалося визначити IP-адресу цієї машини," \ + "а без адреси проксі не знає, на що відповідати." \ + "Впишіть DOMAIN у netpulse.conf — можна й голий IP." + fi + else + ok "адреса системи: $VAL_DOMAIN (IP цієї машини)" + fi + fi + + VAL_TZ=$CFG_TZ + if [ -z "$VAL_TZ" ]; then + VAL_TZ=$(host_tz) + [ -n "$VAL_TZ" ] || VAL_TZ=Europe/Kyiv + ok "часовий пояс: $VAL_TZ (з хоста)" + else + ok "часовий пояс: $VAL_TZ" + fi + + # shared_buffers — чверть пам'яті, і саме тут установник заробляє + # своє існування. Значення з .env.example (512MB) на машині з 4 ГБ + # разом із рештою служб дає перевищення пам'яті: Postgres резервує + # буфери одразу, ядро вбиває його першим, і виглядає це як «база + # іноді падає», а не як помилка налаштування. + _mem=$(host_mem_mb) + if [ "$_mem" -eq 0 ]; then + VAL_SHBUF=256MB + warn "пам'ять невідома — shared_buffers=256MB (свідомо мало)" + else + _sb=$((_mem / 4)) + [ "$_sb" -lt 128 ] && _sb=128 + [ "$_sb" -gt 8192 ] && _sb=8192 + VAL_SHBUF="${_sb}MB" + ok "shared_buffers=$VAL_SHBUF (чверть від $_mem МБ)" + fi + + # Стеля кешу — з тієї самої цифри, а не окремим розрахунком. + # + # Dragonfly без стелі бере пам'ять із доступної на машині, тобто + # рахує ТУ САМУ, яку щойно порахував shared_buffers. Два незалежні + # «візьму чверть» від одного пирога дають суму більшу за пиріг, і + # ядро вбиває когось третього — найімовірніше API, бо він найлегший. + # Восьма частина: кеш тут допоміжний, у ньому живуть сесії й + # короткочасні лічильники, а не дані. + if [ "$_mem" -eq 0 ]; then + VAL_DFMEM=256mb + else + _df=$((_mem / 8)) + [ "$_df" -lt 128 ] && _df=128 + [ "$_df" -gt 2048 ] && _df=2048 + VAL_DFMEM="${_df}mb" + fi + ok "стеля кешу=$VAL_DFMEM" + + # Фонові робітники TimescaleDB конкурують за ядра з самими запитами. + # Вісім на двох ядрах означає, що стиснення чанків заважає опитуванню + # хостів — а опитування тут головне. + _cpu=$(host_cpus) + if [ "$_cpu" -le 0 ]; then + VAL_PGBGW=4 + warn "кількість ядер невідома — фонових робітників 4" + else + VAL_PGBGW=$((_cpu * 2)) + [ "$VAL_PGBGW" -lt 2 ] && VAL_PGBGW=2 + [ "$VAL_PGBGW" -gt 8 ] && VAL_PGBGW=8 + ok "фонових робітників TimescaleDB=$VAL_PGBGW (ядер: $_cpu)" + fi + + compute_traps +} + +compute_traps() { + VAL_TRAPS_SRC="" + case "$CFG_TRAPS" in + "") + VAL_TRAPS_BIND=127.0.0.1 + ok "трапи: лише з цієї машини (порт не виставлений назовні)" + ;; + any|ANY|any/0|0.0.0.0/0) + VAL_TRAPS_BIND=0.0.0.0 + warn "трапи: приймаються ВІД БУДЬ-КОГО на 162/udp." + say " Порт не має автентифікації: хто знає адресу, той пише вам у базу." + say " Якщо це не було свідомим рішенням — TRAPS_FROM=<ваша підмережа>." + ;; + */*) + VAL_TRAPS_BIND=0.0.0.0 + VAL_TRAPS_SRC=$CFG_TRAPS + ok "трапи: з підмережі $CFG_TRAPS (обмеження — правилом DOCKER-USER)" + ;; + *) + # Адреса цієї машини — прив'язуємось до інтерфейсу, і жодного + # правила у файрволі не треба. Чужа адреса — це «приймати лише + # звідти», а прив'язкою docker такого не висловити. + if host_has_addr "$CFG_TRAPS"; then + VAL_TRAPS_BIND=$CFG_TRAPS + ok "трапи: лише на інтерфейсі $CFG_TRAPS" + else + VAL_TRAPS_BIND=0.0.0.0 + VAL_TRAPS_SRC="$CFG_TRAPS/32" + ok "трапи: лише від $CFG_TRAPS (обмеження — правилом DOCKER-USER)" + fi + ;; + esac + + if [ -n "$VAL_TRAPS_SRC" ] && [ "$DRY" -eq 0 ]; then + have iptables || die \ + "TRAPS_FROM=$CFG_TRAPS означає «приймати лише звідти», а зробити це" \ + "можна лише правилом у ланцюжку DOCKER-USER — iptables на цьому хості немає." \ + "" \ + "Варіанти: поставити iptables; або вказати в TRAPS_FROM адресу" \ + "внутрішнього інтерфейсу цієї машини (тоді обмеження дає сама прив'язка);" \ + "або TRAPS_FROM=any, якщо відкрити порт усім — свідоме рішення." + fi + + if [ -n "$VAL_TRAPS_BIND" ] && [ "$VAL_TRAPS_BIND" != 127.0.0.1 ]; then + if port_busy 162 udp; then + warn "порт 162/udp уже хтось слухає (snmptrapd?) — зонд його не займе" + fi + fi +} + +host_has_addr() { + if have ip; then + ip -o addr show 2>/dev/null | grep -q "inet6\{0,1\} $1/" + elif have hostname; then + hostname -I 2>/dev/null | tr ' ' '\n' | grep -qx "$1" + else + return 1 + fi +} + +# Правило джерела для трапів. Ідемпотентне: -C перевіряє наявність, +# і повторний install не плодить копій. +apply_traps_firewall() { + [ -n "$VAL_TRAPS_SRC" ] || return 0 + _added=0 + if ! iptables -C DOCKER-USER -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then + if iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then + _added=1 + else + die "Не вдалося додати правило в ланцюжок DOCKER-USER." \ + "Найчастіша причина — запуск не від root." \ + "Правило, яке треба додати руками:" \ + " iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s $VAL_TRAPS_SRC -j DROP" + fi + fi + if [ "$_added" -eq 1 ]; then + ok "правило DOCKER-USER додано: 162/udp лише з $VAL_TRAPS_SRC" + warn "правило живе до перезавантаження. Щоб пережило — + iptables-save (пакет iptables-persistent) або власний unit" + else + ok "правило DOCKER-USER уже на місці" + fi +} + +# --------------------------------------------------------------------- +# КРОК 4. Секрети +# --------------------------------------------------------------------- +# +# Найважливіше тут — те, чого крок НЕ робить: він не перевипускає +# NETPULSE_DEK, якщо той уже є. Новий ключ шифрування означає, що всі +# збережені паролі SSH і SNMP-community перетворюються на нечитний +# шифротекст, і назад їх не дістати ніяк. Тому повторний install +# підхоплює наявні значення, а не генерує свіжі. + +SEC_PG="" +SEC_APP="" +SEC_WORKER="" +SEC_DEK="" +SEC_JWT="" +SEC_REUSED=0 + +# Пароль їде всередині postgres://user:ПАРОЛЬ@db:5432/netpulse. «/» там +# починає ім'я бази, «@» — адресу хоста, «:» — порт: пароль обривається +# на першому такому символі МОВЧКИ, і застосунок доповідає +# «password authentication failed», тобто вказує зовсім не туди. +assert_dsn_safe() { + case "$2" in + *[/+=@:?\#\&]*) + die "У значенні $1 є символ, який щось означає всередині DSN" \ + "(«/», «+», «=», «@», «:», «?», «#», «&»)." \ + "Такий пароль обірветься при розборі postgres://user:пароль@db" \ + "і дасть «password authentication failed» — помилку не про те." \ + "Виправлення: приберіть рядок $1 з .env і повторіть install," \ + "установник згенерує hex, у якому таких символів немає." ;; + esac +} + +secrets() { + step "секрети" + + if [ -f "$ENV_FILE" ]; then + SEC_PG=$(env_get POSTGRES_PASSWORD) + SEC_APP=$(env_get NETPULSE_APP_PASSWORD) + SEC_WORKER=$(env_get NETPULSE_WORKER_PASSWORD) + SEC_DEK=$(env_get NETPULSE_DEK) + SEC_JWT=$(env_get NETPULSE_JWT_SECRET) + if [ -n "$SEC_DEK" ]; then + SEC_REUSED=1 + ok "знайдено .env — наявні секрети зберігаються без змін" + say " (перевипуск NETPULSE_DEK знищив би всі збережені паролі)" + fi + fi + + if ! have openssl && [ ! -r /dev/urandom ]; then + die "Немає ані openssl, ані /dev/urandom — випадкові значення взяти" \ + "нізвідки, а вигадувати секрети скрипт не має права." + fi + + [ -n "$SEC_PG" ] || SEC_PG=$(rand_hex 32) + [ -n "$SEC_APP" ] || SEC_APP=$(rand_hex 32) + [ -n "$SEC_WORKER" ] || SEC_WORKER=$(rand_hex 32) + [ -n "$SEC_JWT" ] || SEC_JWT=$(rand_hex 32) + [ -n "$SEC_DEK" ] || SEC_DEK="np1=$(rand_hex 32)" + + # Перевірка, а не припущення: одного разу пароль із «/» уже обрізав + # DSN, і поламалось воно не тут, а через три кроки, з повідомленням + # про невірний пароль. + assert_dsn_safe POSTGRES_PASSWORD "$SEC_PG" + assert_dsn_safe NETPULSE_APP_PASSWORD "$SEC_APP" + assert_dsn_safe NETPULSE_WORKER_PASSWORD "$SEC_WORKER" + + [ "$SEC_REUSED" -eq 1 ] || ok "згенеровано: пароль БД, паролі ролей netpulse_app і + netpulse_worker, ключ шифрування секретів, ключ підпису сесій" + ok "формат — hex: жоден символ не має значення всередині DSN" +} + +# --------------------------------------------------------------------- +# КРОК 5. .env +# --------------------------------------------------------------------- + +write_env() { + step ".env" + + _enroll="" + [ -f "$ENV_FILE" ] && _enroll=$(env_get NETPULSE_ENROLL) + + if [ "$DRY" -eq 1 ]; then + ok "у сухому прогоні файл не пишеться; вміст (секрети приховані):" + env_body "СХОВАНО" "СХОВАНО" "СХОВАНО" "np1=СХОВАНО" "СХОВАНО" "$_enroll" | + sed 's/^/ /' + return 0 + fi + + # umask до створення, а не chmod після: між створенням і chmod файл + # із паролями видно всім, і це не теоретично — саме в цю щілину + # потрапляють резервні копії каталогу. + _old=$(umask) + umask 077 + env_body "$SEC_PG" "$SEC_APP" "$SEC_WORKER" "$SEC_DEK" "$SEC_JWT" "$_enroll" \ + > "$ENV_FILE.tmp" || die "Не вдалося записати $ENV_FILE.tmp" + umask "$_old" + chmod 600 "$ENV_FILE.tmp" + mv "$ENV_FILE.tmp" "$ENV_FILE" || die "Не вдалося замінити $ENV_FILE" + ok "$ENV_FILE записано, права 0600" +} + +env_body() { + cat </dev/null 2>&1; then + ok "Postgres відповідає" + return 0 + fi + _i=$((_i + 1)) + sleep 2 + done + die "Postgres не піднявся за дві хвилини." \ + "Подивитись причину: ./netpulse logs db" \ + "Найчастіше це shared_buffers більший за пам'ять машини — тоді в журналі" \ + "буде рядок про неможливість виділити спільну пам'ять." +} + +# --------------------------------------------------------------------- +# КРОК 7. Схема й ролі +# --------------------------------------------------------------------- +# +# Порядок тут не довільний: міграція 0063 СТВОРЮЄ ролі netpulse_app і +# netpulse_worker без паролів, і лише після неї їм є що видавати. А +# піднімати API до видачі паролів не можна взагалі: у DSN уже стоїть +# netpulse_app, і застосунок отримає відмову автентифікації. + +migrate_schema() { + step "схема й ролі" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: docker compose run --rm migrate" + ok "далі: ALTER ROLE netpulse_app / netpulse_worker з новими паролями" + ok "далі: звірка rolbypassrls — у netpulse_app має бути f" + return 0 + fi + + dc run --rm migrate || die \ + "Міграції не накотились. Схема лишилась у тому стані, у якому була:" \ + "кожна міграція йде окремою транзакцією, недокочена не залишається." \ + "Текст помилки вище називає файл, на якому зупинилось." + ok "схема накочена" + + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null </dev/null | tr -d ' \r') + if [ "$_bypass" != "f" ]; then + die "У ролі netpulse_app стоїть BYPASSRLS (rolbypassrls=$_bypass)." \ + "Це означає, що політики ізоляції кабінетів не діятимуть узагалі —" \ + "усе наступне пройде й нічого не змінить." + fi + ok "netpulse_app без BYPASSRLS — політики RLS діють" +} + +# --------------------------------------------------------------------- +# КРОК 8. Служби +# --------------------------------------------------------------------- + +bring_services() { + step "служби" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: docker compose up -d --build api collector proxy" + return 0 + fi + + dc up -d --build api collector proxy || die \ + "Не вдалося підняти api, collector або proxy." \ + "Стан служб: ./netpulse logs" + + ok "чекаємо, поки API відповість на /healthz (до 120 с)" + _i=0 + while [ "$_i" -lt 60 ]; do + if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then + ok "API відповідає" + return 0 + fi + _i=$((_i + 1)) + sleep 2 + done + die "API не відповів за дві хвилини." \ + "Подивитись причину: ./netpulse logs api" \ + "Якщо в журналі «password authentication failed» — паролі ролей і DSN" \ + "розійшлись; повторіть install, він перевидасть паролі з .env." +} + +# --------------------------------------------------------------------- +# КРОК 9. Власник +# --------------------------------------------------------------------- + +create_owner() { + step "власник" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: docker compose run --rm --entrypoint netpulse-user cli \\" + say " -tenant default -create-tenant \"NetPulse\" -login admin -role owner" + ok "пароль — 24 символи з /dev/urandom, показується один раз" + return 0 + fi + + # Роллю ВЛАСНИКА, а не netpulse_app: заведення кабінету — рівно те, + # чого роль під RLS не може за побудовою (0063, розділ про + # core.tenants). Без явного DSN утиліта мовчки нічого не знайшла б. + _dsn="postgres://netpulse:$SEC_PG@db:5432/netpulse?sslmode=disable" + + # --entrypoint обов'язковий: у службі api вже прописаний + # entrypoint netpulse-api, і без заміни утиліта потрапила б до нього + # аргументом, а не запустилась. (deploy/README.md у цьому місці + # помиляється — там команда без --entrypoint.) + if dc run --rm -e "NETPULSE_DSN=$_dsn" --entrypoint netpulse-user cli \ + -tenant default -list 2>/dev/null | grep -q '^ admin '; then + OUT_OWNER_EXISTED=1 + ok "власник admin уже є — пароль не змінюється" + return 0 + fi + + OUT_OWNER_PW=$(rand_pass) || die "Не вдалося згенерувати пароль власника." + + # Пароль іде через stdin, а не прапорцем: аргументи командного рядка + # видно в ps будь-кому на машині, і осідають вони ще й в історії + # оболонки. + printf '%s\n' "$OUT_OWNER_PW" | dc run --rm -T -e "NETPULSE_DSN=$_dsn" \ + --entrypoint netpulse-user cli \ + -tenant default -create-tenant "NetPulse" \ + -login admin -role owner -name "Адміністратор" \ + || die "Не вдалося завести власника." \ + "Якщо в тексті «тенанта не знайдено» — база порожня, і це означає," \ + "що міграції відпрацювали не до кінця." + ok "заведено кабінет NetPulse і власника admin" +} + +# --------------------------------------------------------------------- +# КРОК 10. Строки зберігання +# --------------------------------------------------------------------- +# +# Профіль накладається лише на першій установці. Причина та сама, що й у +# самої міграції 0064: наш «правильний» строк на чужій інсталяції — це +# чиясь втрачена історія. Якщо строки вже правили, повторний install їх +# не чіпає. + +retention_rows() { + case "$1" in + economy) + cat <<'EOF' +metrics_raw 7 +metrics_5m 30 +metrics_1h 180 +icmp_raw 7 +icmp_5m 30 +icmp_1h 180 +ifc_raw 7 +ifc_5m 30 +ifc_1h 180 +link_status 30 +device_status 30 +syslog 7 +traps 7 +agent_health 7 +alerts_history 90 +notifications 30 +login_attempts 90 +audit_log 180 +command_runs 30 +ncm_jobs 30 +discovery_runs 30 +EOF + ;; + normal) + cat <<'EOF' +metrics_raw 35 +metrics_5m 180 +metrics_1h 730 +icmp_raw 35 +icmp_5m 180 +icmp_1h 730 +ifc_raw 35 +ifc_5m 180 +ifc_1h 730 +link_status 180 +device_status 180 +syslog 30 +traps 30 +agent_health 14 +alerts_history 365 +notifications 90 +login_attempts 180 +audit_log 365 +command_runs 90 +ncm_jobs 90 +discovery_runs 90 +EOF + ;; + archive) + cat <<'EOF' +metrics_raw 90 +metrics_5m 365 +metrics_1h 1825 +icmp_raw 90 +icmp_5m 365 +icmp_1h 1825 +ifc_raw 90 +ifc_5m 365 +ifc_1h 1825 +link_status 730 +device_status 730 +syslog 180 +traps 180 +agent_health 30 +alerts_history 1825 +notifications 365 +login_attempts 365 +audit_log 1825 +command_runs 365 +ncm_jobs 365 +discovery_runs 365 +EOF + ;; + esac +} + +apply_retention() { + step "строки зберігання" + + if [ "$DRY" -eq 1 ]; then + ok "профіль $CFG_RETENTION розклався б у core.retention_settings так:" + retention_rows "$CFG_RETENTION" | awk '{ printf " %-16s %s діб\n", $1, $2 }' + ok "потім SELECT core.apply_retention_policies() — накласти політики TimescaleDB" + return 0 + fi + + if [ "$OUT_OWNER_EXISTED" -eq 1 ]; then + ok "система вже стояла — строки зберігання не чіпаємо" + say " (змінити: сторінка «Сховище» в інтерфейсі)" + return 0 + fi + + { + printf 'BEGIN;\n' + retention_rows "$CFG_RETENTION" | while read -r _kind _days; do + [ -n "$_kind" ] || continue + printf "UPDATE core.retention_settings SET keep_days = %s WHERE kind = '%s';\n" \ + "$_days" "$_kind" + done + printf 'COMMIT;\n' + printf 'SELECT core.apply_retention_policies();\n' + } | dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null \ + || die "Не вдалося накласти строки зберігання." \ + "Дані від цього не постраждали: невдала транзакція нічого не змінює." \ + "Строки можна виставити в інтерфейсі: «Сховище»." + + ok "профіль $CFG_RETENTION накладено на 21 вид даних" +} + +# --------------------------------------------------------------------- +# HTTP до API зсередини контейнера +# --------------------------------------------------------------------- +# +# Ходимо wget-ом усередині контейнера api, а не curl-ом з хоста, і це +# свідомо. На чужому сервері curl може бути відсутній, а wget у образі є +# гарантовано — на ньому тримається healthcheck самого compose. Заразом +# зникає питання самопідписаного сертифіката: усередині це звичайний +# http до 127.0.0.1. + +API_TOKEN="" + +api_wget_ok() { + dc exec -T api sh -s <<'INNER' >/dev/null 2>&1 +wget --help 2>&1 | grep -q -- --post-data +INNER +} + +api_get() { + if [ -n "$API_TOKEN" ]; then + dc exec -T api sh -s <&1 +wget -q -O- --header='Authorization: Bearer $API_TOKEN' '$API_INTERNAL$1' +INNER + else + dc exec -T api sh -s <&1 +wget -q -O- '$API_INTERNAL$1' +INNER + fi +} + +api_post() { + if [ -n "$API_TOKEN" ]; then + dc exec -T api sh -s <&1 +wget -q -O- --header='Content-Type: application/json' \ + --header='Authorization: Bearer $API_TOKEN' \ + --post-data='$2' '$API_INTERNAL$1' +INNER + else + dc exec -T api sh -s <&1 +wget -q -O- --header='Content-Type: application/json' \ + --post-data='$2' '$API_INTERNAL$1' +INNER + fi +} + +# --------------------------------------------------------------------- +# КРОК 11. Локальний зонд +# --------------------------------------------------------------------- +# +# Зонд на самому сервері ставиться не для краси. Він проходить увесь +# ланцюг, якого не бачить жодна перевірка HTTP: обмін запрошення на +# постійний токен, gRPC до колектора, реєстрація в core.agents. Якщо +# після установки в системі нуль зондів, вона показує порожні екрани, і +# людина не може відрізнити «ще нічого не налаштовано» від «колектор не +# працює». + +enroll_local_agent() { + step "локальний зонд" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: вхід під власником, POST /api/v1/agent-enrollments," + say " docker compose --profile agent up -d agent" + return 0 + fi + + _have=$(env_get NETPULSE_ENROLL) + if [ -n "$_have" ] && dc ps --services --filter status=running 2>/dev/null | grep -q '^agent$'; then + ok "локальний зонд уже працює" + return 0 + fi + + if ! login_as_owner; then + warn "пароля власника немає (система вже стояла) — нове запрошення не + видається. Додати зонд можна в інтерфейсі: Зонди → Додати зонд" + return 0 + fi + + _r=$(api_post /api/v1/agent-enrollments \ + '{"name_hint":"локальний зонд","modules":["icmp","snmp","topology","ncm","traps"]}') + _tok=$(printf '%s' "$_r" | json_str token) + [ -n "$_tok" ] || die "API не видав запрошення для зонда." \ + "Відповідь: $_r" + + # Запрошення лягає в .env, бо зонд обмінює його на постійний токен при + # ПЕРШОМУ старті, а compose читає змінну на кожному up. + sed -i.bak "s|^NETPULSE_ENROLL=.*|NETPULSE_ENROLL=$_tok|" "$ENV_FILE" \ + || die "Не вдалося записати запрошення в .env" + rm -f "$ENV_FILE.bak" + + dc --profile agent up -d --build agent \ + || die "Не вдалося підняти локальний зонд." + ok "локальний зонд запущено" + + apply_traps_firewall +} + +login_as_owner() { + [ -n "$API_TOKEN" ] && return 0 + + _pw=$OWNER_PASSWORD + [ -n "$_pw" ] || _pw=$OUT_OWNER_PW + if [ -z "$_pw" ]; then + return 1 + fi + + _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}") + API_TOKEN=$(printf '%s' "$_r" | json_str access_token) + if [ -z "$API_TOKEN" ]; then + die "Вхід під власником не вдався — тобто зламане саме те, заради чого" \ + "ця перевірка існує." \ + "" \ + "Відповідь API: $_r" \ + "" \ + "Якщо там 403 і no_membership — база віддала нуль кабінетів під роллю" \ + "netpulse_app. Найчастіша причина: порожній NETPULSE_DSN_WORKER, тобто" \ + "не заданий NETPULSE_WORKER_PASSWORD у .env. Шлях входу читає" \ + "core.memberships JOIN core.tenants пулом воркера, і без нього" \ + "падає назад у пул застосунку, який кабінетів не бачить." + fi + return 0 +} + +# --------------------------------------------------------------------- +# КРОК 12. Самоперевірка +# --------------------------------------------------------------------- +# +# Взірець — перевірка, написана після живої поломки: перший її варіант +# питав «чи не видно чужого» і був зелений, бо RLS справді ховає чуже. +# Зламалось протилежне — не видно СВОГО. Тест на ізоляцію дивиться повз +# це місце за побудовою. +# +# Тому тут перевіряється не ізоляція, а ПРОХІДНІСТЬ: шлях, яким людина +# заходить у систему, і переліки, які після цього мають бути непорожні. +# Порожній перелік тут — привід зупинитись, а не «даних ще немає»: усе +# перелічене наливають міграції, тобто воно є на будь-якій інсталяції +# через хвилину після установки. + +SELFCHECK_FAILED="" + +sc_fail() { SELFCHECK_FAILED="$SELFCHECK_FAILED|$1"; bad "$1"; } + +# Переліки, які МАЮТЬ бути непорожні одразу після установки. Кожен — +# із міграцій, і кожен читається через окремий шлях у store. +sc_must_lists() { + cat <<'EOF' +/api/v1/templates шаблони +/api/v1/ncm/profiles профілі_NCM +/api/v1/check-types типи_перевірок +/api/v1/roles ролі +/api/v1/permissions права +/api/v1/team користувачі +/api/v1/storage/retention строки_зберігання +EOF +} + +# Тут порожньо законно: свіжа система ще не має ані хостів, ані мап. +# Перевіряємо лише те, що ендпоїнт ВІДПОВІДАЄ, — 4xx або 5xx означав +# би, що під роллю без BYPASSRLS зламався запит, а не що даних немає. +sc_may_lists() { + cat <<'EOF' +/api/v1/me я +/api/v1/devices хости +/api/v1/device-groups групи +/api/v1/credentials доступи +/api/v1/maps мапи +/api/v1/dashboards панелі +/api/v1/alerts алерти +/api/v1/alert-rules тригери +/api/v1/audit?limit=20 журнал_аудиту +/api/v1/queues черги +/api/v1/agent-enrollments запрошення_зондів +/api/v1/storage сховище +EOF +} + +selfcheck() { + step "самоперевірка" + + if [ "$DRY" -eq 1 ]; then + ok "справжній вхід POST /api/v1/auth/login справжнім паролем" + ok "звірка, що кабінет НАЗВАВСЯ (tenant_name), а не лише має id" + ok "звірка, що вхід повернув перелік прав" + sc_must_lists | awk '{ gsub(/_/, " ", $2); printf " непорожньо: %s\n", $2 }' + sc_may_lists | awk '{ gsub(/_/, " ", $2); printf " відповідає: %s\n", $2 }' + ok "зонд зареєструвався: /api/v1/agents не порожній" + ok "проксі відповідає HTTPS на публічній адресі" + return 0 + fi + + SELFCHECK_FAILED="" + + api_wget_ok || die \ + "wget у образі api не вміє --post-data, тому справжній вхід звідси не зробити." \ + "Установник не має права оголосити систему готовою без цієї перевірки." \ + "Зробіть її з машини, де є curl:" \ + " curl -sk -X POST https://$VAL_DOMAIN/api/v1/auth/login \\" \ + " -H 'Content-Type: application/json' \\" \ + " -d '{\"login\":\"admin\",\"password\":\"<пароль>\"}'" + + # --- 1. Вхід: рівно той шлях, що ламався -------------------------- + _pw=$OWNER_PASSWORD + [ -n "$_pw" ] || _pw=$OUT_OWNER_PW + if [ -z "$_pw" ]; then + warn "пароля власника немає — повний вхід не перевіряється" + say " (система вже стояла; щоб перевірити вхід: ./netpulse check -p <пароль>)" + else + API_TOKEN="" + _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}") + API_TOKEN=$(printf '%s' "$_r" | json_str access_token) + if [ -z "$API_TOKEN" ]; then + die "ВХІД НЕ ПРОЙШОВ. Контейнери працюють, сторінка відкривається," \ + "а зайти в систему не можна — саме цей стан колись назвали «готово»." \ + "" \ + "Відповідь API: $_r" \ + "" \ + "403 no_membership означає, що база віддала нуль кабінетів під роллю" \ + "netpulse_app. Перевірити NETPULSE_WORKER_PASSWORD у .env: шлях входу" \ + "читає кабінети пулом воркера, і без пароля цей пул стає пулом" \ + "застосунку, який кабінетів не бачить за політикою RLS." + fi + ok "вхід: пройшов" + + # Не наявність ключа tenant, а НАЗВА. id приїжджає з членства й + # лишився б на місці навіть тоді, коли рядок кабінету не віддався, + # — це тиха половина тієї самої поломки. + _tname=$(printf '%s' "$_r" | json_str tenant_name) + if [ -n "$_tname" ]; then + ok "кабінет назвався: $_tname" + else + sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась" + fi + + if printf '%s' "$_r" | tr -d ' \n' | grep -q '"permissions":\["'; then + ok "вхід повернув права" + else + sc_fail "вхід не повернув прав" + fi + fi + + if [ -z "$API_TOKEN" ]; then + warn "далі йдуть лише перевірки, що не потребують входу" + else + # --- 2. Переліки, які МАЮТЬ бути непорожні ---------------------- + # + # Через тимчасовий файл, а не конвеєром: тіло циклу, запущене в + # конвеєрі, працює в підоболонці, і sc_fail дописував би провали в + # її власну змінну. Підсумок після цього виявився б зеленим при + # червоних рядках вище — рівно та брехня, проти якої вся перевірка. + sc_must_lists > /tmp/np_must.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + _b=$(api_get "$_path") + if json_nonempty "$_b"; then + ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО (наливається міграціями, тобто має бути)" + fi + done < /tmp/np_must.$$ + rm -f /tmp/np_must.$$ + + sc_may_lists > /tmp/np_may.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + if api_get "$_path" >/dev/null 2>&1; then + ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200" + fi + done < /tmp/np_may.$$ + rm -f /tmp/np_may.$$ + + # --- 3. Зонд дійшов до колектора --------------------------------- + # Реєстрація йде gRPC-каналом, якого HTTP-перевірки не бачать + # зовсім. Чекаємо, бо обмін запрошення на токен займає секунди. + _i=0 + _agents="" + while [ "$_i" -lt 20 ]; do + _agents=$(api_get /api/v1/agents) + json_nonempty "$_agents" && break + _i=$((_i + 1)) + sleep 3 + done + if json_nonempty "$_agents"; then + ok "зонд зареєструвався в колекторі" + else + sc_fail "жоден зонд не зареєструвався за хвилину — колектор або запрошення" + fi + fi + + # --- 4. Проксі -------------------------------------------------- + check_proxy + + if [ -n "$SELFCHECK_FAILED" ]; then + printf '\n' + die "Самоперевірка не пройшла. Перелічене вище — не попередження:" \ + "система в такому стані виглядає працюючою й не працює." \ + "$(printf '%s' "$SELFCHECK_FAILED" | tr '|' ' ')" + fi + ok "усі вхідні шляхи проходять" +} + +# Проксі перевіряємо окремо й м'якше: TLS на самопідписаному +# сертифікаті поводиться по-різному в різних збірках wget, і зупиняти +# установку через версію busybox було б неправдою про стан системи. +check_proxy() { + if ! dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then + sc_fail "контейнер proxy не працює — назовні система недоступна" + return + fi + if dc exec -T api sh -s </dev/null 2>&1 +wget -q --no-check-certificate -O- --header='Host: $VAL_DOMAIN' 'https://proxy/healthz' +INNER + then + ok "проксі віддає HTTPS для $VAL_DOMAIN" + else + warn "проксі працює, але перевірити HTTPS зсередини не вдалося. + Перевірте з робочої машини: https://$VAL_DOMAIN/healthz" + fi +} + +# --------------------------------------------------------------------- +# Підсумок +# --------------------------------------------------------------------- + +summary() { + printf '\n%s== готово%s\n\n' "$C_B" "$C_0" + printf ' Адреса: https://%s\n' "$OUT_URL" + if [ -z "$CFG_DOMAIN" ]; then + printf ' сертифікат самопідписаний — браузер попередить, це очікувано\n' + fi + printf ' Логін: admin\n' + if [ -n "$OUT_OWNER_PW" ]; then + printf ' Пароль: %s%s%s\n' "$C_B" "$OUT_OWNER_PW" "$C_0" + else + printf ' Пароль: без змін (власник уже існував)\n' + fi + if [ -n "$OUT_ENROLL" ]; then + printf '\n Запрошення для першого віддаленого зонда (дійсне добу):\n' + printf ' %s\n' "$OUT_ENROLL" + printf ' Наступні — в інтерфейсі: Зонди → Додати зонд.\n' + fi + printf '\n %sЦе показано востаннє.%s Пароль і запрошення ніде не зберігаються\n' "$C_R" "$C_0" + printf ' у відкритому вигляді: у базі лежать лише їхні хеші.\n' + printf '\n Ключі шифрування — у .env (права 0600). Втрата NETPULSE_DEK\n' + printf ' означає втрату всіх збережених паролів SSH і SNMP: ./netpulse backup\n' + printf ' кладе їх поруч із дампом, і зберігати їх треба ОКРЕМО від нього.\n\n' +} + +# --------------------------------------------------------------------- +# install +# --------------------------------------------------------------------- + +cmd_install() { + printf '%sNetPulse · установка%s\n' "$C_B" "$C_0" + [ "$DRY" -eq 1 ] && printf 'Сухий прогін: нічого не запускається й не пишеться.\n' + + preflight + read_conf + compute + secrets + write_env + bring_db + migrate_schema + bring_services + create_owner + apply_retention + enroll_local_agent + selfcheck + + OUT_URL=$VAL_DOMAIN + + if [ "$DRY" -eq 1 ]; then + printf '\n%s== сухий прогін завершено%s\n' "$C_B" "$C_0" + printf ' Жодного контейнера не запущено, .env не змінено.\n' + printf ' Прибрати --dry-run, щоб поставити насправді.\n\n' + return 0 + fi + + # Друге запрошення — для першого зонда клієнта. Перше вже витрачене + # локальним зондом, а показувати витрачений токен означає навчити + # людину не вірити тому, що написано на екрані. + if [ -n "$API_TOKEN" ]; then + _r=$(api_post /api/v1/agent-enrollments \ + '{"name_hint":"перший зонд","modules":["icmp","snmp","topology","ncm"]}') + OUT_ENROLL=$(printf '%s' "$_r" | json_str token) + fi + + summary +} + +# --------------------------------------------------------------------- +# check +# --------------------------------------------------------------------- +# +# Ті самі твердження, але на живій системі й у вигляді, придатному для +# «надішліть мені вивід цієї команди»: кожен рядок каже, ЩО саме не так, +# а не «помилка». +# +# Пароля власника тут зазвичай немає, тому перевірки поділені на дві +# групи. Ті, що не потребують пароля, б'ють у те саме місце з боку бази: +# запит, яким шлях входу дістає кабінети, виконується роллю воркера +# напряму. Якщо він порожній — вхід поверне 403, і це видно без входу. + +cmd_check() { + printf '%sNetPulse · перевірка%s\n' "$C_B" "$C_0" + SELFCHECK_FAILED="" + + step "оточення" + pick_compose + [ -n "$DC_KIND" ] || die "docker compose не знайдено." + [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE — систему тут не встановлювали." + ok "docker compose на місці, .env знайдено" + + # ls, а не stat: формат stat різний у GNU, BusyBox і BSD, а нам треба + # рівно десять символів прав — і вони однакові скрізь. Ім'я файлу тут + # наше власне й незмінне, тобто застереження про дивні імена не про цей + # випадок. + # shellcheck disable=SC2012 + _perm=$(ls -l "$ENV_FILE" 2>/dev/null | cut -c1-10) + case "$_perm" in + -rw-------) ok ".env має права 0600" ;; + *) sc_fail ".env має права $_perm — паролі бази й ключі шифрування читає будь-хто" ;; + esac + + VAL_DOMAIN=$(env_get NETPULSE_DOMAIN) + OUT_URL=$VAL_DOMAIN + CFG_DOMAIN=$VAL_DOMAIN + + step "служби" + _running=$(dc ps --services --filter status=running 2>/dev/null) + for _s in db cache api collector proxy; do + if printf '%s\n' "$_running" | grep -q "^$_s\$"; then + ok "$_s працює" + else + sc_fail "$_s НЕ працює" + fi + done + if printf '%s\n' "$_running" | grep -q '^agent$'; then + ok "локальний зонд працює" + else + warn "локального зонда немає (не помилка, якщо зонди стоять окремо)" + fi + + step "місце на диску" + _free=$(free_mb "$ROOT") + [ -n "$_free" ] || _free=0 + if [ "$_free" -lt 5120 ]; then + sc_fail "вільно лише $_free МБ. Від переповненого тому першим падає Postgres, + тобто весь продукт одночасно. Сторінка «Сховище» показує приріст" + elif [ "$_free" -lt 20480 ]; then + warn "вільно $_free МБ — час подивитись на строки зберігання" + else + ok "вільно $_free МБ" + fi + + step "ролі бази" + _app_pw=$(env_get NETPULSE_APP_PASSWORD) + _worker=$(env_get NETPULSE_WORKER_PASSWORD) + if [ -n "$_app_pw" ]; then + ok "застосунок ходить роллю netpulse_app (RLS діє)" + if [ -z "$_worker" ]; then + sc_fail "NETPULSE_WORKER_PASSWORD порожній при заданому NETPULSE_APP_PASSWORD. + Це рівно та комбінація, у якій вхід повертає 403: шлях входу читає + кабінети пулом воркера, а без пароля цей пул стає пулом застосунку, + який кабінетів не бачить за політикою RLS" + else + ok "NETPULSE_DSN_WORKER заповнений" + fi + _b=$(dc exec -T db psql -tAX -U netpulse -d netpulse \ + -c "SELECT rolbypassrls FROM pg_roles WHERE rolname='netpulse_app'" 2>/dev/null | tr -d ' \r') + case "$_b" in + f) ok "netpulse_app без BYPASSRLS" ;; + t) sc_fail "netpulse_app має BYPASSRLS — політики ізоляції не діють узагалі" ;; + *) sc_fail "не вдалося спитати pg_roles: база не відповідає" ;; + esac + else + warn "застосунок ходить роллю netpulse (суперкористувач) — RLS не діє. + Ізоляцію кабінетів тримає лише предикат tenant_id у коді" + fi + + step "шлях входу з боку бази" + # Той самий запит, що й у store.userMemberships. Порожній результат + # тут — це 403 на формі входу, і побачити його можна без пароля. + _n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c " + SELECT count(*) FROM core.memberships m + JOIN core.tenants t ON t.id = m.tenant_id + JOIN core.roles r ON r.id = m.role_id + WHERE m.accepted_at IS NOT NULL" 2>/dev/null | tr -d ' \r') + if [ -z "$_n" ]; then + sc_fail "не вдалося виконати запит до бази" + elif [ "$_n" = "0" ]; then + sc_fail "нуль членств у кабінетах: зайти в систему не зможе ніхто. + Завести власника: docker compose run --rm --entrypoint netpulse-user cli + -tenant default -login admin -role owner" + else + ok "членств у кабінетах: $_n" + fi + + step "API" + if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then + ok "/healthz відповідає" + else + sc_fail "/healthz не відповідає — API живий як контейнер, але не як застосунок" + fi + + # Повний вхід — лише якщо пароль дали. Без нього перевіряємо, що + # форма входу взагалі доходить до звірки пароля: 401 тут означає, що + # core.users читається, а 500 — що ні. + if [ -n "$OWNER_PASSWORD" ]; then + step "справжній вхід" + selfcheck_live + else + step "форма входу" + _r=$(api_post /api/v1/auth/login '{"login":"admin","password":"__свідомо_невірний__"}') + case "$_r" in + *bad_credentials*) ok "форма входу доходить до звірки пароля" ;; + *too_many_attempts*) warn "вхід тимчасово заблокований після невдалих спроб" ;; + *) sc_fail "форма входу відповіла не тим: $_r" ;; + esac + say " Повний вхід перевіряється лише з паролем: ./netpulse check -p <пароль>" + fi + + step "проксі" + check_proxy + + printf '\n%s== підсумок%s\n' "$C_B" "$C_0" + if [ -n "$SELFCHECK_FAILED" ]; then + printf ' %sНЕ ГАРАЗД:%s\n' "$C_R" "$C_0" + printf '%s' "$SELFCHECK_FAILED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ - /' + printf '\n Цей вивід можна надіслати цілком: у ньому немає паролів.\n\n' + exit 1 + fi + printf ' %sУсе гаразд.%s Адреса системи: https://%s\n\n' "$C_G" "$C_0" "$OUT_URL" +} + +# Повний вхід у режимі check: те саме, що робить установка, але без +# права зупиняти світ — тут це діагностика, а не установка. +selfcheck_live() { + API_TOKEN="" + _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$OWNER_PASSWORD\"}") + API_TOKEN=$(printf '%s' "$_r" | json_str access_token) + if [ -z "$API_TOKEN" ]; then + sc_fail "вхід не пройшов: $_r" + return + fi + ok "вхід: пройшов" + _tname=$(printf '%s' "$_r" | json_str tenant_name) + if [ -n "$_tname" ]; then + ok "кабінет назвався: $_tname" + else + sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась" + fi + + sc_must_lists > /tmp/np_must.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + _b=$(api_get "$_path") + if json_nonempty "$_b"; then + ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО" + fi + done < /tmp/np_must.$$ + rm -f /tmp/np_must.$$ + + sc_may_lists > /tmp/np_may.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + if api_get "$_path" >/dev/null 2>&1; then + ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200" + fi + done < /tmp/np_may.$$ + rm -f /tmp/np_may.$$ +} + +# --------------------------------------------------------------------- +# backup / restore / upgrade / logs +# --------------------------------------------------------------------- + +cmd_backup() { + printf '%sNetPulse · бекап%s\n' "$C_B" "$C_0" + pick_compose + [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE." + + _dir="$ROOT/backups" + mkdir -p "$_dir" || die "Не вдалося створити $_dir" + _stamp=$(date +%Y-%m-%d-%H%M) + _dump="$_dir/netpulse-$_stamp.dump" + _keys="$_dir/netpulse-$_stamp.keys" + + step "дамп бази" + # Формат custom, а не простий SQL: стискається і дозволяє відновлювати + # вибірково. --no-owner — бо ролі на цільовій машині можуть бути інші. + dc exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > "$_dump" \ + || die "pg_dump не відпрацював; неповний файл лишився як $_dump" + _size=$(wc -c < "$_dump" | tr -d ' ') + [ "${_size:-0}" -gt 1024 ] || die \ + "Дамп вийшов розміром $_size байтів — це порожній або обірваний файл." \ + "Такий файл виглядає як бекап і не є ним." + ok "$_dump, $((_size / 1024)) КБ" + + step "ключі" + # Без цих двох рядків дамп не відновлюється в робочу систему: у базі + # лежить лише шифротекст секретів. + _old=$(umask); umask 077 + { + printf '# Ключі до дампа netpulse-%s.dump\n' "$_stamp" + printf '# ЗБЕРІГАТИ ОКРЕМО ВІД ДАМПА: разом вони — готовий доступ до всіх\n' + printf '# паролів SSH і SNMP-community з інвентарю.\n' + printf 'NETPULSE_DEK=%s\n' "$(env_get NETPULSE_DEK)" + printf 'NETPULSE_JWT_SECRET=%s\n' "$(env_get NETPULSE_JWT_SECRET)" + } > "$_keys" + umask "$_old" + chmod 600 "$_keys" + ok "$_keys (права 0600)" + + printf '\n Дамп без ключів не відновлюється, ключі поруч із дампом\n' + printf ' скасовують шифрування. Переносьте їх різними шляхами.\n\n' +} + +cmd_restore() { + printf '%sNetPulse · відновлення%s\n' "$C_B" "$C_0" + pick_compose + [ -n "$RESTORE_FILE" ] || die "Не вказано файл: ./netpulse restore -f <дамп>" + [ -f "$RESTORE_FILE" ] || die "Файл $RESTORE_FILE не знайдено." + + if [ "$ASSUME_YES" -eq 0 ]; then + printf '\n %sПоточна база буде знищена%s і замінена вмістом\n' "$C_R" "$C_0" + printf ' %s\n' "$RESTORE_FILE" + printf ' Повторіть із -y, якщо це саме те, що потрібно.\n\n' + exit 1 + fi + + step "зупинка застосунку" + dc stop api collector || die "Не вдалося зупинити api і collector." + ok "api і collector зупинені" + + step "наливання" + # Рамка timescaledb_pre_restore/post_restore обов'язкова: без неї + # фонові процеси агрегації втручаються в наливання, і дамп лягає + # пошкодженим — мовчки. + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d postgres \ + -c 'DROP DATABASE IF EXISTS netpulse; CREATE DATABASE netpulse;' >/dev/null \ + || die "Не вдалося перестворити базу." + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \ + -c 'CREATE EXTENSION IF NOT EXISTS timescaledb; SELECT timescaledb_pre_restore();' >/dev/null \ + || die "Не вдалося перевести TimescaleDB у режим відновлення." + dc exec -T db pg_restore -U netpulse -d netpulse --no-owner < "$RESTORE_FILE" \ + || warn "pg_restore повернув помилки — перегляньте їх перед тим, як користуватись" + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \ + -c 'SELECT timescaledb_post_restore();' >/dev/null \ + || die "Не вдалося вивести TimescaleDB із режиму відновлення." + ok "дані налиті" + + step "запуск" + dc up -d api collector || die "Не вдалося підняти api і collector." + ok "api і collector підняті" + + printf '\n У .env має лежати ТОЙ САМИЙ NETPULSE_DEK, що й на момент дампа.\n' + printf ' Інакше система підніметься, а кожна спроба скористатись збереженим\n' + printf ' паролем поверне помилку розшифрування — і виглядатиме це як\n' + printf ' зламані креденшели, а не як втрачений ключ.\n\n' + printf ' Перевірити стан: ./netpulse check\n\n' +} + +cmd_upgrade() { + printf '%sNetPulse · оновлення%s\n' "$C_B" "$C_0" + pick_compose + [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE." + + # Відкат схеми не передбачений: зворотні міграції на телеметрії + # коштують дорожче, ніж відновлення з дампа. Тому дамп тут не + # ввічливість, а єдиний шлях назад. + cmd_backup + + step "перезбирання" + dc build || die "Збірка образів не вдалася." + ok "образи зібрані" + + step "міграції" + dc run --rm migrate || die \ + "Міграції не накотились — API навмисно не піднімається зі старою схемою." \ + "Уже застосований файл зі зміненою контрольною сумою зупиняє запуск:" \ + "це захист від мовчазного розходження схеми з кодом." + ok "схема накочена" + + step "перезапуск" + dc up -d || die "Не вдалося перезапустити служби." + ok "служби перезапущені" + + printf '\n Перевірити: ./netpulse check\n\n' +} + +cmd_logs() { + pick_compose + if [ $# -gt 0 ]; then + dc logs -f --tail=200 "$@" + else + dc logs -f --tail=200 + fi +} + +# --------------------------------------------------------------------- +# Розбір командного рядка +# --------------------------------------------------------------------- + +usage() { + cat <<'USAGE' +NetPulse — установка й обслуговування. + + ./netpulse install [--dry-run] поставити; повторний запуск безпечний + ./netpulse check [-p ПАРОЛЬ] перевірити живу систему + ./netpulse backup дамп бази + ключі до нього + ./netpulse restore -f ДАМП -y відновити з дампа + ./netpulse upgrade перезібрати, накотити міграції + ./netpulse logs [служба] журнали + +Відповіді на п'ять питань, яких система не може вирішити сама, — +у netpulse.conf. Зразок із поясненнями: netpulse.conf.example. +Без цього файлу install ставить робочу систему на типових відповідях. +USAGE +} + +CMD=${1:-} +[ $# -gt 0 ] && shift + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run|-n) DRY=1 ;; + -y|--yes) ASSUME_YES=1 ;; + -p) shift; OWNER_PASSWORD=${1:-} ;; + -f) shift; RESTORE_FILE=${1:-} ;; + -c) shift; CONF_FILE=${1:-} ;; + -h|--help) usage; exit 0 ;; + *) break ;; + esac + shift +done + +case "$CMD" in + install) cmd_install ;; + check) cmd_check ;; + backup) cmd_backup ;; + restore) cmd_restore ;; + upgrade) cmd_upgrade ;; + logs) cmd_logs "$@" ;; + ""|-h|--help|help) usage ;; + *) printf 'невідома команда: %s\n\n' "$CMD"; usage; exit 2 ;; +esac diff --git a/netpulse.conf.example b/netpulse.conf.example new file mode 100644 index 0000000..86dc296 --- /dev/null +++ b/netpulse.conf.example @@ -0,0 +1,123 @@ +# NetPulse — файл відповідей. +# +# Тут лежить рівно те, чого установник не може вирішити за вас: п'ять +# значень. Решта — секрети, які людині не можна давати вводити руками +# (секрет, який пропонують придумати, виявляється admin123), і +# налаштування, у яких є одне правильне значення або значення, що +# рахується з машини. +# +# Файл НЕ обов'язковий. `./netpulse install` без нього ставить робочу +# систему на самопідписаному сертифікаті, без прийому трапів ззовні, з +# помірними строками зберігання й поясом хоста. Кожен рядок нижче +# каже, що буде, якщо його не чіпати. +# +# cp netpulse.conf.example netpulse.conf +# ./netpulse install +# +# Синтаксис: КЛЮЧ=значення, без лапок, без пробілів навколо «=». + + +# --------------------------------------------------------------------- +# DOMAIN — доменне ім'я, на яке дивиться A-запис цього сервера. +# --------------------------------------------------------------------- +# +# Це рішення людини, бо ані сервер, ані установник не знають, що написано +# в чужій зоні DNS. Перевірити «чи веде домен сюди» до першого запуску +# теж не можна: запис міг з'явитись хвилину тому й ще не розійтися. +# +# ПОРОЖНЬО: адресою системи стає IP цієї машини, а сертифікат — +# самопідписаний. Працює одразу, але браузер щоразу лається, і зонди +# доводиться запускати з -insecure. Let's Encrypt тут ні до чого: він не +# видає сертифікатів на IP-адреси взагалі, тому «залишити порожнім і +# сподіватись на справжній сертифікат» неможливо за побудовою. +# +# Домен можна вписати пізніше: змінити рядок і повторити +# `./netpulse install` — переустановлення нічого не ламає. +DOMAIN= + +# --------------------------------------------------------------------- +# ADMIN_EMAIL — пошта для Let's Encrypt. +# --------------------------------------------------------------------- +# +# На неї приходить лист, коли продовження сертифіката ламається. Це +# єдиний канал, яким про це дізнаються ДО того, як сайт перестане +# відкриватись, — тому адреса має бути та, яку читають, а не та, що +# гарно виглядає. +# +# ПОРОЖНЬО при заданому DOMAIN: сертифікат усе одно береться, але +# попередження про проблеми з продовженням нікуди не піде. +# +# Без DOMAIN не потрібна зовсім: сертифікат самопідписаний, продовжувати +# нічого й нікому скаржитись. +ADMIN_EMAIL= + +# --------------------------------------------------------------------- +# TRAPS_FROM — від кого приймати SNMP-трапи (порт 162/udp). +# --------------------------------------------------------------------- +# +# Це рішення, а не налаштування: 162/udp не має автентифікації взагалі. +# Хто знає адресу — той шле, і система це запише. На машині з публічним +# інтерфейсом «слухати всюди» означає віддати чужим людям право +# наповнювати вашу базу подіями. +# +# ПОРОЖНЬО: трапи слухаються лише на 127.0.0.1. Ззовні не приймається +# нічого. Решта моніторингу (ICMP, SNMP-опитування, збір конфігів) від +# цього не залежить — зонди самі ходять до заліза. +# +# АДРЕСА цієї машини (наприклад 10.0.0.5): порт піднімається лише на +# цьому інтерфейсі. Найдешевший спосіб пустити трапи з внутрішньої +# мережі й не пустити з інтернету — якщо інтерфейси різні. +# +# ПІДМЕРЕЖА (наприклад 10.0.0.0/24) або чужа адреса: порт піднімається +# на всіх інтерфейсах, а джерело обмежується правилом у ланцюжку +# DOCKER-USER. Потребує iptables на хості; якщо його немає, установник +# ЗУПИНИТЬСЯ, а не відкриє порт усім мовчки. Правило не переживає +# перезавантаження саме собою — установник скаже, що з цим робити. +# +# any: слухати всюди, приймати від будь-кого. Установник це зробить і +# гучно скаже, що саме ви щойно дозволили. +TRAPS_FROM= + +# --------------------------------------------------------------------- +# RETENTION — скільки тримати дані. +# --------------------------------------------------------------------- +# +# Рішення організації, а не властивість збірки: «ми маємо бачити +# півроку» — це вимога до вас, а не до продукту. Установник не має права +# вгадати її, бо помилка в один бік коштує диска, а в другий — історії, +# заради якої систему й ставили. +# +# Строк — рівня інсталяції, не кабінету: TimescaleDB видаляє дані цілими +# чанками, а чанк ріжеться за часом і про кабінет не знає нічого +# (пояснення — у міграції 0064). Розкладку профілю по видах даних видно +# на сторінці «Сховище» й там же правиться поштучно. +# +# economy — метрики 7 діб, годинні агрегати півроку, syslog тиждень. +# Для диска на 50 ГБ і кількох сотень хостів. +# normal — метрики 35 діб, годинні агрегати два роки, аудит рік. +# Те, що люди мають на увазі під «як зазвичай». +# archive — метрики 90 діб, годинні агрегати п'ять років, аудит п'ять +# років. Коли історію вимагає регламент. Диск рахуйте +# заздалегідь: це в кілька разів більше за normal. +# +# ПОРОЖНЬО: normal. +# +# Профіль накладається лише на ПЕРШІЙ установці. Повторний запуск +# `install` не чіпає строки: до того часу їх уже могли поправити руками, +# і мовчки повернути наш профіль означало б знищити дані за чужим +# рішенням. +RETENTION=normal + +# --------------------------------------------------------------------- +# TZ — часовий пояс. +# --------------------------------------------------------------------- +# +# У ньому рахуються розклади бекапів конфігів, вікна тиші для сповіщень +# і підписи часу на графіках. Пояс хоста тут не завжди правильна +# відповідь: сервер у чужому дата-центрі цілком може стояти в UTC, а +# «о третій ночі» для вимкнення сповіщень означає третю ночі там, де +# сидять чергові. +# +# ПОРОЖНЬО: береться пояс хоста, а якщо визначити не вдалось — +# Europe/Kyiv. +TZ=Europe/Kyiv diff --git a/proto/netpulse/v1/ncm.proto b/proto/netpulse/v1/ncm.proto index 97ee91b..ba0bf79 100644 --- a/proto/netpulse/v1/ncm.proto +++ b/proto/netpulse/v1/ncm.proto @@ -51,6 +51,48 @@ message ConfigJob { uint64 max_bytes = 12; // Записувати повний транскрипт сесії (для діагностики prompt_regex). bool capture_transcript = 13; + + // Заповнене поле перемикає завдання з «зайди по SSH на пристрій» на + // «прочитай конфіг-файли машини, де ти сам стоїш». Тоді transport, + // credential, commands і prompt_regex не діють: сесії до пристрою + // немає взагалі. + // + // Окреме поле, а не ще одне значення config_type: config_type + // потрапляє в архів як назва зрізу, і навантажувати його ще й + // роллю «яким способом збирати» означало б, що зміна назви зрізу + // тихо змінює спосіб збору. + LocalFilesJob local_files = 14; +} + +// --------------------------------------------------------------------- +// Збір конфіг-файлів САМОЇ машини, на якій працює зонд +// --------------------------------------------------------------------- + +// LocalFilesJob — прохання зібрати локальні конфіг-файли. +// +// НАЙВАЖЛИВІШЕ В ЦЬОМУ ПОВІДОМЛЕННІ — ЧОГО В НЬОМУ НЕМАЄ. +// +// Тут немає й не буде поля зі шляхом, маскою чи каталогом. Зонд стоїть +// на сервері й читає файли його правами; поле «які саме файли читати», +// кероване з кабінету, означало б віддалене читання довільного файлу на +// КОЖНІЙ машині клієнта для будь-кого з правом ncm:write — /etc/shadow, +// приватні ключі, .env із паролем бази, і одразу з усіх серверів. +// +// Тому перелік файлів живе ВИКЛЮЧНО в локальному конфізі зонда +// (agent/internal/localfiles), який редагує адміністратор тієї самої +// машини. Сервер може попросити зібрати — і не може сказати, що саме. +// Наслідок: щоб дістати цим шляхом /etc/shadow, треба вже мати root на +// тому сервері; а маючи його, NetPulse для цього не потрібен. +// +// Поля нижче вміють лише ЗВУЖУВАТИ дозволене машиною: зонд бере мінімум +// зі свого й серверного значення. Нуль означає «не звужую». +message LocalFilesJob { + // Стеля на один файл. + uint64 max_file_bytes = 1; + // Скільки файлів щонайбільше взяти в один набір. + uint32 max_files = 2; + // Стеля на весь набір разом. + uint64 max_total_bytes = 3; } // --------------------------------------------------------------------- @@ -77,6 +119,20 @@ message ConfigHeader { // gzip | none — агент стискає, бо конфіги добре жмуться, // а канал може бути вузьким. string encoding = 6; + + // Відбиток МАШИНИ, з якої знято набір локальних файлів: ім'я хоста + // плюс machine-id. Заповнюється лише збором локальних файлів. + // + // Потрібен через те, що зонд переносний, а картка хоста — ні. + // Посвідчення зонда — це файл; його копіюють разом із контейнером або + // переставляють на іншу машину. Без відбитка історія сервера А + // мовчки продовжилась би файлами сервера Б у тій самій гілці Git — + // найгірший різновид помилки в архіві: він виглядає справним. + // + // Сервер закріплює відбиток за карткою при першому зборі й далі + // відмовляє в прийомі, якщо він змінився, доки людина не підтвердить + // прив'язку заново. + string machine_id = 7; } message ConfigChunk { diff --git a/scripts/check-eol.sh b/scripts/check-eol.sh new file mode 100644 index 0000000..8988439 --- /dev/null +++ b/scripts/check-eol.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env sh +# +# Закінчення рядків: чи не заліз у репозиторій CRLF. +# +# Навіщо окрема перевірка. За один день CRLF зламав дві різні речі, і +# жодну з них не видно в diff: +# +# 1. gofmt почав друкувати як «не відформатовані» файли, яких ніхто не +# чіпав: він вважає \r частиною рядка й пропонує переписати файл +# цілком. Людина бачить червоне на чужому коді, і найкоротший вихід +# із цього — `gofmt -w` — комітить зіпсовані переноси всім. +# 2. db/profiles/build.py, запущений на Windows у текстовому режимі, +# переписав згенеровану міграцію 0014 з \r\n. Байти інші → sha256 +# інший, а netpulse-migrate звіряє суму вже застосованих міграцій і +# зупиняє накат словами «міграцію змінено після застосування». +# Тобто невидима зміна зупинила б оновлення на всіх стендах одразу. +# Порівняння `build.py --check` цього НЕ ловить: Python читає +# текстом і сам перекладає \r\n у \n ще до порівняння. +# +# Перевіряються дві різні речі, і ціна в них різна: +# +# ІНДЕКС (i/crlf) — те, що дістанеться всім і поїде на стенд. Це +# помилка: скрипт падає. У CI це і є сторож. +# РОБОЧА КОПІЯ (w/crlf) — біда однієї машини. У CI її не буває, бо +# checkout свіжий; локально ж це рівно та причина, +# через яку gofmt червоніє на чужому. Тому тут +# попередження з підказкою, а з --strict (як у CI) +# — теж падіння: у свіжому клоні на Linux такого +# бути не може, і якщо воно є, зламаний checkout. +# +# .gitattributes оголошує «у репозиторії — завжди LF». Файли, яким LF не +# призначено (*.ps1 з eol=crlf, двійкові), з перевірки виключені: у них +# CRLF — не порушення, а оголошена норма. +# +# Запуск: +# sh scripts/check-eol.sh # індекс — жорстко, робоча копія — попередження +# sh scripts/check-eol.sh --strict # обидва жорстко (так робить CI) +# +# Полагодити: +# індекс: git add --renormalize . && git commit +# робоча копія: git config core.autocrlf false +# git rm --cached -r . && git reset --hard + +set -u + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" || exit 1 + +STRICT=0 +if [ "${1:-}" = "--strict" ]; then + STRICT=1 +fi + +# `git ls-files --eol` друкує «i/xx w/xx attr/… шлях»: розділювач +# перед шляхом — саме табуляція, тому шляхи з пробілами не розсипаються. +list=$(git ls-files --eol) || { + echo "check-eol: git ls-files не спрацював — це взагалі репозиторій?" >&2 + exit 2 +} + +idx=$(printf '%s\n' "$list" | awk -F'\t' ' + { split($1, c, " ") + if (c[1] == "i/crlf" || c[1] == "i/mixed") print $2 }') + +# eol=crlf у атрибутах означає «цьому файлу CRLF призначено навмисно» +# (.gitattributes: *.ps1). Такий файл у робочій копії з CRLF — норма. +wt=$(printf '%s\n' "$list" | awk -F'\t' ' + { split($1, c, " ") + if ((c[2] == "w/crlf" || c[2] == "w/mixed") && $1 !~ /eol=crlf/) print $2 }') + +rc=0 + +if [ -n "$idx" ]; then + echo "CRLF в ІНДЕКСІ — це поїде всім і зупинить накат міграцій:" + printf '%s\n' "$idx" | sed 's/^/ /' + echo " полагодити: git add --renormalize . && git commit" + rc=1 +fi + +if [ -n "$wt" ]; then + n=$(printf '%s\n' "$wt" | wc -l | tr -d ' ') + if [ "$STRICT" = "1" ]; then + echo "CRLF у РОБОЧІЙ КОПІЇ ($n): у свіжому клоні на Linux такого бути не може." + printf '%s\n' "$wt" | sed 's/^/ /' + rc=1 + else + echo "увага: CRLF у робочій копії ($n файлів) — в індексі при цьому LF." + echo " саме через це gofmt може показувати неформатованими файли," + echo " яких ніхто не чіпав, а генератори — переписувати цілі файли." + printf '%s\n' "$wt" | sed 's/^/ /' + echo " полагодити: git config core.autocrlf false" + echo " git rm --cached -r . && git reset --hard" + fi +fi + +if [ "$rc" = "0" ] && [ -z "$wt" ]; then + echo "закінчення рядків у порядку" +fi + +exit "$rc" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100644 index 0000000..6bfbf66 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env sh +# +# Усі перевірки NetPulse однією командою. +# +# Навіщо окремий скрипт, коли є .forgejo/workflows/ci.yml: раннера +# Forgejo досі немає, тобто CI не запускався жодного разу. Перевірка, яку +# нікому виконати, не ловить нічого — а та сама послідовність, запущена +# руками перед комітом, ловить уже сьогодні. Коли раннер зʼявиться, CI +# викликатиме ті самі кроки; розходитись їм нема з чого. +# +# Запуск: +# sh scripts/check.sh # усе +# sh scripts/check.sh web # лише веб +# sh scripts/check.sh go # лише server + agent +# sh scripts/check.sh repo # лише перевірки репозиторію +# +# На Windows — з Git Bash; PowerShell цей файл не виконує. +# +# Скрипт НЕ зупиняється на першій невдачі навмисно: інакше, щоб +# побачити три поламані місця, довелось би прогнати його тричі. +# Ненульовий код виходу все одно повертається — рівно для гачка +# pre-push і для CI. + +set -u + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" || exit 1 + +FAILED="" +PASSED="" + +# Прогін одного кроку. Назва потрібна для підсумку: список «що саме +# впало» у кінці цінніший за чотириста рядків виводу вище. +step() { + name=$1 + shift + printf '\n\033[1m== %s\033[0m\n' "$name" + if "$@"; then + PASSED="$PASSED|$name" + else + FAILED="$FAILED|$name" + fi +} + +# gofmt -l друкує список файлів, а не код виходу: без явної перевірки +# порожнечі крива форма проїжджає в main непоміченою. +gofmt_check() { + bad=$(gofmt -l "$1") + if [ -n "$bad" ]; then + echo "не відформатовано (виправити: gofmt -w $1):" + echo "$bad" + # На Windows цей крок червоніє ще й від CRLF у робочій копії: gofmt + # вважає \r частиною рядка й пропонує переписати файл цілком. У + # репозиторії лежить LF (див. .gitattributes), тож на Linux-раннері + # той самий файл зелений. Різницю треба назвати вголос: інакше її + # «виправлять» через gofmt -w і закомітять зіпсовані переноси. + echo "якщо різниця в цілому файлі — це CRLF у робочій копії:" + echo " git ls-files --eol $1 | git add --renormalize ." + return 1 + fi + echo "форма в порядку" +} + +# Зонд їде на чуже залізо: перевіряємо, що збирається під усі платформи, +# які обіцяємо, а не лише під ту, де запустили скрипт. +agent_cross() { + out=${TMPDIR:-/tmp}/netpulse-crossbuild + mkdir -p "$out" || return 1 + for target in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/arm64; do + os=${target%/*} + arch=${target#*/} + echo "-- $os/$arch" + ( cd agent && CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \ + -o "$out/netpulse-agent-$os-$arch" ./cmd/netpulse-agent ) || return 1 + done + rm -rf "$out" +} + +go_in() { ( cd "$1" && shift && "$@" ); } + +check_go() { + step "server: gofmt" gofmt_check server + step "server: vet" go_in server go vet ./... + # Тести проти бази самі пропускаються без NETPULSE_TEST_DSN — локальний + # прогін не має ні піднімати Postgres, ні тим паче чіпати бойову базу. + step "server: тести" go_in server go test ./... + step "agent: gofmt" gofmt_check agent + step "agent: vet" go_in agent go vet ./... + step "agent: тести" go_in agent go test ./... + step "agent: крос-збірка" agent_cross +} + +# Перевірки самого репозиторію — не коду, а того, як він лежить. +# +# Обидві ловлять поломки, яких не видно в diff і які проявляються вже на +# стенді: розходження каталогу профілів зі згенерованою міграцією і CRLF, +# що змінює sha256 тієї ж міграції. Чому саме так — у шапках +# scripts/check-eol.sh і db/profiles/build.py. +# +# Тут вони без --strict: у робочій копії на Windows CRLF цілком буває, і +# це попередження, а не привід не дати закомітити. У CI той самий скрипт +# викликається з --strict — там свіжий клон на Linux, і CRLF означав би +# зламане викачування. +check_repo() { + step "репозиторій: закінчення рядків" sh scripts/check-eol.sh + # python3 на Linux, python у Git Bash на Windows: різні імена одного + # й того самого, і вгадувати не треба — беремо той, що є. + py=python3 + command -v python3 >/dev/null 2>&1 || py=python + step "репозиторій: каталог профілів" "$py" db/profiles/build.py --check +} + +check_web() { + # npm ci лише коли залежностей немає: на кожному прогоні він викидає + # node_modules і ставить наново — хвилини замість секунд. + if [ ! -d web/node_modules ]; then + step "web: залежності" go_in web npm ci + fi + step "web: типи" go_in web npx tsc --noEmit + step "web: тести" go_in web npx vitest run + step "web: збірка" go_in web npx vite build +} + +case "${1:-all}" in + web) check_web ;; + go) check_go ;; + repo) check_repo ;; + all) check_repo; check_go; check_web ;; + *) echo "невідомий набір: $1 (є: all, repo, go, web)"; exit 2 ;; +esac + +printf '\n\033[1m== підсумок\033[0m\n' +echo "$PASSED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ ok /' +if [ -n "$FAILED" ]; then + echo "$FAILED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ ВПАЛО /' + exit 1 +fi +echo "усе зелене" diff --git a/server/API.md b/server/API.md index 0ea932c..b147279 100644 --- a/server/API.md +++ b/server/API.md @@ -80,10 +80,14 @@ JWT — ні. | `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) | | `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології | | `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна | -| `GET` | `/api/v1/devices` | інвентар (звужений групами доступу) | +| `GET` | `/api/v1/devices` | інвентар (звужений групами доступу); `?view=archived` — навпаки, ЛИШЕ прибрані хости | | `POST` | `/api/v1/devices` | додати хост (`devices:write`) | | `PATCH` | `/api/v1/devices/{id}` | змінити хост і його групи | | `DELETE` | `/api/v1/devices/{id}` | мʼяко видалити хост | +| `POST` | `/api/v1/devices/bulk-targets` | що саме зачепить масова дія (`mode`: archive/purge/restore) | +| `POST` | `/api/v1/devices/bulk-update` | масова правка хостів | +| `POST` | `/api/v1/devices/bulk-delete` | масове видалення: `mode=archive` (в архів) або `mode=purge` (назавжди, з `ncm:delete` за наявності конфігів) | +| `POST` | `/api/v1/devices/bulk-restore` | повернути хости з архіву (`devices:write`) | | `GET` | `/api/v1/check-types` | що система вміє опитувати | | `GET` | `/api/v1/devices/{id}/checks` | перевірки хоста | | `PUT` | `/api/v1/devices/{id}/checks` | замінити набір перевірок | @@ -109,6 +113,15 @@ JWT — ні. | `POST` | `/api/v1/channels` | створити канал (`alerts:write`) | | `POST` | `/api/v1/channels/{id}/test` | пробне повідомлення (`alerts:write`) | | `DELETE` | `/api/v1/channels/{id}` | видалити канал (`alerts:write`) | +| `GET` | `/api/v1/escalation-policies` | драбини ескалації | +| `POST` | `/api/v1/escalation-policies` | створити драбину (`alerts:write`) | +| `PUT` | `/api/v1/escalation-policies/{id}` | замінити драбину цілком (`alerts:write`) | +| `DELETE` | `/api/v1/escalation-policies/{id}` | видалити драбину (`alerts:write`) | +| `GET` | `/api/v1/storage` | розміри даних, приріст за добу й запас місця | +| `PUT` | `/api/v1/storage/config` | ємність тому під базу (`settings:write`) | +| `GET` | `/api/v1/storage/retention` | строки зберігання за видами даних | +| `POST` | `/api/v1/storage/retention/preview` | **що зникне** від запропонованих строків (`settings:write`) | +| `PUT` | `/api/v1/storage/retention` | зберегти строки й накласти політики (`settings:write`) | | `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів | ### `POST /api/v1/auth/login` — вхід @@ -1024,6 +1037,60 @@ JSON у таблиці правил. `PUT /api/v1/alert-rules/{id}` замінює правило цілком. +### Ескалація + +Сповіщення, надіслане один раз, нічого не гарантує: черговий може спати. +Драбина ескалації відповідає на питання «а якщо ніхто не прочитав» — +через N хвилин мовчання піднімається наступний за списком. + +```jsonc +{ + "name": "Нічне чергування", + "steps": [ // after_min рахується від ПОЧАТКУ алерту + {"after_min": 15, "channel_ids": ["…черговий"]}, + {"after_min": 45, "channel_ids": ["…керівник зміни"]} + ], + "repeat_after_min": 60, // 0 — не повторювати драбину + "max_repeats": 2 +} +``` + +Драбина прив'язується **до правила** (`escalation_policy_id` у тілі +правила). Порожньо — без ескалації, і це типове значення: після +оновлення жоден кабінет не починає будити людей сам собою. Прив'язка до +серйозності дала б одну драбину на всі `high` у кабінеті (а `high` на +тестовому комутаторі й на ядрі — різні люди), прив'язка до групи хостів +— однакову драбину для «завантаження порту» й «пристрій не відповідає». + +Правила проходження: + +- **Стан алерту перевіряється перед КОЖНОЮ сходинкою**, а не один раз при + взведенні. Підтверджений або закритий алерт зупиняє драбину — ескалація + не воскрешає мертве. +- **Заглушення й вікно обслуговування сходинку не витрачають**, а + відкладають: вікно на пів години інакше тихо роззброїло б драбину до + кінця життя алерту. Відкладання обмежене стелею життя драбини. +- **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.** + Якщо каналів не знайшлось (тиха година, поріг серйозності, вимкнений + канал), ескалації не буде: інакше о 15-й хвилині пішло б те, що на + нульовій свідомо не надсилали. +- **Подієві алерти** (`syslog`, `ncm`, `compliance`) проходять драбину + один раз, без повторів. Повтор — це ставка на те, що проблема триває, а + її можна робити лише там, де існування алерту саме по собі є доказом: + метричний алерт зникає, щойно умова перестала виконуватись, подієвий — + ні. +- Стан драбини живе в базі (`alr.alert_escalations`), а рішення пишеться + до надсилання. Перезапуск процесу посеред драбини не подвоює сходинку; + ціна — падіння між записом і надсиланням коштує однієї сходинки (та + сама угода, що й для черги подієвих алертів). + +Журнал сходинок (`alr.escalation_steps`) фіксує і надсилання, і +НЕнадсилання з причиною — «сходинку 2 пропущено: підтверджено о 02:47». +Без цього на питання «чому мене розбудили» відповіді немає. + +У `GET /api/v1/alerts` кожен алерт із живою драбиною має поле +`escalation` — назва драбини, скільки сходинок пройдено, коли наступна. + ### Канали й маршрути Канал зберігає несекретну частину в `config`, а токен — у diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index 1186f2e..7dc3f16 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -37,8 +37,10 @@ func main() { func run() error { var ( - listen = flag.String("listen", envOr("NETPULSE_API_LISTEN", ":8080"), "адреса HTTP") - dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + listen = flag.String("listen", envOr("NETPULSE_API_LISTEN", ":8080"), "адреса HTTP") + dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + dsnWorker = flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"), + "DSN для фонових тактів поверх усіх кабінетів (порожньо — тим самим з'єднанням)") certFile = flag.String("cert", os.Getenv("NETPULSE_API_CERT"), "сертифікат TLS") keyFile = flag.String("key", os.Getenv("NETPULSE_API_KEY"), "приватний ключ TLS") logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error") @@ -57,6 +59,14 @@ func run() error { privateHooks = flag.Bool("allow-private-webhooks", os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1", "дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій") + // Увімкнено за замовчуванням: кнопки під сповіщеннями малюються + // завжди, і інсталяція, де вони є, а приймача немає, — це рівно + // той стан, який цей приймач і виправляє. Прапорець лишається + // для мереж, з яких немає виходу на api.telegram.org: там + // опитування давало б лише потік помилок у журналі. + telegramBot = flag.Bool("telegram-callbacks", + os.Getenv("NETPULSE_TELEGRAM_CALLBACKS") != "0", + "приймати натискання кнопок під сповіщеннями Telegram (довге опитування)") ) flag.Parse() @@ -85,6 +95,15 @@ func run() error { } defer st.Close() + // Друге з'єднання — роллю netpulse_worker, для запитів, які за + // побудовою ходять поверх усіх кабінетів (див. коментар до Store.bg + // і міграцію 0063). Порожня змінна лишає все як було: фонові запити + // підуть основним пулом. Саме тому цю зміну можна викотити наперед, + // а роль перемкнути окремим кроком. + if err := st.UseWorkerDSN(ctx, *dsnWorker); err != nil { + return fmt.Errorf("підключення воркера: %w", err) + } + ring, err := crypto.ParseKeyring(*keysFlag) if err != nil { return fmt.Errorf("-dek: %w", err) @@ -99,6 +118,10 @@ func run() error { } api := httpapi.New(st, signer, log) + // Ключі шифрування потрібні не лише каналам сповіщень: секрет + // дзеркала конфігів лежить у тому самому core.secrets, а движок + // алертів на інсталяції може бути вимкнений. + api = api.WithKeyring(ring) // Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist — // робочий стан: розробка йде проти vite, а API просто віддає API. @@ -117,8 +140,28 @@ func run() error { if *alertEvery > 0 { eng := alerting.New(st, ring, log, *alertEvery, *privateHooks) api = api.WithNotifications(ring, eng.Notifier()) + // Прогін відповідності запускають з UI, тобто з цього процесу — + // і саме він перетворює знахідку на алерт. Без цього тригер + // «порушено вимогу» лишався б тим, чим був: рядком у базі, який + // ніколи не спрацює. + api = api.WithEventAlerts(alerting.NewEventSink(st, log)) go eng.Run(ctx) go eng.RunHousekeeping(ctx, *alertKeep) + + // Приймач натискань кнопок під сповіщеннями Telegram. + // + // Тут же, де й доставка: кнопки малює notify.go, і розводити + // «надіслати» й «прийняти натиснуте» по різних процесах + // означало б інсталяцію, де кнопки є, а відповіді на них немає. + // + // Довге опитування, а не вебхук — розгортання за самопідписаним + // TLS на IP-адресі вебхука не приймає в принципі. Повне + // обґрунтування — у telegram_bot.go, поруч із самим кодом. + // Кілька екземплярів API безпечні: приймач тримає власне + // advisory-блокування, тож getUpdates робить рівно один. + if *telegramBot { + go alerting.NewBot(st, ring, log).Run(ctx) + } } srv := &http.Server{ diff --git a/server/cmd/netpulse-gitsync/main.go b/server/cmd/netpulse-gitsync/main.go index 5a4d900..b49965a 100644 --- a/server/cmd/netpulse-gitsync/main.go +++ b/server/cmd/netpulse-gitsync/main.go @@ -14,6 +14,14 @@ // netpulse-gitsync -dsn ... -dek np1= -git-root /var/lib/netpulse/git // // Повторний запуск безпечний: незмінений вміст нового коміту не створює. +// +// Про дзеркало на зовнішньому Git. Переливання складає коміти заново, і +// їхні ідентифікатори залежать від того, що зараз є в базі: якщо +// ретеншен уже прибрав частину версій, відновлена гілка НЕ збігається з +// тією, що колись поїхала на дзеркало. Такий push буде відхилено як +// non-fast-forward — і це правильно: дзеркало не переписує чужу історію +// само. Після відновлення з бази копію на тому кінці або створюють +// заново порожньою, або перезаписують свідомо з інтерфейсу. package main import ( @@ -39,7 +47,9 @@ func main() { func run() error { var ( - dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + dsnWorker = flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"), + "DSN для фонових тактів поверх усіх кабінетів (порожньо — тим самим з'єднанням)") keys = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключі шифрування: key_id=[,...]") gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"), "корінь сховища версій") tenant = flag.String("tenant", "", "uuid тенанта; порожньо — усі") @@ -71,6 +81,15 @@ func run() error { return fmt.Errorf("підключення до БД: %w", err) } defer st.Close() + // Друге з'єднання — роллю netpulse_worker, для запитів, які за + // побудовою ходять поверх усіх кабінетів (див. коментар до Store.bg + // і міграцію 0063). Порожня змінна лишає все як було: фонові запити + // підуть основним пулом. Саме тому цю зміну можна викотити наперед, + // а роль перемкнути окремим кроком. + if err := st.UseWorkerDSN(ctx, *dsnWorker); err != nil { + return fmt.Errorf("підключення воркера: %w", err) + } + st.UseGit(gitstore.New(*gitRoot)) tenants := []string{*tenant} diff --git a/server/cmd/netpulse-migrate/main.go b/server/cmd/netpulse-migrate/main.go index 00ddd09..eeed961 100644 --- a/server/cmd/netpulse-migrate/main.go +++ b/server/cmd/netpulse-migrate/main.go @@ -6,6 +6,16 @@ // // Міграції вшиті в бінарник через embed: файл, який лежить поруч, // рано чи пізно виявиться версією з іншого релізу. +// +// Крім схеми, ця команда відповідає ще за одне — і саме тому вона тут, а +// не в скрипті установника. На ЧИСТІЙ базі вона видає ролям netpulse_app +// і netpulse_worker паролі з DSN, які їй передали, і перевіряє, що ними +// справді можна зайти. Тобто нова інсталяція вмикає RLS сама, без +// окремого кроку й без вікна, у якому RLS вимкнено. На базі, де вже +// щось накочено, вона паролів не чіпає: перехід на роль без BYPASSRLS +// там — покрокова процедура з відкатом (deploy/RLS-EXISTING-INSTALL.md), +// а не побічний наслідок `docker compose up`. Як саме розрізняються ці +// два випадки — у коментарі до installKind. package main import ( @@ -58,6 +68,10 @@ func main() { func run() error { dsn := flag.String("dsn", os.Getenv("NETPULSE_DSN"), "postgres://user:pass@host:5432/db") + dsnApp := flag.String("dsn-app", os.Getenv("NETPULSE_DSN_APP"), + "DSN, яким ходитимуть api й collector; на чистій базі звідси береться пароль ролі") + dsnWorker := flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"), + "DSN фонових тактів; на чистій базі звідси береться пароль ролі") dryRun := flag.Bool("dry-run", false, "лише показати, що буде застосовано") timeout := flag.Duration("timeout", 10*time.Minute, "стеля на всі міграції") flag.Parse() @@ -106,6 +120,20 @@ func run() error { `SELECT pg_advisory_unlock($1)`, int64(migrateLockKey)) }() + // Ознака інсталяції знімається ТУТ: під блокуванням і до першої + // застосованої міграції. Пізніше вона вже неправдива — після + // накочування будь-яка база виглядає як «наявна інсталяція». + // + // Під -dry-run не знімається взагалі: режим обіцяє «лише показати», + // а перший запис відповіді — теж запис, і він назавжди. + var inst installInfo + if !*dryRun { + inst, err = installKind(ctx, conn.Conn()) + if err != nil { + return err + } + } + applied, err := appliedVersions(ctx, conn.Conn()) if err != nil { return err @@ -154,7 +182,14 @@ func run() error { default: fmt.Printf("застосовано міграцій: %d\n", pending) } - return nil + + if *dryRun { + return nil + } + // Ролі — після схеми, бо саме схема (0063) їх і заводить. І досі + // під блокуванням: два мігратори, що стартували разом, інакше + // виконали б ALTER ROLE ... PASSWORD одночасно. + return ensureRoles(ctx, conn.Conn(), *dsn, *dsnApp, *dsnWorker, inst) } func listMigrations() ([]string, error) { @@ -177,16 +212,94 @@ func listMigrations() ([]string, error) { } func bootstrap(ctx context.Context, conn *pgx.Conn) error { - _, err := conn.Exec(ctx, ` + if _, err := conn.Exec(ctx, ` CREATE TABLE IF NOT EXISTS public.schema_migrations ( version text PRIMARY KEY, checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now() ) + `); err != nil { + return err + } + + // Обидві таблиці заводить мігратор, а не міграція: вони мусять + // існувати ДО того, як накотиться перша з них. + if _, err := conn.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS public.netpulse_install ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + fresh boolean NOT NULL, + applied_was integer NOT NULL, + decided_at timestamptz NOT NULL DEFAULT now() + ) + `); err != nil { + return err + } + _, err := conn.Exec(ctx, ` + COMMENT ON TABLE public.netpulse_install IS + 'Чи була база порожня, коли netpulse-migrate уперше її побачив. ' + 'Від цього рядка залежить, чи вільно міграторові видавати паролі ' + 'ролям, тобто вмикати RLS. Пишеться один раз і не переглядається.' `) return err } +// installKind — «чиста база» чи «оновлення наявної інсталяції». +// +// Питання не риторичне: від відповіді залежить, чи вільно міграторові +// видати ролям паролі й тим самим перевести застосунок на роль без +// BYPASSRLS. Помилка в один бік дає нову інсталяцію, у якій api не може +// підключитись; у другий — робочий стенд, який серед ночі перемкнувся +// сам, бо хтось дописав рядок у .env. +// +// Ознака одна, фактична й перевірна очима: чи була public.schema_migrations +// ПОРОЖНЯ в ту мить, коли мігратор уперше побачив цю базу. +// +// - порожня — жодна міграція ще не котилась, тобто в базі немає ані +// схеми, ані даних, ані клієнтів. Перемикати нічого, ламати нічого; +// роль без BYPASSRLS — просто те, з чим ця інсталяція народжується. +// - непорожня — інсталяція вже працює. Перехід на ній робить людина +// покроково (deploy/RLS-EXISTING-INSTALL.md), а не `docker compose up`. +// +// Чому відповідь ЗАПИСУЄТЬСЯ, а не обчислюється щоразу. Бо обчислення +// дає різні відповіді в різні дні. Перший запуск, що впав на тридцятій +// міграції, лишає непорожню schema_migrations — і повторний запуск +// вирішив би, що це «наявна інсталяція», не видав би паролів, і нова +// система лишилась би без входу. Записаний рядок такої різниці не має: +// його пишуть раз, під тим самим advisory-блокуванням, що й накочування. +// +// Чому не «чи є роль netpulse_app без пароля» і не «чи застосовано 0063». +// Обидві ознаки не розрізняють двох випадків, які треба розрізнити: у +// наявної інсталяції ДО переходу роль теж є й теж без пароля. Тобто +// вгадування дало б рівно ту тиху зміну, проти якої все це написано. +func installKind(ctx context.Context, conn *pgx.Conn) (installInfo, error) { + if _, err := conn.Exec(ctx, ` + INSERT INTO public.netpulse_install (fresh, applied_was) + SELECT count(*) = 0, count(*) FROM public.schema_migrations + ON CONFLICT (singleton) DO NOTHING + `); err != nil { + return installInfo{}, fmt.Errorf("ознака інсталяції: %w", err) + } + + var out installInfo + if err := conn.QueryRow(ctx, + `SELECT fresh, applied_was, decided_at FROM public.netpulse_install`, + ).Scan(&out.fresh, &out.appliedWas, &out.decidedAt); err != nil { + return installInfo{}, fmt.Errorf("ознака інсталяції: %w", err) + } + return out, nil +} + +// installInfo — записана відповідь разом із доказом, з якого вона взялась. +// +// appliedWas зберігається не для коду, а для людини: питання «чому мій +// стенд вважається наявною інсталяцією» має відповідь в одному SELECT, +// а не в читанні цього файлу. +type installInfo struct { + fresh bool + appliedWas int + decidedAt time.Time +} + func appliedVersions(ctx context.Context, conn *pgx.Conn) (map[string]string, error) { rows, err := conn.Query(ctx, `SELECT version, checksum FROM public.schema_migrations`) if err != nil { @@ -241,6 +354,194 @@ func apply(ctx context.Context, conn *pgx.Conn, version, checksum, body string) return tx.Commit(ctx) } +// ensureRoles — крок, після якого застосунок заходить у базу тією роллю, +// якою мав, і про це відомо ще до того, як api спробує піднятись. +// +// Робить рівно дві речі, і друга важливіша за першу. +// +// 1. На ЧИСТІЙ базі видає ролям паролі. Не міграція, бо пароль у +// міграції — це пароль у git і в контрольній сумі +// public.schema_migrations, тобто пароль, який уже не змінити, не +// зачепивши перевірку цілісності. І не установник окремим кроком, +// бо крок, який можна забути, забувають. Пароль береться з САМОГО +// DSN застосунку: у базу лягає рівно той рядок, який потім надішле +// pgx, — той самий текст, розібраний тим самим розбирачем. +// +// 2. Заходить кожним DSN і перевіряє, ким саме зайшов. Це та половина, +// якої бракувало 0063: тоді перевірили, що роль не бачить ЧУЖОГО, і +// не перевірили, що вона взагалі заходить і бачить СВОЄ. Зелена +// перевірка доводить рівно те, що вона перевіряє, тому тут +// перевіряється найдешевше й найгучніше: сам вхід і атрибут +// BYPASSRLS на тому кінці. +// +// Порожній appDSN — робочий стан, а не недоглянута конфігурація: так +// ходять тести, `go run` на стенді розробника й кожен .env, написаний до +// цієї зміни. Тоді функція не робить нічого. +func ensureRoles(ctx context.Context, conn *pgx.Conn, ownerDSN, appDSN, workerDSN string, inst installInfo) error { + if appDSN == "" { + return nil + } + + owner, err := pgx.ParseConfig(ownerDSN) + if err != nil { + return fmt.Errorf("розбір DSN власника: %w", err) + } + app, err := pgx.ParseConfig(appDSN) + if err != nil { + return fmt.Errorf("розбір DSN застосунку: %w%s", err, dsnHint) + } + + // DSN згорнувся до ролі власника — тобто NETPULSE_APP_PASSWORD + // порожній і стек працює як до 0063. Для наявної інсталяції це + // звичайний стан, і мовчати про нього правильно. Для чистої — ні: + // нова система, яка щойно народилась без другого рубежу, має про це + // сказати вголос один раз, поки її ще ніхто не наповнив даними. + if app.User == owner.User { + if inst.fresh { + fmt.Println("УВАГА: чиста база, а NETPULSE_APP_PASSWORD порожній.") + fmt.Println(" Застосунок ходитиме роллю власника, і жодна з політик RLS") + fmt.Println(" не діятиме — ізоляцію кабінетів триматиме лише предикат") + fmt.Println(" tenant_id у коді. Заповніть NETPULSE_APP_PASSWORD і") + fmt.Println(" NETPULSE_WORKER_PASSWORD у .env і накотіть ще раз.") + } + return nil + } + + // Роль застосунку без ролі воркера — найгірший з можливих станів, і + // саме тому це помилка, а не попередження. Інтерфейс при ньому + // працює, а фонові такти мовчки нічого не знаходять: запити-шукачі + // черг ходять поверх усіх кабінетів, і під NOBYPASSRLS повертають + // нуль рядків без жодної помилки в журналі. Бекапи не запускаються, + // алерти не розсилаються, події не доходять до браузера — і все це + // виглядає як тиша, а не як поломка. + if workerDSN == "" { + return fmt.Errorf( + "NETPULSE_APP_PASSWORD задано, а NETPULSE_WORKER_PASSWORD — ні.\n"+ + "Роль %s не має BYPASSRLS, тому фонові такти нічого не знайдуть і не "+ + "поскаржаться. Задайте обидва паролі або жодного", app.User) + } + worker, err := pgx.ParseConfig(workerDSN) + if err != nil { + return fmt.Errorf("розбір DSN воркера: %w%s", err, dsnHint) + } + + switch { + case inst.fresh: + if err := setPassword(ctx, conn, app.User, app.Password); err != nil { + return err + } + if err := setPassword(ctx, conn, worker.User, worker.Password); err != nil { + return err + } + fmt.Printf("чиста база: видано паролі ролям %s і %s\n", app.User, worker.User) + default: + // Наявна інсталяція: паролі тут уже видані руками, за + // deploy/RLS-EXISTING-INSTALL.md. Переписувати їх із .env + // мігратор не береться — це означало б, що файл, який хтось + // відредагував і не перечитав, мовчки змінює доступ до робочої + // бази. Нижче лишається сама перевірка входу, і саме вона + // скаже, якщо крок з паролями пропустили. + fmt.Printf("наявна інсталяція (на момент %s застосованих міграцій: %d): "+ + "паролі ролей не чіпаю\n", + inst.decidedAt.Format("2006-01-02"), inst.appliedWas) + } + + if err := checkLogin(ctx, appDSN, app.User, false); err != nil { + return err + } + if err := checkLogin(ctx, workerDSN, worker.User, true); err != nil { + return err + } + fmt.Printf("вхід перевірено: api й collector — %s (без BYPASSRLS), "+ + "фонові такти — %s (з BYPASSRLS)\n", app.User, worker.User) + return nil +} + +// dsnHint — про пароль, який зіпсував DSN. +// +// Урок цього проєкту, і коштував він півдня. Пароль ролі їде всередині +// URL, тому не кожен рядок у ньому лишається собою: +// +// - `/` обриває користувацьку частину, і хвіст пароля стає шляхом, +// тобто іменем бази. З'єднання не встановлюється, у журналі — +// «database ... does not exist»; +// - `%` читається як початок %XX. Пароль при цьому мовчки стає іншим +// рядком, і помилка виглядає як «authentication failed» без причини. +// +// Обидва символи трапляються в base64 (`/`) і в тому, що люди набирають +// руками (`%`). Виглядає це не як зіпсований рядок у .env, а як «RLS усе +// зламав», і шукають не там. Тому hex — не порада, а вимога. +const dsnHint = "\n" + + "Найчастіша причина — пароль ролі не в hex. Пароль їде всередині URL:\n" + + "`/` обриває його й перетворює хвіст на ім'я бази, `%` читається як\n" + + "початок %XX і мовчки змінює пароль. Генеруйте так: openssl rand -hex 24" + +// setPassword видає роль пароль. +// +// Лапки ставить сам Postgres через format(%I/%L): ALTER ROLE не приймає +// параметрів, а команда, яка ЗАДАЄ пароль, — найгірше місце для склеювання +// рядків руками. Ціна відома й прийнята: якщо на сервері ввімкнено +// log_statement = all, готова команда потрапить у журнал БД разом із +// паролем. Так само поводиться будь-який ALTER ROLE з psql. +func setPassword(ctx context.Context, conn *pgx.Conn, role, password string) error { + if password == "" { + return fmt.Errorf("порожній пароль ролі %s: DSN зібрано неправильно", role) + } + var stmt string + if err := conn.QueryRow(ctx, + `SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text)`, + role, password).Scan(&stmt); err != nil { + return fmt.Errorf("скласти команду для ролі %s: %w", role, err) + } + if _, err := conn.Exec(ctx, stmt); err != nil { + return fmt.Errorf("видати пароль ролі %s: %w", role, err) + } + return nil +} + +// checkLogin підключається наданим DSN і питає базу, ким він зайшов. +// +// Окреме з'єднання, а не SELECT з наявного: перевіряється саме те, що +// ламається, — автентифікація рядком, який зібрав docker compose. +// +// wantBypass — не прикраса. Роль застосунку з BYPASSRLS означає, що все +// нижче зелене й нічого не діє (рівно стан до 0063). Роль воркера БЕЗ +// BYPASSRLS означає протилежну поломку: черги мовчки порожні. Обидві +// беззвучні, тому обидві тут — помилка. +func checkLogin(ctx context.Context, dsn, wantUser string, wantBypass bool) error { + c, err := pgx.Connect(ctx, dsn) + if err != nil { + return fmt.Errorf("вхід роллю %s не вдався: %w%s\n"+ + "Якщо це інсталяція, зроблена до 0063, — паролі ролям видає\n"+ + "deploy/RLS-EXISTING-INSTALL.md, крок 2; мігратор на непорожній базі\n"+ + "робить це навмисно не сам", wantUser, err, dsnHint) + } + defer func() { _ = c.Close(context.WithoutCancel(ctx)) }() + + var got string + var bypass bool + if err := c.QueryRow(ctx, + `SELECT current_user, rolbypassrls FROM pg_roles WHERE rolname = current_user`, + ).Scan(&got, &bypass); err != nil { + return fmt.Errorf("перевірка ролі %s: %w", wantUser, err) + } + if got != wantUser { + return fmt.Errorf("DSN обіцяв роль %s, а з'єднання відкрито як %s", wantUser, got) + } + if bypass != wantBypass { + if wantBypass { + return fmt.Errorf( + "роль %s не має BYPASSRLS: фонові такти нічого не знайдуть і не "+ + "поскаржаться (ALTER ROLE %s BYPASSRLS)", got, got) + } + return fmt.Errorf( + "роль %s має BYPASSRLS: політики RLS на неї не діють, і все нижче "+ + "виглядатиме справним, лишаючись без другого рубежу "+ + "(ALTER ROLE %s NOBYPASSRLS)", got, got) + } + return nil +} + // checkEncoding зупиняє накочування на базі не в UTF-8. // // SQL_ASCII не кодування, а його відсутність: сервер просто пропускає diff --git a/server/cmd/netpulse-migrate/roles_test.go b/server/cmd/netpulse-migrate/roles_test.go new file mode 100644 index 0000000..4488f43 --- /dev/null +++ b/server/cmd/netpulse-migrate/roles_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/jackc/pgx/v5" +) + +// Перевіряються рівно ті гілки ensureRoles, які завершуються ДО першого +// звернення до бази, — тому conn тут nil, і це навмисно: якби якась із +// цих гілок раптом почала ходити в базу, тест упав би на nil, а не +// пройшов би тихо. +// +// Головне, що тут захищається, — властивість «порожні змінні = стара +// поведінка». Її легко зламати наступною правкою, а помітити зламану +// можна лише на чужому робочому стенді. +// +// Чого цей тест НЕ покриває: усе, що починається з підключення. Видача +// паролів, перевірка входу, атрибут BYPASSRLS на тому кінці — жодного +// разу не виконуються без живого Postgres. +func TestEnsureRolesEarlyBranches(t *testing.T) { + const owner = "postgres://netpulse:ownerpw@db:5432/netpulse?sslmode=disable" + + fresh := installInfo{fresh: true, appliedWas: 0} + existing := installInfo{fresh: false, appliedWas: 68} + + // DSN, який docker compose збирає при порожньому NETPULSE_APP_PASSWORD: + // та сама роль, що й у власника. + collapsed := "postgres://netpulse:ownerpw@db:5432/netpulse?sslmode=disable" + appRole := "postgres://netpulse_app:3f9a2b7c1d@db:5432/netpulse?sslmode=disable" + + cases := []struct { + name string + appDSN string + wrkDSN string + inst installInfo + wantErr string // порожньо — має пройти без помилки й без роботи + }{ + {"наявний стенд, .env без нових рядків", collapsed, "", existing, ""}, + {"чиста база без паролів — попередження, не помилка", collapsed, "", fresh, ""}, + {"нічого не передано (тести, go run)", "", "", fresh, ""}, + {"роль застосунку без ролі воркера", appRole, "", fresh, "NETPULSE_WORKER_PASSWORD"}, + {"те саме на наявній інсталяції", appRole, "", existing, "NETPULSE_WORKER_PASSWORD"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ensureRoles(context.Background(), nil, owner, c.appDSN, c.wrkDSN, c.inst) + switch { + case c.wantErr == "" && err != nil: + t.Fatalf("мало бути без роботи й без помилки, вийшло: %v", err) + case c.wantErr != "" && err == nil: + t.Fatalf("очікували відмову зі згадкою %q, вийшло nil", c.wantErr) + case c.wantErr != "" && !strings.Contains(err.Error(), c.wantErr): + t.Fatalf("очікували відмову зі згадкою %q, вийшло: %v", c.wantErr, err) + } + }) + } +} + +// Чому паролі ролей мають бути в hex — на прикладі того самого розбирача, +// яким їх читатиме застосунок. +// +// Тест закріплює не бажану поведінку pgx, а факт: те, що виглядає як +// «пароль не підійшов», насправді буває зіпсованим DSN. +func TestPasswordCharactersInDSN(t *testing.T) { + dsn := func(pw string) string { + return "postgres://netpulse_app:" + pw + "@db:5432/netpulse?sslmode=disable" + } + + // hex доїжджає до бази як є. + cfg, err := pgx.ParseConfig(dsn("3f9a2b7c1d")) + if err != nil { + t.Fatalf("hex-пароль мав розібратись, а вийшло: %v", err) + } + if cfg.Password != "3f9a2b7c1d" || cfg.User != "netpulse_app" { + t.Fatalf("hex-пароль спотворено: user=%q password=%q", cfg.User, cfg.Password) + } + + // Скісна риска (звичайний символ base64) обриває DSN — і це помітно. + if _, err := pgx.ParseConfig(dsn("aB/cD+eF=")); err == nil { + t.Fatal("пароль зі скісною рискою мав дати помилку розбору") + } + + // Відсоток гірший за скісну риску: помилки немає, а пароль інший. + cfg, err = pgx.ParseConfig(dsn("aB%cDef")) + if err != nil { + t.Fatalf("несподівана помилка розбору: %v", err) + } + if cfg.Password == "aB%cDef" { + t.Fatal("очікували, що відсоткову послідовність буде розкодовано; " + + "якщо ні — підказку в dsnHint треба переписати") + } +} diff --git a/server/cmd/netpulse-server/main.go b/server/cmd/netpulse-server/main.go index 79cede8..73da4f0 100644 --- a/server/cmd/netpulse-server/main.go +++ b/server/cmd/netpulse-server/main.go @@ -18,6 +18,7 @@ import ( "time" npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/crypto" "github.com/netpulse/netpulse/server/internal/gitstore" "github.com/netpulse/netpulse/server/internal/grpcapi" @@ -38,8 +39,10 @@ func main() { func run() error { var ( - listen = flag.String("listen", envOr("NETPULSE_LISTEN", ":9443"), "адреса прослуховування gRPC") - dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + listen = flag.String("listen", envOr("NETPULSE_LISTEN", ":9443"), "адреса прослуховування gRPC") + dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + dsnWorker = flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"), + "DSN для фонових тактів поверх усіх кабінетів (порожньо — тим самим з'єднанням)") certFile = flag.String("cert", os.Getenv("NETPULSE_CERT"), "сертифікат сервера") keyFile = flag.String("key", os.Getenv("NETPULSE_KEY"), "приватний ключ") caFile = flag.String("client-ca", os.Getenv("NETPULSE_CLIENT_CA"), "CA для перевірки сертифікатів зондів (mTLS)") @@ -73,6 +76,15 @@ func run() error { } defer st.Close() + // Друге з'єднання — роллю netpulse_worker, для запитів, які за + // побудовою ходять поверх усіх кабінетів (див. коментар до Store.bg + // і міграцію 0063). Порожня змінна лишає все як було: фонові запити + // підуть основним пулом. Саме тому цю зміну можна викотити наперед, + // а роль перемкнути окремим кроком. + if err := st.UseWorkerDSN(ctx, *dsnWorker); err != nil { + return fmt.Errorf("підключення воркера: %w", err) + } + // Сховище версій конфігів. Спільний каталог для API й колектора: // колектор туди пише під час бекапу, API звідти читає для diff. // Різні машини потребують спільного тому — інакше половина історії @@ -81,7 +93,14 @@ func run() error { st.UseGit(gitstore.New(*gitRoot)) } - svc := grpcapi.New(st, ring, log) + // Подієві алерти на журналі й конфігах. + // + // Живуть у цьому процесі, бо саме сюди приходять і те, і те. Сам + // приймач нічого нікуди не шле — він лише піднімає алерт із + // позначкою «розіслати»; розсилає netpulse-api, де є ключі каналів, + // маршрути й тихі години. + svc := grpcapi.New(st, ring, log). + WithEventAlerts(alerting.NewEventSink(st, log)) opts := []grpc.ServerOption{ grpc.ChainUnaryInterceptor(svc.UnaryInterceptor), @@ -144,6 +163,19 @@ func run() error { // Звірка планів: чеки міняє REST-процес, а перезалити план може // лише той, хто тримає сесію зонда. go svc.SyncPlans(ctx) + // Прибиральник старих версій конфігів. Тут, а не в REST-процесі: + // поруч із тим, хто версії створює, і подалі від шляху запитів + // людини — див. ncm_retention.go. + go svc.SweepRetention(ctx) + // Прибиральник телеметрії та журналів за строками зберігання, він + // же — спостерігач за розміром бази. Тут із тих самих міркувань, що + // й попередній: поруч із тим, хто ці дані створює, і подалі від + // шляху запитів людини — див. storage_retention.go. + go svc.SweepDataRetention(ctx) + // Дзеркалення архіву конфігів на зовнішній Git. Окремий такт, а не + // push після коміту: недоступний Forgejo не має коштувати жодного + // бекапу — див. ncm_mirror.go. + go svc.MirrorGit(ctx) errCh := make(chan error, 1) go func() { errCh <- srv.Serve(lis) }() diff --git a/server/internal/alerting/engine.go b/server/internal/alerting/engine.go index 525a138..b4b415c 100644 --- a/server/internal/alerting/engine.go +++ b/server/internal/alerting/engine.go @@ -84,7 +84,7 @@ func (e *Engine) Run(ctx context.Context) { } func (e *Engine) tick(ctx context.Context) (int, error) { - conn, err := e.st.Pool().Acquire(ctx) + conn, err := e.st.WorkerPool().Acquire(ctx) if err != nil { return 0, err } @@ -103,6 +103,19 @@ func (e *Engine) tick(ctx context.Context) (int, error) { `SELECT pg_advisory_unlock($1)`, int64(advisoryLockKey)) }() + // Дві дії нижче стосуються подієвих алертів, які піднімає не цей + // цикл, а приймачі подій (events.go). Вони мають статись навіть у + // кабінеті без жодного метричного правила, тому стоять до вибірки + // й до перевірки на порожньо. + e.expireEvents(ctx) + e.deliverPending(ctx) + + // Ескалація теж не залежить від наявності метричних правил: драбину + // може взвести подієвий алерт у кабінеті, де опитуваних правил немає + // зовсім. І так само, як дві дії вище, вона мусить статись до + // перевірки на порожній перелік правил. + e.escalate(ctx) + rules, err := e.st.ActiveRules(ctx) if err != nil { return 0, fmt.Errorf("читання правил: %w", err) @@ -115,6 +128,14 @@ func (e *Engine) tick(ctx context.Context) (int, error) { // й канали читаються раз на тенант, а не раз на правило. byTenant := map[string][]store.Rule{} for _, r := range rules { + // Подієві джерела цей цикл не чіпає — і не «просто пропускає», + // а мусить пропустити. Обчислення дало б порожній список + // кандидатів, а ResolveMissing слідом закрив би щойно піднятий + // подієвий алерт: із погляду опитування він «зник», хоча + // зникнути він не може за побудовою. + if store.IsEventSource(r.Source) { + continue + } byTenant[r.TenantID] = append(byTenant[r.TenantID], r) } @@ -297,6 +318,60 @@ func (e *Engine) publish(ctx context.Context, tenantID string, fired, resolved, emit("alert.updated", changed) } +// expireEvents гасить подієві алерти, до яких давно не було подій. +// +// Робиться щотіку, а не в прибиральнику раз на чверть години: строк +// життя правило задає в секундах, і «доба» з точністю до п'ятнадцяти +// хвилин виглядала б на екрані як несправність. +func (e *Engine) expireEvents(ctx context.Context) { + n, err := e.st.ExpireEventAlerts(ctx) + if err != nil { + e.log.Error("гасіння подієвих алертів", "помилка", err) + return + } + if n > 0 { + e.log.Info("подієві алерти прострочено", "рядків", n) + } +} + +// deliverPending розсилає алерти, підняті подієвим шляхом. +// +// Подія приходить у процес, який не має ані ключів шифрування каналів, +// ані маршрутів, ані тихих годин — тож розсилка лишається тут, де все +// це вже прочитано, і під тим самим advisory-блокуванням: інакше два +// інстанси API розбудили б людину двічі. +// +// Плата — затримка до одного тіку. Для «конфіг змінився» чи «порушено +// стандарт» це прийнятно: жодне з них не є аварією, на яку біжать за +// секунди. Для метричних алертів затримки як була, так і немає. +func (e *Engine) deliverPending(ctx context.Context) { + pending, err := e.st.TakeNotifyPending(ctx, 200) + if err != nil { + e.log.Error("черга розсилки подієвих алертів", "помилка", err) + return + } + if len(pending) == 0 { + return + } + + byTenant := map[string][]store.Alert{} + for _, a := range pending { + // Придушене не турбує нікого — рівно як у метричному шляху. + if a.State != "firing" { + continue + } + byTenant[a.TenantID] = append(byTenant[a.TenantID], a) + } + for tenantID, as := range byTenant { + e.notifier.Dispatch(ctx, tenantID, as, e.ring) + } +} + +// escalationLogKeep — той самий строк, що в alr.notifications (0007). +// Розходження тут означало б, що на питання «чому мене розбудили» одна +// половина відповіді ще є, а друга вже стерта. +const escalationLogKeep = 90 * 24 * time.Hour + // RunHousekeeping переносить закриті алерти в історію. func (e *Engine) RunHousekeeping(ctx context.Context, keepResolved time.Duration) { t := time.NewTicker(15 * time.Minute) @@ -314,6 +389,14 @@ func (e *Engine) RunHousekeeping(ctx context.Context, keepResolved time.Duration if n > 0 { e.log.Info("алерти заархівовано", "рядків", n) } + // Журнал сходинок ескалації не гіпертаблиця, тож політики + // ретеншену TimescaleDB в нього немає — прибираємо тут, тим + // самим строком, що й у журналу доставки (0007). + if k, err := e.st.PurgeEscalationLog(ctx, escalationLogKeep); err != nil { + e.log.Error("прибирання журналу ескалацій", "помилка", err) + } else if k > 0 { + e.log.Info("журнал ескалацій прибрано", "рядків", k) + } } } } diff --git a/server/internal/alerting/escalation.go b/server/internal/alerting/escalation.go new file mode 100644 index 0000000..9b9c1ea --- /dev/null +++ b/server/internal/alerting/escalation.go @@ -0,0 +1,149 @@ +package alerting + +import ( + "context" + "fmt" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Ескалація: «не підтвердили за 15 хвилин — буди наступного». +// +// Розділення обов'язків тут таке саме, як у решті движка, і воно +// принципове: +// +// - store.PlanEscalation ухвалює РІШЕННЯ і не знає ні про канали, ні +// про мережу. Функція чиста, тому «сходинка НЕ спрацювала, бо алерт +// підтвердили / закрили / хост заглушено» перевіряється тестом без +// бази — а це рівно та половина поведінки, яку легко залишити +// неперевіреною, бо вона нічого не робить. +// +// - цей файл ВИКОНУЄ рішення: бере канали сходинки й шле в них той +// самий алерт з іншою шапкою. +// +// Стан драбини живе в базі (alr.alert_escalations), а не в пам'яті. Це +// не педантизм: викочування нової версії о другій ночі перезапускає +// процес, і драбина в пам'яті померла б рівно посеред тієї аварії, +// заради якої вона й існує. + +// escalationBatch — скільки сходинок обробляти за тік. +// +// Сотні достатньо з великим запасом: сходинка — це подія раз на +// чверть години на алерт, а не потік. Обмеження стоїть, щоб один +// патологічний кабінет не з'їв увесь тік. +const escalationBatch = 100 + +// escalate проганяє сходинки, час яких настав. +// +// Викликається з тіку движка, тобто під тим самим advisory-блокуванням, +// що й решта. Оренда на рядку (EscalationLease) — другий рубіж на +// випадок, коли блокування з якоїсь причини взяли двоє. +func (e *Engine) escalate(ctx context.Context) { + due, err := e.st.TakeDueEscalations(ctx, escalationBatch) + if err != nil { + e.log.Error("черга ескалацій", "помилка", err) + return + } + if len(due) == 0 { + return + } + + // Канали читаються раз на кабінет, а не раз на сходинку: розшифровка + // секретів каналу коштує дорого, а сходинок одного кабінету в партії + // може бути десяток. + channels := map[string]map[string]store.Channel{} + + now := time.Now() + for _, snap := range due { + d := store.PlanEscalation(snap, now) + + // Запис ДО надсилання — той самий порядок, що й у журналі + // доставки, і з тієї ж причини: якщо процес упаде між ними, + // краще не надіслати сходинку, ніж надіслати її вдруге. + if err := e.st.ApplyEscalation(ctx, snap, d); err != nil { + e.log.Error("запис рішення ескалації", "алерт", snap.AlertID, "помилка", err) + continue + } + if d.Action != store.EscFire { + e.log.Debug("ескалацію не продовжено", "алерт", snap.AlertID, + "причина", d.Outcome, "деталі", d.Detail) + continue + } + + byID, ok := channels[snap.TenantID] + if !ok { + cs, err := e.st.LoadChannels(ctx, snap.TenantID, e.ring) + if err != nil { + e.log.Error("читання каналів для ескалації", + "tenant", snap.TenantID, "помилка", err) + channels[snap.TenantID] = map[string]store.Channel{} + continue + } + byID = make(map[string]store.Channel, len(cs)) + for _, c := range cs { + byID[c.ID] = c + } + channels[snap.TenantID] = byID + } + + e.notifier.deliverEscalation(ctx, snap, d, byID) + } +} + +// deliverEscalation шле сходинку в її канали. +func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationSnapshot, + d store.EscalationDecision, byID map[string]store.Channel) { + + a := snap.Alert + head := escalationHeader(snap, d) + + sent := 0 + for _, id := range d.ChannelIDs { + c, ok := byID[id] + if !ok { + continue + } + // Вимкнений канал і поріг серйозності діють на сходинку так + // само, як на звичайне сповіщення: обидва — рішення про канал, а + // не про драбину. Інакше «вимкнув Telegram на час переїзду» + // означало б «вимкнув усе, крім ескалації», тобто рівно те, чого + // людина не просила. + if !c.Enabled || severityRank[a.Severity] < severityRank[c.MinSeverity] { + continue + } + n.sendText(ctx, snap.TenantID, a, c, head+renderMessage(a, c)) + sent++ + } + + if sent == 0 { + // Сходинка вже списана (рішення записано до надсилання), і це + // правильно: інакше вона поверталася б щотіку. Але мовчазна + // втрата сходинки — саме те, від чого ескалація рятує, тож слід + // лишається в журналі процесу. + n.log.Warn("сходинка ескалації не мала куди піти", + "алерт", snap.AlertID, "сходинка", d.StepIdx+1, + "каналів у сходинці", len(d.ChannelIDs)) + } +} + +// escalationHeader пояснює людині, чому вона це читає. +// +// Без цього рядка третє повідомлення про ту саму аварію виглядає як +// збій розсилки, а не як ескалація, — і перше, що зробить розбуджений +// керівник, це вимкне канал. +func escalationHeader(snap store.EscalationSnapshot, d store.EscalationDecision) string { + total := len(snap.Steps) + mins := int(time.Since(snap.Alert.StartedAt).Round(time.Minute).Minutes()) + if mins < 0 { + mins = 0 + } + + head := fmt.Sprintf("🔺 ЕСКАЛАЦІЯ %d/%d (%s): не підтверджено %s\n", + d.StepIdx+1, total, snap.PolicyName, humanDur(mins*60)) + if d.RepeatIdx > 0 { + head = fmt.Sprintf("🔺 ЕСКАЛАЦІЯ %d/%d, повтор %d (%s): не підтверджено %s\n", + d.StepIdx+1, total, d.RepeatIdx, snap.PolicyName, humanDur(mins*60)) + } + return head +} diff --git a/server/internal/alerting/escalation_test.go b/server/internal/alerting/escalation_test.go new file mode 100644 index 0000000..f0d642c --- /dev/null +++ b/server/internal/alerting/escalation_test.go @@ -0,0 +1,97 @@ +package alerting + +import ( + "strings" + "testing" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Взведення драбини перевіряється з боку «не взвели». +// +// Тест «драбину взведено» доводить мало: він проходить і в реалізації, +// яка взводить її завжди. Кожен із випадків нижче — це людина, яку +// система розбудила б о третій ночі без причини, або (у випадку з +// каналами) сповіщення, яке о нульовій хвилині свідомо не надіслали, а +// о п'ятнадцятій раптом надіслали б. + +const policy = "p1" + +func act() store.RuleAction { + return store.RuleAction{EscalationPolicyID: policy, Source: "icmp"} +} + +// Контроль: нормальний випадок драбину взводить. +func TestEscalationArmedForFiringAlert(t *testing.T) { + if !shouldArmEscalation(store.Alert{State: "firing"}, act(), true, 2) { + t.Fatal("алерт, про який справді повідомили, має отримати драбину") + } +} + +// Правило без політики — типовий стан, і він має лишатись мовчазним. +// +// Це головна вимога до оновлення: жоден наявний кабінет не має раптом +// почати будити людей тільки тому, що з'явилась нова можливість. +func TestNoPolicyMeansNoEscalation(t *testing.T) { + empty := store.RuleAction{Source: "icmp"} + if shouldArmEscalation(store.Alert{State: "firing"}, empty, true, 3) { + t.Fatal("правило без політики не має ескалювати") + } + // Правила взагалі немає (його видалили, алерт лишився). + if shouldArmEscalation(store.Alert{State: "firing"}, store.RuleAction{}, false, 3) { + t.Fatal("алерт без правила ескалювати нікуди") + } +} + +// Придушений алерт драбини не отримує: заглушення означає «не +// турбувати», а драбина — це рівно турбування, тільки за розкладом. +func TestSuppressedAlertIsNotArmed(t *testing.T) { + for _, st := range []string{"suppressed", "acknowledged", "resolved", "expired"} { + if shouldArmEscalation(store.Alert{State: st}, act(), true, 2) { + t.Errorf("стан %q отримав драбину", st) + } + } +} + +// Найтонший випадок: перше сповіщення нікуди не пішло. +// +// Каналів могло не знайтись через тиху годину правила, поріг +// серйозності або вимкнений канал. Взвести драбину означало б надіслати +// о 15-й хвилині те, що на нульовій ми свідомо не надсилали, — і людина +// прочитала б це як несправність. Нею це й було б. +func TestEscalationNotArmedWhenNothingWasSent(t *testing.T) { + if shouldArmEscalation(store.Alert{State: "firing"}, act(), true, 0) { + t.Fatal("драбина взведена попри те, що перше сповіщення нікуди не пішло") + } +} + +// --------------------------------------------------------------------- + +// Шапка ескалації має з першого рядка пояснювати, чому це третє +// повідомлення про ту саму аварію. Без цього розбуджений керівник +// вимкне канал, вирішивши, що розсилка зациклилась. +func TestEscalationHeaderExplainsItself(t *testing.T) { + start := time.Now().Add(-45 * time.Minute) + snap := store.EscalationSnapshot{ + PolicyName: "Нічне чергування", + Steps: []store.EscalationStep{ + {AfterMin: 15}, {AfterMin: 45}, + }, + Alert: store.Alert{StartedAt: start}, + } + head := escalationHeader(snap, store.EscalationDecision{StepIdx: 1}) + + for _, want := range []string{"ЕСКАЛАЦІЯ", "2/2", "Нічне чергування", "не підтверджено"} { + if !strings.Contains(head, want) { + t.Errorf("у шапці немає %q:\n%s", want, head) + } + } + + // Повтор має бути видно окремо: «друга сходинка» і «друга сходинка + // втретє» — це різні новини для того, хто їх читає. + rep := escalationHeader(snap, store.EscalationDecision{StepIdx: 1, RepeatIdx: 2}) + if !strings.Contains(rep, "повтор 2") { + t.Errorf("номер повтору не показано:\n%s", rep) + } +} diff --git a/server/internal/alerting/events.go b/server/internal/alerting/events.go new file mode 100644 index 0000000..7e3109c --- /dev/null +++ b/server/internal/alerting/events.go @@ -0,0 +1,820 @@ +package alerting + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "regexp" + "strings" + "sync" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Подієві алерти. +// +// Движок у engine.go працює тактами: раз на пів хвилини перепитує ряди +// вимірів і питає в них «чи виконується умова зараз». Для трьох джерел +// (metric, icmp, interface) це єдиний можливий спосіб — ряд є, питання +// осмислене, відповідь може змінитися будь-коли. +// +// Для журналу, конфігів і відповідності такого ряду немає. Питати +// «чи змінився конфіг зараз» безглуздо: він змінився о 10:42 і більше +// нічого про це не скаже. Тому ці джерела не опитуються взагалі — +// правило перевіряється рівно в ту мить, коли подія надійшла, у тому +// процесі, який її прийняв. +// +// Наслідки цієї різниці треба було вирішити явно, і вони вирішені так: +// +// дедуплікація — один алерт на пару «правило + хост», скільки б подій +// під нього не підпало. Ключ не містить нічого від самої події; +// замість переліку в алерті лічильник. Інакше потік syslog за +// хвилину зробив би дошку алертів нечитабельною — тобто зламав би +// саме те, заради чого вона є. +// +// частота — між двома зверненнями до одного алерту витримується +// min_interval_seconds правила. Пропущені за цей час події не +// губляться: вони накопичуються тут і доливаються в лічильник +// наступним зверненням. Ми економимо запити до бази, а не правду. +// +// гасіння — подієвий алерт не зникає сам, бо зникати нема чому. +// Його закриває або людина, або строк (ExpireEventAlerts, стан +// `expired`). Виняток один — відповідність: там прогін, у якому +// хост правило пройшов, і є чесний сигнал «більше не порушено». +// +// доставка — подія приходить у netpulse-server, а канали, маршрути й +// тихі години живуть у netpulse-api. Тому тут алерт лише +// піднімається з позначкою notify_pending, а розсилає його движок +// наступним тіком — під тим самим advisory-блокуванням, тобто в +// одному екземплярі. + +// SyslogEvent — рядок журналу у вигляді, потрібному правилам. +// +// Власний тип, а не protobuf: пакет алертів не має знати про транспорт +// зондів, інакше кожна зміна .proto тягла б за собою правку движка. +type SyslogEvent struct { + DeviceID string + Message string + Tag string + Severity int +} + +// ConfigEvent — те, що сталося з конфігом хоста. +// +// Kind: "changed" — приїхала версія, відмінна від попередньої; +// "backup_failed" — збір не вдався. +type ConfigEvent struct { + DeviceID string + ConfigType string + Kind string + Detail string +} + +// ComplianceEvent — результат перевірки одного правила на одному хості. +type ComplianceEvent struct { + RuleID string + RuleName string + Severity string + DeviceID string + Passed bool + Line string + LineNumber int +} + +// EventSink приймає події й піднімає за ними алерти. +// +// Безпечний для конкурентного використання: приймач журналу викликає +// його з кожного стріму зонда. +type EventSink struct { + st *store.Store + log *slog.Logger + + // Як довго живе кеш правил і хостів тенанта. + // + // Кеш тут не оптимізація, а умова існування: без нього кожен рядок + // журналу коштував би читання правил, розгортання селектора й + // вибірки вікон обслуговування. Ціна — щойно створене правило + // починає діяти не миттєво, і це чесний розмін: подія, яка сталася + // за півхвилини до появи правила, і так під нього не підпадає. + ttl time.Duration + + mu sync.Mutex + cache map[string]*tenantView + rate map[string]*rateEntry +} + +func NewEventSink(st *store.Store, log *slog.Logger) *EventSink { + if log == nil { + log = slog.Default() + } + return &EventSink{ + st: st, + log: log.With("component", "alerting.events"), + ttl: 30 * time.Second, + cache: map[string]*tenantView{}, + rate: map[string]*rateEntry{}, + } +} + +// tenantView — усе, що потрібно знати про кабінет, щоб вирішити долю +// події, не звертаючись до бази. +type tenantView struct { + at time.Time + rules []compiledRule + devices map[string]string // device_id → ім'я + sup store.Suppression + // Власний словник трапів кабінету. Потрібен лише для тексту + // алерту: «linkDown на sw-core-01» замість + // «1.3.6.1.6.3.1.1.5.3 на sw-core-01». Читається лише коли в + // кабінеті є хоч одне правило на трапи — зайвий запит раз на пів + // хвилини на кожного клієнта, який трапами не користується, нічого + // не вартий рівно доти, доки клієнтів мало. + trapNames map[string]store.TrapMeaning +} + +type compiledRule struct { + rule store.Rule + re *regexp.Regexp + // Хости під селектором. nil означає «усі»: порожній селектор — це + // найчастіший випадок, і перетворювати його на перелік означало б + // щоразу відставати від щойно доданого хоста. + scope map[string]bool +} + +func (c compiledRule) covers(deviceID string) bool { + return c.scope == nil || c.scope[deviceID] +} + +// rateEntry — стан обмежувача частоти для одного алерту. +type rateEntry struct { + last time.Time + // Події, що надійшли, поки діяв проміжок. Не викидаються: людині + // важлива не кожна з них окремо, а те, що їх було багато. + carried int + touched time.Time +} + +// --------------------------------------------------------------------- +// Журнал +// --------------------------------------------------------------------- + +// OnSyslog звіряє пачку рядків журналу з правилами джерела `syslog`. +// +// Зведення робиться до звернення до бази: пачка від зонда — це сотні +// рядків, і половина з них зазвичай про один і той самий порт, що +// мигає. Одна подія на пару «правило + хост» замість сотні запитів — +// різниця між приймачем, який справляється, і тим, який гальмує самі +// зонди. +func (s *EventSink) OnSyslog(ctx context.Context, tenantID string, events []SyslogEvent) { + if len(events) == 0 { + return + } + view := s.view(ctx, tenantID) + if view == nil { + return + } + + // ключ пари «правило+хост» → скільки збігів і останній текст + type hit struct { + rule compiledRule + device string + count int + last string + tag string + sevSeen int + } + hits := map[string]*hit{} + + for _, ev := range events { + if ev.DeviceID == "" || ev.Message == "" { + // Подія з невідомої адреси не належить нікому. Піднімати + // алерт «десь у мережі щось сталося» — гірше, ніж мовчати: + // з ним нічого не можна зробити. + continue + } + for _, c := range view.rules { + if c.rule.Source != "syslog" || c.re == nil || !c.covers(ev.DeviceID) { + continue + } + if lte := c.rule.Condition.SeverityLTE; lte != nil && ev.Severity > *lte { + continue + } + if t := c.rule.Condition.Tag; t != "" && !strings.EqualFold(t, ev.Tag) { + continue + } + if !c.re.MatchString(ev.Message) { + continue + } + key := store.EventDedupKey(c.rule.ID, ev.DeviceID) + h, ok := hits[key] + if !ok { + h = &hit{rule: c, device: ev.DeviceID, sevSeen: ev.Severity} + hits[key] = h + } + h.count++ + h.last = ev.Message + h.tag = ev.Tag + } + } + + for _, h := range hits { + meta := map[string]any{ + "kind": "syslog", + "pattern": h.rule.rule.Condition.Regex, + "sample": trimLine(h.last), + "tag": h.tag, + } + s.raise(ctx, tenantID, view, h.rule, h.device, + trimLine(h.last), h.count, meta) + } +} + +// --------------------------------------------------------------------- +// Конфіги +// --------------------------------------------------------------------- + +// OnConfig піднімає алерти правил джерела `ncm`. +// +// Саме той сценарій, заради якого все це писалося: людина заводить +// тригер «конфіг змінився», і він має спрацювати тоді, коли конфіг +// змінився, — а не ніколи. +func (s *EventSink) OnConfig(ctx context.Context, tenantID string, ev ConfigEvent) { + if ev.DeviceID == "" || ev.Kind == "" { + return + } + view := s.view(ctx, tenantID) + if view == nil { + return + } + + for _, c := range view.rules { + if c.rule.Source != "ncm" || c.rule.Condition.Event != ev.Kind || !c.covers(ev.DeviceID) { + continue + } + msg := "конфіг змінився (" + orDefault(ev.ConfigType, "running") + ")" + if ev.Kind == "backup_failed" { + msg = "збір конфігу не вдався: " + trimLine(ev.Detail) + } + meta := map[string]any{ + "kind": "ncm", + "event": ev.Kind, + "config_type": ev.ConfigType, + "detail": trimLine(ev.Detail), + } + s.raise(ctx, tenantID, view, c, ev.DeviceID, msg, 1, meta) + } +} + +// --------------------------------------------------------------------- +// Відповідність +// --------------------------------------------------------------------- + +// OnCompliance переносить результат прогону у стан алертів. +// +// Єдине з подієвих джерел, у якого є зворотний бік. Прогін перевіряє +// всі хости під правилом і каже про кожен «пройшов» або «ні» — отже, +// «пройшов» і є той самий сигнал зняття, якого немає в журналі. Тому +// тут алерт закривається сам, і це не виняток із правила, а наслідок +// того, що дані інші. +func (s *EventSink) OnCompliance(ctx context.Context, tenantID string, events []ComplianceEvent) { + if len(events) == 0 { + return + } + view := s.view(ctx, tenantID) + if view == nil { + return + } + + var healed []string + for _, ev := range events { + if ev.DeviceID == "" { + continue + } + for _, c := range view.rules { + if c.rule.Source != "compliance" || !c.covers(ev.DeviceID) { + continue + } + if !matchesComplianceRule(c.rule.Condition, ev) { + continue + } + key := store.EventDedupKey(c.rule.ID, ev.DeviceID) + if ev.Passed { + healed = append(healed, key) + continue + } + msg := fmt.Sprintf("порушено вимогу «%s»", ev.RuleName) + if ev.Line != "" { + msg = fmt.Sprintf("%s: рядок %d — %s", msg, ev.LineNumber, trimLine(ev.Line)) + } + meta := map[string]any{ + "kind": "compliance", + "compliance_rule": ev.RuleName, + "compliance_id": ev.RuleID, + "line": trimLine(ev.Line), + "line_number": ev.LineNumber, + "finding_severity": ev.Severity, + } + s.raise(ctx, tenantID, view, c, ev.DeviceID, msg, 1, meta) + } + } + + if len(healed) > 0 { + if _, err := s.st.ResolveEventAlerts(ctx, tenantID, healed, + "хост пройшов перевірку відповідності"); err != nil { + s.log.Error("закриття алертів відповідності", "tenant", tenantID, "помилка", err) + } + } +} + +// matchesComplianceRule звужує тригер до частини знахідок. +// +// Порожня умова означає «будь-яке порушення»: тригер «скажи мені, коли +// щось поїхало» — найчастіший і найкорисніший, і вимагати для нього +// переліку правил означало б, що новий стандарт, доданий завтра, під +// нього не підпаде. +func matchesComplianceRule(cond store.Condition, ev ComplianceEvent) bool { + if len(cond.RuleIDs) > 0 { + var found bool + for _, id := range cond.RuleIDs { + if id == ev.RuleID { + found = true + break + } + } + if !found { + return false + } + } + if cond.MinSeverity != "" && store.SeverityRank(ev.Severity) < store.SeverityRank(cond.MinSeverity) { + return false + } + return true +} + +// --------------------------------------------------------------------- +// SNMP-трапи +// --------------------------------------------------------------------- + +// TrapVarbind — одне поле трапа у вигляді, потрібному правилам. +type TrapVarbind struct { + OID string + Value string +} + +// TrapEvent — трап, зведений до того, про що можна запитати в умові. +// +// Власний тип, а не protobuf: пакет алертів не має знати про транспорт +// зондів. DeviceID порожній, якщо адресу відправника не вдалося +// зіставити з хостом, — і це не помилка, див. OnTrap. +type TrapEvent struct { + DeviceID string + SourceIP string + TrapOID string + Varbinds []TrapVarbind +} + +// OnTrap звіряє пачку трапів із правилами джерела `trap`. +// +// Зведення робиться до звернення до бази — так само, як для журналу: +// порт, що фліпає, дає linkDown/linkUp пачками, і сто UPSERT-ів замість +// одного тут нічого не додають. +// +// Окреме рішення, яке варто знати. Трап БЕЗ хоста піднімає алерт лише +// тоді, коли правило явно назвало адресу джерела (умова source_ip). +// Причина в тому, що алерт без хоста нікуди не маршрутизується, не +// глушиться вікном обслуговування й майже нічого не каже черговому: +// «трап від 10.20.0.77» — це питання, а не аварія. Робити з кожного +// такого питання алерт означало б залити дошку тим, з чим о третій ночі +// не можна зробити нічого. +// +// Але й губити їх не можна: незнайома адреса, що шле трапи, — часто +// перший слід нового заліза в мережі. Тому вони не зникають, а +// потрапляють у власний перелік (inv.trap_unknown_sources), який видно +// на сторінці трапів окремим блоком. Алерт — для того, що вже знаєш; +// перелік — для того, чого ще не знаєш. +func (s *EventSink) OnTrap(ctx context.Context, tenantID string, events []TrapEvent) { + if len(events) == 0 { + return + } + view := s.view(ctx, tenantID) + if view == nil { + return + } + + hits := map[string]*trapHit{} + for _, ev := range events { + for _, c := range view.rules { + if c.rule.Source != "trap" || !trapMatches(c, ev) { + continue + } + key := store.TrapDedupKey(c.rule.ID, ev.DeviceID, ev.SourceIP) + h, ok := hits[key] + if !ok { + h = &trapHit{rule: c, device: ev.DeviceID, ip: ev.SourceIP, oid: ev.TrapOID} + hits[key] = h + } + h.count++ + h.last = ev + } + } + + for key, h := range hits { + meaning := store.ResolveTrapOID(view.trapNames, h.oid) + what := meaning.Name + if what == "" { + // Назви немає — так і кажемо. Вигадана за схожістю префікса + // назва в заголовку алерту була б найгіршим із можливих + // варіантів: саме заголовок читають, коли вирішують, чи + // вставати. + what = "невідомий трап " + orDefault(h.oid, "без OID") + } + msg := what + if h.device == "" { + msg += " від " + h.ip + " (адреси немає серед хостів)" + } + if detail := trapDetail(view, h.last); detail != "" { + msg += " · " + detail + } + + meta := map[string]any{ + "kind": "trap", + "trap_oid": h.oid, + "trap_name": meaning.Name, + "source_ip": h.ip, + "varbinds": trapVarbindMeta(view, h.last), + } + // Підпис для алерту без хоста — сама адреса: це єдине, що про + // такого відправника взагалі відомо. + label := "" + if h.device == "" { + label = h.ip + } + s.raiseKeyed(ctx, tenantID, view, h.rule, key, h.device, label, msg, h.count, meta) + } +} + +// trapHit — зведення однакових трапів до одного звернення до бази. +type trapHit struct { + rule compiledRule + device string + ip string + oid string + count int + last TrapEvent +} + +// trapMatches — чи підпадає трап під умову правила. +func trapMatches(c compiledRule, ev TrapEvent) bool { + cond := c.rule.Condition + + if oid := store.NormalizeOID(cond.TrapOID); oid != "" { + if store.NormalizeOID(ev.TrapOID) != oid { + return false + } + } + + if src := strings.TrimSpace(cond.SourceIP); src != "" { + if !ipMatches(src, ev.SourceIP) { + return false + } + } else if ev.DeviceID == "" { + // Трап без хоста й без явно названої адреси — не алерт. + // Пояснення в коментарі до OnTrap. + return false + } + + // Селектор перевіряємо лише там, де хост є: він оперує хостами, і + // застосувати його до адреси, якої немає в інвентарі, неможливо. + // Тому правило з адресою джерела працює й для незнайомців — інакше + // саме той випадок, заради якого адресу й вписали, не спрацював би + // ніколи. + if ev.DeviceID != "" && !c.covers(ev.DeviceID) { + return false + } + + if vbOID := store.NormalizeOID(cond.VarbindOID); vbOID != "" { + want := strings.TrimSpace(cond.VarbindValue) + var found bool + for _, vb := range ev.Varbinds { + if !varbindIs(vb.OID, vbOID) { + continue + } + // Порожнє очікуване значення означає «щоб такий varbind + // узагалі був»: умова «трап, у якому є ifIndex» осмислена й + // відсіює половину службового шуму. + if want == "" || vb.Value == want { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// varbindIs порівнює OID varbind-а з OID стовпця. +// +// Збіг рахується й за префіксом: у трапі приходить ifOperStatus.7 — +// конкретний порт, — а в умові людина пише ifOperStatus без індексу, бо +// індексу наперед не знає. Вимагати повного збігу означало б, що умова +// «ifOperStatus = down» працює рівно для сьомого порту. +func varbindIs(got, want string) bool { + return got == want || strings.HasPrefix(got, want+".") +} + +// ipMatches — чи належить адреса відправника тому, що написано в умові. +func ipMatches(pattern, ip string) bool { + addr := net.ParseIP(ip) + if addr == nil { + return false + } + if _, netw, err := net.ParseCIDR(pattern); err == nil { + return netw.Contains(addr) + } + return net.ParseIP(pattern).Equal(addr) +} + +// trapDetail добирає з varbind-ів те, що варто показати в тексті. +// +// Не всі підряд: у повідомленні алерту (а звідти — у Telegram) десяток +// OID-ів займе весь екран і не пояснить нічого. Беремо ті, у яких є +// людська назва, — тобто ті, які словник упізнав. Решта лежить у +// контексті алерту й на сторінці трапів. +func trapDetail(view *tenantView, ev TrapEvent) string { + var parts []string + for _, vb := range ev.Varbinds { + name := store.ResolveVarbindOID(view.trapNames, vb.OID) + if name == "" || name == "sysUpTime" || name == "snmpTrapOID" { + continue + } + val := vb.Value + if lbl := store.DescribeVarbindValue(vb.OID, vb.Value); lbl != "" { + val = lbl + } + parts = append(parts, name+"="+val) + if len(parts) == 4 { + break + } + } + return strings.Join(parts, ", ") +} + +// trapVarbindMeta кладе varbind-и в контекст алерту. +func trapVarbindMeta(view *tenantView, ev TrapEvent) []map[string]string { + out := make([]map[string]string, 0, len(ev.Varbinds)) + for _, vb := range ev.Varbinds { + m := map[string]string{"oid": vb.OID, "value": vb.Value} + if name := store.ResolveVarbindOID(view.trapNames, vb.OID); name != "" { + m["name"] = name + } + out = append(out, m) + } + return out +} + +// --------------------------------------------------------------------- +// Спільне +// --------------------------------------------------------------------- + +// raise доводить один збіг до алерту. +func (s *EventSink) raise(ctx context.Context, tenantID string, view *tenantView, + c compiledRule, deviceID, message string, count int, meta map[string]any) { + + s.raiseKeyed(ctx, tenantID, view, c, + store.EventDedupKey(c.rule.ID, deviceID), deviceID, "", message, count, meta) +} + +// raiseKeyed — те саме, але з явним ключем дедуплікації й підписом. +// +// Знадобилось рівно одному джерелу — трапам. Усі інші події приходять +// від хоста, і хост дає і ключ, і назву в заголовку. Трап приходить від +// АДРЕСИ, і адреса не завжди є хостом: саме такі трапи найцікавіші +// (у мережі з'явилось щось, чого інвентар не знає), і зводити їх усі до +// одного безіменного алерту означало б показати «щось десь сталося». +func (s *EventSink) raiseKeyed(ctx context.Context, tenantID string, view *tenantView, + c compiledRule, key, deviceID, label, message string, count int, meta map[string]any) { + + allowed, carried := s.throttle(key, c.rule.MinIntervalSeconds, count) + if !allowed { + return + } + + name := view.devices[deviceID] + if name == "" { + name = label + } + if name == "" { + name = deviceID + } + meta["events"] = carried + ctxJSON, err := json.Marshal(meta) + if err != nil { + ctxJSON = []byte("{}") + } + + fired, err := s.st.RaiseEventAlert(ctx, tenantID, store.EventAlert{ + RuleID: c.rule.ID, + DeviceID: deviceID, + DeviceName: name, + Severity: c.rule.Severity, + Title: name + ": " + c.rule.Name, + Message: message, + DedupKey: key, + Context: ctxJSON, + Count: carried, + SuppressedBy: view.sup.For(deviceID, c.rule.ID), + }) + if err != nil { + s.log.Error("подієвий алерт", "правило", c.rule.Name, "помилка", err) + return + } + if !fired.IsNew { + // Продовження вже відомої події не показуємо окремо: лічильник + // у самому алерті вже виріс, а список алертів перечитується за + // подією `alert.fired`, якої тут навмисно немає. + return + } + + if err := s.st.PublishEvent(ctx, tenantID, "alert.fired", map[string]any{ + "alert_id": fired.ID, "device_id": deviceID, "severity": fired.Severity, + "title": fired.Title, "state": fired.State, "suppressed_by": fired.SuppressedBy, + }); err != nil { + s.log.Error("подія alert.fired", "помилка", err) + } +} + +// throttle вирішує, чи йти в базу зараз. +// +// Обмежувач у пам'яті, а не в SQL, бо захищати треба саме звернення до +// бази: у потоці журналу дорогим є не сам UPSERT, а те, що їх сотня на +// секунду з кожного зонда. Кілька процесів матимуть кожен свій +// обмежувач — і це нормально: остаточну дедуплікацію все одно робить +// унікальний індекс, а тут йдеться лише про кількість спроб. +// +// Повертає, скільки подій слід записати: власні плюс усі, що набігли, +// поки проміжок не минув. +func (s *EventSink) throttle(key string, minInterval, count int) (bool, int) { + if minInterval <= 0 { + return true, count + } + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.rate[key] + if !ok { + e = &rateEntry{} + s.rate[key] = e + } + e.touched = now + if ok && now.Sub(e.last) < time.Duration(minInterval)*time.Second { + e.carried += count + return false, 0 + } + e.last = now + total := e.carried + count + e.carried = 0 + return true, total +} + +// view віддає стан кабінету з кешу, оновлюючи його за потреби. +func (s *EventSink) view(ctx context.Context, tenantID string) *tenantView { + now := time.Now() + + s.mu.Lock() + v, ok := s.cache[tenantID] + s.mu.Unlock() + if ok && now.Sub(v.at) < s.ttl { + return v + } + + rules, err := s.st.EventRules(ctx, tenantID) + if err != nil { + s.log.Error("читання подієвих правил", "tenant", tenantID, "помилка", err) + // Стухлий кеш кращий за жодного: правила рідко міняються, а + // перебій у базі не має вимикати алерти на весь час перебою. + return v + } + if len(rules) == 0 { + fresh := &tenantView{at: now, devices: map[string]string{}} + s.remember(tenantID, fresh) + return fresh + } + + devices, err := s.st.DeviceNames(ctx, tenantID) + if err != nil { + s.log.Error("читання хостів", "tenant", tenantID, "помилка", err) + return v + } + sup, err := s.st.LoadSuppression(ctx, tenantID) + if err != nil { + // Не привід не піднімати алерти: гірше показати те, про що + // просили не турбувати, ніж проґавити справжню подію. + s.log.Warn("вікна обслуговування", "tenant", tenantID, "помилка", err) + } + + fresh := &tenantView{at: now, devices: devices, sup: sup} + if hasSource(rules, "trap") { + // Помилка тут не має вимикати правила: без словника трап + // підпаде під умову так само (умова написана OID-ом), просто в + // заголовку алерту стоятиме число замість назви. Зворотний + // розмін — тиша замість неідеального тексту — був би гіршим. + names, err := s.st.TrapNames(ctx, tenantID) + if err != nil { + s.log.Warn("словник трапів", "tenant", tenantID, "помилка", err) + } + fresh.trapNames = names + } + for _, r := range rules { + c := compiledRule{rule: r} + if r.Source == "syslog" { + re, err := regexp.Compile(r.Condition.Regex) + if err != nil { + // Зразок перевіряється при збереженні, тож сюди можна + // дістатись лише правкою в обхід API. Мовчати не можна: + // правило виглядає ввімкненим. + s.log.Error("зразок правила не компілюється", + "правило", r.Name, "помилка", err) + continue + } + c.re = re + } + if !emptySelector(r.Selector) { + ids, err := s.st.SelectorDevices(ctx, tenantID, r.Selector) + if err != nil { + s.log.Error("розгортання селектора", "правило", r.Name, "помилка", err) + continue + } + c.scope = make(map[string]bool, len(ids)) + for _, id := range ids { + c.scope[id] = true + } + } + fresh.rules = append(fresh.rules, c) + } + + s.remember(tenantID, fresh) + return fresh +} + +func (s *EventSink) remember(tenantID string, v *tenantView) { + s.mu.Lock() + defer s.mu.Unlock() + s.cache[tenantID] = v + + // Обмежувач частоти тримає по рядку на кожен алерт, який колись + // піднімався. Без прибирання це повільний витік у процесі, що + // живе місяцями; година тиші означає, що алерт давно закритий. + cutoff := time.Now().Add(-time.Hour) + for k, e := range s.rate { + if e.touched.Before(cutoff) { + delete(s.rate, k) + } + } +} + +func hasSource(rules []store.Rule, source string) bool { + for _, r := range rules { + if r.Source == source { + return true + } + } + return false +} + +func emptySelector(s store.Selector) bool { + return len(s.DeviceIDs) == 0 && len(s.GroupIDs) == 0 && len(s.SiteIDs) == 0 && + len(s.Kinds) == 0 && len(s.TemplateIDs) == 0 && len(s.Vendors) == 0 && + len(s.Tags) == 0 +} + +// trimLine готує текст події до показу людині. +// +// Рядок журналу буває довжиною в кілограм: у заголовку алерту й у +// повідомленні в Telegram від цього немає користі, а є втрата решти +// тексту. +func trimLine(s string) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + const max = 300 + if len(s) <= max { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} + +func orDefault(s, def string) string { + if s == "" { + return def + } + return s +} diff --git a/server/internal/alerting/events_test.go b/server/internal/alerting/events_test.go new file mode 100644 index 0000000..fd539ef --- /dev/null +++ b/server/internal/alerting/events_test.go @@ -0,0 +1,113 @@ +package alerting + +import ( + "strings" + "testing" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Головне, заради чого писався обмежувач: потік журналу не має +// перетворюватись на потік звернень до бази. +func TestThrottleCollapsesFlood(t *testing.T) { + s := NewEventSink(nil, nil) + + ok, n := s.throttle("r:dev:d1", 60, 1) + if !ok || n != 1 { + t.Fatalf("перша подія має пройти з лічильником 1, отримали %v/%d", ok, n) + } + for i := 0; i < 500; i++ { + if ok, _ := s.throttle("r:dev:d1", 60, 1); ok { + t.Fatal("подія в межах проміжку не має йти в базу") + } + } + + // Проміжок минув — накопичене доливається одним зверненням, а не + // губиться: людині важливо не кожне повідомлення окремо, а те, що + // їх було п'ятсот. + s.rate["r:dev:d1"].last = time.Now().Add(-2 * time.Minute) + ok, n = s.throttle("r:dev:d1", 60, 1) + if !ok { + t.Fatal("після проміжку подія має пройти") + } + if n != 501 { + t.Fatalf("накопичене мало долитись: очікували 501, отримали %d", n) + } +} + +// Різні хости — різні алерти, і обмежувач одного не має глушити інший. +func TestThrottleIsPerAlert(t *testing.T) { + s := NewEventSink(nil, nil) + if ok, _ := s.throttle("r:dev:d1", 60, 1); !ok { + t.Fatal("перша подія хоста d1") + } + if ok, _ := s.throttle("r:dev:d2", 60, 1); !ok { + t.Fatal("хост d2 не має залежати від d1") + } +} + +// Нульовий проміжок означає «без обмежень»: правило про рідкісну подію +// не має чекати хвилину, щоб про неї сказати. +func TestThrottleZeroIntervalAlwaysPasses(t *testing.T) { + s := NewEventSink(nil, nil) + for i := 0; i < 5; i++ { + if ok, n := s.throttle("k", 0, 3); !ok || n != 3 { + t.Fatalf("без проміжку має проходити все, отримали %v/%d", ok, n) + } + } +} + +// Порожній селектор — «усі хости». Це найчастіший випадок, і саме він +// має працювати без перетворення на перелік. +func TestScopeCoversEverythingWhenEmpty(t *testing.T) { + if !(compiledRule{}).covers("будь-який") { + t.Fatal("правило без селектора має накривати всіх") + } + c := compiledRule{scope: map[string]bool{"d1": true}} + if !c.covers("d1") || c.covers("d2") { + t.Fatal("правило із селектором має накривати лише своїх") + } +} + +// Тригер без переліку правил відповідності ловить будь-яке порушення: +// стандарт, доданий завтра, має підпасти під нього сам. +func TestComplianceMatchDefaultsToEverything(t *testing.T) { + ev := ComplianceEvent{RuleID: "c1", Severity: "warning"} + if !matchesComplianceRule(store.Condition{}, ev) { + t.Fatal("порожня умова має ловити будь-яку знахідку") + } +} + +func TestComplianceMatchNarrowsByRuleAndSeverity(t *testing.T) { + ev := ComplianceEvent{RuleID: "c1", Severity: "warning"} + + if matchesComplianceRule(store.Condition{RuleIDs: []string{"c2"}}, ev) { + t.Error("чуже правило відповідності не має підпадати") + } + if !matchesComplianceRule(store.Condition{RuleIDs: []string{"c1", "c2"}}, ev) { + t.Error("своє правило має підпадати") + } + if matchesComplianceRule(store.Condition{MinSeverity: "high"}, ev) { + t.Error("знахідка нижче порога не має підпадати") + } + if !matchesComplianceRule(store.Condition{MinSeverity: "info"}, ev) { + t.Error("знахідка вище порога має підпадати") + } +} + +// Рядок журналу буває довжиною в кілограм. У заголовку алерту від +// цього немає користі, а є втрата решти тексту. +func TestTrimLineFlattensAndCuts(t *testing.T) { + if got := trimLine(" два\nрядки "); got != "два рядки" { + t.Errorf("отримали %q", got) + } + long := strings.Repeat("я", 500) + got := trimLine(long) + if !strings.HasSuffix(got, "…") { + t.Error("надто довгий рядок має бути обрізаний із багатокрапкою") + } + if len([]rune(got)) != 301 { + t.Errorf("очікували 300 символів плюс багатокрапка, отримали %d", len([]rune(got))) + } +} diff --git a/server/internal/alerting/notify.go b/server/internal/alerting/notify.go index 83fee78..1ff2766 100644 --- a/server/internal/alerting/notify.go +++ b/server/internal/alerting/notify.go @@ -74,18 +74,85 @@ func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store actions = map[string]store.RuleAction{} } + // Політики ескалації читаються раз на партію, а не раз на алерт: під + // час масової аварії партія — це сотні алертів на десяток правил, і + // драбина в них та сама. + // + // Помилка тут не має зупиняти доставку: без ескалації сповіщення все + // одно піде, а от навпаки — ні. + policies := map[string]store.EscalationPolicy{} + if ps, err := n.st.ListEscalationPolicies(ctx, tenantID); err != nil { + n.log.Error("читання політик ескалації", "tenant", tenantID, "помилка", err) + } else { + for _, p := range ps { + policies[p.ID] = p + } + } + now := time.Now() for _, a := range alerts { act, hasAct := actions[a.RuleID] if hasAct && a.State == "resolved" && !act.NotifyOnResolve { continue } - for _, c := range n.targets(a, act, hasAct, routes, channels, byID, now, tenantID) { + targets := n.targets(a, act, hasAct, routes, channels, byID, now, tenantID) + for _, c := range targets { n.send(ctx, tenantID, a, c) } + n.armEscalation(ctx, tenantID, a, act, hasAct, policies, len(targets)) } } +// armEscalation ставить драбину на бойовий звід — або свідомо не ставить. +// +// Три умови, і кожна з них — окрема відповідь на «чому мене розбудили» +// (та на дзеркальне «чому не розбудили»): +// +// - Драбина взводиться лише для 'firing'. Придушений алерт — це «не +// турбувати», а сповіщення про відновлення ескалювати нема сенсу: +// воно вже добра новина. +// +// - Драбина взводиться лише тоді, коли перше сповіщення справді мало +// куди піти. Якщо жодного каналу не знайшлось — вимкнені канали, +// поріг серйозності, тиха година правила — то ескалація о 15-й +// хвилині надіслала б те, що на нульовій ми свідомо не надіслали. +// Це виглядало б як несправність, і саме так і було б. +// +// - Політику називає саме правило. Немає політики — немає драбини, і +// це типовий стан: після оновлення жоден кабінет не має раптом +// почати будити людей. +func (n *Notifier) armEscalation(ctx context.Context, tenantID string, a store.Alert, + act store.RuleAction, hasAct bool, policies map[string]store.EscalationPolicy, sent int) { + + if !shouldArmEscalation(a, act, hasAct, sent) { + return + } + p, ok := policies[act.EscalationPolicyID] + if !ok || len(p.Steps) == 0 { + // Правило посилається на політику, якої вже немає (або в неї + // немає жодної сходинки). Мовчати про це не можна: людина + // лишила галочку в формі й вважає, що її прикриють. + n.log.Warn("правило посилається на порожню або зниклу політику ескалації", + "tenant", tenantID, "правило", a.RuleName, "політика", act.EscalationPolicyID) + return + } + if err := n.st.ArmEscalation(ctx, tenantID, a.ID, p.ID, + store.IsEventSource(act.Source), p, a.StartedAt); err != nil { + n.log.Error("взведення ескалації", "алерт", a.ID, "помилка", err) + } +} + +// shouldArmEscalation — сам вирішувач, окремо від запису в базу. +// +// Виділено в чисту функцію навмисно: усі три його «ні» коштують чийогось +// сну, а перевірити їх інакше можна лише піднявши базу. Тест на «драбину +// взведено» доводить мало; тести на «драбину НЕ взведено, бо каналів не +// знайшлось / алерт придушено / політики немає» — це і є те, заради чого +// функція існує. +func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, sent int) bool { + return a.State == "firing" && sent > 0 && hasAct && act.EscalationPolicyID != "" +} + // targets вирішує, у які канали піде цей алерт. // // Порядок вирішення: канали самого правила → маршрути тенанта → усі @@ -159,7 +226,15 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool, } func (n *Notifier) send(ctx context.Context, tenantID string, a store.Alert, c store.Channel) { - text := renderMessage(a, c) + n.sendText(ctx, tenantID, a, c, renderMessage(a, c)) +} + +// sendText відокремлено від send, бо ескалація шле той самий алерт із +// іншою шапкою: людина, розбуджена третьою сходинкою, має з першого +// рядка бачити, що це вже ескалація, а не перше сповіщення, яке вона +// проспала. +func (n *Notifier) sendText(ctx context.Context, tenantID string, a store.Alert, + c store.Channel, text string) { var extID string var err error @@ -274,13 +349,28 @@ func (n *Notifier) sendTelegram(ctx context.Context, c store.Channel, a store.Al if cfg.ThreadID != 0 { body["message_thread_id"] = cfg.ThreadID } - // Кнопки ведуть на веб: підтвердити алерт із телефона — головний - // сценарій чергового, і зайвий перехід у браузер тут коштує хвилин. - body["reply_markup"] = map[string]any{ - "inline_keyboard": [][]map[string]string{{ + // Кнопки: підтвердити алерт із телефона — головний сценарій + // чергового, і зайвий перехід у браузер тут коштує хвилин. + // Натискання приймає telegram_bot.go; те, що лежить у callback_data, + // нічого не авторизує — кабінет і особа з'ясовуються там із чату й + // прив'язки, а не з цього рядка. + // + // Кнопки немає в пробного повідомлення (TestChannel): алерту не + // існує, і «Підтвердити» показувало б дію, якої не буде. + if a.ID != "" { + row := []map[string]string{ {"text": "Підтвердити", "callback_data": "ack:" + a.ID}, - {"text": "Заглушити 1 год", "callback_data": "mute:" + a.DeviceID}, - }}, + } + // «Заглушити» без хоста глушити нічого. Алерт без пристрою — + // звичайна річ (зонд не на зв'язку, черга не встигає), і досі + // така кнопка малювалась усе одно: у callback_data їхало голе + // «mute:», а натискання не могло дати нічого, крім відмови. + if a.DeviceID != "" { + row = append(row, map[string]string{ + "text": "Заглушити 1 год", "callback_data": "mute:" + a.DeviceID, + }) + } + body["reply_markup"] = map[string]any{"inline_keyboard": [][]map[string]string{row}} } payload, err := json.Marshal(body) diff --git a/server/internal/alerting/telegram_bot.go b/server/internal/alerting/telegram_bot.go new file mode 100644 index 0000000..61e6bf7 --- /dev/null +++ b/server/internal/alerting/telegram_bot.go @@ -0,0 +1,662 @@ +package alerting + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Приймач натискань кнопок Telegram. +// +// ЧОМУ ДОВГЕ ОПИТУВАННЯ, А НЕ ВЕБХУК +// +// Bot API дає два способи отримувати оновлення, і вибір тут зробило +// саме розгортання, а не смак. +// +// Вебхук вимагає, щоб Telegram МІГ ДО НАС ДОСТУКАТИСЬ: публічний +// порт із переліку 443/80/88/8443 і TLS-сертифікат, якому довіряє +// їхній бік. Самопідписаний приймається лише як завантажений у +// setWebhook файл, і навіть тоді потрібне ім'я, на яке він виданий. +// Наш стенд — самопідписаний TLS на голій IP-адресі без домену. Це не +// «поки не налаштували», а стан, у якому продукт живе: self-hosted +// інсталяція в мережі оператора зазвичай узагалі не має входу ззовні. +// Вебхук там не запрацює ніколи, і код, написаний під нього, був би +// кодом, який не працює в жодній наявній інсталяції. +// +// Довге опитування не вимагає від нас ані вхідного порту, ані імені, +// ані сертифіката: з'єднання ініціює сервер, TLS перевіряється в бік +// api.telegram.org, тобто в той бік, де сертифікат справжній. Ціна — +// одне висяче HTTP-з'єднання на бота й курсор у базі (0061). +// +// Секретний токен у заголовку X-Telegram-Bot-Api-Secret-Token — це +// захист вебхука від сторонніх POST-ів на наш відкритий шлях. Тут +// відкритого шляху немає взагалі: приймати нема чого, ми самі ходимо +// по оновлення й показуємо в URL токен бота. Отвору, який той заголовок +// затуляє, у цій схемі не існує. +// +// Якщо колись з'явиться домен і справжній сертифікат, вебхук стане +// кращим (менше з'єднань, менша затримка) — і перевірка натискання +// (chatMatch → прив'язка → права → дія) переїде в нього без змін: вона +// навмисно не знає, звідки прийшло оновлення. + +// Bot читає оновлення ботів усіх кабінетів і виконує натиснуте. +type Bot struct { + st *store.Store + ring *crypto.Keyring + log *slog.Logger + + // hc — для довгого опитування. Таймаут свідомо більший за + // pollTimeout: getUpdates мовчить рівно стільки, скільки просили, + // і клієнт, який рветься раніше, перетворював би штатне очікування + // на потік помилок. + hc *http.Client + // action — для коротких викликів (відповідь на натискання, + // редагування повідомлення). Окремий клієнт, бо чекати на них 40 + // секунд немає жодного сенсу. + action *http.Client + + pollTimeout time.Duration +} + +// NewBot створює приймач. ring обов'язковий: без ключів шифрування +// токени ботів не розшифрувати, а отже й опитувати нікого. +func NewBot(st *store.Store, ring *crypto.Keyring, log *slog.Logger) *Bot { + poll := 25 * time.Second + return &Bot{ + st: st, + ring: ring, + log: log.With("component", "telegram"), + hc: &http.Client{Timeout: poll + 15*time.Second}, + action: &http.Client{Timeout: 15 * time.Second}, + pollTimeout: poll, + } +} + +// telegramLockKey — довільна стала, аби її не займав ніхто інший у цій +// же базі. Сусідня з ключем движка алертів (див. engine.go). +const telegramLockKey = 0x6e70_7467 // "nptg" + +// Run тримає опитування до скасування контексту. +// +// Advisory-блокування береться на ВЕСЬ час роботи, а не на такт, як у +// движка алертів. Причина в тому, що getUpdates ексклюзивний: вибране +// оновлення другому читачеві вже не дістанеться, і два процеси на +// одному боті ділили б натискання між собою навпіл. Блокування живе +// разом із з'єднанням, тож падіння процесу звільняє його само — сусід +// підхопить опитування за пів хвилини. +func (b *Bot) Run(ctx context.Context) { + if b.ring == nil { + b.log.Info("приймач Telegram вимкнено: немає ключів шифрування") + return + } + b.log.Info("приймач натискань Telegram запущено") + + for ctx.Err() == nil { + conn, held := b.acquire(ctx) + if !held { + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + continue + } + } + b.serve(ctx, conn) + + // context.WithoutCancel: під час зупинки контекст уже мертвий, + // а блокування зняти все одно треба — інакше сусідній процес + // чекатиме на нього до розриву з'єднання. + _, _ = conn.Exec(context.WithoutCancel(ctx), + `SELECT pg_advisory_unlock($1)`, int64(telegramLockKey)) + conn.Release() + } +} + +func (b *Bot) acquire(ctx context.Context) (*pgxpool.Conn, bool) { + // WorkerPool, а не Pool: блокування має жити стільки ж, скільки + // саме опитування, а опитування за побудовою ходить поверх усіх + // кабінетів — це та сама роль, що й у решти фонових тактів. + conn, err := b.st.WorkerPool().Acquire(ctx) + if err != nil { + return nil, false + } + var got bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, + int64(telegramLockKey)).Scan(&got); err != nil || !got { + conn.Release() + return nil, false + } + return conn, true +} + +// serve крутить такти, доки тримається блокування. +func (b *Bot) serve(ctx context.Context, conn *pgxpool.Conn) { + for ctx.Err() == nil { + // Перелік ботів перечитується щотакту. Такт — це майже завжди + // очікування на getUpdates, тобто раз на ~25 секунд, і за цю + // ціну щойно доданий канал починає слухати кнопки сам, без + // перезапуску процесу. + groups, err := b.groups(ctx) + if err != nil { + b.log.Error("читання каналів Telegram", "помилка", err) + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + continue + } + } + if len(groups) == 0 { + // Жодного telegram-каналу: спати довше, ніж такт опитування. + select { + case <-ctx.Done(): + return + case <-time.After(60 * time.Second): + continue + } + } + + var wg sync.WaitGroup + for _, g := range groups { + wg.Add(1) + go func(g botGroup) { + defer wg.Done() + b.pollOnce(ctx, g) + }(g) + } + wg.Wait() + + // Живе з'єднання — доказ, що блокування ще наше. Мертве означає, + // що Postgres його вже зняв і опитувати далі не можна: сусідній + // процес міг узяти бота собі. + if err := conn.Ping(ctx); err != nil { + b.log.Warn("з'єднання з блокуванням втрачено", "помилка", err) + return + } + } +} + +// botGroup — один бот і всі канали, які через нього шлють. +// +// Групування саме за токеном, а не за каналом: один бот цілком може +// обслуговувати кілька чатів і навіть кілька кабінетів, а getUpdates +// у нього одна черга на всіх. +type botGroup struct { + token string + hash []byte + chans []store.Channel +} + +func (b *Bot) groups(ctx context.Context) ([]botGroup, error) { + channels, err := b.st.TelegramChannels(ctx, b.ring) + if err != nil { + return nil, err + } + byToken := map[string]*botGroup{} + var out []botGroup + for _, c := range channels { + if c.Secret == "" { + continue + } + g, ok := byToken[c.Secret] + if !ok { + sum := sha256.Sum256([]byte(c.Secret)) + g = &botGroup{token: c.Secret, hash: sum[:]} + byToken[c.Secret] = g + } + g.chans = append(g.chans, c) + } + for _, g := range byToken { + out = append(out, *g) + } + return out, nil +} + +// pollOnce робить один getUpdates і обробляє все, що прийшло. +func (b *Bot) pollOnce(ctx context.Context, g botGroup) { + offset, err := b.st.TelegramCursor(ctx, g.hash) + if err != nil { + b.log.Error("читання курсора", "помилка", err) + return + } + + updates, err := b.getUpdates(ctx, g, offset) + if err != nil { + if ctx.Err() != nil { + return + } + b.log.Warn("getUpdates", "бот", g.chans[0].Name, "помилка", err) + // Пауза після помилки: без неї недоступний api.telegram.org + // перетворював би такт на щільний цикл запитів. + select { + case <-ctx.Done(): + case <-time.After(10 * time.Second): + } + return + } + + var next int64 + for _, u := range updates { + if u.UpdateID >= next { + next = u.UpdateID + 1 + } + b.handle(ctx, g, u) + } + if next == 0 { + return + } + // Курсор посувається НЕЗАЛЕЖНО від того, чи вдалася сама дія. + // Оновлення, на якому обробник спіткнувся, інакше приходило б знову + // й знову, і одна крива кнопка глушила б усі наступні назавжди. + // Людина при цьому не лишається без відповіді: невдача їй сказана + // текстом у answerCallbackQuery. + if err := b.st.SaveTelegramCursor(context.WithoutCancel(ctx), g.hash, next); err != nil { + b.log.Error("збереження курсора", "помилка", err) + } +} + +func (b *Bot) getUpdates(ctx context.Context, g botGroup, offset int64) ([]tgUpdate, error) { + body := map[string]any{ + "timeout": int(b.pollTimeout.Seconds()), + // Просимо рівно ті два типи, які вміємо: натискання кнопок і + // повідомлення з командою прив'язки. Решта (правки, реакції, + // вступи в чат) не має навіть потрапляти в чергу — вона займала + // б місце й змушувала б нас її вичитувати. + "allowed_updates": []string{"callback_query", "message"}, + } + if offset > 0 { + body["offset"] = offset + } + + var out struct { + OK bool `json:"ok"` + Description string `json:"description"` + Result []tgUpdate `json:"result"` + } + if err := b.call(ctx, b.hc, g.token, "getUpdates", body, &out); err != nil { + return nil, err + } + if !out.OK { + return nil, fmt.Errorf("%s", out.Description) + } + return out.Result, nil +} + +// handle розводить оновлення по обробниках. +func (b *Bot) handle(ctx context.Context, g botGroup, u tgUpdate) { + switch { + case u.CallbackQuery != nil: + b.handleCallback(ctx, g, u.CallbackQuery) + case u.Message != nil: + b.handleMessage(ctx, g, u.Message) + } +} + +// --------------------------------------------------------------------- +// Натискання кнопки +// --------------------------------------------------------------------- + +// handleCallback виконує натиснуте. +// +// Порядок перевірок навмисно такий: спершу «чий це чат» (звідси +// кабінет), потім «що просять» (розбір callback_data), потім «хто саме +// натиснув» (прив'язка), потім «чи можна йому» (права й доступ до +// хоста) — і лише тоді дія. Жоден крок не бере кабінет чи особу з +// вмісту кнопки: підробити її може будь-хто, хто бачив формат. +func (b *Bot) handleCallback(ctx context.Context, g botGroup, cq *tgCallbackQuery) { + answer := "Не вдалося обробити" + + // Відповідь на натискання обов'язкова й безумовна. Доки її немає, + // Telegram крутить на кнопці годинник — і людина бачить не + // «відмовлено», а «зламалось». Тому вона в defer, а не в кінці + // щасливого шляху, і йде з власним контекстом: під час зупинки + // процесу натискання все одно має отримати відповідь. + defer func() { + ansCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := b.answerCallback(ansCtx, g.token, cq.ID, answer); err != nil { + b.log.Warn("answerCallbackQuery", "помилка", err) + } + }() + + if cq.Message == nil { + answer = "Повідомлення застаріле — відкрийте алерт у NetPulse" + return + } + + ch, ok := matchChannel(g.chans, cq.Message.Chat.ID, cq.Message.ThreadID) + if !ok { + // Бот стоїть у чаті, якого немає в жодному каналі. Кабінет із + // такого натискання не виводиться ніяк, і вгадувати його за + // вмістом кнопки — рівно те, чого робити не можна. + answer = "Цей чат не налаштовано в NetPulse" + b.log.Warn("натискання з невідомого чату", "chat", cq.Message.Chat.ID) + return + } + + act, err := parseCallbackData(cq.Data) + if err != nil { + answer = "Кнопка застаріла або невідома" + b.log.Warn("розбір callback_data", "канал", ch.Name, "помилка", err) + return + } + + acc, err := b.st.TelegramAccountByTgID(ctx, ch.TenantID, cq.From.ID) + if errors.Is(err, store.ErrTelegramNotLinked) { + answer = "Ваш Telegram не прив'язано до NetPulse.\n" + + "Профіль → Telegram → отримати код, потім надішліть боту /link КОД" + return + } + if err != nil { + b.log.Error("пошук прив'язки", "канал", ch.Name, "помилка", err) + return + } + + perms, err := b.st.UserPermissions(ctx, acc.UserID, ch.TenantID) + if err != nil { + b.log.Error("права користувача", "помилка", err) + return + } + if !hasPerm(perms, "alerts:ack") { + // Порожній набір прав означає ще й відкликане членство: людину + // прибрали з кабінету, а прив'язка лишилась. Відповідь однакова + // навмисно — з боку Telegram це та сама відмова. + answer = "Немає права підтверджувати алерти" + return + } + + sc, err := b.st.LoadScope(ctx, ch.TenantID, acc.UserID) + if err != nil { + b.log.Error("доступ до хостів", "помилка", err) + return + } + + var line string + switch act.Kind { + case "ack": + answer, line = b.doAck(ctx, ch, acc, sc, act.ID) + case "mute": + answer, line = b.doMute(ctx, ch, acc, sc, act.ID) + } + if line == "" { + return + } + + _ = b.st.TouchTelegramAccount(ctx, ch.TenantID, acc.ID) + + // Правка самого повідомлення — не прикраса. answerCallbackQuery + // показує спливаючий рядок на кілька секунд і тому, хто натиснув; + // у чат він не потрапляє, а чат читає вся зміна. Без правки + // повідомлення про аварію так і лишається з живими кнопками, і + // наступний черговий натискає їх ще раз. + if err := b.editMessage(ctx, g.token, cq.Message, line); err != nil { + b.log.Warn("правка повідомлення", "помилка", err) + } +} + +// doAck підтверджує алерт від імені прив'язаного користувача. +// +// Викликає той самий store.AckAlert, що й POST /api/v1/alerts/{id}/ack: +// підтвердження з телефона й підтвердження з браузера мають лишати в +// базі однаковий слід, а власна копія логіки розійшлася б із оригіналом +// на першій же зміні — і розбіжність побачили б не тут, а в звіті. +func (b *Bot) doAck(ctx context.Context, ch store.Channel, acc store.TelegramAccount, + sc store.Scope, alertID string) (answer, line string) { + + cur, err := b.st.AlertAckState(ctx, ch.TenantID, alertID) + if errors.Is(err, store.ErrAlertNotFound) { + // Кабінет узято з чату, тож «не знайдено» тут означає саме + // «немає в цьому кабінеті» — зокрема й тоді, коли алерт із + // таким id є в чужому. + return "Алерт не знайдено", "" + } + if err != nil { + b.log.Error("читання алерту", "помилка", err) + return "Не вдалося прочитати алерт", "" + } + if cur.DeviceID != "" && !sc.CanWrite(cur.DeviceID) { + return "Немає доступу до цього хоста", "" + } + + // Ідемпотентність. Друге натискання не має ні падати помилкою, ні + // переписувати автора: перший, хто взявся, лишається першим. + if cur.State == "acknowledged" { + who := cur.AckedByEmail + if who == "" { + who = "невідомо ким" + } + at := time.Now() + if cur.AckedAt != nil { + at = *cur.AckedAt + } + return "Уже підтверджено: " + who, ackLine(who, at) + } + if cur.State == "resolved" || cur.State == "expired" { + return "Алерт уже закрито", "" + } + + a, err := b.st.AckAlert(ctx, ch.TenantID, alertID, acc.UserID, "підтверджено з Telegram") + if errors.Is(err, store.ErrAlertNotFound) { + // Хтось встиг підтвердити між читанням і записом — для людини + // це той самий результат, що й гілка вище. + return "Уже підтверджено", "" + } + if err != nil { + b.log.Error("підтвердження алерту", "помилка", err) + return "Не вдалося підтвердити", "" + } + + at := time.Now() + if a.AckedAt != nil { + at = *a.AckedAt + } + return "Підтверджено", ackLine(acc.Email, at) +} + +// doMute глушить хост на годину — тією ж дією, що й POST /api/v1/mutes. +func (b *Bot) doMute(ctx context.Context, ch store.Channel, acc store.TelegramAccount, + sc store.Scope, deviceID string) (answer, line string) { + + name, err := b.st.DeviceNameInTenant(ctx, ch.TenantID, deviceID) + if errors.Is(err, store.ErrNotFound) { + return "Хост не знайдено", "" + } + if err != nil { + b.log.Error("пошук хоста", "помилка", err) + return "Не вдалося знайти хост", "" + } + if !sc.CanWrite(deviceID) { + return "Немає доступу до хоста " + name, "" + } + + // Ідемпотентність: уже заглушений хост не глушиться вдруге. + // Інакше подвійне натискання мовчки подвоювало б час тиші, і + // дізнались би про це аж тоді, коли алерт не прийшов. + if until, muted, err := b.st.ActiveMute(ctx, ch.TenantID, deviceID); err == nil && muted { + return "Уже заглушено до " + until.In(tgLocation).Format("15:04"), + muteLine(acc.Email, until) + } + + until := time.Now().Add(time.Hour) + if max := time.Now().Add(store.MaxMute); until.After(max) { + until = max + } + if err := b.st.MuteDevice(ctx, ch.TenantID, deviceID, acc.UserID, + "заглушено з Telegram", until); err != nil { + b.log.Error("заглушення хоста", "помилка", err) + return "Не вдалося заглушити", "" + } + return "Заглушено до " + until.In(tgLocation).Format("15:04"), muteLine(acc.Email, until) +} + +func hasPerm(perms []string, want string) bool { + for _, p := range perms { + if p == want || p == "*" { + return true + } + } + return false +} + +// --------------------------------------------------------------------- +// Прив'язка акаунта +// --------------------------------------------------------------------- + +// handleMessage відповідає лише на дві команди й мовчить на решту. +// +// Бот часто стоїть у робочому груповому чаті. Відповідь на кожне +// повідомлення зробила б його джерелом шуму — і першою реакцією +// команди стало б вимкнути сповіщення того чату, тобто рівно те, чому +// продукт має запобігати. +func (b *Bot) handleMessage(ctx context.Context, g botGroup, m *tgMessage) { + if m.From == nil || m.From.IsBot { + return + } + code, isLink := parseLinkCommand(m.Text) + if !isLink { + return + } + if code == "" { + b.reply(ctx, g.token, m, "Надішліть код із профілю NetPulse: /link КОД") + return + } + + // Кабінети, яким належить цей бот. Без цього переліку код був би + // універсальним: чинний код кабінету А, надісланий боту кабінету Б, + // прив'язав би людину туди, де її бот навіть не стоїть. + seen := map[string]bool{} + var tenants []string + for _, c := range g.chans { + if !seen[c.TenantID] { + seen[c.TenantID] = true + tenants = append(tenants, c.TenantID) + } + } + + acc, err := b.st.RedeemTelegramLinkCode(ctx, code, tenants, + m.From.ID, m.From.Username, tgDisplayName(*m.From)) + if errors.Is(err, store.ErrTelegramLinkInvalid) { + b.reply(ctx, g.token, m, "Код недійсний, вже використаний або прострочений. "+ + "Візьміть новий у профілі NetPulse.") + return + } + if err != nil { + b.log.Error("прив'язка telegram", "помилка", err) + b.reply(ctx, g.token, m, "Не вдалося прив'язати. Спробуйте пізніше.") + return + } + + _ = b.st.WriteAudit(ctx, acc.TenantID, store.AuditEntry{ + ActorUserID: acc.UserID, + Action: store.AuditActionTelegramLink, + ObjectType: store.AuditObjectTelegram, + ObjectID: acc.ID, + Meta: map[string]any{"tg_user_id": acc.TgUserID, "tg_username": acc.TgUsername}, + }) + + b.reply(ctx, g.token, m, "Готово: кнопки під алертами тепер працюють від вашого імені. "+ + "Повідомлення з кодом можна видалити — код уже зужито.") +} + +// --------------------------------------------------------------------- +// Виклики Bot API +// --------------------------------------------------------------------- + +func (b *Bot) answerCallback(ctx context.Context, token, queryID, text string) error { + // show_alert=false: спливаючий рядок замість вікна з кнопкою «ОК». + // Черговий тримає телефон однією рукою, і зайве підтвердження на + // кожне натискання коштувало б рівно стільки ж, скільки економить + // сама кнопка. + body := map[string]any{"callback_query_id": queryID, "text": text} + return b.call(ctx, b.action, token, "answerCallbackQuery", body, nil) +} + +// editMessage дописує підсумок у повідомлення й прибирає кнопки. +func (b *Bot) editMessage(ctx context.Context, token string, m *tgMessage, line string) error { + text := withStatus(m.Text, line) + if m.Text == "" { + text = line + } + body := map[string]any{ + "chat_id": m.Chat.ID, + "message_id": m.MessageID, + "text": text, + // parse_mode навмисно НЕ задається, хоч надсилали ми з HTML. + // Telegram віддає в message.text уже готовий текст без розмітки, + // і повторна відправка його як HTML або зламалася б на першому + // «<» у назві інтерфейсу, або перетворила б частину тексту + // алерту на теги. + // + // Порожній inline_keyboard замість пропуску поля: так кнопки + // зникають гарантовано, а не за замовчуванням, на яке довелось + // би покладатися. + "reply_markup": map[string]any{"inline_keyboard": [][]any{}}, + } + return b.call(ctx, b.action, token, "editMessageText", body, nil) +} + +func (b *Bot) reply(ctx context.Context, token string, m *tgMessage, text string) { + body := map[string]any{"chat_id": m.Chat.ID, "text": text} + if m.ThreadID != 0 { + body["message_thread_id"] = m.ThreadID + } + if err := b.call(ctx, b.action, token, "sendMessage", body, nil); err != nil { + b.log.Warn("відповідь боту", "помилка", err) + } +} + +// call — один виклик Bot API. +// +// Токен іде в шляху URL (так вимагає Bot API), тому будь-яка помилка +// транспорту проходить через scrubToken: http.Client вкладає в її текст +// повний URL, а в журналі токен бота — це чинний доступ. +func (b *Bot) call(ctx context.Context, hc *http.Client, token, method string, body, out any) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://api.telegram.org/bot"+token+"/"+method, bytes.NewReader(payload)) + if err != nil { + return scrubToken(err, token) + } + req.Header.Set("Content-Type", "application/json") + + res, err := hc.Do(req) + if err != nil { + return scrubToken(err, token) + } + defer res.Body.Close() + + if out == nil { + // Тіло відповіді нікому не потрібне, але прочитати його треба: + // недочитане з'єднання не повертається в keep-alive, а на + // довгому опитуванні це нове TLS-рукостискання щохвилини. + var sink struct { + OK bool `json:"ok"` + Description string `json:"description"` + } + if err := json.NewDecoder(res.Body).Decode(&sink); err != nil { + return fmt.Errorf("%s: відповідь %d нерозбірлива", method, res.StatusCode) + } + if !sink.OK { + return fmt.Errorf("%s: %s", method, sink.Description) + } + return nil + } + if err := json.NewDecoder(res.Body).Decode(out); err != nil { + return fmt.Errorf("%s: відповідь %d нерозбірлива", method, res.StatusCode) + } + return nil +} diff --git a/server/internal/alerting/telegram_callback.go b/server/internal/alerting/telegram_callback.go new file mode 100644 index 0000000..8ac8b01 --- /dev/null +++ b/server/internal/alerting/telegram_callback.go @@ -0,0 +1,263 @@ +package alerting + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Розбір і перевірка натискання кнопки Telegram. +// +// Усе, що тут лежить, навмисно не знає ні про мережу, ні про базу: +// «кому належить цей чат» і «що взагалі натиснули» — рішення, які +// мають перевірятись тестом, а не стендом із живим ботом. + +// --------------------------------------------------------------------- +// Те, що приходить від Bot API +// --------------------------------------------------------------------- + +type tgUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + IsBot bool `json:"is_bot"` +} + +type tgChat struct { + ID int64 `json:"id"` + Type string `json:"type"` +} + +type tgMessage struct { + MessageID int64 `json:"message_id"` + Chat tgChat `json:"chat"` + From *tgUser `json:"from"` + Text string `json:"text"` + ThreadID int `json:"message_thread_id"` +} + +type tgCallbackQuery struct { + ID string `json:"id"` + From tgUser `json:"from"` + Message *tgMessage `json:"message"` + Data string `json:"data"` +} + +type tgUpdate struct { + UpdateID int64 `json:"update_id"` + Message *tgMessage `json:"message"` + CallbackQuery *tgCallbackQuery `json:"callback_query"` +} + +// --------------------------------------------------------------------- +// callback_data +// --------------------------------------------------------------------- + +// callbackAction — розібране натискання. +type callbackAction struct { + // Kind — "ack" або "mute". Перелік закритий: невідома дія — це не + // «поки не реалізовано», а або наша стара збірка, або чужа спроба. + Kind string + // ID — алерт для ack, хост для mute. Завжди uuid. + ID string +} + +// maxCallbackData — стеля Bot API на callback_data (1–64 байти). +// +// Перевіряється явно, хоч Telegram і не пропустив би довшого: дані +// приходять із мережі, і «нам їх точно вкоротить той бік» — це довіра +// до чужого сервера там, де вона нічого не коштує. +const maxCallbackData = 64 + +// parseCallbackData розбирає вміст кнопки. +// +// У callback_data лежить рівно те, що ми туди поклали (див. notify.go), +// але прийти воно може від будь-кого, хто бачив формат: сам рядок ніяк +// не підписаний і не зашифрований. Тому цей розбір НІЧОГО не +// авторизує — він лише каже, що саме просять зробити. Право на дію +// перевіряється далі й іншим: чатом, з якого прийшло натискання, і +// прив'язаним до нього користувачем. +func parseCallbackData(raw string) (callbackAction, error) { + if raw == "" || len(raw) > maxCallbackData { + return callbackAction{}, fmt.Errorf("некоректна довжина callback_data") + } + kind, id, ok := strings.Cut(raw, ":") + if !ok { + return callbackAction{}, fmt.Errorf("немає роздільника") + } + switch kind { + case "ack", "mute": + default: + return callbackAction{}, fmt.Errorf("невідома дія %q", kind) + } + if !isUUID(id) { + return callbackAction{}, fmt.Errorf("ідентифікатор не uuid") + } + return callbackAction{Kind: kind, ID: id}, nil +} + +// isUUID перевіряє канонічну форму 8-4-4-4-12. +// +// Не для краси: далі цей рядок іде в запит як ::uuid, і кривий текст +// повернувся б помилкою розбору з глибини SQL — тобто «внутрішня +// помилка» замість «кнопка застаріла». +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i := range len(s) { + c := s[i] + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + continue + } + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} + +// --------------------------------------------------------------------- +// Чий це чат +// --------------------------------------------------------------------- + +// matchChannel шукає канал, якому належить чат натискання. +// +// Це головна перевірка всього приймача. Кабінет береться НЕ з +// callback_data — там немає нічого, чому можна вірити, — а з того, у +// який чат прийшло повідомлення з кнопкою. Чат налаштований у каналі +// руками адміністратора кабінету, тож збіг chat_id означає рівно те, +// що потрібно: кнопку натиснули в чаті цього кабінету. +// +// Один бот може обслуговувати кілька каналів (різні чати, а в +// self-hosted — і різні кабінети), тому перебір іде по всій групі +// каналів цього бота. Коли на той самий чат заведено кілька каналів, +// перевага в того, у якого збігається ще й гілка: канал із +// message_thread_id описує чат точніше, ніж канал без нього. +func matchChannel(chans []store.Channel, chatID int64, threadID int) (store.Channel, bool) { + var loose store.Channel + var found bool + for _, c := range chans { + var cfg telegramConfig + if err := json.Unmarshal(c.Config, &cfg); err != nil { + continue + } + if cfg.ChatID.String() != fmt.Sprint(chatID) { + continue + } + if cfg.ThreadID != 0 && cfg.ThreadID == threadID { + return c, true + } + if !found { + loose, found = c, true + } + } + return loose, found +} + +// --------------------------------------------------------------------- +// Що дописати в повідомлення після дії +// --------------------------------------------------------------------- + +// tgLocation — часовий пояс, у якому показуються години в Telegram. +// +// Київ, а не UTC: повідомлення читає черговий, і «підтверджено о 01:12» +// має збігатися з тим, що показує йому годинник на телефоні. Якщо бази +// поясів у системі немає (мінімальний образ без tzdata), лишається UTC — +// зсунута на кілька годин позначка гірша за правильну, але незрівнянно +// краща за відсутність рядка взагалі. +var tgLocation = func() *time.Location { + if l, err := time.LoadLocation("Europe/Kyiv"); err == nil { + return l + } + return time.UTC +}() + +// ackLine — рядок, який дописується в повідомлення після підтвердження. +// +// Ім'я тут обов'язкове. «Підтверджено» без автора не відповідає на +// єдине питання, заради якого решта команди дивиться в чат: чи вже +// хтось узявся, чи всі думають, що взявся хтось інший. +func ackLine(who string, at time.Time) string { + return fmt.Sprintf("✅ Підтверджено: %s, %s", who, at.In(tgLocation).Format("02.01 15:04")) +} + +// muteLine — те саме для заглушення. +func muteLine(who string, until time.Time) string { + return fmt.Sprintf("🔕 Заглушено до %s (%s)", + until.In(tgLocation).Format("02.01 15:04"), who) +} + +// maxMessageText — стеля Bot API на текст повідомлення. +const maxMessageText = 4096 + +// withStatus дописує підсумок до тексту повідомлення. +// +// Повторний рядок не додається: друге натискання має привести +// повідомлення в той самий вигляд, що й перше, а не відростити другий +// «Підтверджено». Це та сама ідемпотентність, що й у самій дії, тільки +// видима — саме її людина й перевіряє очима. +func withStatus(text, line string) string { + if strings.Contains(text, line) { + return text + } + out := strings.TrimRight(text, "\n") + "\n\n" + line + if len(out) > maxMessageText { + // Обрізати доводиться з голови, а не з хвоста: підсумок — + // найновіше й найпотрібніше в повідомленні, а початок алерту + // вже прочитали, коли він прийшов. + out = "…" + out[len(out)-maxMessageText+3:] + } + return out +} + +// tgDisplayName — як назвати того, хто натиснув, коли прив'язки немає. +// +// Використовується лише в текстах відмов: у самому підтвердженні +// автором стоїть користувач NetPulse, а не telegram-акаунт. +func tgDisplayName(u tgUser) string { + if u.Username != "" { + return "@" + u.Username + } + name := strings.TrimSpace(u.FirstName + " " + u.LastName) + if name != "" { + return name + } + return fmt.Sprint(u.ID) +} + +// --------------------------------------------------------------------- +// Команда прив'язки +// --------------------------------------------------------------------- + +// parseLinkCommand дістає код із повідомлення боту. +// +// Приймається і «/link КОД», і «/link@netpulse_bot КОД»: у групових +// чатах Telegram дописує ім'я бота до команди сам, і без цього +// прив'язка працювала б лише в особистих повідомленнях. +// +// Регістр коду не має значення — абетка кодів (див. міграцію 0061) уся +// у верхньому регістрі, а телефон охоче виправляє введене на нижній. +func parseLinkCommand(text string) (string, bool) { + fields := strings.Fields(strings.TrimSpace(text)) + if len(fields) == 0 { + return "", false + } + cmd, _, _ := strings.Cut(fields[0], "@") + if cmd != "/link" { + return "", false + } + if len(fields) < 2 { + return "", true + } + return strings.ToUpper(fields[1]), true +} diff --git a/server/internal/alerting/telegram_callback_test.go b/server/internal/alerting/telegram_callback_test.go new file mode 100644 index 0000000..9e10470 --- /dev/null +++ b/server/internal/alerting/telegram_callback_test.go @@ -0,0 +1,255 @@ +package alerting + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Мережі тут немає навмисно: усе, що вирішує долю натискання, — +// розбір рядка й пошук каналу за чатом. Тест, якому потрібен живий +// бот, не запускають, а отже й не пишуть. + +const goodUUID = "3f2b1a5c-9d84-4e77-b0c1-2a6f8e5d4c31" + +func TestParseCallbackDataAcceptsOwnFormat(t *testing.T) { + for _, kind := range []string{"ack", "mute"} { + got, err := parseCallbackData(kind + ":" + goodUUID) + if err != nil { + t.Fatalf("%s: %v", kind, err) + } + if got.Kind != kind || got.ID != goodUUID { + t.Errorf("%s: отримали %+v", kind, got) + } + } +} + +// callback_data приходить рядком і нічим не підписаний, тож усе, що не +// збігається з нашим форматом до символу, має відхилятись до першого +// звернення до бази. +func TestParseCallbackDataRejectsAnythingElse(t *testing.T) { + bad := map[string]string{ + "порожньо": "", + "без роздільника": "ack" + goodUUID, + "невідома дія": "close:" + goodUUID, + "порожній id": "ack:", + "не uuid": "ack:12345", + "майже uuid": "ack:3f2b1a5c-9d84-4e77-b0c1-2a6f8e5d4c3", + "нешістнадцятковий": "ack:3f2b1a5c-9d84-4e77-b0c1-2a6f8e5d4czz", + "дефіси не на місці": "ack:3f2b1a5c9d84-4e77-b0c1-2a6f8e5d4c311", + "спроба ін'єкції": "ack:' OR 1=1 --", + "два роздільники": "ack:mute:" + goodUUID, + "довше за стелю": "ack:" + strings.Repeat("a", 70), + "регістр дії": "ACK:" + goodUUID, + "пробіл перед дією": " ack:" + goodUUID, + "порожній ідентиф-р ": "mute:", + } + for name, raw := range bad { + if _, err := parseCallbackData(raw); err == nil { + t.Errorf("%s (%q): очікували відмову", name, raw) + } + } +} + +// Верхній регістр у uuid трапляється у вивантаженнях із інших систем і +// коректний за RFC — відкидати його немає підстав. +func TestParseCallbackDataAcceptsUpperCaseUUID(t *testing.T) { + if _, err := parseCallbackData("ack:" + strings.ToUpper(goodUUID)); err != nil { + t.Errorf("верхній регістр uuid має прийматись: %v", err) + } +} + +// --------------------------------------------------------------------- +// Кабінет береться з чату, а не з кнопки +// --------------------------------------------------------------------- + +func tgChannel(id, tenant string, cfg string) store.Channel { + return store.Channel{ID: id, TenantID: tenant, Kind: "telegram", + Name: id, Enabled: true, Config: json.RawMessage(cfg)} +} + +func TestMatchChannelFindsTenantByChat(t *testing.T) { + chans := []store.Channel{ + tgChannel("c1", "t1", `{"chat_id":-1001111111111}`), + tgChannel("c2", "t2", `{"chat_id":"-1002222222222"}`), + } + + got, ok := matchChannel(chans, -1002222222222, 0) + if !ok || got.TenantID != "t2" { + t.Fatalf("очікували канал t2, отримали %+v (ok=%v)", got.ID, ok) + } + + // chat_id пишуть і числом, і рядком — на пошук кабінету це впливати + // не має (див. TestTelegramChatIDAcceptsBothForms). + if got, ok := matchChannel(chans, -1001111111111, 0); !ok || got.TenantID != "t1" { + t.Fatalf("числова форма chat_id не знайшлася: %+v (ok=%v)", got.ID, ok) + } +} + +// Головна перевірка приймача: натискання з чату, якого немає в жодному +// каналі, не має давати кабінету взагалі. Інакше досить було б знати +// формат кнопки, щоб підтвердити чужий алерт зі свого чату. +func TestMatchChannelRejectsUnknownChat(t *testing.T) { + chans := []store.Channel{tgChannel("c1", "t1", `{"chat_id":-1001111111111}`)} + + if _, ok := matchChannel(chans, -1009999999999, 0); ok { + t.Fatal("чужий чат не має зіставлятися з каналом") + } + if _, ok := matchChannel(nil, -1001111111111, 0); ok { + t.Fatal("порожній перелік каналів не має нічого знаходити") + } +} + +// Кривий config каналу не має ламати пошук для решти: один зіпсований +// рядок гасив би кнопки всім, хто ділить із ним бота. +func TestMatchChannelSkipsBrokenConfig(t *testing.T) { + chans := []store.Channel{ + tgChannel("broken", "t1", `{"chat_id":`), + tgChannel("good", "t2", `{"chat_id":-1002222222222}`), + } + got, ok := matchChannel(chans, -1002222222222, 0) + if !ok || got.ID != "good" { + t.Fatalf("очікували good, отримали %+v (ok=%v)", got.ID, ok) + } +} + +// Той самий чат із гілками: точніший канал (із message_thread_id) має +// вигравати, інакше алерт із гілки «Мережа» підтверджувався б від імені +// каналу, налаштованого на весь чат. +func TestMatchChannelPrefersThread(t *testing.T) { + chans := []store.Channel{ + tgChannel("whole", "t1", `{"chat_id":-100777}`), + tgChannel("thread", "t1", `{"chat_id":-100777,"thread_id":42}`), + } + if got, _ := matchChannel(chans, -100777, 42); got.ID != "thread" { + t.Errorf("для гілки 42 очікували канал thread, отримали %s", got.ID) + } + if got, _ := matchChannel(chans, -100777, 0); got.ID != "whole" { + t.Errorf("поза гілкою очікували канал whole, отримали %s", got.ID) + } +} + +// --------------------------------------------------------------------- +// Видима ідемпотентність +// --------------------------------------------------------------------- + +// Друге натискання має привести повідомлення в той самий вигляд, що й +// перше. Другий рядок «Підтверджено» під тим самим алертом читався б як +// два різні підтвердження. +func TestWithStatusAddsLineOnlyOnce(t *testing.T) { + line := ackLine("ops@example.com", time.Date(2026, 8, 27, 3, 12, 0, 0, time.UTC)) + first := withStatus("🔴 HIGH: sw1 не відповідає", line) + + if !strings.Contains(first, "ops@example.com") { + t.Fatalf("у підсумку немає автора: %q", first) + } + if second := withStatus(first, line); second != first { + t.Errorf("повторне дописування змінило текст:\n%q\n%q", first, second) + } +} + +func TestWithStatusKeepsWithinTelegramLimit(t *testing.T) { + long := strings.Repeat("щ", maxMessageText) + got := withStatus(long, muteLine("ops@example.com", time.Now().Add(time.Hour))) + + if len(got) > maxMessageText { + t.Fatalf("довжина %d перевищує стелю %d", len(got), maxMessageText) + } + if !strings.Contains(got, "Заглушено до") { + t.Errorf("обрізали не з того боку — підсумок зник:\n%q", got[:80]) + } +} + +// --------------------------------------------------------------------- +// Команда прив'язки +// --------------------------------------------------------------------- + +func TestParseLinkCommand(t *testing.T) { + cases := []struct { + in string + code string + isLink bool + }{ + {"/link ABCD2345", "ABCD2345", true}, + // У групі Telegram дописує ім'я бота до команди сам. + {"/link@netpulse_bot ABCD2345", "ABCD2345", true}, + // Телефон охоче виправляє набране на нижній регістр. + {"/link abcd2345", "ABCD2345", true}, + {" /link ABCD2345 ", "ABCD2345", true}, + // Команда без коду — привід підказати, а не мовчати. + {"/link", "", true}, + // Усе інше бот ігнорує: він часто стоїть у робочому чаті. + {"привіт", "", false}, + {"", "", false}, + {"/start", "", false}, + {"скажи /link ABCD2345", "", false}, + } + for _, c := range cases { + code, isLink := parseLinkCommand(c.in) + if code != c.code || isLink != c.isLink { + t.Errorf("%q: отримали (%q, %v), очікували (%q, %v)", + c.in, code, isLink, c.code, c.isLink) + } + } +} + +// Прив'язки немає — у відмові має бути видно, ЯКИЙ саме акаунт стукав: +// інакше адміністратор не зрозуміє, кого шукати. +func TestDisplayNameFallsBackToIdentity(t *testing.T) { + if got := tgDisplayName(tgUser{ID: 77, Username: "ops"}); got != "@ops" { + t.Errorf("отримали %q", got) + } + if got := tgDisplayName(tgUser{ID: 77, FirstName: "Іван", LastName: "П."}); got != "Іван П." { + t.Errorf("отримали %q", got) + } + if got := tgDisplayName(tgUser{ID: 77}); got != "77" { + t.Errorf("отримали %q", got) + } +} + +// --------------------------------------------------------------------- +// Розбір оновлення цілком +// --------------------------------------------------------------------- + +// Перевірка на справжній формі відповіді Bot API: поля, за якими +// вирішується кабінет і особа, мають доїжджати повністю. +func TestUpdateDecodesFieldsUsedForAuthorisation(t *testing.T) { + raw := `{ + "update_id": 500123, + "callback_query": { + "id": "4382bfdwdsb323b2d9", + "from": {"id": 9876543210, "is_bot": false, "username": "ops"}, + "message": { + "message_id": 4210, + "chat": {"id": -1001111111111, "type": "supergroup"}, + "message_thread_id": 42, + "text": "🔴 HIGH: sw1 не відповідає" + }, + "data": "ack:` + goodUUID + `" + } + }` + + var u tgUpdate + if err := json.Unmarshal([]byte(raw), &u); err != nil { + t.Fatal(err) + } + cq := u.CallbackQuery + if cq == nil || cq.Message == nil { + t.Fatal("callback_query не розібрався") + } + // 64-бітний id: на нових акаунтах Telegram він уже не вміщається в + // int32, і мовчазне обрізання тут означало б прив'язку не до тієї + // людини. + if cq.From.ID != 9876543210 { + t.Errorf("from.id = %d", cq.From.ID) + } + if cq.Message.Chat.ID != -1001111111111 || cq.Message.ThreadID != 42 { + t.Errorf("чат розібрався як %d/%d", cq.Message.Chat.ID, cq.Message.ThreadID) + } + if act, err := parseCallbackData(cq.Data); err != nil || act.Kind != "ack" { + t.Errorf("дія розібралась як %+v (%v)", act, err) + } +} diff --git a/server/internal/alerting/traps_test.go b/server/internal/alerting/traps_test.go new file mode 100644 index 0000000..8c0c0d9 --- /dev/null +++ b/server/internal/alerting/traps_test.go @@ -0,0 +1,142 @@ +package alerting + +import ( + "testing" + + "github.com/netpulse/netpulse/server/internal/store" +) + +func trapRule(cond store.Condition, scope map[string]bool) compiledRule { + return compiledRule{ + rule: store.Rule{ID: "r1", Source: "trap", Condition: cond}, + scope: scope, + } +} + +var linkDown = TrapEvent{ + DeviceID: "d1", + SourceIP: "10.20.0.7", + TrapOID: "1.3.6.1.6.3.1.1.5.3", + Varbinds: []TrapVarbind{ + {OID: "1.3.6.1.2.1.1.3.0", Value: "12345"}, + {OID: "1.3.6.1.2.1.2.2.1.1.7", Value: "7"}, + {OID: "1.3.6.1.2.1.2.2.1.8.7", Value: "2"}, + }, +} + +// Найпростіша умова, заради якої все й робилось: «цей OID». +func TestTrapMatchesByOID(t *testing.T) { + if !trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.3"}, nil), linkDown) { + t.Fatal("linkDown не збігся з власним OID") + } + if trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.4"}, nil), linkDown) { + t.Fatal("linkUp зловив linkDown") + } + // Провідна крапка не має вирішувати долю правила. + if !trapMatches(trapRule(store.Condition{TrapOID: ".1.3.6.1.6.3.1.1.5.3"}, nil), linkDown) { + t.Fatal("крапка на початку зламала збіг") + } +} + +// Умова на varbind порівнюється зі СТОВПЦЕМ, а не з конкретним рядком: +// у трапі приходить ifOperStatus.7, а людина пише ifOperStatus, бо +// номера порту наперед не знає. +func TestTrapMatchesVarbindByColumn(t *testing.T) { + c := trapRule(store.Condition{ + TrapOID: "1.3.6.1.6.3.1.1.5.3", + VarbindOID: "1.3.6.1.2.1.2.2.1.8", + VarbindValue: "2", + }, nil) + if !trapMatches(c, linkDown) { + t.Fatal("ifOperStatus=down не збігся") + } + + c.rule.Condition.VarbindValue = "1" + if trapMatches(c, linkDown) { + t.Fatal("ifOperStatus=up збігся з трапом, у якому down") + } + + // Порожнє значення означає «щоб такий varbind узагалі був». + c.rule.Condition.VarbindValue = "" + if !trapMatches(c, linkDown) { + t.Fatal("умова «є такий varbind» не спрацювала") + } + c.rule.Condition.VarbindOID = "1.3.6.1.4.1.9.9.999" + if trapMatches(c, linkDown) { + t.Fatal("умова спрацювала на varbind, якого в трапі немає") + } +} + +// Адреса в умові приймає і одну адресу, і підмережу. +func TestTrapMatchesBySourceIP(t *testing.T) { + if !trapMatches(trapRule(store.Condition{SourceIP: "10.20.0.7"}, nil), linkDown) { + t.Fatal("точна адреса не збіглася") + } + if !trapMatches(trapRule(store.Condition{SourceIP: "10.20.0.0/24"}, nil), linkDown) { + t.Fatal("підмережа не збіглася") + } + if trapMatches(trapRule(store.Condition{SourceIP: "10.30.0.0/24"}, nil), linkDown) { + t.Fatal("чужа підмережа збіглася") + } +} + +// Головне рішення цього джерела: трап від адреси, яку не впізнали, +// піднімає алерт ЛИШЕ якщо правило назвало адресу явно. Інакше він +// лишається в переліку невідомих джерел, а не на дошці алертів. +func TestTrapWithoutDeviceNeedsExplicitSource(t *testing.T) { + stranger := linkDown + stranger.DeviceID = "" + stranger.SourceIP = "10.20.0.250" + + if trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.3"}, nil), stranger) { + t.Fatal("трап від невідомої адреси підняв алерт без явної адреси в умові") + } + c := trapRule(store.Condition{ + TrapOID: "1.3.6.1.6.3.1.1.5.3", + SourceIP: "10.20.0.0/24", + }, nil) + if !trapMatches(c, stranger) { + t.Fatal("правило з явною адресою не спрацювало на незнайомця") + } +} + +// Селектор звужує правило до хостів. До трапа від адреси поза +// інвентарем його застосувати неможливо — інакше правило з адресою +// джерела не спрацювало б ніколи. +func TestTrapSelectorAppliesOnlyToKnownHosts(t *testing.T) { + scope := map[string]bool{"d2": true} + if trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.3"}, scope), linkDown) { + t.Fatal("хост поза селектором підпав під правило") + } + + stranger := linkDown + stranger.DeviceID = "" + c := trapRule(store.Condition{SourceIP: "10.20.0.7"}, scope) + if !trapMatches(c, stranger) { + t.Fatal("селектор заблокував правило про адресу поза інвентарем") + } +} + +func TestVarbindPrefixMatchIsNotSubstring(t *testing.T) { + // 1.3.6.1.2.1.2.2.1.10 (ifInOctets) не має вважатись збігом для + // 1.3.6.1.2.1.2.2.1.1 (ifIndex) — інакше умова про індекс порту + // зривалась би на лічильнику байтів. + if varbindIs("1.3.6.1.2.1.2.2.1.10", "1.3.6.1.2.1.2.2.1.1") { + t.Fatal("сусідній стовпець зарахований як збіг") + } + if !varbindIs("1.3.6.1.2.1.2.2.1.1.7", "1.3.6.1.2.1.2.2.1.1") { + t.Fatal("рядок стовпця не зарахований") + } + if !varbindIs("1.3.6.1.2.1.1.3.0", "1.3.6.1.2.1.1.3.0") { + t.Fatal("точний збіг не зарахований") + } +} + +// Текст алерту має пояснювати подію, а не перелічувати OID-и. +func TestTrapDetailShowsNamedFields(t *testing.T) { + view := &tenantView{} + got := trapDetail(view, linkDown) + if got != "ifIndex=7, ifOperStatus=down" { + t.Fatalf("подробиці трапа: %q", got) + } +} diff --git a/server/internal/gitstore/branches.go b/server/internal/gitstore/branches.go new file mode 100644 index 0000000..344b193 --- /dev/null +++ b/server/internal/gitstore/branches.go @@ -0,0 +1,180 @@ +package gitstore + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" +) + +// Видалення гілки пристрою — локально й на дзеркалі. +// +// Поруч із RenameBranch і з тієї ж причини: ім'я гілки складається з +// імені та адреси хоста, тож усе, що відбувається з хостом, рано чи +// пізно доходить сюди. Перейменування вже дійшло; видалення — друга +// половина тієї ж думки. +// +// ГОЛОВНЕ РІШЕННЯ ФАЙЛУ — те, чого тут немає: prune. +// +// Спокуса очевидна: дзеркалення й так знає обидва боки, і один прапорець +// `--prune` прибирав би на тому кінці все, чого немає тут. Саме тому +// його тут і немає. Дзеркало заводять на випадок втрати локального +// диска. Репозиторій, який пошкодився або відкотився до вчорашнього +// стану, з prune миттєво знищив би віддалену копію — тобто рівно ту, по +// яку в цей момент прийшли. Механізм, здатний стерти копію на підставі +// стану оригіналу, копією не є. +// +// Тому видалення адресне: система знає ім'я гілки, яку щойно прибрала, +// і надсилає видалення рівно цієї гілки. Усе, чого вона не називала +// явно, на дзеркалі лишається — включно з гілками, про які вона нічого +// не знає. + +// DeleteBranch прибирає локальну гілку. +// +// Повертає false, якщо гілки не було. Це не помилка: пристрій міг ніколи +// не комітитись, а видалення — повторюватись (черга видалень повторює +// спробу, доки не дійде до дзеркала). +// +// Історія при цьому не зникає з диска одразу: Git тримає об'єкти доти, +// доки їх не збере gc. Це не недоробка, а властивість формату — обіцяти +// «конфіги стерто з диска» на підставі знятого посилання було б +// неправдою. Тіла конфігів прибираються там, де вони справді лежать, — +// у core.secrets (див. store.deleteConfigRows). +func (s *Store) DeleteBranch(repoName, branch string) (bool, error) { + if s.root == "" { + return false, ErrDisabled + } + if strings.TrimSpace(branch) == "" { + return false, nil + } + path := filepath.Join(s.root, repoName) + + mu := s.lockFor(path) + mu.Lock() + defer mu.Unlock() + + repo, err := open(path) + if err != nil { + if errors.Is(err, git.ErrRepositoryNotExists) { + // Репозиторію немає — прибирати нічого. Так виглядає + // інсталяція, у якій жодного конфігу ще не зібрано. + return false, nil + } + return false, err + } + + ref := plumbing.NewBranchReferenceName(branch) + if _, err := repo.Reference(ref, false); err != nil { + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return false, nil + } + return false, fmt.Errorf("гілка %s: %w", branch, err) + } + if err := repo.Storer.RemoveReference(ref); err != nil { + return false, fmt.Errorf("прибрати гілку %s: %w", branch, err) + } + return true, nil +} + +// DeleteRemoteRefs просить дзеркало прибрати названі гілки. +// +// Refspec виду ":refs/heads/<гілка>" — порожній лівий бік і є +// видаленням. Кожна гілка називається окремо: шаблон тут неможливий за +// побудовою, і це та сама відмінність від mirrorRefSpec, заради якої +// написано весь файл. +// +// Atomic НЕ вмикається, на відміну від звичайного push. Там атомарність +// потрібна, бо наполовину доїхане дзеркало виглядає як ціле. Тут +// навпаки: видалення незалежні одне від одного, і відмова по одній +// гілці (наприклад, її на тому кінці вже немає) не має скасовувати +// решту. +// +// Відсутність гілки на дзеркалі помилкою не вважається — саме так +// виглядає повтор після часткового успіху, а він тут звичайна річ. +func (s *Store) DeleteRemoteRefs(ctx context.Context, repoName string, r Remote, branches []string) error { + if s.root == "" { + return ErrDisabled + } + specs := make([]config.RefSpec, 0, len(branches)) + for _, b := range branches { + b = strings.TrimSpace(b) + if b == "" { + continue + } + specs = append(specs, config.RefSpec(":"+plumbing.NewBranchReferenceName(b).String())) + } + if len(specs) == 0 { + return nil + } + + full := filepath.Join(s.root, repoName) + repo, err := git.PlainOpen(full) + if err != nil { + if errors.Is(err, git.ErrRepositoryNotExists) { + return ErrNoRepo + } + return fmt.Errorf("gitstore: відкриття %s: %w", repoName, err) + } + + auth, err := r.authMethod() + if err != nil { + return err + } + + remote := git.NewRemote(repo.Storer, &config.RemoteConfig{ + Name: "mirror", + URLs: []string{r.URL}, + }) + + ctx, cancel := context.WithTimeout(ctx, r.timeout()) + defer cancel() + + err = remote.PushContext(ctx, &git.PushOptions{ + RemoteName: "mirror", + RefSpecs: specs, + Auth: auth, + // Прапорець Prune тут не ставиться ніколи — див. коментар до + // файлу. Видаляє саме перелік specs, і нічого крім нього. + }) + switch { + case err == nil, errors.Is(err, git.NoErrAlreadyUpToDate): + return nil + case isMissingRemoteRef(err): + // Гілки там уже немає. Для видалення це і є успіх. + return nil + default: + return r.explain(err) + } +} + +// isMissingRemoteRef — чи каже помилка «такого посилання там немає». +// +// Текстом, бо go-git і сервери на тому кінці повідомляють про це +// по-різному й жодного окремого типу помилки для цього не мають. +// Помилитись тут дешево в один бік і дорого в інший: прийняти чужу +// помилку за «уже видалено» означало б зняти рядок із черги, не +// видаливши гілку. Тому перелік вузький — рівно ті формулювання, які +// означають відсутність посилання. +func isMissingRemoteRef(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + for _, s := range []string{ + "reference not found", + "remote ref does not exist", + "deleting a non-existent ref", + "unable to delete", + "no such ref", + } { + if strings.Contains(msg, s) { + return true + } + } + return false +} diff --git a/server/internal/gitstore/branches_test.go b/server/internal/gitstore/branches_test.go new file mode 100644 index 0000000..ed4559a --- /dev/null +++ b/server/internal/gitstore/branches_test.go @@ -0,0 +1,143 @@ +package gitstore + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Видалення гілки: локально й на «дзеркалі». +// +// Дзеркалом тут працює звичайний голий репозиторій на диску, до якого +// ходимо по file://. Це не спрощення заради тесту: у справжньому push +// відрізняється лише транспорт, а перевіряється тут інше — що видалення +// адресне. Тобто що після нього на тому кінці зникає РІВНО названа +// гілка, а сусідня, про яку ніхто не говорив, лишається на місці. Саме +// це відрізняє нашу поведінку від prune, якого ми свідомо не робимо. + +func gitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func TestDeleteBranchLocal(t *testing.T) { + s := New(t.TempDir()) + + write(t, s, "device/sw-01-10.0.0.1", "sw-01/running.cfg", "hostname sw-01\n") + write(t, s, "device/sw-02-10.0.0.2", "sw-02/running.cfg", "hostname sw-02\n") + + removed, err := s.DeleteBranch("t.git", "device/sw-01-10.0.0.1") + if err != nil { + t.Fatalf("видалення: %v", err) + } + if !removed { + t.Fatal("гілка була — мало повернутись true") + } + + refs := gitOut(t, filepath.Join(s.Root(), "t.git"), "branch", "--list") + if strings.Contains(refs, "sw-01") { + t.Errorf("гілка лишилась: %s", refs) + } + if !strings.Contains(refs, "sw-02") { + t.Errorf("сусідню гілку не мало зачепити: %s", refs) + } + + // Повтор — не помилка: черга видалень повторює спробу, доки не + // дійде до дзеркала, і другий прохід не має падати. + removed, err = s.DeleteBranch("t.git", "device/sw-01-10.0.0.1") + if err != nil { + t.Fatalf("повторне видалення: %v", err) + } + if removed { + t.Error("гілки вже не було — мало повернутись false") + } + + // Репозиторію немає взагалі — теж не помилка: хост могли видалити + // до першого зібраного конфігу. + if _, err := s.DeleteBranch("немає.git", "device/x"); err != nil { + t.Errorf("відсутній репозиторій: %v", err) + } +} + +// localURLPath робить із шляху файлової системи шлях для file://. +func localURLPath(dir string) string { + p := filepath.ToSlash(dir) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return p +} + +func TestDeleteRemoteRefIsTargeted(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("для file:// потрібен git у PATH") + } + root := t.TempDir() + s := New(root) + + write(t, s, "device/sw-01-10.0.0.1", "sw-01/running.cfg", "hostname sw-01\n") + write(t, s, "device/sw-02-10.0.0.2", "sw-02/running.cfg", "hostname sw-02\n") + + // «Дзеркало». + remoteDir := filepath.Join(root, "mirror.git") + gitOut(t, root, "init", "--bare", remoteDir) + // Приймати push у поточну гілку голого репозиторію нікому не + // заважає, але Forgejo цього не вимагає — вирівнюємо поведінку. + gitOut(t, remoteDir, "config", "receive.denyCurrentBranch", "ignore") + + // Три скісні, не дві: у file:// одразу за ними йде ХОСТ, і на + // Windows "file://C:/..." означає репозиторій на машині "C:". + // На Linux провідна скісна в шляху вже є, тому помилки там не + // видно — тест зеленів, доки його не прогнали на Windows. + rem := Remote{URL: "file://" + localURLPath(remoteDir), Auth: AuthNone} + if _, err := s.Mirror(context.Background(), "t.git", rem); err != nil { + t.Fatalf("перший push: %v", err) + } + if out := gitOut(t, remoteDir, "branch", "--list"); !strings.Contains(out, "sw-01") || + !strings.Contains(out, "sw-02") { + t.Fatalf("дзеркало не отримало обидві гілки: %s", out) + } + + // Хост видалено: локальна гілка знята, і на дзеркало йде видалення + // рівно її. + if _, err := s.DeleteBranch("t.git", "device/sw-01-10.0.0.1"); err != nil { + t.Fatalf("локальне видалення: %v", err) + } + if err := s.DeleteRemoteRefs(context.Background(), "t.git", rem, + []string{"device/sw-01-10.0.0.1"}); err != nil { + t.Fatalf("видалення на дзеркалі: %v", err) + } + + out := gitOut(t, remoteDir, "branch", "--list") + if strings.Contains(out, "sw-01") { + t.Errorf("гілка лишилась на дзеркалі: %s", out) + } + if !strings.Contains(out, "sw-02") { + t.Errorf("сусідня гілка мала лишитись — ми її не називали: %s", out) + } + + // Повтор після успіху. Черга повторює спробу після будь-якої + // невизначеності (обрив на відповіді, перезапуск процесу), і + // «такої гілки там немає» для видалення означає успіх, а не збій. + if err := s.DeleteRemoteRefs(context.Background(), "t.git", rem, + []string{"device/sw-01-10.0.0.1"}); err != nil { + t.Errorf("повторне видалення на дзеркалі: %v", err) + } + + // І головне: звичайне дзеркалення після цього не намагається + // повернути видалену гілку назад. + if _, err := s.Mirror(context.Background(), "t.git", rem); err != nil { + t.Fatalf("push після видалення: %v", err) + } + if out := gitOut(t, remoteDir, "branch", "--list"); strings.Contains(out, "sw-01") { + t.Errorf("push повернув видалену гілку: %s", out) + } +} diff --git a/server/internal/gitstore/deploykey.go b/server/internal/gitstore/deploykey.go new file mode 100644 index 0000000..0486e25 --- /dev/null +++ b/server/internal/gitstore/deploykey.go @@ -0,0 +1,77 @@ +package gitstore + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "strings" + + cssh "golang.org/x/crypto/ssh" +) + +// GenerateDeployKey створює пару ключів для дзеркалення. +// +// НАВІЩО СЕРВЕР, А НЕ ЛЮДИНА. Дзеркалення й раніше вміло ходити по SSH, +// але приватний ключ треба було зробити самому й вставити в поле. Це +// працює й це неправильно: приватний ключ мандрує через ssh-keygen, +// буфер обміну, історію команд і, як щастить, через месенджер до +// колеги. Найцінніший секрет системи проходить рівно тим шляхом, яким +// секрети й витікають. +// +// Тому пару робить сервер: приватна половина одразу лягає зашифрованою +// й назовні не виходить ніколи, а людині показується ПУБЛІЧНА — та, яку +// й треба віднести в репозиторій. +// +// НАВІЩО САМЕ КЛЮЧ РОЗГОРТАННЯ. Особистий токен відкриває всі +// репозиторії власника; ключ розгортання — рівно один. Для системи, яка +// щоночі складає туди конфіги всієї мережі, різниця між «доступ до +// одного репозиторію» і «доступ до всього, що є в акаунта» — це +// різниця між інцидентом і катастрофою. +// +// ed25519, а не RSA: коротший, швидший, підтримується Forgejo, Gitea, +// GitHub і GitLab, і не має параметра «скільки біт», у якому можна +// помилитись у бік 1024. +func GenerateDeployKey(comment string) (privatePEM string, publicKey string, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", fmt.Errorf("генерація ключа: %w", err) + } + + block, err := cssh.MarshalPrivateKey(priv, comment) + if err != nil { + return "", "", fmt.Errorf("серіалізація ключа: %w", err) + } + + sshPub, err := cssh.NewPublicKey(pub) + if err != nil { + return "", "", fmt.Errorf("публічна частина: %w", err) + } + + // Коментар у кінці рядка — те, що людина побачить у переліку ключів + // репозиторію. Без нього там стоїть безіменний ключ, і за півроку + // ніхто не згадає, чий він і чи можна його прибрати. + authorized := strings.TrimSpace(string(cssh.MarshalAuthorizedKey(sshPub))) + if comment != "" { + authorized += " " + comment + } + return string(pem.EncodeToMemory(block)), authorized, nil +} + +// PublicKeyOf відновлює публічну частину зі збереженого приватного ключа. +// +// Потрібне, щоб показати ключ ще раз: людина заводить його в репозиторії +// не тієї ж хвилини, коли створює, а публічну половину ми ніде не +// зберігаємо — вона повністю виводиться з приватної, і друга копія +// означала б два джерела однієї істини. +func PublicKeyOf(privatePEM, comment string) (string, error) { + signer, err := cssh.ParsePrivateKey([]byte(privatePEM)) + if err != nil { + return "", fmt.Errorf("розбір ключа: %w", err) + } + authorized := strings.TrimSpace(string(cssh.MarshalAuthorizedKey(signer.PublicKey()))) + if comment != "" { + authorized += " " + comment + } + return authorized, nil +} diff --git a/server/internal/gitstore/deploykey_test.go b/server/internal/gitstore/deploykey_test.go new file mode 100644 index 0000000..28b225c --- /dev/null +++ b/server/internal/gitstore/deploykey_test.go @@ -0,0 +1,56 @@ +package gitstore + +import ( + "strings" + "testing" + + cssh "golang.org/x/crypto/ssh" +) + +// Згенерована пара має бути придатною там, куди її понесуть: приватна — +// для go-git, публічна — у рядок authorized_keys репозиторію. +func TestGenerateDeployKeyUsable(t *testing.T) { + priv, pub, err := GenerateDeployKey("netpulse: дзеркало") + if err != nil { + t.Fatalf("генерація: %v", err) + } + + if _, err := cssh.ParsePrivateKey([]byte(priv)); err != nil { + t.Fatalf("приватний ключ не розбирається: %v", err) + } + if !strings.HasPrefix(pub, "ssh-ed25519 ") { + t.Fatalf("публічний ключ не у форматі authorized_keys: %q", pub) + } + if !strings.HasSuffix(pub, " netpulse: дзеркало") { + t.Fatalf("публічний ключ без коментаря: %q", pub) + } + if _, _, _, _, err := cssh.ParseAuthorizedKey([]byte(pub)); err != nil { + t.Fatalf("публічний ключ не приймається як authorized_key: %v", err) + } +} + +// Публічну половину має бути видно ще раз — людина заводить ключ у +// репозиторії не тієї ж хвилини, коли створює. +func TestPublicKeyOfMatchesGenerated(t *testing.T) { + priv, pub, err := GenerateDeployKey("np") + if err != nil { + t.Fatalf("генерація: %v", err) + } + again, err := PublicKeyOf(priv, "np") + if err != nil { + t.Fatalf("відновлення: %v", err) + } + if again != pub { + t.Fatalf("відновлена публічна половина інша:\n%s\n%s", pub, again) + } +} + +// Дві пари не можуть збігтись — інакше «свій» ключ відкривав би чужий +// репозиторій. +func TestGenerateDeployKeyIsRandom(t *testing.T) { + _, a, _ := GenerateDeployKey("x") + _, b, _ := GenerateDeployKey("x") + if a == b { + t.Fatal("дві згенеровані пари однакові") + } +} diff --git a/server/internal/gitstore/mirror.go b/server/internal/gitstore/mirror.go new file mode 100644 index 0000000..666c0cd --- /dev/null +++ b/server/internal/gitstore/mirror.go @@ -0,0 +1,624 @@ +// Дзеркалення локального репозиторію на зовнішній Git. +// +// Що це таке в одному реченні: `git push --all` на Forgejo/Gitea, +// GitHub або GitLab, зроблений чистим Go (go-git), без виклику +// системного git і без cgo — образ і далі збирається без C-тулчейна. +// +// Три рішення, які тут зафіксовані. +// +// ПЕРШЕ: push ніколи не входить у шлях збору конфігу. Цей файл не +// викликається ані зі StoreConfig, ані з диспетчера завдань. Його +// смикає окремий фоновий такт (grpcapi/ncm_mirror.go), і недоступний +// віддалений сервер коштує рівно нічого — бекап уже ліг у базу й у +// локальний Git до того, як хтось згадав про дзеркало. +// +// ДРУГЕ: без -f. Refspec навмисно без плюса, тобто push або +// перемотується вперед, або відмовляється. Дзеркало заводять як копію +// на випадок втрати; механізм, який уміє мовчки переписати віддалену +// історію, — це не копія, а другий спосіб її втратити. Перезапис +// існує, але окремою свідомою дією людини (Remote.Force). +// +// ТРЕТЄ: ключ SSH-сервера звіряється завжди. Для мережевого заліза в +// цьому продукті ухвалено протилежне (agent/internal/ncmx/transport.go), +// і там воно правильне: сотня коробок, кожна перегенеровує ключ після +// прошивки. Тут один сервер, і везе він за периметр архів конфігів із +// ключами та хешами паролів. Довіритись першому, хто відповів на порту +// 22, означало б віддати цей архів тому, хто перехопив DNS. +package gitstore + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "net" + "net/url" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/go-git/go-git/v5/storage/memory" + cssh "golang.org/x/crypto/ssh" +) + +// Remote — куди й чим пушити. +// +// Secret ніколи не потрапляє ні в структуру відповіді API, ні в журнал, +// ні в текст помилки: усе, що йде назовні з цього файлу, проходить +// через Scrub. +type Remote struct { + URL string + // Auth: "https" — токен у Secret; "ssh" — приватний ключ у Secret; + // "none" — анонімно (буває лише для перевірки читання). + Auth string + User string + Secret string + HostKey string + + // Force дозволяє перезаписати віддалену гілку, яка розійшлася з + // локальною. Ніколи не вмикається само: див. коментар до пакета. + Force bool + + Timeout time.Duration +} + +const ( + AuthNone = "none" + AuthHTTPS = "https" + AuthSSH = "ssh" +) + +// PushStat — підсумок такту дзеркалення. +type PushStat struct { + // Refs — скільки гілок поїхало (або вже там було). + Refs int + // StateHash — відбиток локальних посилань на момент push. Поки він + // не змінився, у мережу ходити немає по що. + StateHash string + UpToDate bool + Took time.Duration +} + +// ProbeStat — що видно на тому кінці до будь-якого запису. +type ProbeStat struct { + // Refs — скільки посилань уже є у віддаленому репозиторії. Нуль — + // порожній репозиторій, найчастіший і найпростіший випадок. + Refs int + Empty bool + // HostKey — ключ, який показав SSH-сервер. Порожній для HTTPS. + HostKey string + // HostKeyKnown — чи збігся він із закріпленим. + HostKeyKnown bool +} + +var ( + ErrNoRepo = errors.New("локального репозиторію ще немає — жодного конфігу не зібрано") + ErrHostKeyUnset = errors.New("ключ SSH-сервера не закріплено: спершу виконайте перевірку з'єднання") + ErrDiverged = errors.New("віддалене дерево розійшлося з локальним") + ErrNoRemoteURL = errors.New("не задано адресу віддаленого репозиторію") + ErrPlainHTTP = errors.New("http дозволено лише до адрес усередині мережі") + ErrBadRemoteURL = errors.New("адресу репозиторію не розібрано") + ErrUnsupportedTr = errors.New("підтримуються лише https:// і ssh:// (або git@хост:шлях)") +) + +// mirrorRefSpec — усі локальні гілки в однойменні віддалені. +// +// Шаблон, а не перелік. Це і є відповідь на питання «що дзеркалити»: +// не «конфіги», а «все, що лежить у репозиторії тенанта». Гілка +// device/ сьогодні одна за видом, але наступний вид даних, який +// хтось покладе в цей самий репозиторій, поїде без жодної правки тут — +// саме тому перелік гілок не зашитий. +// +// refs/heads/*, а не refs/*: службові простори (refs/notes, +// refs/pull/*, які створює сам Forgejo) дзеркало не чіпає. +const mirrorRefSpec = "refs/heads/*:refs/heads/*" + +// Mirror пушить усі гілки репозиторію тенанта на віддалений сервер. +// +// Блокування репозиторію НЕ береться. Push читає об'єкти й посилання, +// а lockFor охороняє запис; тримати його на весь час мережевої операції +// означало б, що повільний Forgejo зупиняє нічний збір конфігів — рівно +// та залежність, якої весь цей модуль уникає. +func (s *Store) Mirror(ctx context.Context, repoName string, r Remote) (PushStat, error) { + var stat PushStat + if s.root == "" { + return stat, ErrDisabled + } + + started := time.Now() + full := filepath.Join(s.root, repoName) + repo, err := git.PlainOpen(full) + if err != nil { + if errors.Is(err, git.ErrRepositoryNotExists) { + return stat, ErrNoRepo + } + return stat, fmt.Errorf("gitstore: відкриття %s: %w", repoName, err) + } + + refs, hash, err := localRefs(repo) + if err != nil { + return stat, err + } + stat.Refs, stat.StateHash = len(refs), hash + if len(refs) == 0 { + // Порожній репозиторій пушити нічим, і це не помилка: так + // виглядає інсталяція, де дзеркало ввімкнули до першого бекапу. + stat.UpToDate = true + stat.Took = time.Since(started) + return stat, nil + } + + auth, err := r.authMethod() + if err != nil { + return stat, err + } + + spec := mirrorRefSpec + if r.Force { + spec = "+" + spec + } + + remote := git.NewRemote(repo.Storer, &config.RemoteConfig{ + Name: "mirror", + URLs: []string{r.URL}, + }) + + ctx, cancel := context.WithTimeout(ctx, r.timeout()) + defer cancel() + + err = remote.PushContext(ctx, &git.PushOptions{ + RemoteName: "mirror", + RefSpecs: []config.RefSpec{config.RefSpec(spec)}, + Auth: auth, + Force: r.Force, + // Atomic: віддалений сервер або приймає весь набір гілок, або + // жодної. Наполовину доїхане дзеркало виглядає як ціле — і саме + // так його й прочитають у момент, коли воно знадобиться. + Atomic: true, + }) + stat.Took = time.Since(started) + + switch { + case err == nil: + case errors.Is(err, git.NoErrAlreadyUpToDate): + stat.UpToDate = true + default: + return stat, r.explain(err) + } + return stat, nil +} + +// LocalState — відбиток посилань локального репозиторію. +// +// Потрібен фоновому такту, щоб не ходити в мережу, коли нічого не +// змінилось: дзеркалення не має бути постійним трафіком на чужий +// сервер, а нічний збір міняє репозиторій раз на добу. +func (s *Store) LocalState(repoName string) (refs int, hash string, err error) { + if s.root == "" { + return 0, "", ErrDisabled + } + repo, err := git.PlainOpen(filepath.Join(s.root, repoName)) + if err != nil { + if errors.Is(err, git.ErrRepositoryNotExists) { + return 0, "", ErrNoRepo + } + return 0, "", err + } + list, h, err := localRefs(repo) + return len(list), h, err +} + +// localRefs повертає гілки й відбиток їхнього стану. +func localRefs(repo *git.Repository) ([]string, string, error) { + iter, err := repo.References() + if err != nil { + return nil, "", err + } + defer iter.Close() + + var lines []string + err = iter.ForEach(func(ref *plumbing.Reference) error { + if ref.Type() != plumbing.HashReference || !ref.Name().IsBranch() { + return nil + } + lines = append(lines, ref.Name().String()+" "+ref.Hash().String()) + return nil + }) + if err != nil { + return nil, "", err + } + + // Порядок обходу посилань не гарантований, а відбиток мусить + // залежати лише від вмісту: інакше такт «бачив би зміну» щоразу й + // ходив би в мережу кожну хвилину. + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return lines, hex.EncodeToString(sum[:]), nil +} + +// Probe перевіряє з'єднання, не змінюючи нічого — ні тут, ні там. +// +// Окрема дія існує через дуже конкретний сценарій: людина вводить +// адресу й токен і має дізнатись про помилку зараз, а не завтра з +// журналу. Читання посилань (ls-remote) для цього достатньо: воно +// проходить ту саму автентифікацію, що й push. +// +// Що воно НЕ доводить: право на запис. Токен «лише читання» проходить +// перевірку й падає на першому push. Сказати про це чесно дешевше, ніж +// створювати сміттєву гілку заради доведення. +func Probe(ctx context.Context, r Remote) (ProbeStat, error) { + var stat ProbeStat + + if strings.TrimSpace(r.URL) == "" { + return stat, ErrNoRemoteURL + } + if err := ValidateRemoteURL(r.URL); err != nil { + return stat, err + } + + // Для SSH спершу дивимось на ключ сервера: людині треба показати + // його ДО того, як вона щось збереже, інакше закріплювати нічого. + if r.transportKind() == AuthSSH { + key, err := ScanHostKey(ctx, r.URL, r.timeout()) + if err != nil { + return stat, Scrub(err, r.Secret) + } + stat.HostKey = key + stat.HostKeyKnown = r.HostKey != "" && sameHostKey(r.HostKey, key) + if r.HostKey == "" { + // Перший контакт: ключ показано, закріплення — за людиною. + // Пробувати автентифікуватись до нього нема сенсу, бо саме + // цей крок вона ще не підтвердила. + return stat, nil + } + if !stat.HostKeyKnown { + return stat, fmt.Errorf( + "ключ сервера змінився: закріплено %s, показано %s", + Fingerprint(r.HostKey), Fingerprint(key)) + } + } + + auth, err := r.authMethod() + if err != nil { + return stat, err + } + + remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ + Name: "probe", + URLs: []string{r.URL}, + }) + + ctx, cancel := context.WithTimeout(ctx, r.timeout()) + defer cancel() + + refs, err := remote.ListContext(ctx, &git.ListOptions{Auth: auth}) + switch { + case err == nil: + stat.Refs = len(refs) + case errors.Is(err, transport.ErrEmptyRemoteRepository): + // Свіжостворений репозиторій у Forgejo. Найчастіший і + // найкращий випадок — саме в такий і треба пушити. + stat.Empty = true + default: + return stat, r.explain(err) + } + return stat, nil +} + +// ScanHostKey відкриває TCP-з'єднання й повертає ключ, який показує +// SSH-сервер, у форматі authorized_keys. +// +// Аналог `ssh-keyscan`. Ключ не приймається автоматично: він +// повертається людині, і закріплює його вона, натиснувши «зберегти». +// Автоматичне TOFU виглядало б так само зручно й нічого б не +// перевіряло — довіра тоді дістається тому, хто відповів першим. +func ScanHostKey(ctx context.Context, rawURL string, timeout time.Duration) (string, error) { + ep, err := transport.NewEndpoint(rawURL) + if err != nil { + return "", ErrBadRemoteURL + } + port := ep.Port + if port == 0 { + port = 22 + } + addr := net.JoinHostPort(ep.Host, fmt.Sprint(port)) + + if timeout <= 0 { + timeout = 15 * time.Second + } + d := net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return "", fmt.Errorf("з'єднання з %s: %w", addr, err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + var seen cssh.PublicKey + cfg := &cssh.ClientConfig{ + User: "netpulse-keyscan", + HostKeyCallback: func(_ string, _ net.Addr, key cssh.PublicKey) error { + seen = key + return nil + }, + Timeout: timeout, + } + // Рукостискання свідомо доводиться до відмови в автентифікації: + // ключ сервера показується ДО неї, а користувача з таким іменем на + // сервері немає й не має бути. + c, _, _, err := cssh.NewClientConn(conn, addr, cfg) + if c != nil { + _ = c.Close() + } + if seen == nil { + if err != nil { + return "", fmt.Errorf("не вдалося прочитати ключ %s: %w", addr, err) + } + return "", fmt.Errorf("сервер %s не показав ключа", addr) + } + return strings.TrimSpace(string(cssh.MarshalAuthorizedKey(seen))), nil +} + +// Fingerprint — короткий відбиток ключа для показу людині. +func Fingerprint(authorizedKey string) string { + pub, _, _, _, err := cssh.ParseAuthorizedKey([]byte(authorizedKey)) + if err != nil { + return "невідомий ключ" + } + return pub.Type() + " " + cssh.FingerprintSHA256(pub) +} + +func sameHostKey(a, b string) bool { + pa, _, _, _, ea := cssh.ParseAuthorizedKey([]byte(a)) + pb, _, _, _, eb := cssh.ParseAuthorizedKey([]byte(b)) + if ea != nil || eb != nil { + return false + } + return subtle.ConstantTimeCompare(pa.Marshal(), pb.Marshal()) == 1 +} + +// ValidateRemoteURL відсіює адреси, якими дзеркало бути не може. +// +// file:// і локальні шляхи заборонені окремо й навмисно: адресу задає +// користувач тенанта, а процес сервера має право писати на свій диск. +// «Дзеркало» в /var/lib було б не копією за периметром, а способом +// покласти дані туди, куди попросили. +func ValidateRemoteURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return ErrNoRemoteURL + } + ep, err := transport.NewEndpoint(raw) + if err != nil { + return ErrBadRemoteURL + } + switch ep.Protocol { + case "https", "ssh": + return nil + case "http": + // Конфіги везуть community-рядки, ключі й хеші паролів. Віддати + // їх у відкритому вигляді через інтернет не можна; всередині + // своєї мережі — питання власника мережі, і саме там стоїть + // більшість самостійно піднятих Forgejo. + host := ep.Host + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("не вдалося розв'язати %s", host) + } + for _, ip := range ips { + if !(ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()) { + return ErrPlainHTTP + } + } + return nil + default: + return ErrUnsupportedTr + } +} + +// --- автентифікація --------------------------------------------------- + +func (r Remote) transportKind() string { + ep, err := transport.NewEndpoint(r.URL) + if err != nil { + return AuthNone + } + if ep.Protocol == "ssh" { + return AuthSSH + } + return AuthHTTPS +} + +func (r Remote) timeout() time.Duration { + if r.Timeout > 0 { + return r.Timeout + } + // Півгодини. Перший push інсталяції, яка рік збирала конфіги, — + // це вся історія за раз: на стенді один ZTE дає 32 тисячі рядків + // на версію, і сотня хостів за рік — гігабайти. Хвилинний таймаут + // зробив би перше вмикання неможливим саме там, де воно найпотрібніше. + return 30 * time.Minute +} + +func (r Remote) authMethod() (transport.AuthMethod, error) { + switch r.transportKind() { + case AuthSSH: + if strings.TrimSpace(r.Secret) == "" { + return nil, errors.New("для SSH потрібен приватний ключ") + } + if strings.TrimSpace(r.HostKey) == "" { + return nil, ErrHostKeyUnset + } + + user := r.User + if user == "" { + if ep, err := transport.NewEndpoint(r.URL); err == nil && ep.User != "" { + user = ep.User + } + } + if user == "" { + user = "git" + } + + keys, err := gitssh.NewPublicKeys(user, []byte(r.Secret), "") + if err != nil { + // Текст go-ssh про розбір ключа не містить самого ключа, + // але Scrub тут стоїть як правило, а не як виняток. + return nil, Scrub(fmt.Errorf("приватний ключ: %w", err), r.Secret) + } + + cb, algos, err := pinnedHostKey(r.HostKey) + if err != nil { + return nil, err + } + keys.HostKeyCallback = cb + keys.HostKeyAlgorithms = algos + return keys, nil + + default: + if strings.TrimSpace(r.Secret) == "" { + return nil, nil + } + user := r.User + if user == "" { + if ep, err := transport.NewEndpoint(r.URL); err == nil && ep.User != "" { + user = ep.User + } + } + if user == "" { + // GitHub і Forgejo приймають будь-який непорожній логін, + // коли пароль — токен. Порожній логін вони відхиляють, і + // повідомлення при цьому нічого не пояснює. + user = "netpulse" + } + return &githttp.BasicAuth{Username: user, Password: r.Secret}, nil + } +} + +// pinnedHostKey — перевірка ключа сервера за закріпленим значенням. +func pinnedHostKey(line string) (cssh.HostKeyCallback, []string, error) { + pub, _, _, _, err := cssh.ParseAuthorizedKey([]byte(line)) + if err != nil { + return nil, nil, fmt.Errorf("закріплений ключ сервера не розібрано: %w", err) + } + want := pub.Marshal() + + algos := []string{pub.Type()} + if pub.Type() == cssh.KeyAlgoRSA { + // Сервер із RSA-ключем підписує рукостискання алгоритмом + // rsa-sha2-*; сам ключ при цьому той самий. Без цього переліку + // сучасний OpenSSH просто не запропонував би закріплений ключ. + algos = []string{cssh.KeyAlgoRSASHA512, cssh.KeyAlgoRSASHA256, cssh.KeyAlgoRSA} + } + + return func(hostname string, _ net.Addr, key cssh.PublicKey) error { + if subtle.ConstantTimeCompare(key.Marshal(), want) == 1 { + return nil + } + return fmt.Errorf("ключ сервера %s не збігається із закріпленим: показано %s, очікували %s", + hostname, cssh.FingerprintSHA256(key), cssh.FingerprintSHA256(pub)) + }, algos, nil +} + +// --- помилки ---------------------------------------------------------- + +// userInfoRe знаходить «схема://щось@» — саме туди go-git і http.Client +// вкладають логін із токеном, коли адресу ввели у вигляді +// https://user:token@forgejo.example/repo.git. +var userInfoRe = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.\-]*://)[^/@\s]*@`) + +// Scrub прибирає з тексту помилки все, що не має потрапити ні в журнал, +// ні в базу, ні на екран. +// +// Дві різні витоки, і друга неочевидна. Перша — сам токен, якщо він +// потрапив у повідомлення. Друга — URL із логіном усередині: go-git +// охоче вкладає повну адресу в текст помилки транспорту, а люди так +// само охоче вставляють у поле адреси рядок, скопійований із інструкції +// «git clone https://токен@…». Тому чиститься і те, і те. +func Scrub(err error, secrets ...string) error { + if err == nil { + return nil + } + msg := err.Error() + for _, s := range secrets { + s = strings.TrimSpace(s) + // Коротке значення замінювати небезпечно: воно збіглося б із + // випадковим підрядком і перетворило повідомлення на кашу. + if len(s) < 8 { + continue + } + msg = strings.ReplaceAll(msg, s, "«секрет»") + // Приватний ключ багаторядковий: у повідомленні від нього + // зазвичай лишається лише перший рядок. + for _, line := range strings.Split(s, "\n") { + if line = strings.TrimSpace(line); len(line) >= 16 { + msg = strings.ReplaceAll(msg, line, "«секрет»") + } + } + } + msg = userInfoRe.ReplaceAllString(msg, "$1") + return errors.New(msg) +} + +// explain перекладає типові відмови транспорту зрозумілою мовою. +// +// Не косметика: «authentication required» нічого не каже людині, яка +// щойно вставила токен і не знає, чи він не той, чи його не бачать, чи +// репозиторію взагалі немає. Кожен із трьох випадків лікується різним +// рухом. +func (r Remote) explain(err error) error { + if err == nil { + return nil + } + switch { + case errors.Is(err, transport.ErrAuthenticationRequired): + // RedactURL, а не r.URL: у поле адреси регулярно вставляють + // рядок виду https://токен@forgejo/… з чужої інструкції, і без + // цього виклику текст помилки поніс би токен у базу й на екран. + return fmt.Errorf("сервер вимагає автентифікації: для %s потрібні логін і токен", + RedactURL(r.URL)) + // Закріплений ключ звужує перелік алгоритмів до типу самого ключа — + // інакше сучасний OpenSSH запропонував би ed25519 там, де ми + // закріпили rsa, і збіг був би неможливий. Зворотний бік: коли ключ + // сервера СПРАВДІ замінили на інший тип, рукостискання падає ще до + // нашої перевірки, і go-ssh каже про алгоритми. Перевірено наживо на + // Gitea з rsa-ключем і закріпленим ed25519 — саме цей текст. + case strings.Contains(err.Error(), "no common algorithm for host key"): + return errors.New("сервер показав ключ іншого типу, ніж закріплений — " + + "найімовірніше, ключ сервера замінили. Виконайте перевірку з'єднання " + + "й звірте відбиток, перш ніж закріплювати новий") + case errors.Is(err, transport.ErrAuthorizationFailed): + return errors.New("доступ відхилено: токен не має права запису в цей репозиторій") + case errors.Is(err, transport.ErrRepositoryNotFound): + return fmt.Errorf("репозиторію за адресою %s немає — створіть його на сервері", + RedactURL(r.URL)) + // ErrForceNeeded і текстове «non-fast-forward update: » — це + // два боки однієї відмови: перший приходить із перевірки набору + // команд, другий із відповіді сервера на конкретну гілку. + case errors.Is(err, git.ErrForceNeeded) || + strings.Contains(err.Error(), "non-fast-forward"): + return fmt.Errorf("%w: у ньому є коміти, яких немає тут. "+ + "Дзеркало не переписує чужу історію само — або вкажіть порожній репозиторій, "+ + "або підтвердьте перезапис вручну", ErrDiverged) + } + return Scrub(err, r.Secret) +} + +// RedactURL — адреса без логіна й пароля, придатна для журналу. +func RedactURL(raw string) string { + if u, err := url.Parse(raw); err == nil && u.User != nil { + u.User = nil + return u.String() + } + return userInfoRe.ReplaceAllString(raw, "$1") +} diff --git a/server/internal/gitstore/mirror_test.go b/server/internal/gitstore/mirror_test.go new file mode 100644 index 0000000..b2b8c91 --- /dev/null +++ b/server/internal/gitstore/mirror_test.go @@ -0,0 +1,201 @@ +package gitstore + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "fmt" + "net" + "strings" + "testing" + + "github.com/go-git/go-git/v5/plumbing/transport" + cssh "golang.org/x/crypto/ssh" +) + +// Токен не має витікати в текст помилки — ЖОДНИМ із трьох шляхів. +// +// Шлях перший: сам токен у повідомленні транспорту. +// Шлях другий: адреса з логіном усередині, яку go-git вкладає в помилку +// цілком. +// Шлях третій — найпідступніший: наш власний текст, зібраний із r.URL, +// у якому людина залишила токен, скопіювавши адресу з інструкції. +// +// Третій і був помилкою в першій редакції цього файлу: explain +// підставляв r.URL напряму, і повідомлення «сервер вимагає +// автентифікації для https://токен@forgejo/...» лягало б і в базу +// (mirror_last_error), і на екран, і в журнал. + +const fakeToken = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + +func TestScrubRemovesTokenFromError(t *testing.T) { + err := Scrub(fmt.Errorf( + `unexpected client error: unexpected requesting "https://x:%s@forgejo.example/np.git/info/refs" status code: 403`, + fakeToken), fakeToken) + + if strings.Contains(err.Error(), fakeToken) { + t.Fatalf("токен лишився в помилці: %s", err) + } + // Тут спрацювали обидві сітки одразу: заміна токена, а потім зняття + // логіна з адреси разом із нею. Лишитись у тексті має читабельна + // адреса — саме вона й пояснює, куди не поїхало. + if !strings.Contains(err.Error(), "https://forgejo.example/np.git") { + t.Errorf("адреса мала лишитись читабельною: %s", err) + } + + // А коли токен стоїть не в адресі, від нього лишається позначка: + // прибрати мовчки означало б зробити повідомлення незрозумілим. + plain := Scrub(fmt.Errorf("token %s rejected by server", fakeToken), fakeToken) + if strings.Contains(plain.Error(), fakeToken) { + t.Fatalf("токен лишився: %s", plain) + } + if !strings.Contains(plain.Error(), "«секрет»") { + t.Errorf("очікували заміну на «секрет», отримали %s", plain) + } +} + +// Логін в адресі прибирається навіть тоді, коли самого токена ми не +// знаємо: у полі URL може лежати чужий рядок, а Secret — порожній. +func TestScrubStripsUserInfoWithoutKnowingSecret(t *testing.T) { + err := Scrub(errors.New( + `Post "https://netpulse:s3cr3t-token-value@git.example/np.git/git-receive-pack": timeout`)) + + if strings.Contains(err.Error(), "s3cr3t-token-value") { + t.Fatalf("логін з адреси лишився: %s", err) + } + if !strings.Contains(err.Error(), "https://git.example/np.git") { + t.Errorf("адреса має лишитись читабельною, отримали %s", err) + } +} + +// Наші власні пояснення збирають текст із r.URL — і саме там токен +// найлегше пронести повз Scrub, бо Secret у цьому випадку порожній. +func TestExplainNeverEchoesURLCredentials(t *testing.T) { + r := Remote{URL: "https://netpulse:" + fakeToken + "@forgejo.example/np.git"} + + for name, in := range map[string]error{ + "потрібна автентифікація": transport.ErrAuthenticationRequired, + "репозиторію немає": transport.ErrRepositoryNotFound, + "чужа помилка": fmt.Errorf("dial tcp: %s", r.URL), + } { + got := r.explain(in) + if strings.Contains(got.Error(), fakeToken) { + t.Errorf("%s: токен просочився: %s", name, got) + } + } +} + +// Приватний ключ у повідомленні — той самий клас витоку, що й токен, +// тільки багаторядковий. +func TestScrubRemovesPrivateKeyBody(t *testing.T) { + key := "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmU\n-----END OPENSSH PRIVATE KEY-----" + err := Scrub(fmt.Errorf("ssh: не вдалося розібрати %s", key), key) + if strings.Contains(err.Error(), "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU") { + t.Fatalf("тіло ключа лишилось: %s", err) + } +} + +// Короткий секрет не замінюється: інакше випадковий підрядок перетворив +// би повідомлення на кашу. +func TestScrubIgnoresShortSecrets(t *testing.T) { + err := Scrub(errors.New("connection to git refused"), "git") + if err.Error() != "connection to git refused" { + t.Fatalf("коротке значення не мало замінюватись: %s", err) + } +} + +// --- ключ хоста ------------------------------------------------------- + +func newHostKey(t *testing.T) (cssh.Signer, string) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := cssh.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + line := strings.TrimSpace(string(cssh.MarshalAuthorizedKey(signer.PublicKey()))) + return signer, line +} + +func TestPinnedHostKeyAcceptsOnlyPinned(t *testing.T) { + ours, line := newHostKey(t) + other, _ := newHostKey(t) + + cb, algos, err := pinnedHostKey(line) + if err != nil { + t.Fatal(err) + } + if len(algos) == 0 || algos[0] != cssh.KeyAlgoED25519 { + t.Errorf("алгоритм ключа має бути закріплений, отримали %v", algos) + } + addr := &net.TCPAddr{IP: net.IPv4(10, 0, 0, 1), Port: 22} + + if err := cb("git.example:22", addr, ours.PublicKey()); err != nil { + t.Fatalf("закріплений ключ мав пройти: %v", err) + } + err = cb("git.example:22", addr, other.PublicKey()) + if err == nil { + t.Fatal("чужий ключ пройшов перевірку") + } + // Повідомлення має нести обидва відбитки: людині треба звірити те, + // що вона бачить, із тим, що показує сервер, а не гадати. + if !strings.Contains(err.Error(), "SHA256:") { + t.Errorf("у помилці немає відбитка: %v", err) + } +} + +// Без закріпленого ключа push по SSH не збирається взагалі. Це і є +// рішення, протилежне до того, що ухвалено для мережевого заліза. +func TestSSHRefusesWithoutPinnedHostKey(t *testing.T) { + r := Remote{ + URL: "ssh://git@forgejo.example/np/configs.git", + Secret: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----", + } + if _, err := r.authMethod(); !errors.Is(err, ErrHostKeyUnset) { + t.Fatalf("очікували ErrHostKeyUnset, отримали %v", err) + } +} + +// --- адреси ----------------------------------------------------------- + +func TestValidateRemoteURL(t *testing.T) { + ok := []string{ + "https://forgejo.example/np/configs.git", + "ssh://git@forgejo.example:2222/np/configs.git", + "git@github.com:np/configs.git", + // http всередину мережі — типовий самостійно піднятий Forgejo. + "http://127.0.0.1:3000/np/configs.git", + "http://10.8.0.5/np/configs.git", + } + for _, u := range ok { + if err := ValidateRemoteURL(u); err != nil { + t.Errorf("%s мала пройти: %v", u, err) + } + } + + bad := map[string]error{ + "": ErrNoRemoteURL, + "file:///var/lib/x.git": ErrUnsupportedTr, + "/var/lib/netpulse/x.git": ErrUnsupportedTr, + // Відкритий http назовні везе ключі й хеші паролів як є. + "http://8.8.8.8/np/configs.git": ErrPlainHTTP, + } + for u, want := range bad { + if err := ValidateRemoteURL(u); !errors.Is(err, want) { + t.Errorf("%q: очікували %v, отримали %v", u, want, err) + } + } +} + +func TestRedactURL(t *testing.T) { + got := RedactURL("https://netpulse:" + fakeToken + "@forgejo.example/np.git") + if strings.Contains(got, fakeToken) { + t.Fatalf("токен лишився: %s", got) + } + if got != "https://forgejo.example/np.git" { + t.Errorf("отримали %s", got) + } +} diff --git a/server/internal/gitstore/store.go b/server/internal/gitstore/store.go index c9359cf..b88d16c 100644 --- a/server/internal/gitstore/store.go +++ b/server/internal/gitstore/store.go @@ -458,3 +458,49 @@ func short(sha string) string { } return sha } + +// RenameBranch переносить гілку під нове ім'я. +// +// Потрібне, коли хост перейменували або змінили йому адресу: ім'я гілки +// складається з них, і без переносу історія пристрою розпалась би на +// дві — половина під старим іменем, половина під новим, і знайти першу +// можна лише знаючи, як хост звався колись. +// +// Сама історія при цьому не змінюється: переставляється лише посилання. +// Відсутність старої гілки помилкою не вважається — пристрій міг ще не +// комітитись, або перенос уже відбувся раніше. +func (s *Store) RenameBranch(repoName, from, to string) error { + if from == to || from == "" || to == "" { + return nil + } + path := filepath.Join(s.root, repoName) + + mu := s.lockFor(path) + mu.Lock() + defer mu.Unlock() + + repo, err := open(path) + if err != nil { + return err + } + + oldRef := plumbing.NewBranchReferenceName(from) + ref, err := repo.Reference(oldRef, true) + if err != nil { + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return nil + } + return fmt.Errorf("гілка %s: %w", from, err) + } + + newRef := plumbing.NewBranchReferenceName(to) + if err := repo.Storer.SetReference(plumbing.NewHashReference(newRef, ref.Hash())); err != nil { + return fmt.Errorf("нова гілка %s: %w", to, err) + } + // Стару прибираємо ПІСЛЯ того, як нова вказала на той самий коміт: + // обрив між двома діями лишає історію досяжною хоч під одним іменем. + if err := repo.Storer.RemoveReference(oldRef); err != nil { + return fmt.Errorf("прибрати гілку %s: %w", from, err) + } + return nil +} diff --git a/server/internal/grpcapi/integration_test.go b/server/internal/grpcapi/integration_test.go index 3e04ffd..659c3bb 100644 --- a/server/internal/grpcapi/integration_test.go +++ b/server/internal/grpcapi/integration_test.go @@ -277,10 +277,25 @@ func TestControlHandshake(t *testing.T) { if plan == nil { t.Fatal("план задач не надійшов") } - if len(plan.Tasks) != 1 || plan.Tasks[0].CheckId != f.checkID { - t.Fatalf("план зібрано неправильно: %+v", plan.Tasks) + // У плані не один чек, а два. Другий — `topology.identify`, і його + // заводить сам сервер при підключенні зонда (service.go, + // EnsureIdentifyChecks): хост зі SNMP-доступом отримує розпізнавання + // без жодного натискання. + // + // Тест писався до появи розпізнавання й перевіряв рівність одиниці. + // Півтора року він цього не помічав, бо мовчки пропускався без + // NETPULSE_TEST_DSN — перший же справжній прогін його завалив. + // Шукаємо СВІЙ чек серед решти, а не покладаємось на їхню кількість: + // наступний автоматичний чек інакше завалить його знову. + var task *npv1.Task + for _, tk := range plan.Tasks { + if tk.CheckId == f.checkID { + task = tk + } + } + if task == nil { + t.Fatalf("свого чека в плані немає: %+v", plan.Tasks) } - task := plan.Tasks[0] if task.CheckType != "icmp.ping" { t.Fatalf("check_type = %q", task.CheckType) } @@ -1088,6 +1103,22 @@ func TestPlanHashSkipsResend(t *testing.T) { ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second) defer cancel() + // Спершу даємо серверу завести автоматичні чеки, і лише потім + // рахуємо хеш. + // + // Інакше тест перевіряє не те, що збирався. Розпізнавання заводиться + // при підключенні зонда, тобто МІЖ нашим BuildPlan і Hello: хеш, з + // яким ми прийшли, застаріває дорогою, сервер чесно вирішує + // переслати план — і тест звинувачує його в тому, що зробив сам. + // + // Виклик ідемпотентний: другий раз чек не заводиться, тож після + // нього хеш уже стабільний — саме та властивість, яку тест і + // перевіряє. + if _, err := f.store.EnsureIdentifyChecks(f.ctx, + &store.Agent{ID: f.agentID, TenantID: f.tenantID}); err != nil { + t.Fatalf("EnsureIdentifyChecks: %v", err) + } + // Перше підключення: дізнаємось хеш. plan, err := f.store.BuildPlan(f.ctx, &store.Agent{ID: f.agentID, TenantID: f.tenantID}) if err != nil { diff --git a/server/internal/grpcapi/ncm_commands.go b/server/internal/grpcapi/ncm_commands.go new file mode 100644 index 0000000..292012c --- /dev/null +++ b/server/internal/grpcapi/ncm_commands.go @@ -0,0 +1,216 @@ +package grpcapi + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "time" + + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Масове виконання команд: серверна половина. +// +// Транспорту тут свого немає жодного — і в цьому вся суть. Завдання +// їде тим самим ConfigJob, вивід повертається тим самим стрімом +// UploadConfig, диспетчер працює в тому самому такті. Різниця в одному +// полі (config_type) і в тому, куди лягає результат. + +// commandOutcome — те, що надсилає зонд (див. ncmx.CommandReport). +type commandOutcome struct { + Command string `json:"command"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} + +type commandReport struct { + Outcomes []commandOutcome `json:"outcomes"` + Error string `json:"error,omitempty"` +} + +// isCommandUpload розрізняє вивід команд і зібраний конфіг. +// +// Заголовок від зонда — підказка, а не доказ: він приходить із поля, яке +// агент лише переспівує з завдання, і зонд старішої версії заповнить +// його як завгодно. Тому вирішує база: id завдання або є рядком прогону +// команд, або ні. Ціна — один запит за первинним ключем; ціна помилки — +// вивід `display cpu` у ролі версії конфігу в архіві. +func (s *Service) isCommandUpload(ctx context.Context, header *npv1.ConfigHeader) bool { + if header.GetJobId() == "" { + return false + } + if header.GetConfigType() != store.CommandConfigType { + // Швидкий шлях для звичайного бекапу: зайвий запит на кожен + // зібраний конфіг нічого не дав би. + return false + } + ok, err := s.store.IsCommandTarget(ctx, header.GetJobId()) + if err != nil { + s.log.Warn("звірка завдання з прогоном команд", "job", header.GetJobId(), "err", err) + return false + } + return ok +} + +// storeCommandResult кладе вивід команд у рядок прогону. +// +// Повертає готову квитанцію, а не помилку: зонд має отримати відповідь +// у будь-якому разі — інакше він вважатиме вивантаження незавершеним і +// повторить його разом із новою сесією до пристрою. +func (s *Service) storeCommandResult(ctx context.Context, header *npv1.ConfigHeader, + body []byte, tr *npv1.ConfigTrailer) *npv1.ConfigReceipt { + + targetID := header.GetJobId() + + if !tr.GetSuccess() { + // Зонд не дійшов до команд: не під'єднався, не дочекався + // привітання. Показувати нічого, крім причини й стенограми, — + // і саме вони тут найпотрібніші. + msg := tr.GetError().GetMessage() + if msg == "" { + msg = "зонд не зміг виконати команди" + } + if err := s.store.FinishCommandTarget(ctx, targetID, "failed", msg, nil, + tr.GetTranscript()); err != nil { + s.log.Error("запис невдалого виконання команд", "target", targetID, "err", err) + } + return &npv1.ConfigReceipt{JobId: targetID, Accepted: true, Error: tr.GetError()} + } + + plain, err := decodeBody(body, header.GetEncoding()) + if err != nil { + _ = s.store.FinishCommandTarget(ctx, targetID, "failed", + "не вдалося розпакувати вивід: "+err.Error(), nil, tr.GetTranscript()) + return &npv1.ConfigReceipt{ + JobId: targetID, Accepted: false, + Error: &npv1.Error{Code: "bad_encoding", Message: err.Error()}, + } + } + + // Сума звіряється так само, як для конфігу: обірваний посеред + // передачі JSON розібрався б із помилкою, а обірваний на межі + // елемента — мовчки, і хост лишився б із половиною виводу, схожою + // на цілий. + if want := tr.GetContentSha256(); len(want) > 0 { + got := sha256.Sum256(plain) + if !bytes.Equal(got[:], want) { + _ = s.store.FinishCommandTarget(ctx, targetID, "failed", + "тіло не відповідає заявленому sha256", nil, tr.GetTranscript()) + return &npv1.ConfigReceipt{ + JobId: targetID, Accepted: false, + Error: &npv1.Error{ + Code: "checksum_mismatch", Retryable: true, + Message: "тіло не відповідає заявленому sha256", + }, + } + } + } + + var rep commandReport + if err := json.Unmarshal(plain, &rep); err != nil { + _ = s.store.FinishCommandTarget(ctx, targetID, "failed", + "незрозумілий формат виводу від зонда: "+err.Error(), nil, tr.GetTranscript()) + return &npv1.ConfigReceipt{ + JobId: targetID, Accepted: false, + Error: &npv1.Error{Code: "bad_body", Message: err.Error()}, + } + } + + // Підготовчі команди профілю приїхали разом із командами людини — + // зонд про різницю не знає й знати не мусить. Розмічаємо тут, за + // переліком самого прогону. + userCmds, err := s.store.CommandRunCommands(ctx, targetID) + if err != nil { + s.log.Warn("перелік команд прогону", "target", targetID, "err", err) + } + isUser := make(map[string]bool, len(userCmds)) + for _, c := range userCmds { + isUser[c] = true + } + + outcomes := make([]store.CommandOutcome, 0, len(rep.Outcomes)) + for _, o := range rep.Outcomes { + outcomes = append(outcomes, store.CommandOutcome{ + Command: o.Command, + Output: o.Output, + Error: o.Error, + Prep: len(isUser) > 0 && !isUser[o.Command], + }) + } + + status := "success" + if rep.Error != "" { + status = "failed" + } + if err := s.store.FinishCommandTarget(ctx, targetID, status, rep.Error, + outcomes, tr.GetTranscript()); err != nil { + s.log.Error("запис результату команд", "target", targetID, "err", err) + } + + s.log.Info("вивід команд прийнято", + "target", targetID, "команд", len(outcomes), "стан", status) + + return &npv1.ConfigReceipt{JobId: targetID, Accepted: true} +} + +// --------------------------------------------------------------------- +// Диспетчер +// --------------------------------------------------------------------- + +// dispatchCommandJobs роздає хости прогонів живим сесіям. +// +// Їде в такті збору конфігів, а не власним таймером: це той самий шлях +// «база → жива сесія», і другий цикл із власним інтервалом додав би ще +// одне місце, де налаштування розходяться. +func (s *Service) dispatchCommandJobs(ctx context.Context, online []string) { + jobs, err := s.store.ClaimCommandJobs(ctx, online, 16, s.ring) + if err != nil { + s.log.Error("вибірка команд для виконання", "err", err) + return + } + + for _, j := range jobs { + ok := s.PushToAgent(j.AgentID, &npv1.ControlDown{ + Payload: &npv1.ControlDown_ConfigJob{ConfigJob: j.Job}, + }) + if !ok { + // Сесія обірвалась між вибіркою й відправкою. Хост падає, а + // не повертається в чергу: на пристрої ще нічого не + // відбувалось, але мовчазний повтор на зонді, який зараз + // відвалюється, крутив би прогін нескінченно. Один рядок із + // причиною — і людина сама вирішує, чи повторювати. + _ = s.store.FinishCommandTarget(ctx, j.TargetID, "failed", + "зонд відключився до надсилання завдання", nil, "") + continue + } + if err := s.store.MarkCommandTargetSent(ctx, j.TargetID); err != nil { + s.log.Warn("позначка про надсилання команд", "target", j.TargetID, "err", err) + } + s.log.Info("команди надіслано зонду", + "target", j.TargetID, "agent", j.AgentID, "device", j.DeviceName) + } +} + +// reapCommandRuns прибирає зависле. +// +// Рідкісним тактом навмисно: обидва випадки — це хвилини очікування, і +// перевіряти їх щоп'ять секунд означало б платити запитом за подію, якої +// в нормальній роботі не буває. +func (s *Service) reapCommandRuns(ctx context.Context) { + if n, err := s.store.ReapStuckCommandTargets(ctx, 2*time.Minute); err != nil { + s.log.Warn("прибирання завислих команд", "err", err) + } else if n > 0 { + s.log.Warn("зависле виконання команд перезаведено або закрито", "рядків", n) + } +} + +// settleCommandRuns закриває прогони, у яких не лишилось роботи. +func (s *Service) settleCommandRuns(ctx context.Context) { + if n, err := s.store.SettleCommandRuns(ctx); err != nil { + s.log.Warn("закриття прогонів команд", "err", err) + } else if n > 0 { + s.log.Info("прогони команд завершено", "прогонів", n) + } +} diff --git a/server/internal/grpcapi/ncm_dispatch.go b/server/internal/grpcapi/ncm_dispatch.go index 127b09a..fc58421 100644 --- a/server/internal/grpcapi/ncm_dispatch.go +++ b/server/internal/grpcapi/ncm_dispatch.go @@ -40,13 +40,48 @@ func (s *Service) DispatchConfigJobs(ctx context.Context, every time.Duration) { } else if n > 0 { s.log.Warn("завдання збору зависли й позначені як невдалі", "рядків", n) } + s.reapCommandRuns(ctx) + // Відкат прибирається в тому ж такті, але його «зависло» + // означає інше, ніж у решти: зонд міг зникнути ПОСЕРЕД + // заливки, і на пристрої лишився стан, якого не описує + // жоден із двох конфігів. Тому там не відмова, а + // контрольний збір — див. store.ReapStuckRollbacks. + s.reapRollbacks(ctx) case <-t.C: + // Закриття доведених до кінця прогонів — на швидкому такті + // й ДО перевірки, чи є живі зонди. Прогін, у якому останній + // хост щойно відзвітував (або жоден і не мав зонда), + // інакше висів би «у роботі» до прибиральника, а сторінка + // весь цей час перечитувала б його щодві секунди. + s.settleCommandRuns(ctx) + // Підсумок контрольних зборів після відкатів — теж на + // швидкому такті й теж до перевірки живих зондів: намір, + // у якого перевірка вже зібралась, інакше висів би + // «перевіряємо» до наступного прибиральника, а сторінка + // весь цей час його перечитувала б. + s.settleRollbacks(ctx) + online := s.onlineAgentIDs() if len(online) == 0 { continue } + // Прохання «розпізнати зараз» їде тим самим тактом: + // це той самий шлях REST → база → жива сесія, і власний + // таймер задля кількох рядків був би зайвою деталлю. + s.dispatchIdentify(ctx, online) + + // Масове виконання команд — так само: та сама черга в базі, + // та сама жива сесія. Власний такт додав би ще один + // інтервал, який довелося б тримати узгодженим із цим. + s.dispatchCommandJobs(ctx, online) + + // Відкат — тим самим тактом і тією ж живою сесією. Власної + // черги йому не потрібно: у .proto для нього є окреме + // повідомлення, а шлях «база → сесія» той самий. + s.dispatchRollbacks(ctx, online) + jobs, err := s.store.ClaimConfigJobs(ctx, online, 16, s.ring) if err != nil { s.log.Error("вибірка завдань збору", "err", err) @@ -84,3 +119,43 @@ func (s *Service) onlineAgentIDs() []string { } return out } + +// dispatchIdentify роздає прохання розпізнати хост. +// +// Зонд не виконує все, що прилетіло: DiscoveryRequest лише зрушує +// задачі, які вже є в його розкладі. Тому чек розпізнавання створюється +// раніше — у RequestIdentify, — а сюди доходить саме поштовх. +func (s *Service) dispatchIdentify(ctx context.Context, online []string) { + reqs, err := s.store.ClaimIdentifyRequests(ctx, online, 64) + if err != nil { + s.log.Warn("вибірка прохань розпізнати", "err", err) + return + } + if len(reqs) == 0 { + return + } + + // Спершу план, потім поштовх. RequestIdentify міг щойно завести + // чек, а зонд зрушує лише те, що вже має в розкладі: без цього + // рядка кнопка мовчки нічого не робила б саме там, де вона + // найпотрібніша — на щойно заведеному хості. + s.syncPlansTick(ctx) + + for _, r := range reqs { + ok := s.PushToAgent(r.AgentID, &npv1.ControlDown{ + Payload: &npv1.ControlDown_DiscoveryRequest{ + DiscoveryRequest: &npv1.DiscoveryRequest{ + RunId: "identify:" + r.DeviceID, + DeviceIds: []string{r.DeviceID}, + }, + }, + }) + if !ok { + s.log.Warn("зонд відключився до надсилання прохання розпізнати", + "agent", r.AgentID, "device", r.DeviceID) + continue + } + s.log.Info("розпізнавання зрушено вручну", + "agent", r.AgentID, "device", r.DeviceID) + } +} diff --git a/server/internal/grpcapi/ncm_mirror.go b/server/internal/grpcapi/ncm_mirror.go new file mode 100644 index 0000000..1cb96aa --- /dev/null +++ b/server/internal/grpcapi/ncm_mirror.go @@ -0,0 +1,335 @@ +package grpcapi + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/netpulse/netpulse/server/internal/gitstore" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Фоновий такт дзеркалення архіву конфігів на зовнішній Git. +// +// ГОЛОВНЕ РІШЕННЯ ЦЬОГО ФАЙЛУ — те, що його немає в шляху збору. +// +// Був простіший варіант: пушити одразу після коміту, у StoreConfig. +// Він на десять рядків коротший і має рівно один недолік — робить +// доступність чужого сервера умовою успішного бекапу. Збір конфігів +// тут — нічний розклад по сотнях пристроїв через живі сесії зондів; на +// кожному з них push додав би секунди мережевого очікування, а на +// недоступному Forgejo — таймаут. Тобто зламаний бекап там, де все +// залізо відповіло, конфіг зібрано й у базі він уже лежить. +// +// Дзеркало — копія. Копія не має права ламати оригінал. +// +// Звідси решта: власний такт, власне advisory-блокування, витримка +// повтору в БАЗІ (а не в пам'яті процесу), і жодного зворотного зв'язку +// в ncm.jobs. +// +// Живе в колекторі поруч із прибиральником (ncm_retention.go) і з тих +// самих причин: тут стоїть той, хто версії створює, тут уже є ключ +// шифрування, і це не процес, який обслуговує запити людини. + +// mirrorLockKey — «npmr». Своє, не спільне з прибиральником і +// планувальником: перший повний push інсталяції з річною історією може +// тривати десятки хвилин, і зупиняти на цей час чистку чи розклад збору +// не має жодних підстав. +const mirrorLockKey = 0x6e70_6d72 + +// MirrorInterval — як часто дивитись, чи є що дзеркалити. +// +// Хвилина. Такт дешевий: якщо локальні посилання не змінились із +// останнього вдалого push, у мережу він не йде взагалі (див. нижче). +// Рідший такт означав би, що ручне «надіслати зараз» після зміни +// налаштувань чекає невідомо скільки. +const MirrorInterval = time.Minute + +// mirrorVerifyEvery — як часто ходити на сервер, навіть коли локально +// нічого не змінилось. +// +// Відбиток посилань каже, чи змінилось у НАС. Він нічого не каже про +// те, чи ціле дзеркало: репозиторій на тому кінці могли видалити, +// перестворити порожнім або відкликати токен. Без цієї перевірки +// інсталяція, де конфіги стабільні тижнями, вважала б дзеркало живим +// рівно доти, доки воно не знадобиться. +const mirrorVerifyEvery = 6 * time.Hour + +// mirrorAlertAfter — скільки невдач поспіль до алерту. +// +// Три. З витримкою повтору це приблизно вісім хвилин — досить, щоб +// пережити перезавантаження Forgejo, і замало, щоб відмова прожила +// непоміченою день. +const mirrorAlertAfter = 3 + +// MirrorGit тримає такт дзеркалення. +// +// Кілька екземплярів колектора безпечні: тік бере advisory-блокування, +// тож пушить рівно один. Два одночасні push у той самий репозиторій +// не зіпсували б віддалене дерево (без -f воно або перемотується, або +// відмовляє), але дали б два з'єднання й два суперечливі записи стану. +func (s *Service) MirrorGit(ctx context.Context) { + t := time.NewTicker(MirrorInterval) + defer t.Stop() + + s.log.Info("дзеркалення конфігів у зовнішній Git запущено", "інтервал", MirrorInterval) + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.mirrorTick(ctx) + } + } +} + +func (s *Service) mirrorTick(ctx context.Context) { + conn, err := s.store.WorkerPool().Acquire(ctx) + if err != nil { + s.log.Error("дзеркало: з'єднання", "err", err) + return + } + defer conn.Release() + + var got bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, + int64(mirrorLockKey)).Scan(&got); err != nil { + s.log.Error("дзеркало: блокування", "err", err) + return + } + if !got { + // Пушить інший інстанс — штатний стан. + return + } + defer func() { + _, _ = conn.Exec(context.WithoutCancel(ctx), + `SELECT pg_advisory_unlock($1)`, int64(mirrorLockKey)) + }() + + // Спершу видалення гілок, потім звичайний push. + // + // Порядок не косметичний: шаблонний refspec дзеркалення (`*:*`) + // зниклі гілки не прибирає й ніколи не прибиратиме — див. чому в + // store/ncm_refqueue.go. Тобто це єдине місце, де видалений хост + // зникає з дзеркала, і робити його після push означало б лишати + // гілку живою на тому кінці ще на такт. + s.drainRefDeletes(ctx) + + targets, err := s.store.MirrorTargets(ctx, s.ring) + if err != nil { + s.log.Error("дзеркало: перелік кабінетів", "err", err) + return + } + + for _, t := range targets { + if ctx.Err() != nil { + return + } + s.mirrorOne(ctx, t) + } +} + +func (s *Service) mirrorOne(ctx context.Context, t store.MirrorTarget) { + refs, hash, err := s.store.LocalMirrorState(t.TenantID) + switch { + case errors.Is(err, gitstore.ErrNoRepo), err == nil && refs == 0: + // Дзеркало ввімкнули до першого бекапу. Це не відмова й не + // успіх: позначити його вдалим означало б поставити на сторінці + // час «останньої синхронізації», якої не було. + return + case errors.Is(err, gitstore.ErrDisabled): + s.mirrorFailed(ctx, t, errors.New("версіювання в Git вимкнено на цьому сервері")) + return + case err != nil: + s.mirrorFailed(ctx, t, err) + return + } + + // Нічого не змінилось і нещодавно перевіряли — у мережу не йдемо. + // Дзеркалення не має бути постійним трафіком на чужий сервер: + // архів конфігів міняється раз на добу, а такт тікає щохвилини. + if hash == t.StateHash && t.LastOkAt != nil && time.Since(*t.LastOkAt) < mirrorVerifyEvery { + return + } + + stat, err := s.store.PushMirror(ctx, t.TenantID, t.Remote) + if err != nil { + s.mirrorFailed(ctx, t, err) + return + } + + if err := s.store.MarkMirror(ctx, t.TenantID, t.RepoID, store.MirrorOutcome{ + OK: true, + Refs: stat.Refs, + StateHash: stat.StateHash, + Duration: stat.Took, + }); err != nil { + s.log.Error("дзеркало: запис стану", "tenant", t.TenantID, "err", err) + } + + if t.FailCount > 0 { + if err := s.store.ResolveMirrorAlert(ctx, t.TenantID, t.RepoID); err != nil { + s.log.Error("дзеркало: зняття алерту", "tenant", t.TenantID, "err", err) + } + } + if !stat.UpToDate { + s.log.Info("дзеркало оновлено", "tenant", t.TenantID, + "гілок", stat.Refs, "тривалість", stat.Took.Round(time.Millisecond)) + } +} + +// refDeleteBatch — скільки гілок розглядати за один такт. +// +// Хвилинний такт і сто гілок означають, що масове видалення двохсот +// хостів доїде до дзеркала за дві хвилини. Брати всі одразу немає +// сенсу: кожна гілка — окрема мережева операція, і черга, яка займає +// такт на десять хвилин, затримує сам push. +const refDeleteBatch = 100 + +// drainRefDeletes прибирає гілки видалених хостів. +// +// Два кроки на рядок, і другий може не відбутись роками, не заважаючи +// першому: +// +// 1. локальна гілка — на нашому ж диску, тож або зникає, або її вже +// немає; помилка тут означає зламаний репозиторій, і рядок лишається +// в черзі з поясненням; +// 2. гілка на дзеркалі — по мережі, з витримкою повтору в базі. +// +// Дзеркало не налаштоване — рядок знімається після першого кроку: на +// тому кінці цієї гілки не було ніколи. +func (s *Service) drainRefDeletes(ctx context.Context) { + tasks, err := s.store.PendingRefDeletes(ctx, s.ring, refDeleteBatch) + if err != nil { + s.log.Error("черга видалень гілок", "err", err) + return + } + for _, t := range tasks { + if ctx.Err() != nil { + return + } + if !t.LocalDone { + removed, err := s.store.DeleteLocalBranch(t.TenantID, t.Branch) + switch { + case errors.Is(err, gitstore.ErrDisabled): + // Версіювання вимкнено на цьому сервері — гілок немає + // ні тут, ні на дзеркалі. Рядок нема кому виконувати. + _ = s.store.FinishRefDelete(ctx, t.ID) + continue + case err != nil: + s.failRefDelete(ctx, t, err) + continue + } + if removed { + s.log.Info("гілку видаленого хоста прибрано локально", + "гілка", t.Branch, "хост", t.DeviceName) + } + if err := s.store.MarkRefDeleteLocalDone(ctx, t.ID); err != nil { + s.log.Error("черга видалень гілок: позначка", "err", err) + } + } + + if !t.MirrorReady { + _ = s.store.FinishRefDelete(ctx, t.ID) + continue + } + if err := s.store.DeleteMirrorRefs(ctx, t.TenantID, t.Remote, []string{t.Branch}); err != nil { + s.failRefDelete(ctx, t, err) + continue + } + s.log.Info("гілку видаленого хоста прибрано на дзеркалі", + "гілка", t.Branch, "хост", t.DeviceName, + "url", gitstore.RedactURL(t.Remote.URL)) + if err := s.store.FinishRefDelete(ctx, t.ID); err != nil { + s.log.Error("черга видалень гілок: зняття", "err", err) + } + } +} + +func (s *Service) failRefDelete(ctx context.Context, t store.RefDeleteTask, cause error) { + wait := mirrorBackoff(t.Attempts + 1) + // Витримка та сама, що й у дзеркалення: недоступний сервер не має + // отримувати з'єднання щохвилини цілий тиждень. Окремого алерту тут + // немає навмисно — його вже піднімає сам push, і другий про той + // самий сервер лише розмив би перший. + s.log.Warn("гілку видаленого хоста прибрати не вдалось", + "гілка", t.Branch, "хост", t.DeviceName, + "спроб_поспіль", t.Attempts+1, "повтор_через", wait, "err", cause) + if err := s.store.FailRefDelete(ctx, t.ID, cause, time.Now().Add(wait)); err != nil { + s.log.Error("черга видалень гілок: запис відмови", "err", err) + } +} + +// mirrorFailed записує відмову, призначає наступну спробу й, коли +// відмов уже забагато, піднімає алерт. +func (s *Service) mirrorFailed(ctx context.Context, t store.MirrorTarget, cause error) { + fails := t.FailCount + 1 + wait := mirrorBackoff(fails) + + // Текст помилки вже очищений від секрету в gitstore (Scrub), але + // журнал отримує ще й адресу без логіна: у полі URL цілком може + // лежати https://токен@forgejo/…, вставлений із інструкції. + s.log.Warn("дзеркало: push не вдався", + "tenant", t.TenantID, "url", gitstore.RedactURL(t.Remote.URL), + "спроб_поспіль", fails, "повтор_через", wait, "err", cause) + + if err := s.store.MarkMirror(ctx, t.TenantID, t.RepoID, store.MirrorOutcome{ + OK: false, + Err: cause.Error(), + NextAt: time.Now().Add(wait), + }); err != nil { + s.log.Error("дзеркало: запис стану", "tenant", t.TenantID, "err", err) + } + + if fails < mirrorAlertAfter { + return + } + + // Дві градації, і різниця між ними — не в кількості спроб, а в + // тому, скільки часу вже немає копії. Годину без дзеркала переживе + // будь-хто; доба без нього означає, що вчорашніх конфігів за + // периметром немає взагалі. + severity, since := "average", "жодного разу" + if t.LastOkAt == nil || time.Since(*t.LastOkAt) > 24*time.Hour { + severity = "high" + } + if t.LastOkAt != nil { + since = t.LastOkAt.Format("2006-01-02 15:04") + } + + msg := fmt.Sprintf( + "Архів конфігів не їде на %s. Спроб поспіль: %d. Останній вдалий обмін: %s. Причина: %s", + gitstore.RedactURL(t.Remote.URL), fails, since, cause) + + if err := s.store.RaiseMirrorAlert(ctx, t.TenantID, t.RepoID, severity, + "Дзеркало архіву конфігів не працює", msg); err != nil { + s.log.Error("дзеркало: алерт", "tenant", t.TenantID, "err", err) + } +} + +// mirrorBackoff — витримка перед наступною спробою. +// +// Наростає й упирається в годину. Недоступний сервер не має отримувати +// з'єднання щохвилини цілий тиждень: це і марний трафік, і — коли він +// повернеться — сплеск від усіх інсталяцій одночасно. Стеля саме +// година, а не доба: копія, яка відновлюється через добу після +// полагодження мережі, спізнюється рівно на ту добу, за яку її й могли +// б утратити. +func mirrorBackoff(fails int) time.Duration { + switch { + case fails <= 1: + return time.Minute + case fails == 2: + return 2 * time.Minute + case fails == 3: + return 5 * time.Minute + case fails == 4: + return 15 * time.Minute + case fails == 5: + return 30 * time.Minute + default: + return time.Hour + } +} diff --git a/server/internal/grpcapi/ncm_retention.go b/server/internal/grpcapi/ncm_retention.go new file mode 100644 index 0000000..d2d43ca --- /dev/null +++ b/server/internal/grpcapi/ncm_retention.go @@ -0,0 +1,98 @@ +package grpcapi + +import ( + "context" + "time" +) + +// retentionLockKey — окреме advisory-блокування під прибиральника. +// +// Своє, а не спільне з планувальником бекапів: обидва тікають незалежно, +// і спільний ключ означав би, що довгий перший прохід очистки заодно +// зупиняє розклад збору. +const retentionLockKey = 0x6e70_7263 // "nprc" + +// RetentionInterval — як часто прибирати старі версії. +// +// Година. Найдрібніша одиниця політики — доба, тож частіше немає чого +// шукати; рідше — означало б, що на інсталяції, яка вперше вмикає +// чистку, звільнення місця починається невідомо коли. +const RetentionInterval = time.Hour + +// SweepRetention прибирає старі версії конфігів за політикою. +// +// Живе в колекторі, а не в REST-процесі, і це не байдуже. +// +// По-перше, тут уже стоїть той, хто версії СТВОРЮЄ: планувальник +// бекапів і диспетчер збору. Механізм, що прибирає, доречно тримати +// поруч із тим, що накопичує, — інакше два процеси зі спільною +// відповідальністю за одну таблицю живуть у різних релізних циклах. +// +// По-друге, REST-процес — шлях запитів людини, і його масштабують +// копіями. Довге видалення в тому самому процесі конкурувало б за +// з'єднання з тим, хто зараз відкриває сторінку конфігів. +// +// По-третє, ключ шифрування тут не потрібен зовсім: видалення не читає +// тіл. Тому прохід безпечний і на інсталяції, де REST запущено без DEK. +// +// Кілька екземплярів колектора безпечні: тік бере advisory-блокування, +// тож прибирає рівно один. +func (s *Service) SweepRetention(ctx context.Context) { + t := time.NewTicker(RetentionInterval) + defer t.Stop() + + s.log.Info("прибиральник старих конфігів запущено", "інтервал", RetentionInterval) + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.retentionTick(ctx) + } + } +} + +func (s *Service) retentionTick(ctx context.Context) { + conn, err := s.store.WorkerPool().Acquire(ctx) + if err != nil { + s.log.Error("прибиральник: з'єднання", "err", err) + return + } + defer conn.Release() + + var got bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, + int64(retentionLockKey)).Scan(&got); err != nil { + s.log.Error("прибиральник: блокування", "err", err) + return + } + if !got { + // Прибирає інший інстанс — штатний стан. + return + } + defer func() { + _, _ = conn.Exec(context.WithoutCancel(ctx), + `SELECT pg_advisory_unlock($1)`, int64(retentionLockKey)) + }() + + tenants, err := s.store.TenantsWithRetention(ctx) + if err != nil { + s.log.Error("прибиральник: перелік тенантів", "err", err) + return + } + + for _, tenantID := range tenants { + stat, err := s.store.SweepRetention(ctx, tenantID) + if err != nil { + // Один тенант не має зупиняти решту: політика в кожного + // своя, і збій на чужих даних тут нічого не означає. + s.log.Error("прибиральник: прохід", "tenant", tenantID, "err", err) + continue + } + if stat.Deleted > 0 { + s.log.Info("прибрано старі версії конфігів", + "tenant", tenantID, "версій", stat.Deleted, "байтів", stat.FreedBytes) + } + } +} diff --git a/server/internal/grpcapi/ncm_rollback.go b/server/internal/grpcapi/ncm_rollback.go new file mode 100644 index 0000000..404c6a0 --- /dev/null +++ b/server/internal/grpcapi/ncm_rollback.go @@ -0,0 +1,139 @@ +package grpcapi + +import ( + "context" + + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Відкат конфігурації: серверна половина. +// +// Транспорту тут свого немає: ConfigApplyJob і ConfigApplyResult лежали +// в .proto від першого дня, у ControlDown/ControlUp для них були +// зарезервовані гілки, і бракувало рівно коду по обидва боки. Диспетчер +// іде в тому самому такті, що збір конфігів і масові команди, — це той +// самий шлях «база → жива сесія зонда», і власний таймер додав би ще +// один інтервал, який довелось би тримати узгодженим із цим. + +// rollbackBatch — скільки відкатів віддаємо за один такт. +// +// Свідомо мало, на порядок менше за збір конфігів. Заливка конфігу — +// не читання: десять одночасних відкатів означають десять вузлів, що +// одночасно перебудовуються, і якщо в плані була помилка, вона +// повторюється десять разів, перш ніж хтось устигне натиснути «стоп». +const rollbackBatch = 4 + +// dispatchRollbacks роздає погоджені наміри живим сесіям. +func (s *Service) dispatchRollbacks(ctx context.Context, online []string) { + jobs, err := s.store.ClaimRollbacks(ctx, online, rollbackBatch, s.ring) + if err != nil { + s.log.Error("вибірка відкатів", "err", err) + return + } + + for _, j := range jobs { + ok := s.PushToAgent(j.AgentID, &npv1.ControlDown{ + Payload: &npv1.ControlDown_ConfigApplyJob{ConfigApplyJob: j.Job}, + }) + if !ok { + // Сесія обірвалась між вибіркою й відправкою. Намір падає, а + // не повертається в чергу: на пристрої ще нічого не + // відбувалось, і мовчазний повтор означав би, що заливка + // колись поїде сама — без людини, яка на це дивиться. + // Погодження при цьому лишається в журналі, тож повторити + // відкат коштує одного натискання. + _ = s.store.FailRollback(ctx, j.RollbackID, + "зонд відключився до надсилання завдання — відкат не почався") + continue + } + s.log.Warn("відкат конфігу надіслано зонду", + "rollback", j.RollbackID, "agent", j.AgentID, "device", j.DeviceName, + "команд", len(j.Job.GetCommands())) + } +} + +// storeApplyResult приймає звіт про заливку. +// +// Тут навмисно немає жодного рішення про долю відкату. Усе, що знає це +// місце, — «зонд щось відповів»; чи став пристрій таким, як хотіли, +// вирішує контрольний збір, який ставить FinishApply. Вивід CLI на це +// питання не відповідає: пристрій відповідає рядком тексту, і мовчання +// означає «прочитав», а не «застосував». +func (s *Service) storeApplyResult(ctx context.Context, res *npv1.ConfigApplyResult) { + id := res.GetRollbackId() + if id == "" { + s.log.Warn("результат заливки без ідентифікатора відкату") + return + } + + tenantID, redact, err := s.store.RollbackRedactPatterns(ctx, id) + if err != nil { + s.log.Error("невідомий відкат у результаті заливки", "rollback", id, "err", err) + return + } + + outcomes := make([]store.ApplyOutcome, 0, len(res.GetOutcomes())) + for _, o := range res.GetOutcomes() { + outcomes = append(outcomes, store.ApplyOutcome{ + Index: int(o.GetIndex()), + Command: o.GetCommand(), + Output: o.GetOutput(), + Success: o.GetSuccess(), + ErrorLine: o.GetErrorLine(), + }) + } + + rep := store.ApplyReport{ + RollbackID: id, + Success: res.GetSuccess(), + Committed: res.GetCommitted(), + Outcomes: outcomes, + Transcript: res.GetTranscript(), + Error: res.GetError().GetMessage(), + } + if !rep.Success && rep.Error == "" { + rep.Error = "заливка не завершилась, а причини зонд не назвав" + } + + if err := s.store.FinishApply(ctx, tenantID, rep, redact); err != nil { + s.log.Error("запис результату відкату", "rollback", id, "err", err) + return + } + + // Warn, а не Info, і за будь-якого результату. Запис на живе залізо + // — подія, яку шукають у журналі через тиждень після аварії, і + // шукають її разом із рештою тривожного. + s.log.Warn("відкат залито, призначено контрольний збір", + "rollback", id, "успіх", rep.Success, "збережено", rep.Committed, + "команд", len(outcomes)) +} + +// settleRollbacks підбиває підсумок контрольних зборів. +func (s *Service) settleRollbacks(ctx context.Context) { + n, err := s.store.SettleRollbackVerification(ctx, s.ring) + if err != nil { + s.log.Warn("підсумок перевірки відкатів", "err", err) + return + } + if n > 0 { + s.log.Info("перевірку відкатів завершено", "намірів", n) + } +} + +// reapRollbacks розбирається з тими, від кого немає відповіді. +func (s *Service) reapRollbacks(ctx context.Context) { + n, err := s.store.ReapStuckRollbacks(ctx) + if err != nil { + s.log.Warn("прибирання завислих відкатів", "err", err) + return + } + if n > 0 { + // Саме Warn: зонд зник посеред заливки, і на пристрої лишився + // стан, якого не описує жоден із двох конфігів. Контрольний + // збір уже призначено, але подія варта того, щоб її помітили + // без нагадування. + s.log.Warn("зонд не відповів під час заливки — перевіряємо стан пристроїв", + "намірів", n) + } +} diff --git a/server/internal/grpcapi/ncm_schedule.go b/server/internal/grpcapi/ncm_schedule.go index eee675c..89b5d10 100644 --- a/server/internal/grpcapi/ncm_schedule.go +++ b/server/internal/grpcapi/ncm_schedule.go @@ -35,7 +35,7 @@ func (s *Service) ScheduleBackups(ctx context.Context) { } func (s *Service) scheduleTick(ctx context.Context) int { - conn, err := s.store.Pool().Acquire(ctx) + conn, err := s.store.WorkerPool().Acquire(ctx) if err != nil { s.log.Error("планувальник: з'єднання", "err", err) return 0 diff --git a/server/internal/grpcapi/plan_sync.go b/server/internal/grpcapi/plan_sync.go index fc764bf..bbc670f 100644 --- a/server/internal/grpcapi/plan_sync.go +++ b/server/internal/grpcapi/plan_sync.go @@ -29,12 +29,50 @@ func (s *Service) SyncPlans(ctx context.Context) { t := time.NewTicker(PlanSyncInterval) defer t.Stop() + id := time.NewTicker(IdentifySyncInterval) + defer id.Stop() + for { select { case <-ctx.Done(): return case <-t.C: s.syncPlansTick(ctx) + case <-id.C: + s.ensureIdentifyTick(ctx) + } + } +} + +// IdentifySyncInterval — як часто перевіряти, кому бракує розпізнавання. +// +// Окремий, повільніший такт, ніж у звірки планів. Звірка читає хеш, +// а це — запит із записом; ганяти його щоп'ять секунд на кожен зонд +// заради події, яка трапляється при заведенні хоста, не варто. Хвилини +// досить: рівно стільки чекатиме людина, що допіру дала хосту SNMP. +const IdentifySyncInterval = time.Minute + +// ensureIdentifyTick заводить чеки розпізнавання новим хостам. +// +// Самої лише появи чека досить: він змінює хеш плану, і найближча +// звірка сама донесе його до зонда. +func (s *Service) ensureIdentifyTick(ctx context.Context) { + s.mu.RLock() + sessions := make([]*agentSession, 0, len(s.sessions)) + for _, sess := range s.sessions { + sessions = append(sessions, sess) + } + s.mu.RUnlock() + + for _, sess := range sessions { + made, err := s.store.EnsureIdentifyChecks(ctx, sess.agent) + if err != nil { + s.log.Warn("чек розпізнавання", "agent", sess.agent.ID, "err", err) + continue + } + if made > 0 { + s.log.Info("заведено чеки розпізнавання", + "agent", sess.agent.ID, "кількість", made) } } } diff --git a/server/internal/grpcapi/service.go b/server/internal/grpcapi/service.go index 256f836..fe254d8 100644 --- a/server/internal/grpcapi/service.go +++ b/server/internal/grpcapi/service.go @@ -16,6 +16,7 @@ import ( "time" npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/crypto" "github.com/netpulse/netpulse/server/internal/store" "google.golang.org/grpc" @@ -38,6 +39,14 @@ type Service struct { ring *crypto.Keyring log *slog.Logger + // Приймач подій для правил джерел `syslog` і `ncm`. + // + // Живе саме тут, бо саме сюди приходять рядки журналу й зібрані + // конфіги: правило на подію не має де спрацювати, крім тієї миті, + // коли подія надійшла. nil означає інсталяцію з вимкненими + // алертами — тоді приймач просто мовчить. + events *alerting.EventSink + // Живі сесії за agent_id. Потрібні, щоб штовхнути зонду // TaskDelta або ConfigJob, коли щось змінилось в UI. mu sync.RWMutex @@ -84,6 +93,16 @@ func New(st *store.Store, ring *crypto.Keyring, log *slog.Logger) *Service { } } +// WithEventAlerts вмикає подієві алерти на журналі й конфігах. +// +// Окремим методом, а не аргументом New: приймач подій потрібен не +// кожній збірці (тести, читальні інстанси), і вимагати його від них +// означало б тягнути пакет алертів туди, де алертів немає. +func (s *Service) WithEventAlerts(sink *alerting.EventSink) *Service { + s.events = sink + return s +} + // --------------------------------------------------------------------- // Автентифікація // --------------------------------------------------------------------- @@ -203,6 +222,12 @@ func (s *Service) Control(stream npv1.AgentService_ControlServer) error { } }() + // До побудови плану, а не після: інакше щойно заведений чек + // розпізнавання поїхав би до зонда лише наступною звіркою. + if _, err := s.store.EnsureIdentifyChecks(ctx, agent); err != nil { + s.log.Warn("чек розпізнавання", "agent", agent.ID, "err", err) + } + plan, err := s.store.BuildPlan(ctx, agent) if err != nil { return status.Errorf(codes.Internal, "побудова плану: %v", err) @@ -321,6 +346,16 @@ func (s *Service) readControl(ctx context.Context, stream npv1.AgentService_Cont Payload: &npv1.ControlDown_Credentials{Credentials: bundle}, }) + case *npv1.ControlUp_ConfigApplyResult: + // Результат заливки конфігу. Обробляється в окремій + // горутині: FinishApply ставить контрольний збір і чекає + // на кілька запитів до бази, а контрольний цикл цієї сесії + // тим часом має відповідати на ping — інакше зонд, який + // щойно зробив найнебезпечнішу роботу, буде визнаний + // мертвим саме через неї. + result := p.ConfigApplyResult + go s.storeApplyResult(context.WithoutCancel(ctx), result) + case *npv1.ControlUp_Event: s.log.Info("подія зонда", "agent", agent.ID, "kind", p.Event.GetKind().String(), diff --git a/server/internal/grpcapi/storage_retention.go b/server/internal/grpcapi/storage_retention.go new file mode 100644 index 0000000..804a875 --- /dev/null +++ b/server/internal/grpcapi/storage_retention.go @@ -0,0 +1,155 @@ +package grpcapi + +import ( + "context" + "time" +) + +// dataRetentionLockKey — окреме advisory-блокування під прибирання +// даних. +// +// Своє, а не спільне з очисткою конфігів (retentionLockKey): обидва +// такти тікають незалежно, і спільний ключ означав би, що довгий перший +// прохід по прогонах команд заодно зупиняє прибирання версій конфігів. +const dataRetentionLockKey = 0x6e70_6472 // "npdr" + +// DataRetentionInterval — як часто прибирати й міряти. +// +// Година. Найдрібніша одиниця строку — доба, тож частіше шукати нема +// чого. Рідше — означало б, що інсталяція, яка вперше поставила строк, +// починає звільняти місце невідомо коли; а на переповненому диску +// «невідомо коли» — це вже після аварії. +const DataRetentionInterval = time.Hour + +// SweepDataRetention прибирає застарілі дані за налаштованими строками +// й веде ряд спостережень за розміром. +// +// Живе в колекторі, а не в REST-процесі, з тих самих трьох міркувань, +// що й очистка конфігів (ncm_retention.go): тут стоїть той, хто дані +// СТВОРЮЄ; REST-процес масштабують копіями й він же обслуговує запити +// людини; ключ шифрування тут не потрібен зовсім. +// +// Кілька екземплярів колектора безпечні: тік бере advisory-блокування, +// тож працює рівно один. +func (s *Service) SweepDataRetention(ctx context.Context) { + t := time.NewTicker(DataRetentionInterval) + defer t.Stop() + + s.log.Info("прибиральник даних запущено", "інтервал", DataRetentionInterval) + + // Перший знімок розміру знімається одразу, не чекаючи години. + // Приріст рахується з РІЗНИЦІ спостережень, тож перше з них має + // з'явитись якомога раніше: до нього сторінка не має що сказати про + // швидкість, а саме заради швидкості її й відкривають. + s.dataRetentionTick(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.dataRetentionTick(ctx) + } + } +} + +func (s *Service) dataRetentionTick(ctx context.Context) { + conn, err := s.store.Pool().Acquire(ctx) + if err != nil { + s.log.Error("прибиральник даних: з'єднання", "err", err) + return + } + defer conn.Release() + + var got bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, + int64(dataRetentionLockKey)).Scan(&got); err != nil { + s.log.Error("прибиральник даних: блокування", "err", err) + return + } + if !got { + // Прибирає інший інстанс — штатний стан. + return + } + defer func() { + _, _ = conn.Exec(context.WithoutCancel(ctx), + `SELECT pg_advisory_unlock($1)`, int64(dataRetentionLockKey)) + }() + + // 1. Звірка політик TimescaleDB зі збереженими строками. + // + // Політики накладаються одразу при збереженні, тож у нормальному + // житті тут завжди нуль. Такт потрібен для іншого: політику знімають + // руками під час обслуговування бази, а pg_dump не везе фонових + // задач TimescaleDB узагалі — тобто після відновлення з дампа + // строки в таблиці є, а видалення не працює, і дізнаються про це + // лише тоді, коли диск уже закінчився. Один запит на годину — мала + // ціна за те, щоб цей стан не пережив першої ж години. + if n, err := s.store.SyncRetentionPolicies(ctx); err != nil { + s.log.Error("прибиральник даних: політики TimescaleDB", "err", err) + } else if n > 0 { + s.log.Warn("політики зберігання розійшлися зі строками й виправлені", + "політик", n) + } + + // 2. Пакетне прибирання звичайних таблиць. + stats, err := s.store.SweepDataRetention(ctx) + if err != nil { + s.log.Error("прибиральник даних: прохід", "err", err) + } + for _, st := range stats { + if st.Deleted == 0 { + // Підпадало, а не прибралось. Помилки немає, і саме тому це + // найнебезпечніший зі станів: тиша читається як «усе + // гаразд». Майже завжди причина одна — з'єднання відкрито + // роллю під RLS без заданого кабінету, тобто NETPULSE_DSN + // колектора не веде на роль netpulse_worker (0063). + s.log.Warn("прибирання не зачепило нічого, хоча дані під строком є", + "вид", st.Kind, "підпадало", st.Due, + "ймовірна_причина", "з'єднання під RLS без BYPASSRLS — перевірте DSN колектора") + continue + } + s.log.Info("прибрано застарілі дані", + "вид", st.Kind, "рядків", st.Deleted, "лишилось_ще", st.More) + } + + // 3. Знімок розмірів. + // + // Після прибирання, а не до: інакше ряд спостережень описував би + // базу, якої вже немає, і приріст за добу вважався б із піків перед + // кожним проходом. + if err := s.store.SampleStorage(ctx); err != nil { + s.log.Error("прибиральник даних: знімок розмірів", "err", err) + } + + // 4. Попередження про вичерпання місця. + // + // Тут, а не в движку правил, і не окремим тактом. + // + // Не в движку — бо метричне правило обчислюється запитом із JOIN + // inv.devices, тобто вимагає хоста, а хоста для тому бази не існує + // (повний розбір — у 0067_storage_alert.sql і store/storage_alert.go). + // + // Не окремим тактом — бо перевірка спирається рівно на ті числа, які + // щойно ліг у ряд знімок вище. Свій такт означав би, що алерт іноді + // рахується з даних, старших за годину, і найчастіше — саме тоді, + // коли швидкість щойно змінилась. + // + // Одразу ПІСЛЯ знімка, а не до: інакше перша година життя інсталяції + // проходила б без жодного спостереження, тобто перевірка на першому + // такті була б сліпою за побудовою. + if st, err := s.store.CheckStorageAlert(ctx); err != nil { + s.log.Error("прибиральник даних: попередження про місце", "err", err) + } else if st.Level != "" { + s.log.Warn("місце на томі бази закінчується", + "рівень", st.Level, "причина", st.Reason, "текст", st.Message) + } else if st.Blind != "" && st.Enabled { + // Найнебезпечніший зі станів і тому не Debug: попередження + // увімкнене, але спертись йому нема на що. Ззовні це + // невідрізнимо від «усе гаразд» — рівно те, чого ця робота мала + // позбутись. + s.log.Warn("попередження про місце не має на що спертись", + "причина", st.Blind, + "що_зробити", "вказати ємність тому або змонтувати том бази в збірник") + } +} diff --git a/server/internal/grpcapi/streams.go b/server/internal/grpcapi/streams.go index 10b37b9..1e94ffd 100644 --- a/server/internal/grpcapi/streams.go +++ b/server/internal/grpcapi/streams.go @@ -9,6 +9,7 @@ import ( "io" npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/store" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -87,6 +88,27 @@ func (s *Service) StreamTelemetry(stream npv1.AgentService_StreamTelemetryServer s.log.Debug("батч записано", "agent", agent.ID, "batch", batch.GetBatchId(), "samples", st.Samples, "icmp", st.Icmp, "interfaces", st.Interfaces) + // Перелік рядків динамічних таблиць їде в payload результатів + // snmp.walk — тим самим батчем, що й метрики, бо збирає його та + // сама задача зонда. + // + // Окремо від WriteBatch навмисно: там усе лягає одним pgx.Batch + // без жодного читання, а тут — читання, порівняння зі станом і + // перебудова чеків. Помилка тут не має нікачити батч: метрики + // вже записані, і просити зонд переслати їх заради рядків + // означало б подвоїти телеметрію через таблицю дисків. + // + // Штовхати зонду новий план звідси не треба: звірка планів + // (SyncPlans) щоп'ять секунд помітить інший хеш і перезаллє + // його сама — тим самим шляхом, яким доїжджають чеки, створені + // у вебі. + if changed, err := s.store.ApplyWalkResults(ctx, agent, batch.GetCheckResults()); err != nil { + s.log.Warn("рядки прототипів не застосувались", "agent", agent.ID, "err", err) + } else if changed > 0 { + s.log.Info("склад рядків прототипів змінився", + "agent", agent.ID, "хостів", changed) + } + if err := stream.Send(&npv1.TelemetryAck{ AckedThroughBatchId: acked, MaxInFlight: uint32(agent.Limits.MaxInFlight), @@ -134,6 +156,8 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error { } s.triggerSyslogBackups(ctx, agent, batch.GetSyslog()) + s.raiseSyslogAlerts(ctx, agent, batch.GetSyslog()) + s.handleTraps(ctx, agent, batch.GetTraps()) if batch.GetBatchId() > acked { acked = batch.GetBatchId() } @@ -179,6 +203,98 @@ func (s *Service) triggerSyslogBackups(ctx context.Context, agent *store.Agent, } } +// raiseSyslogAlerts звіряє щойно прийняті рядки з подієвими правилами. +// +// Робиться тут, одразу після запису, і саме з тієї ж причини, що й +// позачерговий бекап поруч: правило «у журналі зʼявилось %LINK-3-UPDOWN» +// має спрацювати за секунди, а не тоді, коли хтось наступного разу +// відкриє журнал. Опитувати ts.syslog розкладом було б і дорожче +// (гіпертаблиця на мільярд рядків), і брехливіше — вікно опитування +// завжди або пропускає події, або рахує їх двічі. +// +// Помилка тут не зриває приймання: рядки вже записані, і втратити +// журнал через проблему з алертами було б обміном гіршим за пропущений +// алерт. Тому весь розбір мовчить у сам приймач, а гучний він усередині. +func (s *Service) raiseSyslogAlerts(ctx context.Context, agent *store.Agent, entries []*npv1.SyslogEntry) { + if s.events == nil || len(entries) == 0 { + return + } + evs := make([]alerting.SyslogEvent, 0, len(entries)) + for _, e := range entries { + evs = append(evs, alerting.SyslogEvent{ + DeviceID: e.GetDeviceId(), + Message: e.GetMessage(), + Tag: e.GetTag(), + Severity: int(e.GetSeverity()), + }) + } + s.events.OnSyslog(ctx, agent.TenantID, evs) +} + +// handleTraps робить із щойно прийнятих трапів дві речі. +// +// Перша — алерти, тим самим шляхом і з тих самих міркувань, що й для +// журналу: правило «linkDown на магістральному порту» має спрацювати за +// секунди, а не тоді, коли хтось наступного разу відкриє журнал. +// +// Друга — облік відправників, яких зонд не зміг зіставити з хостом. +// Це не побічний ефект, а половина сенсу приймача. Трап від адреси, +// якої немає в інвентарі, — найчастіше перший слід нового заліза в +// мережі, і рівно він губиться у всіх системах, де подія без хоста +// просто відкидається. Алертом його не зробиш (алерт без хоста нікуди +// не маршрутизується), тому він потрапляє в окремий перелік, який видно +// на сторінці трапів. +// +// Обидві дії гучні всередині й мовчазні назовні: трапи вже записані, і +// втратити стрім через проблему з алертами було б обміном гіршим за +// пропущений алерт. +func (s *Service) handleTraps(ctx context.Context, agent *store.Agent, traps []*npv1.SnmpTrap) { + if len(traps) == 0 { + return + } + + if err := s.store.NoteUnknownTrapSources(ctx, agent.TenantID, agent.ID, traps); err != nil { + s.log.Error("облік невідомих джерел трапів", "agent", agent.ID, "err", err) + } + + if s.events == nil { + return + } + evs := make([]alerting.TrapEvent, 0, len(traps)) + for _, t := range traps { + ev := alerting.TrapEvent{ + DeviceID: t.GetDeviceId(), + SourceIP: t.GetSourceIp(), + TrapOID: t.GetTrapOid(), + } + for _, vb := range t.GetVarbinds() { + ev.Varbinds = append(ev.Varbinds, alerting.TrapVarbind{ + OID: vb.GetOid(), Value: vb.GetValue(), + }) + } + evs = append(evs, ev) + } + s.events.OnTrap(ctx, agent.TenantID, evs) +} + +// raiseConfigAlert доводить долю збору конфігу до подієвих правил. +// +// Дві події, а не одна: «конфіг змінився» і «конфіг не зібрався» — +// різні новини для різних людей. Перша цікавить того, хто відповідає за +// зміни; друга — того, хто відповідає за те, щоб бекапи взагалі були. +// Звести їх в одну означало б, що ввімкнувши потрібну, отримуєш і зайву. +func (s *Service) raiseConfigAlert(ctx context.Context, tenantID, deviceID, configType, kind, detail string) { + if s.events == nil || deviceID == "" { + return + } + s.events.OnConfig(ctx, tenantID, alerting.ConfigEvent{ + DeviceID: deviceID, + ConfigType: configType, + Kind: kind, + Detail: detail, + }) +} + // --------------------------------------------------------------------- // Автовиявлення // --------------------------------------------------------------------- @@ -208,11 +324,21 @@ func (s *Service) ReportDiscovery(ctx context.Context, rep *npv1.DiscoveryReport // Системна інформація приїжджає тим самим звітом і дає найдешевший // онбординг з можливих: пристрій сам сказав, що він таке, і шаблон // причепився без жодного натискання. - if info, err := s.store.ApplySystemInfo(ctx, agent.TenantID, rep.GetDevices()); err != nil { + info, err := s.store.ApplySystemInfo(ctx, agent.TenantID, rep.GetDevices()) + switch { + case err != nil: s.log.Error("системна інформація не застосувалась", "agent", agent.ID, "err", err) - } else if info.Assigned > 0 { - s.log.Info("шаблони призначено за sysObjectID", - "agent", agent.ID, "хостів", info.Described, "призначено", info.Assigned) + case info.Failed > 0: + // Не Error: решта хостів у звіті оброблена, і зупиняти на цьому + // онбординг немає підстав. Але й ховати не можна — причина + // лежить у картці кожного з них, а тут видно масштаб. + s.log.Warn("частину хостів не розпізнано", + "agent", agent.ID, "хостів", info.Described, + "невдач", info.Failed, "err", info.LastError) + case info.Assigned > 0 || info.HardwareChanged > 0: + s.log.Info("розпізнавання застосовано", + "agent", agent.ID, "хостів", info.Described, + "шаблонів", info.Assigned, "заміна_заліза", info.HardwareChanged) } return &npv1.DiscoveryAck{ @@ -340,6 +466,17 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro } tr := p.Trailer + // Масове виконання команд повертається цим самим стрімом: + // шлях сервер→зонд→сервер уже є, і другий такий самий + // заради іншого призначення виводу був би копією з власними + // помилками. Розвилка стоїть саме тут, до розбору тіла, бо + // далі йде логіка бекапу — звірка з попередньою версією, + // коміт у Git, — якої для `show version` не існує. + if s.isCommandUpload(ctx, header) { + return stream.SendAndClose( + s.storeCommandResult(ctx, header, body, tr)) + } + if !tr.GetSuccess() { s.log.Warn("зонд не зміг зібрати конфіг", "agent", agent.ID, "job", header.GetJobId(), @@ -349,6 +486,8 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro // вважатиметься таким, що збирається. _ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed", tr.GetError().GetMessage(), tr.GetTranscript()) + s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(), + header.GetConfigType(), "backup_failed", tr.GetError().GetMessage()) return stream.SendAndClose(&npv1.ConfigReceipt{ JobId: header.GetJobId(), Accepted: false, Error: tr.GetError(), }) @@ -364,6 +503,8 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro "agent", agent.ID, "encoding", header.GetEncoding(), "err", err) _ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed", err.Error(), tr.GetTranscript()) + s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(), + header.GetConfigType(), "backup_failed", err.Error()) return stream.SendAndClose(&npv1.ConfigReceipt{ JobId: header.GetJobId(), Accepted: false, @@ -375,6 +516,38 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro } body = plain + // Набір конфіг-файлів сервера несе відбиток машини, з якої + // його знято. Звіряємо ДО збереження. + // + // Перевірка стоїть тут, а не в StoreConfig: той нічого не + // знає про зонди й машини й не має починати. А знати про це + // мусить рівно одне місце — те, куди приходять набори. + // + // Ціна помилки саме тут найвища: історія конфігів сервера + // живе в одній гілці Git, і файли іншої машини, дописані в + // неї, виглядають звичайною зміною конфігу. Помітити таке + // можна хіба через півроку — коли з архіву треба + // відновлюватись. + if mid := header.GetMachineId(); mid != "" { + if err := s.store.PinSelfMachine(ctx, agent.TenantID, + header.GetDeviceId(), mid); err != nil { + s.log.Warn("набір конфіг-файлів відхилено: не та машина", + "agent", agent.ID, "device", header.GetDeviceId(), "err", err) + _ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed", + err.Error(), tr.GetTranscript()) + s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(), + header.GetConfigType(), "backup_failed", err.Error()) + return stream.SendAndClose(&npv1.ConfigReceipt{ + JobId: header.GetJobId(), + Accepted: false, + Error: &npv1.Error{ + Code: "machine_mismatch", + Message: err.Error(), + }, + }) + } + } + outcome, err := s.store.StoreConfig(ctx, agent, store.ConfigSubmission{ JobID: header.GetJobId(), DeviceID: header.GetDeviceId(), @@ -390,6 +563,9 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro "agent", agent.ID, "device", header.GetDeviceId()) _ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed", "тіло не відповідає заявленому sha256", tr.GetTranscript()) + s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(), + header.GetConfigType(), "backup_failed", + "тіло не відповідає заявленому sha256") return stream.SendAndClose(&npv1.ConfigReceipt{ JobId: header.GetJobId(), Accepted: false, @@ -414,6 +590,15 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro } _ = s.store.FinishConfigJob(ctx, header.GetJobId(), finalStatus, "", tr.GetTranscript()) + // Подія рівно тоді, коли конфіг СПРАВДІ інший. Збіг хеша — + // не зміна, і алертувати на кожен нічний збір означало б + // щоранку віддавати черговому сорок повідомлень «усе як + // було». + if outcome.Accepted && !outcome.Unchanged { + s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(), + header.GetConfigType(), "changed", outcome.CommitSHA) + } + return stream.SendAndClose(&npv1.ConfigReceipt{ JobId: header.GetJobId(), Accepted: outcome.Accepted, diff --git a/server/internal/httpapi/alerts.go b/server/internal/httpapi/alerts.go index 04d1c30..2076646 100644 --- a/server/internal/httpapi/alerts.go +++ b/server/internal/httpapi/alerts.go @@ -11,12 +11,9 @@ import ( "github.com/netpulse/netpulse/server/internal/store" ) -// maxMute — стеля ручного заглушення. -// -// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути -// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень -// достатньо для будь-яких робіт і замало, щоб забути назовсім. -const maxMute = 7 * 24 * time.Hour +// Стеля ручного заглушення живе в шарі store: ту саму кнопку тепер +// натискають і з інтерфейсу, і з Telegram, а політика в них одна. +const maxMute = store.MaxMute func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Principal) { if !requirePerm(w, p, "alerts:read") { @@ -168,14 +165,9 @@ func (s *Server) handleListAlertRules(w http.ResponseWriter, r *http.Request, p writeJSON(w, http.StatusOK, map[string]any{"rules": rules}) } -// validSources і validSeverities дублюють ENUM у БД навмисно: помилка -// в назві джерела має давати 400 з зрозумілим текстом, а не 500 від -// невдалого приведення типу в глибині запиту. -var validSources = map[string]bool{ - "metric": true, "icmp": true, "interface": true, "link": true, - "syslog": true, "trap": true, "ncm": true, "agent": true, "compliance": true, -} - +// validSeverities дублює ENUM alr.severity навмисно: помилка в назві +// має давати 400 зі зрозумілим текстом, а не 500 від невдалого +// приведення типу в глибині запиту. var validSeverities = map[string]bool{ "info": true, "warning": true, "average": true, "high": true, "disaster": true, } @@ -199,6 +191,13 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p ChannelIDs []string `json:"channel_ids"` NotifySchedule json.RawMessage `json:"notify_schedule"` NotifyOnResolve *bool `json:"notify_on_resolve"` + // Тільки для подієвих джерел. + AutoCloseSeconds *int `json:"auto_close_seconds"` + MinIntervalSeconds *int `json:"min_interval_seconds"` + // Драбина ескалації. Порожньо — без ескалації, і це типове + // значення: правило, яке нікого не будить понад перше + // сповіщення, поводиться рівно так, як поводилось до 0066. + EscalationPolicyID string `json:"escalation_policy_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON") @@ -209,8 +208,14 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва правила") return } - if !validSources[req.Source] { - writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело правила") + // Джерело, якого движок не вміє, тепер не зберігається мовчки. + // + // Саме мовчання й було помилкою: правило з джерелом `trap` лягало в + // базу, показувалось увімкненим і не спрацьовувало ніколи. Людина + // читала нуль алертів як «подій не було» — і була впевнена в цьому + // рівно доти, доки щось не згоріло. + if reason := store.UnsupportedSourceReason(req.Source); reason != "" { + writeError(w, http.StatusBadRequest, "bad_source", reason) return } if req.Severity == "" { @@ -224,10 +229,71 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p writeError(w, http.StatusBadRequest, "bad_request", "потрібна умова") return } + // Умову подієвого правила перевіряємо тут, а не при спрацюванні: + // зламаний регулярний вираз, помічений через пів року мовчання, — + // це та сама порожня обіцянка, тільки в іншому місці. + if err := store.ValidateRuleCondition(req.Source, req.Condition); err != nil { + writeError(w, http.StatusBadRequest, "bad_condition", err.Error()) + return + } if req.ForSeconds <= 0 { req.ForSeconds = 60 } + // Подієвому алерту нема від чого «зникнути»: подія сталась і не + // перестане. Тому в нього є строк — і за замовчуванням це доба, + // тобто «встиг побачити на наступній зміні». Нуль лишається + // дозволеним і означає «закриє людина». + autoClose, minInterval := 0, 0 + if store.IsEventSource(req.Source) { + autoClose, minInterval = 86400, 60 + if req.AutoCloseSeconds != nil { + autoClose = *req.AutoCloseSeconds + } + if req.MinIntervalSeconds != nil { + minInterval = *req.MinIntervalSeconds + } + if autoClose < 0 || autoClose > 30*86400 { + writeError(w, http.StatusBadRequest, "bad_request", + "строк життя подієвого алерту — від 0 (тільки руками) до 30 діб") + return + } + if minInterval < 0 || minInterval > 3600 { + writeError(w, http.StatusBadRequest, "bad_request", + "проміжок між подіями — від 0 до 3600 секунд") + return + } + } + + // Драбина має належати цьому ж кабінету. + // + // Зовнішній ключ перевіряє лише те, що така політика існує десь у + // базі, — не те, що вона ваша: перевірка FK іде повз RLS. Наслідок + // був би не витоком (чужу драбину все одно не прочитати), а гіршим: + // правило виглядало б налаштованим на ескалацію й не ескалювало б + // ніколи. Тобто рівно та мовчазна обіцянка, яку 0058 вже одного разу + // прибрала з тригерів. + if id := strings.TrimSpace(req.EscalationPolicyID); id != "" { + ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID) + if err != nil { + s.log.Error("перевірка політики ескалації", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + found := false + for _, x := range ps { + if x.ID == id { + found = true + break + } + } + if !found { + writeError(w, http.StatusBadRequest, "bad_escalation", + "драбину ескалації не знайдено — оберіть її зі списку на сторінці «Ескалації»") + return + } + } + in := store.RuleInput{ Name: req.Name, Description: req.Description, @@ -241,6 +307,11 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p ChannelIDs: req.ChannelIDs, NotifySchedule: string(req.NotifySchedule), NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve, + + AutoCloseSeconds: autoClose, + MinIntervalSeconds: minInterval, + + EscalationPolicyID: strings.TrimSpace(req.EscalationPolicyID), } if in.ChannelIDs == nil { in.ChannelIDs = []string{} @@ -493,3 +564,122 @@ func isBadUUID(err error) bool { func isUniqueViolation(err error) bool { return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505") } + +// --------------------------------------------------------------------- +// Політики ескалації +// --------------------------------------------------------------------- + +func (s *Server) handleListEscalationPolicies(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:read") { + return + } + ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID) + if err != nil { + s.log.Error("перелік політик ескалації", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if ps == nil { + ps = []store.EscalationPolicy{} + } + writeJSON(w, http.StatusOK, map[string]any{"policies": ps}) +} + +// handleSaveEscalationPolicy приймає і створення, і зміну. +// +// Один обробник на обидва, як і в правил: форма показує драбину цілком, +// і розводити її на дві гілки означало б розійтися в перевірках рівно +// там, де вони найдорожчі — у тому, кого й коли будити вночі. +func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + + var req struct { + Name string `json:"name"` + Description string `json:"description"` + Steps []store.EscalationStep `json:"steps"` + RepeatAfterMin int `json:"repeat_after_min"` + MaxRepeats int `json:"max_repeats"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON") + return + } + if strings.TrimSpace(req.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва політики") + return + } + // Перевірка драбини живе в store, а не тут: ту саму відмову має + // давати і збереження з форми, і будь-який інший шлях запису. + if err := store.ValidateEscalationSteps(req.Steps); err != nil { + writeError(w, http.StatusBadRequest, "bad_steps", err.Error()) + return + } + // Повтор без стелі — це нескінченний дзвінок, тому обидва числа + // перевіряються разом: «повторювати» без «скільки разів» не + // зберігається взагалі. + if req.RepeatAfterMin < 0 || req.RepeatAfterMin > 1440 { + writeError(w, http.StatusBadRequest, "bad_request", + "повтор драбини — від 1 до 1440 хвилин (0 — не повторювати)") + return + } + if req.MaxRepeats < 0 || req.MaxRepeats > store.MaxEscalationRepeats { + writeError(w, http.StatusBadRequest, "bad_request", + "повторів не більше "+strconv.Itoa(store.MaxEscalationRepeats)) + return + } + if req.RepeatAfterMin > 0 && req.MaxRepeats == 0 { + writeError(w, http.StatusBadRequest, "bad_request", + "вказано проміжок повтору, але нуль повторів — драбина не повториться жодного разу") + return + } + + id, err := s.store.SaveEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"), + store.EscalationPolicy{ + Name: strings.TrimSpace(req.Name), + Description: req.Description, + Steps: req.Steps, + RepeatAfterMin: req.RepeatAfterMin, + MaxRepeats: req.MaxRepeats, + }) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "політику не знайдено") + return + } + if isUniqueViolation(err) { + writeError(w, http.StatusConflict, "duplicate", "політика з такою назвою вже є") + return + } + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + return + } + s.log.Error("збереження політики ескалації", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, map[string]any{"id": id}) +} + +func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + err := s.store.DeleteEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id")) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "політику не знайдено") + return + } + if err != nil { + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + return + } + s.log.Error("видалення політики ескалації", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/httpapi/audit.go b/server/internal/httpapi/audit.go new file mode 100644 index 0000000..02a8a49 --- /dev/null +++ b/server/internal/httpapi/audit.go @@ -0,0 +1,193 @@ +package httpapi + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Журнал аудиту: читання, і тільки читання. +// +// Тут навмисно немає ані PUT, ані PATCH, ані DELETE — і це не «поки що +// не додали». Журнал, який можна виправити, не доводить нічого: перше, +// що зробить той, чиї дії в ньому записані, — виправить запис. Тому в +// цьому файлі три GET, а в базі поверх них стоїть тригер (0050), який +// відмовляє в UPDATE/DELETE/TRUNCATE навіть власнику з'єднання. +// +// Право одне на всі три ручки: перелік, подробиці й довідники дають ту +// саму інформацію, лише порціями різного розміру. Роздати «дивитись +// перелік, але не подробиці» означало б залишити чорний хід — сам +// перелік уже показує, хто, коли й над чим. + +const auditPerm = "audit:read" + +// Стеля глибини вибірки. Не налаштовується: період і фільтри звужують +// запит, а «покажи все» на журналі, що росте вічно, — це не глибина, а +// відсутність питання. +const auditMaxRange = 366 * 24 * time.Hour + +func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, auditPerm) { + return + } + + q := r.URL.Query() + f := store.AuditFilter{ + Actions: splitParam(q, "action"), + ObjectTypes: splitParam(q, "object_type"), + ActorUserIDs: splitParam(q, "actor"), + ActorTokenIDs: splitParam(q, "token"), + ActorIP: strings.TrimSpace(q.Get("ip")), + Query: strings.TrimSpace(q.Get("q")), + Cursor: q.Get("cursor"), + } + // «-» замість ідентифікатора означає «будь-який машинний токен». + // Окреме значення, а не порожній параметр: порожній губиться в + // адресному рядку й у кожній другій бібліотеці. + users := f.ActorUserIDs[:0] + for _, a := range f.ActorUserIDs { + if a == "-" { + f.AnyToken = true + continue + } + users = append(users, a) + } + f.ActorUserIDs = users + + var err error + if f.From, err = parseTimeParam(q.Get("from")); err != nil { + writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «від»") + return + } + if f.To, err = parseTimeParam(q.Get("to")); err != nil { + writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «до»") + return + } + // Верхня межа за замовчуванням — зараз, а не «без межі». Без неї + // подія, записана між першою сторінкою й наступною, зсунула б усю + // вибірку на рядок: курсор рахує від низу, а нове приходить згори. + // Зафіксована «до» робить гортання відтворюваним. + if f.To.IsZero() { + f.To = time.Now().UTC() + } + if f.From.IsZero() { + f.From = f.To.Add(-30 * 24 * time.Hour) + } + if !f.From.Before(f.To) { + writeError(w, http.StatusBadRequest, "invalid", "початок періоду має бути раніше за кінець") + return + } + if f.To.Sub(f.From) > auditMaxRange { + writeError(w, http.StatusBadRequest, "invalid", + "період не більший за рік — звузьте діапазон або скористайтесь фільтрами") + return + } + + if n, err := strconv.Atoi(q.Get("limit")); err == nil { + f.Limit = n + } + + // Обмеження видимості (Scope) тут не застосовується, і це рішення, + // а не пропуск. Scope відповідає на «які ХОСТИ видно», а рядок + // журналу — не про хост: у нього може не бути об'єкта взагалі + // (зміна політики очистки), а може бути перелік із сорока хостів у + // meta. Показати такий рядок наполовину означало б підсунути + // неповну правду під виглядом повної. Право audit:read має лише + // власник і адмін, а вони й так не обмежені групами. + page, err := s.store.ListAudit(r.Context(), p.TenantID, f) + if err != nil { + s.writeStoreError(w, "перелік журналу аудиту", err) + return + } + writeJSON(w, http.StatusOK, page) +} + +// handleGetAuditEvent віддає один запис разом із before/after/meta. +// +// Окремо від переліку, бо вміст важить непередбачувано: meta прогону +// команд на п'ятсот хостів несе п'ятсот імен, і возити це в кожному +// рядку переліку означало б платити мегабайтами за те, на що дивляться +// раз. +func (s *Server) handleGetAuditEvent(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, auditPerm) { + return + } + ts, err := parseTimeParam(r.URL.Query().Get("ts")) + if err != nil || ts.IsZero() { + // ts обов'язковий: первинний ключ гіпертаблиці — (ts, id), і + // без часу пошук за самим id означав би обхід усіх чанків. + writeError(w, http.StatusBadRequest, "invalid", "потрібен параметр ts") + return + } + ev, err := s.store.GetAuditEvent(r.Context(), p.TenantID, r.PathValue("id"), ts) + if err != nil { + s.writeStoreError(w, "запис журналу аудиту", err) + return + } + writeJSON(w, http.StatusOK, ev) +} + +// handleAuditMeta — усе, що потрібно сторінці для фільтрів, одним +// викликом. +// +// Одним, а не трьома: це стан сторінки на момент відкриття, і зібраний +// із трьох відповідей у різні секунди він нічого не виграє, зате додає +// два стани завантаження на екран. +func (s *Server) handleAuditMeta(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, auditPerm) { + return + } + + actors, err := s.store.AuditActors(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "перелік акторів журналу", err) + return + } + bounds, err := s.store.AuditBounds(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "межі журналу", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "actions": store.AuditActions(), + "object_types": store.AuditObjectTypes(), + "actors": actors, + "bounds": bounds, + "blind_spots": store.AuditBlindSpots(), + // Стеля періоду — щоб сторінка могла сказати про неї заздалегідь, + // а не відмовою після натискання. + "max_range_days": int(auditMaxRange / (24 * time.Hour)), + }) +} + +// splitParam читає повторюваний параметр і в обох формах: ?action=a&action=b +// та ?action=a,b. Друга потрібна тому, що посилання на відфільтровану +// сторінку люди пересилають одне одному, і коротший рядок читабельніший. +func splitParam(q map[string][]string, key string) []string { + var out []string + for _, v := range q[key] { + for _, part := range strings.Split(v, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + } + return out +} + +// parseTimeParam приймає RFC3339. Порожній рядок — не помилка, а +// «не задано»: межі періоду мають типові значення. +func parseTimeParam(s string) (time.Time, error) { + if s = strings.TrimSpace(s); s == "" { + return time.Time{}, nil + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, err + } + return t.UTC(), nil +} diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go index 8e5368c..eda5a2e 100644 --- a/server/internal/httpapi/checks.go +++ b/server/internal/httpapi/checks.go @@ -590,3 +590,36 @@ func (s *Server) handleGetDeviceCredentials(w http.ResponseWriter, r *http.Reque } writeJSON(w, http.StatusOK, map[string]any{"credential_ids": ids}) } + +// handleIdentifyDevice просить опитати хост по SNMP просто зараз. +// +// Відмова — це 200 з поясненням, а не 4xx: «немає SNMP-доступу» і +// «зонд не на зв'язку» не помилки запиту, а стан системи, який людині +// треба показати в тій самій формі, а не в діалозі про збій. +func (s *Server) handleIdentifyDevice(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + deviceID := r.PathValue("id") + if !p.Scope().CanWrite(deviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста") + return + } + + d, err := s.store.RequestIdentify(r.Context(), p.TenantID, deviceID) + if err != nil { + s.writeStoreError(w, "прохання розпізнати хост", err) + return + } + writeJSON(w, http.StatusOK, d) +} + +// handleGetDevice — один хост. +func (s *Server) handleGetDevice(w http.ResponseWriter, r *http.Request, p *Principal) { + d, err := s.store.GetDevice(r.Context(), p.TenantID, r.PathValue("id"), p.Scope()) + if err != nil { + s.writeStoreError(w, "хост", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"device": d}) +} diff --git a/server/internal/httpapi/compliance.go b/server/internal/httpapi/compliance.go index 7ec5a1b..8121a0f 100644 --- a/server/internal/httpapi/compliance.go +++ b/server/internal/httpapi/compliance.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/store" ) @@ -45,6 +46,12 @@ func (s *Server) handleSaveComplianceRule(w http.ResponseWriter, r *http.Request if in.Severity == "" { in.Severity = "medium" } + // Типово — конфіг заліза. Правило без явного типу писали під нього, + // і мовчки перевіряти ним набір файлів сервера означало б дати + // впевнену неправильну відповідь замість жодної. + if in.ConfigType == "" { + in.ConfigType = "running" + } id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in) if err != nil { @@ -102,5 +109,23 @@ func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p * s.writeStoreError(w, "перевірка відповідності", err) return } + + // Прогін — єдина мить, коли відомі обидві половини правди: + // які хости порушили вимогу й на яких вона більше не порушується. + // Тому тригери джерела `compliance` обробляються рівно тут, а не + // окремим опитуванням таблиці результатів, яке однаково не змогло б + // відрізнити «стало погано» від «було погано вчора». + if s.events != nil && len(stat.Findings) > 0 { + evs := make([]alerting.ComplianceEvent, 0, len(stat.Findings)) + for _, f := range stat.Findings { + evs = append(evs, alerting.ComplianceEvent{ + RuleID: f.RuleID, RuleName: f.RuleName, Severity: f.Severity, + DeviceID: f.DeviceID, Passed: f.Passed, + Line: f.Line, LineNumber: f.LineNumber, + }) + } + s.events.OnCompliance(r.Context(), p.TenantID, evs) + } + writeJSON(w, http.StatusOK, stat) } diff --git a/server/internal/httpapi/device_detail.go b/server/internal/httpapi/device_detail.go new file mode 100644 index 0000000..cffda74 --- /dev/null +++ b/server/internal/httpapi/device_detail.go @@ -0,0 +1,74 @@ +package httpapi + +import ( + "net/http" +) + +// Ручки картки хоста. +// +// Три окремі, а не одна велика: вкладки відкривають по одній, і тягнути +// сто портів разом із двомастами рядами заради вкладки «Огляд» означало +// б платити за все щоразу. Спільне в них лише перевірка доступу — і вона +// винесена в deviceReadable. + +// deviceReadable — право на розділ і доступ саме до цього хоста. +// +// Двома окремими перевірками, бо це два незалежні виміри: право каже, +// що людині взагалі можна дивитись на хости, scope — на які саме. +func deviceReadable(w http.ResponseWriter, r *http.Request, p *Principal) (string, bool) { + if !requirePerm(w, p, "devices:read") { + return "", false + } + id := r.PathValue("id") + if !p.Scope().CanRead(id) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return "", false + } + return id, true +} + +// handleDeviceOverview — паспорт хоста й зміст решти вкладок. +func (s *Server) handleDeviceOverview(w http.ResponseWriter, r *http.Request, p *Principal) { + id, ok := deviceReadable(w, r, p) + if !ok { + return + } + o, err := s.store.DeviceOverviewData(r.Context(), p.TenantID, id) + if err != nil { + s.writeStoreError(w, "огляд хоста", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"overview": o}) +} + +// handleDeviceLatest — усі метрики хоста з останнім значенням і походженням. +// +// Перевірки віддаються тією ж відповіддю, а не окремою ручкою: походження +// метрики посилається на чек, і два незалежні запити давали б на екрані +// посилання в нікуди рівно в той момент, коли чек щойно прибрали. +func (s *Server) handleDeviceLatest(w http.ResponseWriter, r *http.Request, p *Principal) { + id, ok := deviceReadable(w, r, p) + if !ok { + return + } + res, err := s.store.DeviceLatestData(r.Context(), p.TenantID, id) + if err != nil { + s.writeStoreError(w, "останні дані хоста", err) + return + } + writeJSON(w, http.StatusOK, res) +} + +// handleDeviceInterfaces — порти хоста з останніми лічильниками. +func (s *Server) handleDeviceInterfaces(w http.ResponseWriter, r *http.Request, p *Principal) { + id, ok := deviceReadable(w, r, p) + if !ok { + return + } + list, err := s.store.DeviceInterfaces(r.Context(), p.TenantID, id) + if err != nil { + s.writeStoreError(w, "порти хоста", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"interfaces": list}) +} diff --git a/server/internal/httpapi/devices_bulk.go b/server/internal/httpapi/devices_bulk.go new file mode 100644 index 0000000..9780b2f --- /dev/null +++ b/server/internal/httpapi/devices_bulk.go @@ -0,0 +1,628 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/netpulse/netpulse/server/internal/cronx" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Масова правка й масове видалення хостів. +// +// Запобіжники тут ті самі, що й у масовому виконанні команд, і саме +// тому, що вони ті самі: дві масові операції в одній системі мусять +// поводитись однаково, інакше людина, яка звикла до однієї, помилиться +// в другій. +// +// - право devices:write, а не лише devices:read; +// - у набір потрапляє тільки те, що людині дозволено ЗМІНЮВАТИ +// (Scope.Writable), а не те, що вона бачить; +// - клієнт шле перелік id разом із фільтром, сервер бере ПЕРЕТИН, а +// те, що відпало, повертає окремим списком — мовчазна різниця між +// підтвердженим і зробленим тут неприпустима; +// - стеля на кількість за раз (store.MaxBulkDevices); +// - кожна масова дія — рядок в аудиті з іменами хостів. +// +// Окремим правом, як ncm:exec, ця дія не стала свідомо. ncm:exec +// закриває те, чого інакше не зробити взагалі, — виконання довільної +// команди на залізі. Тут же немає жодної зміни, якої не можна було б +// зробити картками хостів по одній: масова форма економить час, а не +// відмикає нові можливості. Заводити під економію часу окреме право +// означало б, що інженер із devices:write, який має видаляти хости, +// видаляє їх поштучно й довше — з тим самим результатом. + +// bulkDeviceBody — спільне тіло всіх трьох ручок. +// +// Фільтр і перелік id разом: перелік каже, що людина бачила на екрані, +// фільтр — за яким наміром вона це відбирала. Сервер бере перетин, тож +// хост, заведений між переглядом і натисканням, у дію не потрапить, +// навіть якщо підпадає під фільтр. +type bulkDeviceBody struct { + Filter store.DeviceFilter `json:"filter"` + DeviceIDs []string `json:"device_ids"` + Changes store.BulkDeviceChanges `json:"changes"` + + // Mode — яке саме видалення. Читається ручкою видалення, а ще — + // добором цілей, бо від нього залежить, чи видно прибрані хости. + // + // archive — хост зникає з переліків, зібране лишається в базі + // purge — хоста й усього, що з ним пов'язане, більше немає + // restore — не видалення: добір цілей для повернення з архіву + // + // Порожнє значення означає archive, і це не заготовка «на потім», а + // сумісність: ручка існувала до появи вибору, і клієнт, який про + // нього не знає, не має отримати незворотну дію замість тієї, яку + // просив. + Mode string `json:"mode"` +} + +// resolveBulk — спільний перший крок: перетин і те, що з нього випало. +func (s *Server) resolveBulk(w http.ResponseWriter, r *http.Request, p *Principal, + in bulkDeviceBody) ([]store.BulkDeviceTarget, []string, bool) { + + // Повне видалення й повернення бачать і прибраних. Інакше «прибрати + // з переліку» було б пасткою: хост зникає звідусіль разом із + // можливістю його добити, а зібране лишається в базі назавжди. + // + // Дві дії, які адресовані САМЕ прибраним хостам, і жодної третьої: + // масова правка архіву сенсу не має (міняти налаштування тому, кого + // в переліках немає, — це спосіб повернути його наполовину), а + // архівне видалення вже архівованого не робить нічого. + resolve := s.store.ResolveBulkTargets + if in.Mode == "purge" || in.Mode == "restore" { + resolve = s.store.ResolveBulkPurgeTargets + } + targets, err := resolve(r.Context(), p.TenantID, p.Scope(), + in.Filter, in.DeviceIDs) + if err != nil { + s.writeStoreError(w, "добір хостів для масової дії", err) + return nil, nil, false + } + + keep := make(map[string]bool, len(targets)) + for _, t := range targets { + keep[t.DeviceID] = true + } + dropped := []string{} + for _, id := range in.DeviceIDs { + if !keep[id] { + dropped = append(dropped, id) + } + } + return targets, dropped, true +} + +// handleBulkDeviceTargets — «що саме я зараз зачеплю». +// +// Окремий крок і окрема ручка, як і в масовому виконанні команд: +// підтвердження без точного переліку не вважається підтвердженням, і +// клієнт не має змоги його оминути. Разом із переліком їдуть числа +// прив'язаного — чеки, алерти, вузли на мапах, розклад: усе, про що +// питають ПІСЛЯ видалення, має бути видно ДО нього. +func (s *Server) handleBulkDeviceTargets(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + var in bulkDeviceBody + if !decodeBody(w, r, &in) { + return + } + + targets, dropped, ok := s.resolveBulk(w, r, p, in) + if !ok { + return + } + if targets == nil { + targets = []store.BulkDeviceTarget{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "devices": targets, + // Хости, які людина відмітила, але які не пройшли: або зникли, + // або лишились без доступу на запис, або більше не підпадають + // під фільтр. Мовчати про них не можна — інакше «обрано 50, + // змінено 43» людина побачить лише в аудиті. + "dropped": dropped, + "max": store.MaxBulkDevices, + }) +} + +func (s *Server) handleBulkUpdateDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + var in bulkDeviceBody + if !decodeBody(w, r, &in) { + return + } + + // Розклад збору конфігів живе за іншим правом і в іншому розділі. + // Масова форма не має ставати обхідним шляхом до нього: хто не + // може змінити розклад одному хосту, не міняє його й п'ятдесяти. + if in.Changes.Backup != nil { + if !requirePerm(w, p, "ncm:write") { + return + } + if in.Changes.Backup.Op == "set" { + cron := strings.TrimSpace(in.Changes.Backup.Cron) + if cron == "" { + cron = "0 3 * * *" + } + // Розклад перевіряємо тут, а не в планувальнику: інакше + // людина дізнається про друкарську помилку через добу + // мовчання — і одразу на всіх хостах набору. + if _, err := cronx.Parse(cron); err != nil { + writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error()) + return + } + in.Changes.Backup.Cron = cron + } + } + if in.Changes.Kind != nil && in.Changes.Kind.Op == "set" && + !validDeviceKinds[in.Changes.Kind.Value] { + writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою") + return + } + if !s.checkBulkChecksOp(w, r, p, in.Changes.Checks) { + return + } + + targets, dropped, ok := s.resolveBulk(w, r, p, in) + if !ok { + return + } + if len(targets) == 0 { + writeError(w, http.StatusBadRequest, "no_targets", + "жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз") + return + } + + ids := make([]string, 0, len(targets)) + for _, t := range targets { + ids = append(ids, t.DeviceID) + } + + changed, names, checkRes, err := s.store.BulkUpdateDevices(r.Context(), p.TenantID, p.Scope(), + ids, in.Changes) + if err != nil { + s.writeStoreError(w, "масова правка хостів", err) + return + } + + meta := map[string]any{ + "devices": len(changed), + "device_ids": changed, + // Імена, а не лише id: через рік хостів може вже не бути, а + // питання «що тоді зробили» лишиться. + "names": names, + "changes": in.Changes, + "filter": in.Filter, + } + // Наслідок дії над перевірками, а не лише намір. «Просив додати + // сорока, додав дванадцятьом» — це те, заради чого журнал і + // читають; із самого changes цього не видно ніяк. + if checkRes != nil { + meta["checks"] = map[string]any{ + "op": checkRes.Op, + "check_type": checkRes.CheckType, + "done": checkRes.Done, + "skipped": len(checkRes.Skipped), + "failed": checkRes.Failed, + } + } + + // Аудит пишеться після успіху й не блокує відповідь помилкою: якщо + // журнал не прийняв рядок, правка вже в базі, і приховувати її від + // людини було б гірше, ніж лишити слід тільки в логах сервера. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionDeviceBulkUpdate, + ObjectType: store.AuditObjectDevice, + Meta: meta, + }); err != nil { + s.log.Error("аудит масової правки хостів", "err", err) + } + + s.log.Info("масова правка хостів", + "хостів", len(changed), "користувач", p.Username, "user_id", p.UserID) + + out := map[string]any{ + "updated": len(changed), + "names": names, + "dropped": dropped, + } + if checkRes != nil { + out["checks"] = checkRes + } + writeJSON(w, http.StatusOK, out) +} + +// checkBulkChecksOp — запобіжники масової дії над ручними перевірками. +// +// Окремим правом вона не стала: усе те саме людина з devices:write +// робить у картці хоста поштучно (handleSetDeviceChecks), і масова +// форма економить час, а не відмикає нові можливості. +// +// Що тут перевіряється й чому саме тут: +// +// - тип із довідника. Невідомий ключ інакше доїхав би до зонда й помер +// там як задача неіснуючому модулю — за кілометри від того місця, де +// його ввели. Так само, як у handleSetDeviceChecks; +// - auto_managed нізвідки не додається й нікуди не прибирається: +// систему вже завела свій такий чек і сама тримає йому розклад, а +// прибраний вона заведе знову на найближчому такті — тобто дія +// виглядала б виконаною й нічого не міняла; +// - обов'язкові за схемою параметри мають бути заповнені. Це і є та +// межа, де масове додавання впирається в природу параметрів: url у +// http.status обов'язковий, спільного значення для сорока хостів у +// нього немає, і єдина відповідь, яка не бреше, — підстановка +// {{address}} / {{name}} / {{fqdn}}. Порожньо лишити не можна, бо +// чек без url не запуститься взагалі. +func (s *Server) checkBulkChecksOp(w http.ResponseWriter, r *http.Request, p *Principal, + op *store.BulkChecksOp) bool { + + if op == nil { + return true + } + + known, err := s.store.ListCheckTypes(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "типи перевірок", err) + return false + } + var ct *store.CheckType + for i := range known { + if known[i].Key == op.CheckType { + ct = &known[i] + break + } + } + if ct == nil { + writeError(w, http.StatusBadRequest, "bad_check_type", + "невідомий тип перевірки: "+op.CheckType) + return false + } + if ct.AutoManaged { + writeError(w, http.StatusBadRequest, "auto_managed_check", + "перевірку «"+ct.Name+"» заводить і планує система — масово її не додають і не прибирають") + return false + } + + if op.IntervalSec != nil && (*op.IntervalSec < 5 || *op.IntervalSec > 86400) { + writeError(w, http.StatusBadRequest, "bad_interval", + "інтервал опитування має бути від 5 секунд до доби") + return false + } + if op.TimeoutMs != nil && *op.TimeoutMs <= 0 { + writeError(w, http.StatusBadRequest, "bad_timeout", "таймаут має бути додатним") + return false + } + if op.Retries != nil && *op.Retries < 0 { + writeError(w, http.StatusBadRequest, "bad_retries", "повторів не може бути менше нуля") + return false + } + + if op.Op != "add" { + // Прибрати чи перелаштувати треба вміти й те, що лишилось від + // вимкненого плагіна: інакше ці рядки не прибрати ніяк, а вони + // далі стоять у планах і в переліку «Черги». + return true + } + if !ct.Available { + writeError(w, http.StatusBadRequest, "plugin_disabled", + "плагін «"+ct.PluginKey+"» вимкнений — перевірку цього типу не додати") + return false + } + + var schema struct { + Required []string `json:"required"` + } + _ = json.Unmarshal(ct.ParamsSchema, &schema) + if len(schema.Required) == 0 { + return true + } + + var params map[string]any + if len(op.Params) > 0 { + if err := json.Unmarshal(op.Params, ¶ms); err != nil { + writeError(w, http.StatusBadRequest, "bad_params", + "параметри перевірки мають бути об'єктом JSON") + return false + } + } + for _, k := range schema.Required { + v, ok := params[k] + if !ok || v == nil { + writeError(w, http.StatusBadRequest, "missing_param", + "параметр «"+k+"» обов'язковий для "+op.CheckType) + return false + } + if str, isStr := v.(string); isStr && strings.TrimSpace(str) == "" { + writeError(w, http.StatusBadRequest, "missing_param", + "параметр «"+k+"» обов'язковий для "+op.CheckType) + return false + } + if arr, isArr := v.([]any); isArr && len(arr) == 0 { + writeError(w, http.StatusBadRequest, "missing_param", + "параметр «"+k+"» обов'язковий для "+op.CheckType) + return false + } + } + return true +} + +func (s *Server) handleBulkDeleteDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + var in bulkDeviceBody + if !decodeBody(w, r, &in) { + return + } + + targets, dropped, ok := s.resolveBulk(w, r, p, in) + if !ok { + return + } + if len(targets) == 0 { + writeError(w, http.StatusBadRequest, "no_targets", + "жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз") + return + } + + ids := make([]string, 0, len(targets)) + for _, t := range targets { + ids = append(ids, t.DeviceID) + } + + switch in.Mode { + case "", "archive": + s.archiveDevices(w, r, p, in, ids, dropped) + case "purge": + s.purgeDevices(w, r, p, in, targets, ids, dropped) + default: + writeError(w, http.StatusBadRequest, "bad_mode", + "невідомий спосіб видалення: "+in.Mode) + } +} + +// archiveDevices — колишня поведінка: хост зникає з переліків, зібране +// лишається. +func (s *Server) archiveDevices(w http.ResponseWriter, r *http.Request, p *Principal, + in bulkDeviceBody, ids, dropped []string) { + + deleted, names, err := s.store.BulkDeleteDevices(r.Context(), p.TenantID, p.Scope(), ids) + if err != nil { + s.writeStoreError(w, "масове видалення хостів", err) + return + } + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionDeviceBulkDelete, + ObjectType: store.AuditObjectDevice, + Meta: map[string]any{ + "mode": "archive", + "devices": len(deleted), + "device_ids": deleted, + "names": names, + "filter": in.Filter, + }, + }); err != nil { + s.log.Error("аудит масового видалення хостів", "err", err) + } + + s.log.Info("масове видалення хостів", + "хостів", len(deleted), "користувач", p.Username, "user_id", p.UserID) + + writeJSON(w, http.StatusOK, map[string]any{ + "mode": "archive", + "deleted": len(deleted), + "names": names, + "dropped": dropped, + }) +} + +// purgeDevices — видалення назавжди. +// +// Додаткове право, і воно не з обережності. У цій системі вже ухвалено, +// що масова форма не має ставати обхідним шляхом до чужого розділу: хто +// не може змінити розклад збору конфігів одному хосту, не міняє його й +// п'ятдесяти (див. handleBulkUpdateDevices). Тут те саме, лише сильніше: +// видалення однієї версії конфігу вимагає ncm:delete — окремого права +// саме тому, що архів конфігів це те, чим доводять, ЯК виглядало +// залізо в конкретний день. Дозволити стерти весь архів хоста через +// сторінку інвентарю означало б роздати ncm:delete всім, хто має +// devices:write, і не сказати про це нікому. +// +// Хост без жодної версії такої вимоги не має: там нема чого захищати, а +// зайве право означало б, що звичайний інженер не може прибрати за +// собою тестовий хост. +func (s *Server) purgeDevices(w http.ResponseWriter, r *http.Request, p *Principal, + in bulkDeviceBody, targets []store.BulkDeviceTarget, ids, dropped []string) { + + withConfigs := 0 + for _, t := range targets { + withConfigs += t.Configs + } + if withConfigs > 0 && !requirePerm(w, p, deletePerm) { + return + } + + res, err := s.store.PurgeDevices(r.Context(), p.TenantID, p.Scope(), ids) + if err != nil { + s.writeStoreError(w, "повне видалення хостів", err) + return + } + + // Аудит пише ІМЕНА Й АДРЕСИ, а не самі лише id. + // + // Після цієї дії за id не стоїть нічого: рядка немає, і жоден запит + // його вже не розкриє. Журнал, у якому лежить + // «8d1b1df8-fa6b-…видалено», відповідає на питання «чи хтось це + // робив» і не відповідає на єдине, заради якого його читають, — + // «що саме зникло». + gone := make([]map[string]any, 0, len(res.Purged)) + names := make([]string, 0, len(res.Purged)) + for _, d := range res.Purged { + names = append(names, d.Name) + gone = append(gone, map[string]any{ + "id": d.DeviceID, "name": d.Name, "address": d.Address, + "configs": d.Configs, "series": d.Series, + "samples": d.Samples, "branches": d.Branches, + }) + } + // Окремий ключ дії, а не спільний із архівним видаленням. + // + // Досі обидва режими писались як inv.device.bulk_delete, а різниця + // між «прибрано з переліку» й «немає більше ніде» лежала в + // meta.mode. Для журналу це найгірше з можливих місць: фільтр за + // дією їх не розрізняє, позначка «незворотна» стоїть однаково на + // обох, а прочитати meta можна лише розгорнувши подію по одній. + // Ключ, за яким шукають найстрашнішу дію в системі, має бути + // власним. + // + // meta.mode лишається як був: за ним уже шукають, і прибирати поле + // заради краси означало б зламати чужі збережені фільтри. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionDeviceBulkPurge, + ObjectType: store.AuditObjectDevice, + Meta: map[string]any{ + "mode": "purge", + "devices": len(res.Purged), + "hosts": gone, + "names": names, + "configs": res.Configs, + "freed_bytes": res.FreedBytes, + "series": res.Series, + "samples": res.Samples, + "failed": res.Failed, + "filter": in.Filter, + }, + }); err != nil { + s.log.Error("аудит повного видалення хостів", "err", err) + } + + s.log.Info("повне видалення хостів", + "хостів", len(res.Purged), "версій_конфігу", res.Configs, + "рядів", res.Series, "вимірювань", res.Samples, + "не_вдалося", len(res.Failed), + "користувач", p.Username, "user_id", p.UserID) + + writeJSON(w, http.StatusOK, map[string]any{ + "mode": "purge", + "deleted": len(res.Purged), + "names": names, + "dropped": dropped, + "purged": res, + }) +} + +// handleBulkRestoreDevices — повернути хости з архіву. +// +// Окрема ручка, а не третій режим bulk-delete. Ручка видалення описує +// одну дію з двома ступенями суворості; повернення — дія протилежна, і +// класти її під той самий шлях означало б, що клієнт, який помилився в +// полі `mode`, робить не те, що збирався, у бік, який навіть не +// обговорювався. +// +// Право те саме, що й у решти правок інвентарю (devices:write), і +// окремого «restore» тут немає свідомо — з тієї ж причини, що й у +// масової правки: повернення не відмикає нічого, чого людина з +// devices:write не могла б зробити інакше. Зворотне теж правда й +// важливіше: якби повернення вимагало більшого права, ніж архівування, +// система дозволяла б прибрати хост і не дозволяла б виправити це тому +// самому інженерові. +// +// Права на КОЖЕН хост окремо перевіряє добір: у набір потрапляє лише +// те, що людині дозволено змінювати (Scope.Writable). +func (s *Server) handleBulkRestoreDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + var in bulkDeviceBody + if !decodeBody(w, r, &in) { + return + } + // Режим не питаємо в клієнта: ця ручка адресована архіву завжди, а + // добір без цього не побачив би жодної цілі. + in.Mode = "restore" + + targets, dropped, ok := s.resolveBulk(w, r, p, in) + if !ok { + return + } + if len(targets) == 0 { + writeError(w, http.StatusBadRequest, "no_targets", + "жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз") + return + } + + ids := make([]string, 0, len(targets)) + for _, t := range targets { + ids = append(ids, t.DeviceID) + } + + res, err := s.store.RestoreDevices(r.Context(), p.TenantID, p.Scope(), ids) + if err != nil { + s.writeStoreError(w, "повернення хостів з архіву", err) + return + } + + names := make([]string, 0, len(res.Restored)) + // Окремо — ті, хто повернувся вимкненим або без прив'язки до зонда. + // Це і є відповідь на питання «чому хост на місці, а даних немає», + // і в журналі вона потрібна не менше, ніж на екрані: через тиждень + // про сам факт повернення вже ніхто не згадає. + offline := []string{} + unbound := []string{} + for _, d := range res.Restored { + names = append(names, d.Name) + if !d.Enabled { + offline = append(offline, d.Name) + } + if d.SelfAgentDropped { + unbound = append(unbound, d.Name) + } + } + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionDeviceBulkRestore, + ObjectType: store.AuditObjectDevice, + Meta: map[string]any{ + "devices": len(res.Restored), + "device_ids": ids, + "names": names, + "checks": res.Checks, + "left_disabled": offline, + "unbound": unbound, + "failed": res.Failed, + }, + }); err != nil { + s.log.Error("аудит повернення хостів з архіву", "err", err) + } + + s.log.Info("повернення хостів з архіву", + "хостів", len(res.Restored), "перевірок", res.Checks, + "не_вдалося", len(res.Failed), + "користувач", p.Username, "user_id", p.UserID) + + writeJSON(w, http.StatusOK, map[string]any{ + "restored": len(res.Restored), + "names": names, + "dropped": dropped, + "result": res, + }) +} diff --git a/server/internal/httpapi/downloads.go b/server/internal/httpapi/downloads.go new file mode 100644 index 0000000..5c8a17f --- /dev/null +++ b/server/internal/httpapi/downloads.go @@ -0,0 +1,130 @@ +package httpapi + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/netpulse/netpulse/server/internal/auth" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Завантаження файлів звичайним посиланням. +// +// Уся решта API живе на Bearer-токені в заголовку. Заголовок уміє додати +// лише fetch, а fetch кладе відповідь у пам'ять вкладки: для звіту на +// десятки мегабайтів це означає зібрати весь файл у браузері, перш ніж +// людина побачить діалог збереження, а в частині оточень (кіоски, +// вбудовані webview, політики, що глушать blob:) збереження такого +// об'єкта не спрацьовує взагалі. +// +// Тому файл тягне завантажувач браузера за звичайним посиланням, а право +// доступу лежить у самому URL — короткоживучим квитком. Механіка й ціна +// описані в міграції 0038; тут — рівно два обробники: видати квиток +// (під звичайною автентифікацією й правом) і віддати за ним файл. +// +// Що квиток НЕ дає: сесії, права, доступу до будь-чого, крім одного +// об'єкта в одному форматі. Він не поновлюється й через дві хвилини +// мертвий. + +// issueDownloadTicket видає квиток і повертає шлях до файлу. +// +// Шлях, а не повний URL: сервер за проксі не знає свого зовнішнього +// імені, і вгадування дало б посилання, яке нікуди не веде. Той самий +// висновок, що й у посилання для телевізора. +func (s *Server) issueDownloadTicket(w http.ResponseWriter, r *http.Request, p *Principal, + kind, objectID, format string) bool { + + token, err := auth.NewToken("np_dl_") + if err != nil { + s.log.Error("токен завантаження", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return false + } + if err := s.store.CreateDownloadTicket(r.Context(), token, p.TenantID, p.UserID, + kind, objectID, format); err != nil { + s.writeStoreError(w, "посилання на завантаження", err) + return false + } + writeJSON(w, http.StatusOK, map[string]any{ + "path": "/api/v1/downloads/" + token, + // Скільки посилання житиме. Клієнт має піти за ним одразу, а не + // покласти в змінну «на потім»; секунди тут — щоб це було видно + // без читання коду сервера. + "expires_in": int(store.DownloadTicketTTL.Seconds()), + }) + return true +} + +// handleDownload віддає файл за квитком. +// +// Без автентифікації — її замінює токен у шляху. Тому перше, що тут +// відбувається, — звірка квитка, і лише потім будь-яке читання даних. +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.PathValue("token")) + + t, err := s.store.ResolveDownloadTicket(r.Context(), token) + if errors.Is(err, store.ErrNotFound) { + // Однакова відповідь на неіснуючий і на протухлий квиток: + // різниця між ними — підказка тому, хто перебирає. + writeError(w, http.StatusNotFound, "not_found", "посилання недійсне або застаріле") + return + } + if err != nil { + s.log.Error("квиток завантаження", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + switch t.Kind { + case store.DownloadKindCommandReport: + s.streamCommandReport(w, r, t) + default: + // Квитка з невідомим видом бути не може — його заводить цей же + // код. Якщо він з'явився, це наш недогляд, а не помилка того, хто + // прийшов за файлом. + s.log.Error("невідомий вид квитка", "kind", t.Kind) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + } +} + +// streamCommandReport віддає звіт про прогін команд. +// +// Заголовки виставляються в колбеку, який store викликає рівно тоді, коли +// прогін знайдено, а перший байт тіла ще не пішов. Інакше 404 на +// видалений прогін довелось би віддавати всередині вже початого файлу. +func (s *Server) streamCommandReport(w http.ResponseWriter, r *http.Request, t store.DownloadTicket) { + err := s.store.StreamCommandRunReport(r.Context(), t.TenantID, t.ObjectID, t.Format, + func(run store.CommandRun) (io.Writer, error) { + name := store.ReportFileName(run, t.Format) + ct := "text/plain; charset=utf-8" + if t.Format == store.ReportFormatCSV { + ct = "text/csv; charset=utf-8" + } + w.Header().Set("Content-Type", ct) + // Без Content-Disposition браузер покаже текст у вкладці, а + // людина прийшла по файл. + w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + // Довжини не знаємо й знати не можемо: звіт формується на + // льоту. no-store — бо посилання одноразове за змістом, і + // кешований звіт пережив би квиток. + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return w, nil + }) + + switch { + case err == nil: + return + case errors.Is(err, store.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found", "прогін уже видалено") + case errors.Is(err, store.ErrInvalid): + writeError(w, http.StatusBadRequest, "invalid", err.Error()) + default: + // Сюди потрапляє й обрив посеред потоку. Відповідь уже пішла, і + // змінити її статус не можна — слід про обрив дописано в сам + // файл, а тут лишається журнал. + s.log.Error("звіт про прогін команд", "run", t.ObjectID, "err", err) + } +} diff --git a/server/internal/httpapi/git_mirror.go b/server/internal/httpapi/git_mirror.go new file mode 100644 index 0000000..c0a98f9 --- /dev/null +++ b/server/internal/httpapi/git_mirror.go @@ -0,0 +1,329 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/netpulse/netpulse/server/internal/gitstore" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Дзеркалення архіву конфігів на зовнішній Git. +// +// Чотири дії, і межа між ними проходить по тому, ХТО чекає. +// +// Читання стану й збереження налаштувань — миттєві, їх робить цей +// процес. Перевірка з'єднання теж: вона коротка (одне ls-remote) і саме +// заради неї людина зараз сидить перед екраном. +// +// А от сам push цей процес не робить НІКОЛИ. «Надіслати зараз» +// прибирає витримку повтору й позначку останнього стану — і фоновий +// такт колектора підхоплює це протягом хвилини. Причина та сама, що й +// у прибиральника версій: REST — шлях запитів людини, його масштабують +// копіями, а перший повний push інсталяції з річною історією — це +// десятки хвилин і гігабайти. Тримати таке в обробнику HTTP означало б +// або обірваний запит, або зайнятий надовго процес, який зараз комусь +// малює сторінку. + +// handleGetMirror — стан дзеркала. +// +// Право ncm:read, а не ncm:mirror, і це навмисно: помітити, що копія +// тиждень не їде, має могти той, хто щодня дивиться на конфіги, а не +// лише власник кабінету. Налаштувати — інша річ, і право там інше. +func (s *Server) handleGetMirror(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + m, err := s.store.GetMirror(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "дзеркало архіву конфігів", err) + return + } + // Публічна половина ключа їде разом зі станом: людина заводить його + // в репозиторії не тієї ж хвилини, коли створює, і повертатись сюди + // по неї має бути куди. Помилка тут не ламає сторінку — без ключа + // вона просто не покаже блока з ним. + if pub, err := s.store.MirrorPublicKey(r.Context(), p.TenantID, s.keyring); err == nil { + m.PublicKey = pub + } + writeJSON(w, http.StatusOK, m) +} + +// handleSetMirror зберігає налаштування. +func (s *Server) handleSetMirror(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:mirror") { + return + } + var in store.MirrorInput + if !decodeBody(w, r, &in) { + return + } + if s.keyring == nil && strings.TrimSpace(in.Secret) != "" { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування — зберегти токен ніде") + return + } + + // Увімкнути дзеркало без адреси неможливо: інакше на сторінці + // стояло б «увімкнено», а такт мовчки нічого не робив би. + if in.Enabled && strings.TrimSpace(in.URL) == "" { + writeError(w, http.StatusBadRequest, "bad_request", + "щоб увімкнути дзеркало, потрібна адреса віддаленого репозиторію") + return + } + + if err := s.store.SaveMirror(r.Context(), p.TenantID, in, s.keyring); err != nil { + if isMirrorInputError(err) { + writeError(w, http.StatusBadRequest, "bad_remote", err.Error()) + return + } + s.writeStoreError(w, "збереження дзеркала", err) + return + } + + m, err := s.store.GetMirror(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "дзеркало архіву конфігів", err) + return + } + + // У журналі — намір і адреса, ніколи токен. RedactURL прибирає + // логін із адреси на випадок, коли людина вставила рядок виду + // https://токен@forgejo/…, скопійований із чужої інструкції. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionMirrorUpdate, + ObjectType: store.AuditObjectMirror, + ObjectID: m.RepoID, + Meta: map[string]any{ + "enabled": m.Enabled, + "url": gitstore.RedactURL(m.URL), + "auth": m.Auth, + "has_secret": m.HasSecret, + "host_key_fp": m.HostKeyFP, + }, + }); err != nil { + s.log.Error("аудит дзеркала", "err", err) + } + + writeJSON(w, http.StatusOK, m) +} + +// mirrorTestResult — відповідь на «перевірити з'єднання». +// +// Завжди 200, навіть коли не вийшло. Помилка тут — це відповідь на +// питання, а не збій запиту: людина натиснула саме для того, щоб +// дізнатись текст. HTTP-код 502 показав би їй «щось пішло не так» +// замість «токен не має права запису». +type mirrorTestResult struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Refs int `json:"refs"` + Empty bool `json:"empty"` + + HostKey string `json:"host_key,omitempty"` + HostKeyFP string `json:"host_key_fp,omitempty"` + HostKeyKnown bool `json:"host_key_known"` + // NeedsPin — сервер показав ключ, якого ми ще не знаємо. Далі + // вирішує людина: закріпити цей чи зупинитись. + NeedsPin bool `json:"needs_pin"` +} + +// handleTestMirror перевіряє з'єднання ДО збереження. +// +// Окрема дія існує рівно тому, що альтернатива — дізнатись про +// помилку через добу з журналу. Токен береться з тіла запиту (людина +// щойно його ввела) або зі збереженого, якщо поле лишили порожнім. +func (s *Server) handleTestMirror(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:mirror") { + return + } + var in store.MirrorInput + if !decodeBody(w, r, &in) { + return + } + + remote := gitstore.Remote{ + URL: strings.TrimSpace(in.URL), + User: strings.TrimSpace(in.User), + Secret: in.Secret, + HostKey: strings.TrimSpace(in.HostKey), + // Півхвилини: перевірка має або відповісти, поки людина + // дивиться на екран, або чесно сказати, що сервер мовчить. + Timeout: 30 * time.Second, + } + + // Порожні поля добираються зі збереженого: типовий сценарій — + // «перевір те, що вже налаштовано», без повторного введення токена, + // якого форма й не показує. + if remote.Secret == "" || remote.URL == "" { + saved, err := s.store.MirrorRemote(r.Context(), p.TenantID, s.keyring) + if err == nil { + if remote.URL == "" { + remote.URL = saved.URL + } + if remote.Secret == "" { + remote.Secret = saved.Secret + } + if remote.User == "" { + remote.User = saved.User + } + if remote.HostKey == "" { + remote.HostKey = saved.HostKey + } + } + } + + stat, err := gitstore.Probe(r.Context(), remote) + res := mirrorTestResult{ + Refs: stat.Refs, + Empty: stat.Empty, + HostKey: stat.HostKey, + HostKeyKnown: stat.HostKeyKnown, + } + if stat.HostKey != "" { + res.HostKeyFP = gitstore.Fingerprint(stat.HostKey) + res.NeedsPin = !stat.HostKeyKnown + } + switch { + case err != nil: + // Друга сітка поверх тієї, що вже стоїть у gitstore: текст + // звідти проходить через Scrub, але цей рядок їде до людини й + // у браузерну консоль, і платити за припущення «там уже + // почищено» довелося б токеном. + res.Error = gitstore.Scrub(err, remote.Secret).Error() + case res.NeedsPin: + res.Error = "сервер показав ключ, якого ми ще не знаємо. Звірте відбиток і збережіть — далі кожен обмін перевірятиме саме його" + default: + res.OK = true + } + writeJSON(w, http.StatusOK, res) +} + +// handlePushMirror просить надіслати негайно. +// +// Сам push тут не робиться (див. коментар угорі файлу): знімається +// витримка повтору й відбиток останнього надісланого стану, і +// найближчий такт колектора йде в мережу, навіть якщо локально нічого +// не змінилось. Відповідь — оновлений стан; результат людина побачить +// на тій самій сторінці протягом хвилини. +func (s *Server) handlePushMirror(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:mirror") { + return + } + // force — окреме, свідоме «перезаписати віддалене дерево». Потрібне + // рівно в одному випадку: локальний репозиторій відновили з бази + // (netpulse-gitsync), коміти склались наново, і звичайний push + // чесно відмовляється затирати те, що вже поїхало. Без цієї дії + // вихід із такого стану був би лише через ручний git на сервері. + var in struct { + Force bool `json:"force"` + } + if r.ContentLength > 0 && !decodeBody(w, r, &in) { + return + } + + if err := s.store.RequestMirrorPush(r.Context(), p.TenantID, in.Force); err != nil { + s.writeStoreError(w, "дзеркало архіву конфігів", err) + return + } + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionMirrorPush, + ObjectType: store.AuditObjectMirror, + Meta: map[string]any{"force": in.Force}, + }); err != nil { + s.log.Error("аудит дзеркала", "err", err) + } + + m, err := s.store.GetMirror(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "дзеркало архіву конфігів", err) + return + } + writeJSON(w, http.StatusOK, m) +} + +// handleForgetMirrorSecret прибирає збережений токен або ключ. +func (s *Server) handleForgetMirrorSecret(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:mirror") { + return + } + if err := s.store.ForgetMirrorSecret(r.Context(), p.TenantID); err != nil { + s.writeStoreError(w, "секрет дзеркала", err) + return + } + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionMirrorUpdate, + ObjectType: store.AuditObjectMirror, + Meta: map[string]any{"enabled": false, "has_secret": false}, + }); err != nil { + s.log.Error("аудит дзеркала", "err", err) + } + w.WriteHeader(http.StatusNoContent) +} + +// isMirrorInputError — чи це помилка введення, а не збій сервера. +// Від відповіді залежить код: 400 просить виправити поле, 500 — +// звернутись до адміністратора. +func isMirrorInputError(err error) bool { + for _, e := range []error{ + gitstore.ErrNoRemoteURL, gitstore.ErrBadRemoteURL, + gitstore.ErrUnsupportedTr, gitstore.ErrPlainHTTP, + gitstore.ErrHostKeyUnset, + } { + if errors.Is(err, e) { + return true + } + } + return false +} + +// handleMirrorDeployKey створює ключ розгортання й показує публічну +// половину. +// +// POST, хоч і виглядає як «дай ключ»: він створюється й замінює +// попередній. Друге натискання — це новий ключ, і старий одразу +// перестає працювати; сказати про це має інтерфейс, а не з'ясувати +// нічний бекап. +func (s *Server) handleMirrorDeployKey(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:mirror") { + return + } + // Адреса з форми: людина могла щойно замінити https на SSH і ще не + // зберегти. Без неї ключ ліг би з типом старої адреси. + var in struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + + pub, err := s.store.GenerateMirrorDeployKey(r.Context(), p.TenantID, in.URL, s.keyring) + if err != nil { + s.writeStoreError(w, "ключ розгортання", err) + return + } + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionMirrorUpdate, + ObjectType: store.AuditObjectMirror, + // Публічну половину — можна: вона й так їде в чужий + // репозиторій. Приватна в аудит не потрапляє ніколи. + Meta: map[string]any{"deploy_key": true, "public_key": pub}, + }); err != nil { + s.log.Error("аудит дзеркала", "err", err) + } + writeJSON(w, http.StatusOK, map[string]any{"public_key": pub}) +} diff --git a/server/internal/httpapi/maps_write.go b/server/internal/httpapi/maps_write.go index 494d437..6ad9013 100644 --- a/server/internal/httpapi/maps_write.go +++ b/server/internal/httpapi/maps_write.go @@ -104,6 +104,58 @@ func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, p *Princ writeJSON(w, http.StatusOK, st) } +// handleMapInterfaces — порти хостів мапи для ручного зіставлення. +// +// Право читання мапи, а не хостів: перелік імен портів пристроїв, які +// вже намальовані на цій мапі, нічого не додає до того, що людина +// й так бачить. +func (s *Server) handleMapInterfaces(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:read") { + writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read") + return + } + + mapID := r.PathValue("id") + if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil && + level == "deny" { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи") + return + } + + ifs, err := s.store.MapInterfaces(r.Context(), p.TenantID, mapID) + if err != nil { + s.writeStoreError(w, "порти мапи", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"interfaces": ifs}) +} + +// handleMapDiscover просить зібрати топологію з хостів цієї мапи. +// +// devices:write, а не maps:write: дія заводить хостам перевірку й +// змушує зонд їх опитати. Це зміна опитування, і той, кому дозволили +// лише малювати схеми, робити її не має. +func (s *Server) handleMapDiscover(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("devices:write") { + writeError(w, http.StatusForbidden, "forbidden", "немає права devices:write") + return + } + + mapID := r.PathValue("id") + if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil && + level == "deny" { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи") + return + } + + res, err := s.store.RequestMapDiscovery(r.Context(), p.TenantID, mapID, p.Scope()) + if err != nil { + s.writeStoreError(w, "збір топології", err) + return + } + writeJSON(w, http.StatusOK, res) +} + // handleUndoMap відкочує полотно до попереднього знімка. func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, p *Principal) { if !p.Can("maps:write") { diff --git a/server/internal/httpapi/metrics_devices.go b/server/internal/httpapi/metrics_devices.go new file mode 100644 index 0000000..1182258 --- /dev/null +++ b/server/internal/httpapi/metrics_devices.go @@ -0,0 +1,44 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// handleMetricDevices — перелік хостів сторінки «Метрики» за фільтром. +// +// POST, хоч і нічого не змінює, — з тієї самої причини, що в доборі +// цілей для команд і в переліку хостів «Конфігів»: фільтр це структура +// з кількома списками, і складати її в рядок запиту означало б винайти +// власне кодування масивів заради того, щоб виглядати як GET. +// +// Право devices:read — те саме, що вже потрібне обом ручкам метрик +// (перелік рядів і точки графіка). Той, кому дозволено бачити графіки, +// має право й відібрати хост, чиї графіки дивиться. +func (s *Server) handleMetricDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + + var f store.MetricsFilter + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати фільтр") + return + } + + list, err := s.store.ListMetricDevices(r.Context(), p.TenantID, p.Scope(), f) + if err != nil { + s.writeStoreError(w, "добір хостів за фільтром", err) + return + } + if list == nil { + list = []store.MetricDeviceRow{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "devices": list, + "total": len(list), + }) +} diff --git a/server/internal/httpapi/ncm_commands.go b/server/internal/httpapi/ncm_commands.go new file mode 100644 index 0000000..6638cc1 --- /dev/null +++ b/server/internal/httpapi/ncm_commands.go @@ -0,0 +1,336 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Масове виконання команд по фільтру. +// +// Найнебезпечніша дія в системі: одна команда на двісті пристроїв, без +// попереднього перегляду наслідків і без відкату. Тому запобіжники тут +// не «на майбутнє», а частина функції: +// +// - окреме право ncm:exec (не ncm:write і тим паче не devices:read); +// - обмеження видимості: у прогін потрапляє лише те, що людині +// дозволено ЗМІНЮВАТИ, а не лише бачити; +// - перелік хостів повертається до запуску, і запуск іде по перетину +// фільтра з тим переліком, який людина бачила; +// - кожен прогін і кожна зупинка — рядок у журналі аудиту. +// +// Право перевіряється однакове на читання й на запуск. Вивід команди +// показує стан заліза детальніше за будь-яку іншу сторінку, і давати +// його всім, хто має ncm:read, означало б зробити обхідний шлях до +// того, заради чого й заведено окреме право. + +const execPerm = "ncm:exec" + +// handlePreviewCommandTargets — «кого зачепить цей фільтр». +// +// POST, хоч і нічого не змінює: фільтр — це структура з кількома +// списками, і складати її в рядок запиту означало б винайти власне +// кодування масивів заради того, щоб виглядати як GET. +func (s *Server) handlePreviewCommandTargets(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, execPerm) { + return + } + + var f store.DeviceFilter + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати фільтр") + return + } + + list, err := s.store.ResolveCommandTargets(r.Context(), p.TenantID, p.Scope(), f) + if err != nil { + s.writeStoreError(w, "добір хостів за фільтром", err) + return + } + if list == nil { + list = []store.CommandCandidate{} + } + + // Ліміт повідомляється разом із переліком: людина має побачити, що + // впирається в стелю, ще у вікні підтвердження, а не отримати + // відмову після натискання. + writeJSON(w, http.StatusOK, map[string]any{ + "devices": list, + "total": len(list), + "max": store.MaxRunDevices, + }) +} + +func (s *Server) handleListCommandRuns(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, execPerm) { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + runs, err := s.store.ListCommandRuns(r.Context(), p.TenantID, limit) + if err != nil { + s.writeStoreError(w, "перелік прогонів", err) + return + } + if runs == nil { + runs = []store.CommandRun{} + } + writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) +} + +func (s *Server) handleGetCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, execPerm) { + return + } + // Стенограма — за окремим проханням: сторінка перечитує прогін раз + // на дві секунди, поки він живий, а стенограма сесії до великого + // шасі важить сотні кілобайт на хост. + withTranscript := r.URL.Query().Get("transcript") == "1" + + run, err := s.store.GetCommandRun(r.Context(), p.TenantID, r.PathValue("id"), withTranscript) + if err != nil { + s.writeStoreError(w, "прогін команд", err) + return + } + writeJSON(w, http.StatusOK, run) +} + +func (s *Server) handleCreateCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, execPerm) { + return + } + + var in store.CommandRunInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати запит") + return + } + + // Перелік звіряється з фільтром ще раз, уже на сервері. + // + // Не паранойя, а два різні захисти в одному місці. По-перше, тіло + // запиту складає клієнт, і без звірки сюди можна було б покласти + // будь-який ідентифікатор — зокрема хост із чужої філії, який + // фільтр ніколи б не повернув. По-друге, між переглядом і + // натисканням минає час: хост могли вимкнути, перевести в іншу + // групу або завести новий, що теж підпадає під фільтр. Перетин + // означає рівно те, що обіцяє підтвердження, — «виконати на тому, + // що я щойно бачив, і ні на чому більше». + matched, err := s.store.ResolveCommandTargets(r.Context(), p.TenantID, p.Scope(), in.Filter) + if err != nil { + s.writeStoreError(w, "добір хостів за фільтром", err) + return + } + allowed := make(map[string]store.CommandCandidate, len(matched)) + for _, c := range matched { + allowed[c.DeviceID] = c + } + + var ( + final []string + names []string + dropped []string + ) + for _, id := range in.DeviceIDs { + c, ok := allowed[id] + if !ok { + dropped = append(dropped, id) + continue + } + final = append(final, id) + names = append(names, c.Name) + } + if len(final) == 0 { + writeError(w, http.StatusBadRequest, "no_targets", + "жоден з обраних хостів більше не підпадає під фільтр — перегляньте перелік ще раз") + return + } + in.DeviceIDs = final + + run, err := s.store.CreateCommandRun(r.Context(), p.TenantID, p.UserID, in) + if err != nil { + s.writeStoreError(w, "створення прогону команд", err) + return + } + + // Аудит пишеться після успіху й не блокує відповідь помилкою: якщо + // журнал не прийняв рядок, прогін уже існує, і приховувати його від + // людини було б гірше, ніж лишити слід лише в логах сервера. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionCommandRunCreate, + ObjectType: store.AuditObjectCommandRun, + ObjectID: run.ID, + Meta: map[string]any{ + "commands": run.Commands, + "devices": len(final), + "device_ids": final, + "names": names, + "filter": in.Filter, + "concurrency": run.Concurrency, + "timeout_sec": run.TimeoutSec, + }, + }); err != nil { + s.log.Error("аудит прогону команд", "run", run.ID, "err", err) + } + + s.log.Info("масове виконання команд запущено", + "run", run.ID, "хостів", len(final), "команд", len(run.Commands), + "користувач", p.Username, "user_id", p.UserID) + + writeJSON(w, http.StatusOK, map[string]any{ + "run": run, + // Хости, що відпали між переглядом і запуском. Мовчати про них + // не можна: людина підтверджувала перелік, і різницю має + // побачити вона, а не наступний, хто відкриє журнал. + "dropped": dropped, + }) +} + +func (s *Server) handleCancelCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, execPerm) { + return + } + runID := r.PathValue("id") + + stopped, err := s.store.CancelCommandRun(r.Context(), p.TenantID, runID, p.UserID) + if err != nil { + s.writeStoreError(w, "зупинка прогону", err) + return + } + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionCommandRunCancel, + ObjectType: store.AuditObjectCommandRun, + ObjectID: runID, + Meta: map[string]any{"stopped": stopped}, + }); err != nil { + s.log.Error("аудит зупинки прогону", "run", runID, "err", err) + } + + s.log.Info("прогін команд зупинено", + "run", runID, "не_почато", stopped, "користувач", p.Username) + + writeJSON(w, http.StatusOK, map[string]any{"stopped": stopped}) +} + +// handleDeleteCommandRun прибирає прогін разом із виводом. +// +// Право — те саме ncm:exec, і поруч тепер є спокуслива альтернатива: +// міграція 0037 завела ncm:delete для видалення версій конфігів. Брати +// його сюди було б помилкою, і різниця тут не в масштабі, а в тому, чи +// можна знищене відтворити. +// +// Версія конфігу — єдиний слід того, як виглядало залізо в конкретний +// день; стерти її означає стерти доказ, і другого такого дня не буде. +// Вивід прогону відтворюється повторним запуском тієї самої команди — +// причому тим самим, хто натискає «видалити»: побачити прогін узагалі +// може лише власник ncm:exec. Вимагати для видалення право, без якого +// цих даних не видно й не створити, — це не захист, а зайвий прапорець, +// який хтось однаково видасть разом із першим. +// +// Захищає тут не кнопка, а журнал. Команди, перелік хостів, автор і час +// лежать у core.audit_log із моменту запуску, звідси не прибираються, і +// саме видалення теж лягає туди — разом зі зведенням, що саме зникло. +func (s *Server) handleDeleteCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, execPerm) { + return + } + runID := r.PathValue("id") + + run, err := s.store.DeleteCommandRun(r.Context(), p.TenantID, runID) + if errors.Is(err, store.ErrRunActive) { + // 409, а не 400: із запитом усе гаразд, це стан системи, який + // сам мине. Текст пояснює, чого чекати, — див. store. + writeError(w, http.StatusConflict, "run_active", err.Error()) + return + } + if err != nil { + s.writeStoreError(w, "видалення прогону", err) + return + } + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionCommandRunDelete, + ObjectType: store.AuditObjectCommandRun, + ObjectID: runID, + Meta: map[string]any{ + "commands": run.Commands, + "devices": run.Total, + "counts": run.Counts, + "status": run.Status, + "created_by": run.CreatedBy, + "created_at": run.CreatedAt, + }, + }); err != nil { + s.log.Error("аудит видалення прогону", "run", runID, "err", err) + } + + s.log.Info("прогін команд видалено", + "run", runID, "хостів", run.Total, "користувач", p.Username) + + w.WriteHeader(http.StatusNoContent) +} + +// handleCommandRunReportLink видає посилання на файл звіту. +// +// Окремий крок, а не одразу файл, бо файл тягне браузер звичайним +// посиланням, без заголовка Authorization. Чому саме так — у +// downloads.go; тут важливо, що право перевіряється саме на цьому кроці, +// під нормальною автентифікацією, і квиток живе хвилини. +func (s *Server) handleCommandRunReportLink(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, execPerm) { + return + } + runID := r.PathValue("id") + + var in struct { + Format string `json:"format"` + } + // Порожнє тіло — не помилка: типовий формат читає людина. + _ = json.NewDecoder(r.Body).Decode(&in) + if in.Format == "" { + in.Format = store.ReportFormatTxt + } + if in.Format != store.ReportFormatTxt && in.Format != store.ReportFormatCSV { + writeError(w, http.StatusBadRequest, "bad_format", "формат звіту — txt або csv") + return + } + + // Прогін звіряється тут, а не при завантаженні: посилання, яке + // віддає 404, людина побачить уже у вкладці завантажень, де пояснити + // їй нічого не можна. + run, err := s.store.CommandRunHead(r.Context(), p.TenantID, runID) + if err != nil { + s.writeStoreError(w, "прогін команд", err) + return + } + + // Аудит на видачу, а не на завантаження: за квитком файл забирає + // браузер, і особи в тому запиті вже немає. Вивід команд показує стан + // заліза детальніше за будь-яку сторінку — те, що він поїхав назовні + // файлом, має лишити слід. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionCommandRunReport, + ObjectType: store.AuditObjectCommandRun, + ObjectID: runID, + Meta: map[string]any{"format": in.Format, "devices": run.Total}, + }); err != nil { + s.log.Error("аудит звіту про прогін", "run", runID, "err", err) + } + + s.issueDownloadTicket(w, r, p, store.DownloadKindCommandReport, runID, in.Format) +} diff --git a/server/internal/httpapi/ncm_config_devices.go b/server/internal/httpapi/ncm_config_devices.go new file mode 100644 index 0000000..28dbfdc --- /dev/null +++ b/server/internal/httpapi/ncm_config_devices.go @@ -0,0 +1,74 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// handleConfigDevices — перелік хостів сторінки «Конфіги» за фільтром. +// +// POST, хоч і нічого не змінює — з тієї самої причини, що й у доборі +// цілей для команд: фільтр це структура з кількома списками, і складати +// її в рядок запиту означало б винайти власне кодування масивів заради +// того, щоб виглядати як GET. +// +// Право ncm:read, а не ncm:write: людина дивиться архів конфігів, а не +// чіпає розклад. Той, хто має право бачити сторінку, має право й +// відібрати на ній потрібне. +func (s *Server) handleConfigDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + + var f store.ConfigFilter + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати фільтр") + return + } + + scope := p.Scope() + list, err := s.store.ListConfigDevices(r.Context(), p.TenantID, scope, f) + if err != nil { + s.writeStoreError(w, "добір хостів за фільтром", err) + return + } + if list == nil { + list = []store.ConfigDeviceRow{} + } + + // Перелік профілів їде разом із хостами, а не окремим запитом. + // + // Він потрібен рівно там, де показується фільтр, і рахується з тих + // самих меж видимості. Другий похід по мережу заради півдесятка + // рядків подвоїв би затримку кожного натискання в полі пошуку. + profiles, err := s.store.ConfigProfilesInUse(r.Context(), p.TenantID, scope) + if err != nil { + s.writeStoreError(w, "перелік профілів збору", err) + return + } + if profiles == nil { + profiles = []store.ConfigProfileOption{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "devices": list, + "total": len(list), + "profiles": profiles, + // Поріг застарілості віддається назад, бо сервер його підставляє + // й підрізає: інтерфейс має показувати те число, за яким справді + // відібрано, а не те, яке ввела людина. + "stale_days": staleDaysOf(f), + }) +} + +func staleDaysOf(f store.ConfigFilter) int { + if f.StaleDays <= 0 { + return store.DefaultStaleDays + } + if f.StaleDays > 3650 { + return 3650 + } + return f.StaleDays +} diff --git a/server/internal/httpapi/ncm_configs.go b/server/internal/httpapi/ncm_configs.go new file mode 100644 index 0000000..c82cda7 --- /dev/null +++ b/server/internal/httpapi/ncm_configs.go @@ -0,0 +1,176 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Очистка сховища версій конфігів: політика й ручне видалення. +// +// Два різні права, і це навмисно. +// +// Політику читає й пише той, хто вже керує збором (ncm:read / ncm:write): +// це налаштування, воно нічого не знищує в момент збереження, і живе +// поруч зі спільним розкладом, з яким його й налаштовують за один захід. +// +// Ручне видалення — окреме право ncm:delete. Різниця не в масштабі, а в +// природі дії: розклад можна повернути назад, видалену версію — ні. +// Архів конфігів це те, чим доводять, ЯК виглядало залізо в конкретний +// день; право стирати цей доказ не має видаватись разом із правом +// правити розклад. Той самий висновок, що й у 0036 для ncm:exec. + +const deletePerm = "ncm:delete" + +// handleGetRetention — політика очистки разом із тим, що вона зробить. +func (s *Server) handleGetRetention(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + pol, err := s.store.GetRetention(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "політика очистки конфігів", err) + return + } + writeJSON(w, http.StatusOK, pol) +} + +func (s *Server) handleSetRetention(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:write") { + return + } + var in struct { + Enabled bool `json:"enabled"` + Versions int `json:"versions"` + Days int `json:"days"` + } + if !decodeBody(w, r, &in) { + return + } + + if err := s.store.SetRetention(r.Context(), p.TenantID, store.RetentionPolicy{ + Enabled: in.Enabled, Versions: in.Versions, Days: in.Days, + }); err != nil { + s.writeStoreError(w, "збереження політики очистки", err) + return + } + + // Вмикання політики — не видалення, але його наслідок. Слід у + // журналі потрібен саме тут: сам прохід прибиральника нікого не + // питає, і питання «хто дозволив чистити архів» має мати відповідь. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionRetentionUpdate, + ObjectType: store.AuditObjectBackupDefaults, + Meta: map[string]any{ + "enabled": in.Enabled, "versions": in.Versions, "days": in.Days, + }, + }); err != nil { + s.log.Error("аудит політики очистки", "err", err) + } + + pol, err := s.store.GetRetention(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "політика очистки конфігів", err) + return + } + writeJSON(w, http.StatusOK, pol) +} + +// handleDeleteConfigs видаляє обрані версії. +// +// POST зі списком, а не DELETE /configs/{id} по одній. Дві причини, і +// обидві не про смаки в REST. Підтвердження людина дає один раз на весь +// набір — і саме цей набір має або пройти, або лишити зрозумілий звіт; +// N окремих запитів дали б N рядків аудиту й половину видаленого при +// обриві мережі. І тіло в DELETE підтримується транспортом настільки +// по-різному, що складати список у рядок запиту довелося б власним +// кодуванням масиву. +// +// Захищені версії не роблять запит помилковим: оператор міг обрати +// десять, з яких одна виявилась останньою. Відповідь каже, що зникло, а +// що лишилось і чому. +func (s *Server) handleDeleteConfigs(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, deletePerm) { + return + } + + var in struct { + IDs []string `json:"ids"` + } + if !decodeBody(w, r, &in) { + return + } + if len(in.IDs) == 0 { + writeError(w, http.StatusBadRequest, "no_ids", "не обрано жодної версії") + return + } + // Стеля на розмір запиту: перелік складає клієнт, і без неї одне + // натискання могло б забрати весь архів тенанта. + if len(in.IDs) > 500 { + writeError(w, http.StatusBadRequest, "too_many", + "за один раз можна видалити не більше 500 версій") + return + } + + scope := p.Scope() + res, err := s.store.DeleteConfigs(r.Context(), p.TenantID, in.IDs, scope.CanWrite) + if errors.Is(err, store.ErrNothingToDelete) { + // 409, а не 400: запит правильний, просто жодна з цих версій + // видаленню не підлягає — і клієнт має показати перелік причин. + writeJSON(w, http.StatusConflict, map[string]any{ + "error": map[string]string{ + "code": "nothing_to_delete", "message": store.ErrNothingToDelete.Error(), + }, + "kept": res.Kept, + }) + return + } + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор версії") + return + } + if err != nil { + s.writeStoreError(w, "видалення версій конфігу", err) + return + } + + // Аудит після успіху й не блокує відповідь: версій уже немає, і + // приховати це від людини було б гірше, ніж лишити слід лише в + // журналі сервера. Дати й імена хостів пишуться в meta саме тому, + // що самих версій уже не існує — з'ясувати їх потім не буде як. + items := make([]map[string]any, 0, len(res.Deleted)) + for _, d := range res.Deleted { + items = append(items, map[string]any{ + "id": d.ID, "device_id": d.DeviceID, "device": d.DeviceName, + "collected_at": d.CollectedAt, "size_bytes": d.SizeBytes, + }) + } + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionConfigDelete, + ObjectType: store.AuditObjectConfig, + Meta: map[string]any{ + "deleted": len(res.Deleted), + "freed_bytes": res.FreedBytes, + "kept": len(res.Kept), + "versions": items, + }, + }); err != nil { + s.log.Error("аудит видалення конфігів", "err", err) + } + + s.log.Info("видалено версії конфігів", + "кількість", len(res.Deleted), "байтів", res.FreedBytes, + "користувач", p.Username, "user_id", p.UserID) + + if res.Kept == nil { + res.Kept = []store.KeptConfig{} + } + writeJSON(w, http.StatusOK, res) +} diff --git a/server/internal/httpapi/ncm_localfiles.go b/server/internal/httpapi/ncm_localfiles.go new file mode 100644 index 0000000..d351d5e --- /dev/null +++ b/server/internal/httpapi/ncm_localfiles.go @@ -0,0 +1,138 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Конфіг-файли серверів: хости, які збирають самі себе. +// +// Ручок рівно три, і жодна з них не приймає шляху до файлу. Це не +// економія, а суть задачі: зонд читає файли машини своїми правами, і +// поле «які саме», кероване з кабінету, дало б будь-кому з правом +// ncm:write читання довільного файлу на ВСІХ серверах клієнта одразу. +// Перелік файлів живе лише в локальному конфізі зонда, і в HTTP-шарі +// його не видно взагалі — ані щоб задати, ані щоб прочитати. +// +// Збір «зараз» і збір за розкладом навмисно НЕ мають тут своїх ручок: +// і те, і те вже робить ncm.jobs через handleCollectConfig і +// ScheduleBackups. Друга кнопка «зібрати файли» означала б другу чергу +// й другий розклад, які довелося б тримати узгодженими вручну. + +// handleListServerFiles — сторінка «Конфіги серверів». +func (s *Server) handleListServerFiles(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + + devices, err := s.store.ListSelfDevices(r.Context(), p.TenantID, p.Scope()) + if err != nil { + s.writeStoreError(w, "перелік серверів зі збором файлів", err) + return + } + if devices == nil { + devices = []store.SelfDeviceRow{} + } + + // Довідник зондів їде разом зі списком, а не окремим запитом: він + // потрібен рівно тут, у формі прив'язки, і другий похід у мережу + // заради десятка рядків подвоїв би затримку відкриття сторінки. + agents, err := s.store.SelfAgentOptions(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "перелік зондів", err) + return + } + if agents == nil { + agents = []store.SelfAgentOption{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "devices": devices, + "agents": agents, + }) +} + +// handleSetSelfAgent оголошує хост машиною зонда. +// +// Право devices:write, а не ncm:write, і це не дрібниця. ncm:write +// означає «може міняти розклад збору» — дія про час, а не про предмет. +// Тут же вирішується, ЩО саме є цим хостом: картка перестає бути +// записом про залізо в мережі й починає означати конкретну машину. +// Заразом дія переставляє inv.devices.agent_id, тобто змінює інвентар. +func (s *Server) handleSetSelfAgent(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + deviceID := r.PathValue("id") + if !p.Scope().CanWrite(deviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + + var body struct { + AgentID string `json:"agent_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AgentID == "" { + writeError(w, http.StatusBadRequest, "bad_json", "не вказано зонда") + return + } + + if err := s.store.BindSelfDevice(r.Context(), p.TenantID, deviceID, body.AgentID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "хост не знайдено") + return + } + s.writeStoreError(w, "прив'язка хоста до власного зонда", err) + return + } + + s.auditSelfAgent(r, p, store.AuditActionDeviceSelfAgent, deviceID, + map[string]any{"agent_id": body.AgentID}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleClearSelfAgent знімає прив'язку. +// +// Вона ж і єдиний спосіб перезакріпити відбиток машини: зонд, +// перенесений на інший сервер, отримає відмову в прийомі набору доти, +// доки людина свідомо не зніме й не поставить прив'язку заново. Автомат +// «ну добре, значить переїхав» зробив би всю перевірку декоративною. +func (s *Server) handleClearSelfAgent(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + deviceID := r.PathValue("id") + if !p.Scope().CanWrite(deviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + + if err := s.store.UnbindSelfDevice(r.Context(), p.TenantID, deviceID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "хост не знайдено") + return + } + s.writeStoreError(w, "зняття прив'язки хоста до зонда", err) + return + } + + s.auditSelfAgent(r, p, store.AuditActionDeviceSelfAgentUn, deviceID, nil) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) auditSelfAgent(r *http.Request, p *Principal, action, deviceID string, meta map[string]any) { + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: action, + ObjectType: store.AuditObjectDevice, + ObjectID: deviceID, + Meta: meta, + }); err != nil { + s.log.Error("аудит прив'язки хоста до зонда", "дія", action, "хост", deviceID, "err", err) + } +} diff --git a/server/internal/httpapi/ncm_rollback.go b/server/internal/httpapi/ncm_rollback.go new file mode 100644 index 0000000..f9c3ae5 --- /dev/null +++ b/server/internal/httpapi/ncm_rollback.go @@ -0,0 +1,368 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Відкат конфігурації: намір, погодження, перегляд результату. +// +// ПРАВА, і чому саме такі. +// +// Усе, крім політики, — під ncm:rollback. Це право заведене ще в 0010 і +// свідомо не входить у роль «Інженер»: інженер має ncm:write, тобто +// може налаштувати збір, і давати йому разом із цим можливість писати +// конфіг на живе залізо означало б роздати найнебезпечнішу дію системи +// всім, хто вже працює. Той самий висновок, що й для ncm:exec у 0036. +// +// Читання теж під ncm:rollback, а не під ncm:read. Причина та, що й у +// масовому виконанні команд: перелік команд і стенограма сесії +// показують стан заліза детальніше за будь-яку іншу сторінку, і +// віддавати їх усім, хто має право дивитись конфіги, означало б зробити +// обхідний шлях до того, заради чого й заведено окреме право. +// +// А от ПОЛІТИКА погодження читається з ncm:rollback, але змінюється +// лише з settings:write — і це найважливіший рядок у файлі. Вимикач +// «погодження не потрібне» не має належати тому, хто робить відкати: +// інакше двоетапне погодження перетворюється на одну галочку, яку +// знімає той самий, кого воно мало стримати. + +const rollbackPerm = "ncm:rollback" + +// handleRollbackPlan — що саме зробить відкат, без жодних змін. +// +// Окремий крок перед створенням наміру. Не заради зручності: план і є +// те, що людина погоджує. Без нього підтвердження звучало б як +// «відкотити на версію від 12 березня» — тобто як обіцянка без змісту, +// під якою нема чого підписувати. +func (s *Server) handleRollbackPlan(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + + var in struct { + DeviceID string `json:"device_id"` + TargetConfigID string `json:"target_config_id"` + } + if !decodeBody(w, r, &in) { + return + } + if in.DeviceID == "" || in.TargetConfigID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібні device_id і target_config_id") + return + } + // Право змінювати саме цей хост, а не просто «право на відкати». + // Обмеження видимості — окремий від прав вимір: інженер над однією + // філією й інженер над усією мережею мають однакову роль. + if !p.Scope().CanWrite(in.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "цей хост поза вашим доступом") + return + } + if !s.hasKeyring(w) { + return + } + + prev, err := s.store.PrepareRollback(r.Context(), p.TenantID, in.DeviceID, in.TargetConfigID, s.keyring) + if err != nil { + s.writeRollbackError(w, "план відкату", err) + return + } + if prev.Commands == nil { + prev.Commands = []string{} + } + if prev.Manual == nil { + prev.Manual = []string{} + } + writeJSON(w, http.StatusOK, prev) +} + +func (s *Server) handleCreateRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + + var in store.RollbackInput + if !decodeBody(w, r, &in) { + return + } + if in.DeviceID == "" || in.TargetConfigID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібні device_id і target_config_id") + return + } + if !p.Scope().CanWrite(in.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "цей хост поза вашим доступом") + return + } + if !s.hasKeyring(w) { + return + } + + rb, err := s.store.CreateRollback(r.Context(), p.TenantID, p.UserID, in, s.keyring) + if err != nil { + s.writeRollbackError(w, "створення відкату", err) + return + } + + // Аудит пишеться після успіху й не блокує відповідь: намір уже + // існує, і приховати його від людини було б гірше, ніж лишити слід + // лише в журналі сервера. У meta лягає ЗАМАСКОВАНИЙ план — той + // самий, що бачила людина: журнал аудиту читають ширше, ніж + // сторінку відкатів, і секретам там місця тим більше немає. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionRollbackCreate, + ObjectType: store.AuditObjectRollback, + ObjectID: rb.ID, + Meta: map[string]any{ + "device": rb.DeviceName, + "device_id": rb.DeviceID, + "target_config_id": rb.TargetConfigID, + "base_config_id": rb.BaseConfigID, + "commands": rb.Commands, + "manual": rb.Manual, + "reason": rb.Reason, + "requires_approval": rb.RequiresApproval, + }, + }); err != nil { + s.log.Error("аудит створення відкату", "rollback", rb.ID, "err", err) + } + + s.log.Info("створено намір відкату", + "rollback", rb.ID, "device", rb.DeviceName, + "команд", len(rb.Commands), "вручну", len(rb.Manual), + "погодження", rb.RequiresApproval, "користувач", p.Username) + + writeJSON(w, http.StatusOK, rb) +} + +// handleApproveRollback — та сама друга людина. +// +// Машинному токену тут відмовляємо окремо й навмисно. Погодження — це +// підпис людини під незворотною дією; токен інтеграції підписати +// нічого не може, а можливість погодити ним звела б двоетапність до +// одного curl із того самого скрипта, який намір і створив. +func (s *Server) handleApproveRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + if !p.IsUser() { + writeError(w, http.StatusForbidden, "human_required", + "відкат погоджує людина — машинний токен для цього не годиться") + return + } + + var in struct { + Note string `json:"note"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + + id := r.PathValue("id") + rb, err := s.store.ApproveRollback(r.Context(), p.TenantID, id, p.UserID, in.Note) + if err != nil { + s.writeRollbackError(w, "погодження відкату", err) + return + } + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionRollbackApprove, + ObjectType: store.AuditObjectRollback, + ObjectID: id, + Meta: map[string]any{ + "device": rb.DeviceName, + "requested_by": rb.RequestedBy, + "note": in.Note, + }, + }); err != nil { + s.log.Error("аудит погодження відкату", "rollback", id, "err", err) + } + + s.log.Info("відкат погоджено", + "rollback", id, "device", rb.DeviceName, + "автор", rb.RequestedBy, "погодив", p.Username) + + writeJSON(w, http.StatusOK, rb) +} + +func (s *Server) handleRejectRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + + var in struct { + Note string `json:"note"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + + id := r.PathValue("id") + rb, err := s.store.RejectRollback(r.Context(), p.TenantID, id, p.UserID, in.Note) + if err != nil { + s.writeRollbackError(w, "відмова у відкаті", err) + return + } + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionRollbackReject, + ObjectType: store.AuditObjectRollback, + ObjectID: id, + Meta: map[string]any{"device": rb.DeviceName, "note": in.Note}, + }); err != nil { + s.log.Error("аудит відмови у відкаті", "rollback", id, "err", err) + } + + writeJSON(w, http.StatusOK, rb) +} + +func (s *Server) handleListRollbacks(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + list, err := s.store.ListRollbacks(r.Context(), p.TenantID, + r.URL.Query().Get("device_id"), limit) + if err != nil { + s.writeRollbackError(w, "перелік відкатів", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"rollbacks": list}) +} + +// handleGetRollback — один намір; стенограма за окремим проханням. +// +// Стенограма не віддається завжди з тієї самої причини, що й у прогоні +// команд: сторінка перечитує намір раз на кілька секунд, поки він +// живий, а стенограма сесії заливки важить сотні кілобайт. +func (s *Server) handleGetRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + withLog := r.URL.Query().Get("transcript") == "1" + rb, err := s.store.GetRollback(r.Context(), p.TenantID, r.PathValue("id"), withLog) + if err != nil { + s.writeRollbackError(w, "відкат", err) + return + } + writeJSON(w, http.StatusOK, rb) +} + +func (s *Server) handleGetRollbackPolicy(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + pol, err := s.store.GetRollbackPolicy(r.Context(), p.TenantID) + if err != nil { + s.writeRollbackError(w, "політика відкату", err) + return + } + // Клієнту важливо не лише значення, а й те, чи має він право його + // змінити: кнопка, яка дає 403 після натискання, гірша за відсутню. + writeJSON(w, http.StatusOK, map[string]any{ + "policy": pol, + "editable": p.Can("settings:write"), + }) +} + +// handleSetRollbackPolicy — окреме право, і не ncm:rollback. +// +// Див. коментар на початку файлу: вимикач вимоги «другої людини» не має +// належати тому, кого ця вимога стримує. +func (s *Server) handleSetRollbackPolicy(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + + var in store.RollbackPolicySettings + if !decodeBody(w, r, &in) { + return + } + + pol, err := s.store.SetRollbackPolicy(r.Context(), p.TenantID, p.UserID, in) + if err != nil { + s.writeRollbackError(w, "збереження політики відкату", err) + return + } + + // Аудит обов'язковий саме тут. Вимкнення погодження нічого не + // ламає в момент збереження — воно змінює правила для всього, що + // станеться далі, і питання «хто дозволив відкочувати наодинці» + // має мати відповідь із іменем і часом. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionRollbackPolicy, + ObjectType: store.AuditObjectRollbackPolicy, + Meta: map[string]any{ + "require_approval": pol.RequireApproval, + "allow_self_approve": pol.AllowSelfApprove, + }, + }); err != nil { + s.log.Error("аудит політики відкату", "err", err) + } + + s.log.Info("політику відкату змінено", + "погодження", pol.RequireApproval, "самопогодження", pol.AllowSelfApprove, + "користувач", p.Username) + + writeJSON(w, http.StatusOK, map[string]any{"policy": pol, "editable": true}) +} + +// writeRollbackError розкладає помилки відкату на коди відповіді. +// +// Окремо від writeStoreError, бо тут майже кожна помилка — не збій, а +// стан, у якому дію робити не можна, і клієнт має показати різні тексти +// різними способами. 409 проти 400 тут не педантизм: «запит +// неправильний» людина виправляє в полях форми, а «конфіг на пристрої +// змінився» — перезавантаженням сторінки й новим поглядом на різницю. +func (s *Server) writeRollbackError(w http.ResponseWriter, what string, err error) { + switch { + case errors.Is(err, store.ErrRollbackStale): + writeError(w, http.StatusConflict, "plan_stale", err.Error()) + case errors.Is(err, store.ErrRollbackSelfApprove): + writeError(w, http.StatusConflict, "self_approve", err.Error()) + case errors.Is(err, store.ErrRollbackState): + writeError(w, http.StatusConflict, "bad_state", err.Error()) + case errors.Is(err, store.ErrRollbackNotSupported): + // 422, а не 400: запит правильний, просто ця родина заліза не + // вміє того, про що просять. Клієнт показує пояснення профілю, + // а не «перевірте поля». + writeError(w, http.StatusUnprocessableEntity, "not_supported", err.Error()) + case errors.Is(err, store.ErrRollbackNothing): + writeError(w, http.StatusUnprocessableEntity, "nothing_to_do", err.Error()) + case errors.Is(err, store.ErrRollbackTooBig): + writeError(w, http.StatusUnprocessableEntity, "too_big", err.Error()) + case errors.Is(err, store.ErrNoProfile): + writeError(w, http.StatusUnprocessableEntity, "no_profile", err.Error()) + case errors.Is(err, store.ErrConfigNotFound): + writeError(w, http.StatusNotFound, "not_found", err.Error()) + case isBadUUID(err): + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + default: + s.writeStoreError(w, what, err) + } +} + +// hasKeyring — тіла конфігів зашифровані, і без ключа план не побудувати. +// +// Окремою перевіркою, а не «спробуємо й побачимо»: без ключа помилка +// прийшла б із глибини розшифровки й читалась би як поломка даних, а не +// як «сервер запущено без NETPULSE_MASTER_KEY». +func (s *Server) hasKeyring(w http.ResponseWriter) bool { + if s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування") + return false + } + return true +} diff --git a/server/internal/httpapi/queues.go b/server/internal/httpapi/queues.go new file mode 100644 index 0000000..4890f2c --- /dev/null +++ b/server/internal/httpapi/queues.go @@ -0,0 +1,231 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "slices" + "sync" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Сторінка «Черги»: чи система встигає, і якщо ні — де саме. +// +// Ціна самої сторінки тут важить не менше за її користь. Сторінка, яку +// відкривають саме тоді, коли системі важко, і яка щодві секунди рахує +// агрегати по гіпертаблицях, добиває базу, яку мала стерегти. Тому: +// +// - один знімок на всі черги, а не ендпоїнт на кожну; +// - знімок кешується на кілька секунд і ділиться між усіма, хто +// дивиться: десять відкритих вкладок мають коштувати як одна; +// - самі запити читають хвости за частковими індексами, а не +// перераховують таблиці (див. store/queues.go). +const ( + // Вікно, за яким рахуються надходження, обробка й втрати. + // Година, а не хвилини: бекап конфігу трапляється раз на добу на + // хост, і на п'ятнадцятихвилинному вікні здорова система показувала + // б нулі в усіх стовпчиках. + queueWindow = time.Hour + + // Скільки живе знімок. П'ять секунд — це менше, ніж такт + // диспетчера, тож нічого не пропускається, і водночас достатньо, + // щоб опитування з кількох вкладок злилось в один запит до бази. + queueTTL = 5 * time.Second + + // Через скільки оновлюється точка відліку для накопичувальних + // лічильників. Дві хвилини: менше — і приріст стрибав би від + // одного heartbeat (їх раз на півхвилини), більше — і сторінка + // довго показувала б «щойно втрачено» після того, як усе минулось. + queueBaseAge = 2 * time.Minute +) + +// queueTenantState — пам'ять спостерігача про один кабінет. +// +// Живе в процесі, а не в базі: потрібна лише для того, щоб відрізнити +// «лічильник ріс щойно» від «лічильник ненульовий з учора». Після +// перезапуску API спостереження чесно починається заново — і сторінка +// про це так і каже, замість вигадати приріст. +type queueTenantState struct { + report *store.QueueReport + reportAt time.Time + + // Знімок, з якого зроблено звіт, і приріст до нього. Потрібні + // детальному переліку: він мусить пояснювати рівно те число, яке + // людина щойно бачила на картці. Перезняти їх у момент кліку + // означало б показати перелік, що суперечить числу над ним. + facts *store.QueueFacts + deltas store.QueueDeltas + + // Деталі кешуються окремо від звіту й окремо по кожній черзі: + // відкритий перелік теж оновлюється, а платити за нього повним + // знімком усіх черг немає за що. + details map[string]*store.QueueDetail + detailsAt map[string]time.Time + + baseAt time.Time + baseDropped map[string]int64 + basePool int64 +} + +type queueWatcher struct { + mu sync.Mutex + tenants map[string]*queueTenantState +} + +var queues = &queueWatcher{tenants: map[string]*queueTenantState{}} + +// report віддає знімок — свіжий або щойно зроблений. +// +// Збір відбувається під тим самим замком, що й читання кешу, навмисно: +// коли база пригальмовує, десяток одночасних запитів має чекати на один +// збір, а не влаштувати десять власних. Саме в цю мить сторінка й +// небезпечна для бази. +func (w *queueWatcher) report(ctx context.Context, st *store.Store, + tenantID string) (*store.QueueReport, error) { + + w.mu.Lock() + defer w.mu.Unlock() + + t := w.tenants[tenantID] + if t == nil { + t = &queueTenantState{} + w.tenants[tenantID] = t + } + now := time.Now() + if t.report != nil && now.Sub(t.reportAt) < queueTTL { + return t.report, nil + } + + facts, err := st.CollectQueueFacts(ctx, tenantID, queueWindow) + if err != nil { + return nil, err + } + + d := store.QueueDeltas{AgentDropped: map[string]int64{}} + if !t.baseAt.IsZero() { + d.ObservedSec = int(now.Sub(t.baseAt).Seconds()) + for _, a := range facts.Agents { + if a.Dropped == nil { + continue + } + was, ok := t.baseDropped[a.ID] + // Лічильник, що поїхав назад, означає перезапуск зонда, а не + // втрату «мінус сто»: за точку відліку беремо нове значення. + if ok && *a.Dropped > was { + d.AgentDropped[a.ID] = *a.Dropped - was + } + } + if facts.Pool.EmptyAcquires > t.basePool { + d.PoolEmpty = facts.Pool.EmptyAcquires - t.basePool + } + } + + if t.baseAt.IsZero() || now.Sub(t.baseAt) >= queueBaseAge { + t.baseAt = now + t.basePool = facts.Pool.EmptyAcquires + t.baseDropped = make(map[string]int64, len(facts.Agents)) + for _, a := range facts.Agents { + if a.Dropped != nil { + t.baseDropped[a.ID] = *a.Dropped + } + } + } + + t.report, t.reportAt = store.BuildQueueReport(facts, d), now + t.facts, t.deltas = facts, d + // Новий знімок робить старі переліки застарілими: лишити їх означало + // б показувати під новим числом старі рядки. + t.details, t.detailsAt = nil, nil + return t.report, nil +} + +// detail віддає перелік за однією чергою. +// +// Знімок для нього не збирається окремо: береться той самий, з якого +// зроблено картку (за потреби — свіжий, через report). Інакше перелік +// пояснював би одне число, а на екрані поруч стояло б інше. +func (w *queueWatcher) detail(ctx context.Context, st *store.Store, + tenantID, key string) (*store.QueueDetail, error) { + + // Знімок береться поза власним замком — report бере його сам. + if _, err := w.report(ctx, st, tenantID); err != nil { + return nil, err + } + + w.mu.Lock() + defer w.mu.Unlock() + + t := w.tenants[tenantID] + if t == nil || t.facts == nil { + return nil, errNoSnapshot + } + now := time.Now() + if at, ok := t.detailsAt[key]; ok && now.Sub(at) < queueTTL { + return t.details[key], nil + } + + det, err := st.QueueDetailFor(ctx, tenantID, key, queueWindow, t.facts, t.deltas) + if err != nil { + return nil, err + } + if t.details == nil { + t.details, t.detailsAt = map[string]*store.QueueDetail{}, map[string]time.Time{} + } + t.details[key], t.detailsAt[key] = det, now + return det, nil +} + +var errNoSnapshot = errors.New("знімка черг ще немає") + +// handleQueues — GET /api/v1/queues. +// +// Право agents:read, а не власне: сторінка не показує нічого, чого не +// показують «Зонди», — вона лише складає з цього висновок. Заводити під +// висновок окреме право означало б, що людина, яка відповідає за збір, +// не бачить, чи він устигає. +func (s *Server) handleQueues(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("agents:read") { + writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read") + return + } + + rep, err := queues.report(r.Context(), s.store, p.TenantID) + if err != nil { + s.log.Error("знімок черг", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, rep) +} + +// handleQueueDetail — GET /api/v1/queues/{key}. +// +// Окремий виклик, а не поле у знімку, навмисно. Знімок їде раз на п'ять +// секунд усім, хто відкрив сторінку; перелік зачеплених хостів потрібен +// одному й лише після того, як він натиснув на картку. Возити його +// разом зі знімком означало б платити за нього постійно, а дивитись — +// зрідка. +func (s *Server) handleQueueDetail(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("agents:read") { + writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read") + return + } + + key := r.PathValue("key") + // Білий список, а не довільний ключ у switch: інакше друкарська + // помилка в адресі давала б 500 замість зрозумілої відмови. + if !slices.Contains(store.QueueDetailKeys(), key) { + writeError(w, http.StatusNotFound, "not_found", "немає такої черги: "+key) + return + } + + det, err := queues.detail(r.Context(), s.store, p.TenantID, key) + if err != nil { + s.log.Error("деталі черги", "key", key, "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, det) +} diff --git a/server/internal/httpapi/roles.go b/server/internal/httpapi/roles.go new file mode 100644 index 0000000..c945ea4 --- /dev/null +++ b/server/internal/httpapi/roles.go @@ -0,0 +1,293 @@ +package httpapi + +import ( + "errors" + "net/http" + "sort" + "strings" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Ролі та права. +// +// Редагування ролей іде під тим самим правом, що й керування +// користувачами (users:write), і окремого права під це НЕ заведено +// свідомо. Той, хто може призначити людині роль «Адмін», уже може все, +// що вміє адмін; додаткова галочка «а ще й правити самі ролі» розділяла +// б те, чого немає різниці розділяти, і водночас створювала б ілюзію +// двох рівнів доступу там, де рівень один. +// +// Опис users:write у 0053 переписано так, щоб це було видно з самого +// опису: «Заводити користувачів, змінювати ролі та склад самих ролей». + +const rolesReadPerm = "users:read" +const rolesWritePerm = "users:write" + +// maxRoleName — межа не з бази (там text), а з екрана: назва ролі +// стоїть у випадному списку поруч із іменем людини, і довша за це вона +// або обрізається, або ламає рядок. +const maxRoleName = 64 +const maxRoleDescription = 300 + +// handlePermissionCatalog — довідник прав для екрана редактора. +// +// Окрема ручка, а не поле всередині /api/v1/roles: перелік прав — це +// властивість збірки продукту, однакова для всіх ролей і всіх кабінетів, +// і повторювати двадцять чотири описи в кожній із п'яти ролей означало +// б слати ту саму сторінку тексту п'ять разів. +func (s *Server) handlePermissionCatalog(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesReadPerm) { + return + } + groups, err := s.store.PermissionCatalog(r.Context()) + if err != nil { + s.writeStoreError(w, "довідник прав", err) + return + } + if groups == nil { + groups = []store.PermissionGroup{} + } + writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) +} + +// handleRoleMemberCounts не існує окремо: скільки людей у кожній ролі, +// сторінка бачить із /api/v1/team, який вона й так завантажує. Друга +// ручка на те саме розійшлася б із першою рівно тоді, коли їх покажуть +// поруч на одному екрані. + +type roleBody struct { + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` +} + +func (in *roleBody) normalize() { + in.Name = strings.TrimSpace(in.Name) + in.Description = strings.TrimSpace(in.Description) + sort.Strings(in.Permissions) +} + +func (in *roleBody) validate(w http.ResponseWriter) bool { + if in.Name == "" { + writeError(w, http.StatusBadRequest, "invalid", "у ролі має бути назва") + return false + } + if len([]rune(in.Name)) > maxRoleName { + writeError(w, http.StatusBadRequest, "invalid", "назва ролі задовга") + return false + } + if len([]rune(in.Description)) > maxRoleDescription { + writeError(w, http.StatusBadRequest, "invalid", "опис ролі задовгий") + return false + } + return true +} + +// checkGrantable — заборона роздати більше, ніж маєш сам. +// +// Класичне підвищення привілеїв: людина з users:write, але без ncm:exec, +// заводить роль із ncm:exec і садить у неї підставний обліковий запис — +// або просто колегу, який попросив. Своєї ролі вона змінити не може +// (handleSetRole забороняє це окремо), а от створити роль сильнішу за +// власну — цілком, і жодне з наявних правил цього не ловить. +// +// Правило одне й перевіряється на кожному ключі: віддати можна лише те, +// що маєш. Власника воно не зачіпає взагалі — у нього всі 24 права, — +// тобто ціна правила падає рівно на того, кому доступ і звужували. +func checkGrantable(w http.ResponseWriter, p *Principal, perms []string) bool { + var missing []string + for _, k := range perms { + if !p.Can(k) { + missing = append(missing, k) + } + } + if len(missing) == 0 { + return true + } + writeError(w, http.StatusForbidden, "not_grantable", + "не можна надати роль права, яких немає у вас самих: "+strings.Join(missing, ", ")) + return false +} + +func (s *Server) handleCreateRole(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesWritePerm) { + return + } + var in roleBody + if !decodeBody(w, r, &in) { + return + } + in.normalize() + if !in.validate(w) || !checkGrantable(w, p, in.Permissions) { + return + } + + role, err := s.store.CreateRole(r.Context(), p.TenantID, store.RoleInput{ + Name: in.Name, + Description: in.Description, + Permissions: in.Permissions, + }) + if err != nil { + s.writeRoleError(w, "створення ролі", err) + return + } + + s.auditRole(r, p, store.AuditActionRoleCreate, role.ID, map[string]any{ + "key": role.Key, + "name": role.Name, + "after": map[string]any{ + "name": role.Name, + "description": role.Description, + "permissions": role.Permissions, + }, + }) + writeJSON(w, http.StatusCreated, role) +} + +func (s *Server) handleUpdateRole(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesWritePerm) { + return + } + var in roleBody + if !decodeBody(w, r, &in) { + return + } + in.normalize() + if !in.validate(w) || !checkGrantable(w, p, in.Permissions) { + return + } + + roleID := r.PathValue("id") + before, after, err := s.store.UpdateRole(r.Context(), p.TenantID, roleID, store.RoleInput{ + Name: in.Name, + Description: in.Description, + Permissions: in.Permissions, + }) + if err != nil { + s.writeRoleError(w, "зміна ролі", err) + return + } + + added, removed := diffPerms(before.Permissions, after.Permissions) + s.auditRole(r, p, store.AuditActionRoleUpdate, after.ID, map[string]any{ + "key": after.Key, + "before": map[string]any{ + "name": before.Name, + "description": before.Description, + "permissions": before.Permissions, + }, + "after": map[string]any{ + "name": after.Name, + "description": after.Description, + "permissions": after.Permissions, + }, + // Два переліки поруч із двома знімками: знімки відповідають на + // «як було й як стало», а ці — на «що саме змінилось». Друге + // шукають першим, а рахувати різницю двох масивів очима в + // журналі — саме те, чого від журналу не чекають. + "granted": added, + "revoked": removed, + }) + writeJSON(w, http.StatusOK, after) +} + +func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesWritePerm) { + return + } + roleID := r.PathValue("id") + + gone, err := s.store.DeleteRole(r.Context(), p.TenantID, roleID) + if err != nil { + s.writeRoleError(w, "видалення ролі", err) + return + } + + s.auditRole(r, p, store.AuditActionRoleDelete, gone.ID, map[string]any{ + "key": gone.Key, + "name": gone.Name, + // Знімок саме тут найцінніший: після видалення ролі більше + // немає ніде, і питання «а що вона давала» лишається без + // відповіді назавжди. + "before": map[string]any{ + "name": gone.Name, + "description": gone.Description, + "permissions": gone.Permissions, + }, + }) + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------- + +// auditRole пише слід зміни доступу. +// +// Аудит іде після успіху й не перетворює збій журналу на помилку +// запиту: роль уже змінено, і сховати це від людини було б гірше, ніж +// лишити слід лише в логах сервера. Той самий порядок, що й у прогонах +// команд. +// +// «Було» і «стало» лягають у meta, а не в колонки before/after таблиці: +// store.AuditEntry цих полів ще не має, а дописувати їх треба разом із +// рештою місць, які пишуть аудит. Пошук по журналу від цього не +// страждає — індекс audit_search_trgm_idx із 0050 накриває meta так +// само, як before і after. +func (s *Server) auditRole(r *http.Request, p *Principal, action, roleID string, meta map[string]any) { + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: action, + ObjectType: store.AuditObjectRole, + ObjectID: roleID, + Meta: meta, + }); err != nil { + s.log.Error("аудит зміни ролі", "дія", action, "роль", roleID, "err", err) + } +} + +func diffPerms(before, after []string) (added, removed []string) { + was := make(map[string]bool, len(before)) + for _, k := range before { + was[k] = true + } + now := make(map[string]bool, len(after)) + for _, k := range after { + now[k] = true + } + for _, k := range after { + if !was[k] { + added = append(added, k) + } + } + for _, k := range before { + if !now[k] { + removed = append(removed, k) + } + } + return added, removed +} + +// writeRoleError — три відмови, які людина мусить зрозуміти з першого +// разу, бо кожна з них означає окрему подальшу дію. +func (s *Server) writeRoleError(w http.ResponseWriter, what string, err error) { + switch { + case errors.Is(err, store.ErrRoleSystem): + writeError(w, http.StatusForbidden, "role_system", + "це вбудована роль — вона спільна для всієї інсталяції й не редагується. "+ + "Зробіть її копію та змініть копію") + + case errors.Is(err, store.ErrRoleInUse): + writeError(w, http.StatusConflict, "role_in_use", + err.Error()+". Спершу переведіть їх в іншу роль") + + case errors.Is(err, store.ErrLastAdmin): + writeError(w, http.StatusConflict, "last_admin", + "після цієї зміни в організації не лишиться жодного учасника з правом "+ + "users:write — повернути його не зміг би ніхто, навіть ви") + + default: + s.writeStoreError(w, what, err) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index adece2e..8a0c6a5 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -29,6 +29,12 @@ type Server struct { // а /api/v1/channels відповідає 503 із поясненням. keyring *crypto.Keyring notifier *alerting.Notifier + + // Приймач подій для правил джерела `compliance`: прогін + // відповідності запускається саме звідси, і саме він — та мить, + // коли порушення стає подією. nil означає, що движок алертів на + // цій інсталяції вимкнено. + events *alerting.EventSink } // New створює сервер. signer може бути nil лише в тестах, які не @@ -52,6 +58,27 @@ func (s *Server) WithNotifications(ring *crypto.Keyring, n *alerting.Notifier) * return s } +// WithEventAlerts вмикає подієві алерти на прогоні відповідності. +// +// Окремо від WithNotifications, бо це різні речі: там — доставка, тут — +// сама поява алерту. Інсталяція без каналів усе одно має бачити +// порушення на дошці. +func (s *Server) WithEventAlerts(sink *alerting.EventSink) *Server { + s.events = sink + return s +} + +// WithKeyring дає серверу ключі шифрування без движка алертів. +// +// До появи дзеркала конфігів ключ приходив лише разом із каналами +// сповіщень (WithNotifications), і на інсталяції з вимкненим движком +// алертів його не було зовсім. Секрет дзеркала до алертів стосунку не +// має, тож і залежності такої бути не повинно. +func (s *Server) WithKeyring(ring *crypto.Keyring) *Server { + s.keyring = ring + return s +} + // Hub — доступ до трансляції для зовнішнього коду (тести, метрики). func (s *Server) Hub() *Hub { return s.hub } @@ -73,11 +100,23 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/me", s.authenticated(s.handleMe)) mux.Handle("POST /api/v1/auth/password", s.authenticated(s.handleChangePassword)) + // Прив'язка telegram-акаунта. Ендпоїнта, який приймав би натискання + // кнопок, тут немає й не буде: Telegram до цього сервера не + // достукається (самопідписаний TLS на IP), тому оновлення забирає + // довге опитування — див. alerting/telegram_bot.go. + mux.Handle("GET /api/v1/me/telegram", s.authenticated(s.handleGetTelegramLink)) + mux.Handle("POST /api/v1/me/telegram/code", s.authenticated(s.handleCreateTelegramLinkCode)) + mux.Handle("DELETE /api/v1/me/telegram", s.authenticated(s.handleDeleteTelegramLink)) + mux.Handle("GET /api/v1/team", s.authenticated(s.handleListTeam)) mux.Handle("POST /api/v1/team", s.authenticated(s.handleCreateUser)) mux.Handle("PATCH /api/v1/team/{id}", s.authenticated(s.handleSetRole)) mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember)) mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles)) + mux.Handle("POST /api/v1/roles", s.authenticated(s.handleCreateRole)) + mux.Handle("PATCH /api/v1/roles/{id}", s.authenticated(s.handleUpdateRole)) + mux.Handle("DELETE /api/v1/roles/{id}", s.authenticated(s.handleDeleteRole)) + mux.Handle("GET /api/v1/permissions", s.authenticated(s.handlePermissionCatalog)) mux.Handle("GET /api/v1/dashboards", s.authenticated(s.handleListDashboards)) mux.Handle("POST /api/v1/dashboards", s.authenticated(s.handleSaveDashboard)) @@ -102,6 +141,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap)) mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap)) mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap)) + mux.Handle("GET /api/v1/maps/{id}/interfaces", s.authenticated(s.handleMapInterfaces)) + mux.Handle("POST /api/v1/maps/{id}/discover", s.authenticated(s.handleMapDiscover)) mux.Handle("GET /api/v1/maps/{id}/permissions", s.authenticated(s.handleListMapPermissions)) mux.Handle("PUT /api/v1/maps/{id}/permissions", s.authenticated(s.handleSetMapPermissions)) mux.Handle("GET /api/v1/icons", s.authenticated(s.handleListIcons)) @@ -110,12 +151,29 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /api/v1/icons/{id}", s.authenticated(s.handleDeleteIcon)) mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices)) mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice)) + mux.Handle("GET /api/v1/devices/{id}", s.authenticated(s.handleGetDevice)) mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice)) mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice)) + // Картка хоста: паспорт, останні дані з походженням, порти. + mux.Handle("GET /api/v1/devices/{id}/overview", s.authenticated(s.handleDeviceOverview)) + mux.Handle("GET /api/v1/devices/{id}/latest", s.authenticated(s.handleDeviceLatest)) + mux.Handle("GET /api/v1/devices/{id}/interfaces", s.authenticated(s.handleDeviceInterfaces)) + + // Масова правка й масове видалення. Перегляд набору — окремий крок + // і окрема ручка, як і в масовому виконанні команд: підтвердження + // без точного переліку не вважається підтвердженням. + mux.Handle("POST /api/v1/devices/bulk-targets", s.authenticated(s.handleBulkDeviceTargets)) + mux.Handle("POST /api/v1/devices/bulk-update", s.authenticated(s.handleBulkUpdateDevices)) + mux.Handle("POST /api/v1/devices/bulk-delete", s.authenticated(s.handleBulkDeleteDevices)) + mux.Handle("POST /api/v1/devices/bulk-restore", s.authenticated(s.handleBulkRestoreDevices)) + + mux.Handle("POST /api/v1/devices/{id}/identify", s.authenticated(s.handleIdentifyDevice)) mux.Handle("POST /api/v1/devices/{id}/collect-config", s.authenticated(s.handleCollectConfig)) mux.Handle("GET /api/v1/devices/{id}/config-jobs", s.authenticated(s.handleListConfigJobs)) mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs)) + // Добір хостів на сторінці конфігів: спільний фільтр плюс стан архіву. + mux.Handle("POST /api/v1/ncm/config-devices", s.authenticated(s.handleConfigDevices)) mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig)) mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs)) mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy)) @@ -134,19 +192,94 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults)) mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance)) + // Масове виконання команд. Перегляд переліку хостів — окремий крок + // і окремий ендпоїнт: підтвердження без точного переліку не + // вважається підтвердженням, і клієнт не має змоги його оминути. + mux.Handle("POST /api/v1/ncm/command-targets", s.authenticated(s.handlePreviewCommandTargets)) + mux.Handle("GET /api/v1/ncm/command-runs", s.authenticated(s.handleListCommandRuns)) + mux.Handle("POST /api/v1/ncm/command-runs", s.authenticated(s.handleCreateCommandRun)) + mux.Handle("GET /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleGetCommandRun)) + mux.Handle("POST /api/v1/ncm/command-runs/{id}/cancel", s.authenticated(s.handleCancelCommandRun)) + mux.Handle("DELETE /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleDeleteCommandRun)) + // Звіт віддається не тут, а за квитком: файл тягне завантажувач + // браузера, який заголовка Authorization не додає. Див. downloads.go. + mux.Handle("POST /api/v1/ncm/command-runs/{id}/report-link", s.authenticated(s.handleCommandRunReportLink)) + + // Відкат конфігу. План — окремий крок і окремий ендпоїнт, з тієї ж + // причини, що й перегляд хостів у масових командах: підтвердження + // без точного переліку того, що поїде на пристрій, підтвердженням + // не є. Погодження й відмова — окремі дії, бо в них інший автор. + // + // Політика лежить поруч, але змінюється іншим правом + // (settings:write): вимикач вимоги «другої людини» не має належати + // тому, кого ця вимога стримує. Див. ncm_rollback.go. + mux.Handle("POST /api/v1/ncm/rollbacks/plan", s.authenticated(s.handleRollbackPlan)) + mux.Handle("GET /api/v1/ncm/rollbacks", s.authenticated(s.handleListRollbacks)) + mux.Handle("POST /api/v1/ncm/rollbacks", s.authenticated(s.handleCreateRollback)) + mux.Handle("GET /api/v1/ncm/rollbacks/{id}", s.authenticated(s.handleGetRollback)) + mux.Handle("POST /api/v1/ncm/rollbacks/{id}/approve", s.authenticated(s.handleApproveRollback)) + mux.Handle("POST /api/v1/ncm/rollbacks/{id}/reject", s.authenticated(s.handleRejectRollback)) + mux.Handle("GET /api/v1/ncm/rollback-policy", s.authenticated(s.handleGetRollbackPolicy)) + mux.Handle("PUT /api/v1/ncm/rollback-policy", s.authenticated(s.handleSetRollbackPolicy)) + + // Завантаження файлу за короткоживучим квитком. Друга (після режиму + // NOC TV) частина API без входу — право лежить у токені шляху. + mux.HandleFunc("GET /api/v1/downloads/{token}", s.handleDownload) + + // Конфіг-файли серверів: хости, які збирають самі себе. Збір і + // розклад окремих ручок не мають — і те, і те вже робить черга + // ncm.jobs (collect-config вище, ScheduleBackups у grpcapi). + mux.Handle("GET /api/v1/ncm/server-files", s.authenticated(s.handleListServerFiles)) + mux.Handle("PUT /api/v1/devices/{id}/self-agent", s.authenticated(s.handleSetSelfAgent)) + mux.Handle("DELETE /api/v1/devices/{id}/self-agent", s.authenticated(s.handleClearSelfAgent)) + mux.Handle("GET /api/v1/ncm/backup-defaults", s.authenticated(s.handleGetBackupDefaults)) mux.Handle("PUT /api/v1/ncm/backup-defaults", s.authenticated(s.handleSetBackupDefaults)) + // Очистка сховища версій: політика поруч зі спільним розкладом, + // ручне видалення — окремим правом ncm:delete (див. ncm_configs.go). + mux.Handle("GET /api/v1/ncm/retention", s.authenticated(s.handleGetRetention)) + mux.Handle("PUT /api/v1/ncm/retention", s.authenticated(s.handleSetRetention)) + mux.Handle("POST /api/v1/ncm/configs/delete", s.authenticated(s.handleDeleteConfigs)) + + // Дзеркало архіву на зовнішній Git. Стан видно з ncm:read (помітити + // відмову має могти будь-хто, хто дивиться на конфіги), а міняти + // налаштування — лише з ncm:mirror. Самого push тут немає: його + // робить фоновий такт колектора, див. git_mirror.go. + mux.Handle("GET /api/v1/ncm/mirror", s.authenticated(s.handleGetMirror)) + mux.Handle("PUT /api/v1/ncm/mirror", s.authenticated(s.handleSetMirror)) + mux.Handle("POST /api/v1/ncm/mirror/test", s.authenticated(s.handleTestMirror)) + mux.Handle("POST /api/v1/ncm/mirror/push", s.authenticated(s.handlePushMirror)) + mux.Handle("POST /api/v1/ncm/mirror/deploy-key", s.authenticated(s.handleMirrorDeployKey)) + mux.Handle("DELETE /api/v1/ncm/mirror/secret", s.authenticated(s.handleForgetMirrorSecret)) + + // Журнал SNMP-трапів. Окремим розділом, а не вкладкою в хості: + // трап приходить від АДРЕСИ, і половина цікавого в ньому — це саме + // ті адреси, за якими хоста немає. Всередині картки хоста такий + // трап не показати ніде. + mux.Handle("GET /api/v1/traps", s.authenticated(s.handleListTraps)) + mux.Handle("GET /api/v1/traps/meta", s.authenticated(s.handleTrapsMeta)) + mux.Handle("PUT /api/v1/traps/names", s.authenticated(s.handleSaveTrapName)) + mux.Handle("DELETE /api/v1/traps/names/{oid}", s.authenticated(s.handleDeleteTrapName)) + mux.Handle("DELETE /api/v1/traps/unknown/{ip}", s.authenticated(s.handleForgetUnknownTrapSource)) + mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes)) mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks)) mux.Handle("PUT /api/v1/devices/{id}/checks", s.authenticated(s.handleSetDeviceChecks)) mux.Handle("GET /api/v1/devices/{id}/series", s.authenticated(s.handleListSeries)) mux.Handle("GET /api/v1/devices/{id}/metrics", s.authenticated(s.handleQueryMetrics)) + // Добір хостів для сторінки метрик — тим самим фільтром, що в + // «Командах», масових діях і «Конфігах». + mux.Handle("POST /api/v1/metrics/devices", s.authenticated(s.handleMetricDevices)) mux.Handle("GET /api/v1/templates", s.authenticated(s.handleListTemplates)) mux.Handle("POST /api/v1/templates", s.authenticated(s.handleSaveTemplate)) mux.Handle("GET /api/v1/templates/export", s.authenticated(s.handleExportTemplates)) mux.Handle("POST /api/v1/templates/import", s.authenticated(s.handleImportTemplates)) + // Перевірка фільтра складу портів на справжньому хості — до + // збереження шаблону, а не після. + mux.Handle("GET /api/v1/templates/interface-devices", s.authenticated(s.handleInterfaceFilterDevices)) + mux.Handle("POST /api/v1/templates/interface-preview", s.authenticated(s.handleInterfaceFilterPreview)) mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate)) mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate)) mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate)) @@ -178,6 +311,29 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/agent-enrollments", s.authenticated(s.handleCreateEnrollment)) mux.Handle("DELETE /api/v1/agent-enrollments/{id}", s.authenticated(s.handleDeleteEnrollment)) + // Один ендпоїнт на всі черги: питання «чи все встигає» стосується + // системи цілком, і відповідь, зібрана з семи запитів у різні + // секунди, суперечила б сама собі. Див. queues.go. + mux.Handle("GET /api/v1/queues", s.authenticated(s.handleQueues)) + // А деталі — окремо й на вимогу: перелік зачеплених хостів потрібен + // після кліку по картці, а знімок їде всім раз на п'ять секунд. + mux.Handle("GET /api/v1/queues/{key}", s.authenticated(s.handleQueueDetail)) + + // Журнал аудиту — лише GET, і це не тимчасово: ручки, яка міняє чи + // стирає запис, не буде. Див. audit.go. + // Сховище: розміри, приріст і строки зберігання. Дивитись — + // devices:read (нахил має бачити той, хто щодня дивиться на + // систему), міняти — settings:write. Див. storage.go. + mux.Handle("GET /api/v1/storage", s.authenticated(s.handleStorageUsage)) + mux.Handle("PUT /api/v1/storage/config", s.authenticated(s.handleSetStorageConfig)) + mux.Handle("GET /api/v1/storage/retention", s.authenticated(s.handleGetRetentionSettings)) + mux.Handle("POST /api/v1/storage/retention/preview", s.authenticated(s.handleRetentionPreview)) + mux.Handle("PUT /api/v1/storage/retention", s.authenticated(s.handleSetRetentionSettings)) + + mux.Handle("GET /api/v1/audit", s.authenticated(s.handleListAudit)) + mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta)) + mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent)) + mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts)) mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert)) mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert)) @@ -189,6 +345,14 @@ func (s *Server) Handler() http.Handler { mux.Handle("PATCH /api/v1/alert-rules/{id}", s.authenticated(s.handlePatchAlertRule)) mux.Handle("DELETE /api/v1/alert-rules/{id}", s.authenticated(s.handleDeleteAlertRule)) + // Драбини ескалації. Читання під alerts:read разом із рештою + // сповіщень: побачити, хто буде розбуджений другим, має право той + // самий, хто бачить алерти. Зміна — під alerts:write. + mux.Handle("GET /api/v1/escalation-policies", s.authenticated(s.handleListEscalationPolicies)) + mux.Handle("POST /api/v1/escalation-policies", s.authenticated(s.handleSaveEscalationPolicy)) + mux.Handle("PUT /api/v1/escalation-policies/{id}", s.authenticated(s.handleSaveEscalationPolicy)) + mux.Handle("DELETE /api/v1/escalation-policies/{id}", s.authenticated(s.handleDeleteEscalationPolicy)) + mux.Handle("GET /api/v1/channels", s.authenticated(s.handleListChannels)) mux.Handle("POST /api/v1/channels", s.authenticated(s.handleCreateChannel)) mux.Handle("PUT /api/v1/channels/{id}", s.authenticated(s.handleCreateChannel)) @@ -361,7 +525,23 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, p *Pr return } - devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope()) + // ?view=archived — перелік прибраних хостів. + // + // Окреме значення, а не прапорець «і прибраних теж»: перелік або + // про те, що працює, або про те, що прибрано, і третього стану + // («усе разом») тут немає навмисно — дії над живим і прибраним + // хостом різні, і один перелік на обидва означав би кнопку, яка + // для половини рядків не має сенсу. + // + // Невідоме значення читається як «живі»: клієнт старішої збірки, + // який дописав щось своє, має отримати колишню поведінку, а не + // відмову. + view := store.DeviceViewLive + if r.URL.Query().Get("view") == string(store.DeviceViewArchived) { + view = store.DeviceViewArchived + } + + devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope(), view) if err != nil { s.log.Error("перелік пристроїв", "err", err) writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") diff --git a/server/internal/httpapi/storage.go b/server/internal/httpapi/storage.go new file mode 100644 index 0000000..31cb053 --- /dev/null +++ b/server/internal/httpapi/storage.go @@ -0,0 +1,236 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Сховище: розміри, приріст і строки зберігання даних. +// +// ПРО ПРАВА +// +// Дивитись — devices:read, тобто кожен, хто взагалі бачить систему. +// Це те саме рішення, що й для дзеркала конфігів (0054): помітити, що +// диск закінчується, має могти той, хто щодня дивиться на моніторинг, а +// не лише власник кабінету. Ховати розмір бази немає від кого, а от не +// побачити нахил вчасно коштує всієї інсталяції. +// +// Міняти — settings:write. Строк зберігання незворотний за наслідками й +// діє на всю інсталяцію, тобто це рівно те, що описує це право: +// «налаштування організації». Досі його не питав жоден обробник (воно +// значилось у dormantPerms як «налаштувань організації ще немає») — +// тепер вони є. +// +// Попередній перегляд теж під settings:write, хоч він нічого й не +// змінює. Дві причини: він частина шляху зміни, а не самостійна +// довідка, і він дорогий — точний підрахунок рядків по хвосту +// гіпертаблиці має право запускати той, хто збирається натиснути +// «застосувати», а не будь-яка відкрита вкладка. + +// handleStorageUsage — скільки що важить і на скільки вистачить місця. +func (s *Server) handleStorageUsage(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + out, err := s.store.StorageUsage(r.Context()) + if err != nil { + s.writeStoreError(w, "розміри сховища", err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleSetStorageConfig зберігає все, що людина знає про том: його +// ємність (або шлях, за яким її можна виміряти) і пороги попередження. +// +// Ємність питають у людини, бо більше ні в кого: функції «скільки +// вільного на диску» в Postgres немає, а процес API живе в іншому +// контейнері. Без цього числа сторінка чесно не показує запасу — і це +// краще, ніж показати дату переповнення, взяту зі стелі. З 0067 у +// цього числа є й другий шлях: том, змонтований у контейнер збірника, +// міряється, а не вводиться. +// +// Ємність і пороги — одна ручка навмисно. Вони не просто «поруч», вони +// одна річ: поріг «лишилось менше N діб» без ємності не рахується +// взагалі, а ємність без порога нікого ні про що не сповіщає. Дві ручки +// дали б стан «ємність указали, попередження лишили вимкненим» — прилад +// без сигналізації, зібраний тим, хто щойно прийшов його налаштувати. +func (s *Server) handleSetStorageConfig(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + // Усі поля попередження — вказівники, і не з любові до вказівників. + // Відсутність поля має означати «не чіпай», а не «постав нуль»: + // інакше перше ж збереження ємності зі старої вкладки тихо вимкнуло + // б попередження й обнулило пороги. Для вимикача ця різниця + // вирішальна — «не сказали» й «вимкни» тут протилежні за наслідками. + var in struct { + DiskBytes int64 `json:"disk_bytes"` + WarnPct int `json:"warn_pct"` + + DataPath *string `json:"data_path"` + AlertEnabled *bool `json:"alert_enabled"` + DaysWarn *int `json:"alert_days_warn"` + DaysCrit *int `json:"alert_days_crit"` + } + if !decodeBody(w, r, &in) { + return + } + if in.WarnPct == 0 { + in.WarnPct = 80 + } + if err := s.store.SetStorageConfig(r.Context(), in.DiskBytes, in.WarnPct, p.UserID); err != nil { + s.writeStoreError(w, "збереження ємності тому", err) + return + } + + // Поточні значення читаються ДО накладання нових: форма може + // прислати лише те, що на ній змінили, а решта має лишитись такою, + // якою була, а не такою, якою її вгадає нуль структури. + cur, err := s.store.StorageAlertSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "налаштування попередження про місце", err) + return + } + if in.AlertEnabled != nil { + cur.Enabled = *in.AlertEnabled + } + if in.DaysWarn != nil { + cur.DaysWarn = *in.DaysWarn + } + if in.DaysCrit != nil { + cur.DaysCrit = *in.DaysCrit + } + if err := s.store.SetStorageAlert(r.Context(), cur, in.DataPath, p.UserID); err != nil { + s.writeStoreError(w, "збереження порогів попередження", err) + return + } + out, err := s.store.StorageUsage(r.Context()) + if err != nil { + s.writeStoreError(w, "розміри сховища", err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleGetRetentionSettings — строки разом зі словником видів даних. +// +// Словник їде тим самим запитом, а не окремим: без пояснення «від чого +// залежить обсяг цього виду» форма перетворюється на двадцять полів із +// числами, і заповнюють її навмання. +func (s *Server) handleGetRetentionSettings(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + rows, err := s.store.RetentionSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "строки зберігання", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "kinds": rows, + // Чи вільно цій людині міняти строки. Клієнт міг би вивести це + // сам зі свого переліку прав, але тоді правило жило б у двох + // місцях і розійшлося б на першій же зміні. + "can_edit": p.Can("settings:write"), + }) +} + +// retentionBody — набір строків цілком. +// +// Саме цілком, а не «що змінилось»: половина перевірок тут про +// стосунки між видами («згортка не живе менше за джерело»), і на +// одному полі їх не перевірити. Значення — вказівник: null означає «не +// видаляти», і це не те саме, що нуль. +type retentionBody struct { + Keep map[string]*int `json:"keep"` +} + +// handleRetentionPreview — «що зникне, якщо застосувати ці строки». +// +// Окремий крок і окрема ручка, як у масовому видаленні хостів: +// підтвердження без точного переліку не вважається підтвердженням. +// Різниця лише в тому, що тут зникає не об'єкт зі списку, а хвіст +// історії — і побачити його інакше ніяк. +func (s *Server) handleRetentionPreview(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + var in retentionBody + if !decodeBody(w, r, &in) { + return + } + if err := store.ValidateRetention(in.Keep); err != nil { + s.writeRetentionError(w, err) + return + } + + out, err := s.store.PreviewRetention(r.Context(), in.Keep) + if err != nil { + s.writeStoreError(w, "попередній перегляд очистки", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": out}) +} + +// handleSetRetentionSettings зберігає строки й застосовує політики. +func (s *Server) handleSetRetentionSettings(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + var in retentionBody + if !decodeBody(w, r, &in) { + return + } + + // Знімок ДО збереження: у журнал має поїхати «з 35 діб на 7», а не + // «зберегли форму». Читаємо до, бо після цієї відповіді вже немає. + before, err := s.store.RetentionSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "строки зберігання", err) + return + } + + if _, err := s.store.SaveRetention(r.Context(), in.Keep, p.UserID); err != nil { + s.writeRetentionError(w, err) + return + } + + // Аудит пишеться ПІСЛЯ успішного збереження й не скасовує його при + // збої: строк уже діє, і мовчазне «не вдалось» тут гірше за рядок, + // якого немає в журналі. Той самий порядок, що й у політики очистки + // конфігів. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionRetentionSettings, + ObjectType: store.AuditObjectRetention, + Meta: store.RetentionAuditMeta(before, in.Keep), + }); err != nil { + s.log.Error("аудит строків зберігання", "err", err) + } + + rows, err := s.store.RetentionSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "строки зберігання", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"kinds": rows, "can_edit": true}) +} + +// writeRetentionError відрізняє «числа суперечать одне одному» від +// решти. +// +// Обидва — 400, але код різний, і це не формальність: клієнт показує +// такий текст цілком, і «строки суперечать» треба показати поруч із +// полями, а не в загальній смужці помилки нагорі. +func (s *Server) writeRetentionError(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrRetentionInvalid) { + writeError(w, http.StatusBadRequest, "retention_conflict", err.Error()) + return + } + s.writeStoreError(w, "строки зберігання", err) +} diff --git a/server/internal/httpapi/telegram_link.go b/server/internal/httpapi/telegram_link.go new file mode 100644 index 0000000..736804e --- /dev/null +++ b/server/internal/httpapi/telegram_link.go @@ -0,0 +1,97 @@ +package httpapi + +import ( + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Прив'язка telegram-акаунта до користувача NetPulse. +// +// Навіщо це взагалі в API, адже саму прив'язку робить бот. Бот робить +// другу половину: він приймає код і зіставляє акаунти. Першу половину — +// «видати код тому, хто зараз увійшов» — може зробити лише те місце, де +// людина вже довела, ким вона є, тобто сесія в браузері. +// +// Права окремого немає навмисно. Прив'язка не дає нічого, чого людина +// не має в інтерфейсі: кнопка в Telegram виконує рівно ті самі дії й +// рівно з тими самими перевірками прав і доступу до хостів. Вимагати +// ще й окреме право означало б, що адміністратор має роздати його всім, +// хто вже має alerts:ack, — тобто зайвий крок без жодного рішення. + +// handleGetTelegramLink — що показувати в профілі. +func (s *Server) handleGetTelegramLink(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.IsUser() { + writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має профілю") + return + } + link, err := s.store.TelegramLink(r.Context(), p.TenantID, p.UserID) + if err != nil { + s.log.Error("прив'язка telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, map[string]any{"link": link}) +} + +// handleCreateTelegramLinkCode видає одноразовий код. +// +// Код повертається рівно тут і більше ніде: у базі лежить лише його +// sha256, тож «показати ще раз» неможливо навіть теоретично — можна +// тільки взяти новий, і старий тоді згасне. +func (s *Server) handleCreateTelegramLinkCode(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.IsUser() { + writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має профілю") + return + } + + code, expires, err := s.store.CreateTelegramLinkCode(r.Context(), p.TenantID, p.UserID) + if err != nil { + s.log.Error("код прив'язки telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "code": code, + "expires_at": expires, + // Команду складає сервер, а не браузер: формат розбирає бот + // (parseLinkCommand), і дві його копії розійшлися б рівно тоді, + // коли формат зміниться. + "command": "/link " + code, + }) +} + +// handleDeleteTelegramLink знімає прив'язку. +// +// Разом із нею гаснуть і невикористані коди: лишити чинний код після +// «відв'язати» означало б, що прив'язка повертається сама, варто +// комусь надіслати боту рядок, який людина вже вважає скасованим. +func (s *Server) handleDeleteTelegramLink(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.IsUser() { + writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має профілю") + return + } + + link, err := s.store.TelegramLink(r.Context(), p.TenantID, p.UserID) + if err != nil { + s.log.Error("прив'язка telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if err := s.store.UnlinkTelegram(r.Context(), p.TenantID, p.UserID); err != nil { + s.log.Error("зняття прив'язки telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if link != nil { + _ = s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorIP: clientIP(r), + Action: store.AuditActionTelegramUnlink, + ObjectType: store.AuditObjectTelegram, + ObjectID: link.ID, + Meta: map[string]any{"tg_user_id": link.TgUserID, "tg_username": link.TgUsername}, + }) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go index 72b3ea6..cd81cbe 100644 --- a/server/internal/httpapi/templates.go +++ b/server/internal/httpapi/templates.go @@ -53,7 +53,10 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P Name string `json:"name"` Description string `json:"description"` Vendor string `json:"vendor"` - Items []struct { + // Фільтр складу портів для snmp.if. Приходить тим самим + // документом, що й решта шаблону: у формі це сусідня вкладка. + IfFilter *store.InterfaceFilter `json:"if_filter"` + Items []struct { Key string `json:"key"` Name string `json:"name"` CheckType string `json:"check_type"` @@ -65,6 +68,11 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P Enabled *bool `json:"enabled"` Params json.RawMessage `json:"params"` } `json:"items"` + // Прототипи їдуть тим самим документом, що й елементи: у формі + // це сусідня вкладка, і окрема ручка означала б, що шаблон + // можна зберегти наполовину — з новими метриками й старим + // правилом виявлення рядків. + Prototypes []store.TemplatePrototype `json:"prototypes"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту") @@ -78,12 +86,62 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P return } + // Зразок перевіряємо тут, а не там, де він застосується: криву + // дужку показують тому, хто її щойно надрукував, а не через добу + // в журналі сервера рядком «фільтр не компілюється». + if in.IfFilter != nil && !in.IfFilter.IsEmpty() { + if err := store.ValidateInterfaceFilter(*in.IfFilter); err != nil { + writeError(w, http.StatusBadRequest, "bad_filter", "фільтр портів: "+err.Error()) + return + } + } + + // Прототипи перевіряємо тут із тих самих міркувань, що й фільтр + // портів: криву дужку в зразку показують тому, хто її щойно + // надрукував. Прототип без OID виявлення мовчки не знайшов би + // жодного рядка — тобто виглядав би збереженим і не робив нічого. + for i := range in.Prototypes { + pr := &in.Prototypes[i] + pr.Key = strings.TrimSpace(pr.Key) + pr.Name = strings.TrimSpace(pr.Name) + if pr.Key == "" { + pr.Key = slugify(pr.Name) + } + if pr.Key == "" { + pr.Key = fmt.Sprintf("proto-%d", i+1) + } + if strings.TrimSpace(pr.DiscoverOID) == "" { + writeError(w, http.StatusBadRequest, "bad_request", + "прототип «"+pr.Name+"» без OID виявлення рядків") + return + } + if pr.RowFilter != nil && !pr.RowFilter.IsEmpty() { + if err := store.ValidateRowFilter(*pr.RowFilter); err != nil { + writeError(w, http.StatusBadRequest, "bad_filter", + "фільтр рядків прототипу «"+pr.Name+"»: "+err.Error()) + return + } + } + for _, m := range pr.Metrics { + // Метрика без OID або без ключа не збирає нічого й нікуди: + // зберегти її означало б показати прототип, який виглядає + // робочим. Те саме правило, що для елементів snmp.get. + if strings.TrimSpace(m.OID) == "" || strings.TrimSpace(m.MetricKey) == "" { + writeError(w, http.StatusBadRequest, "bad_request", + "метрика прототипу «"+pr.Name+"» потребує OID і ключ метрики") + return + } + } + } + t := store.Template{ ID: r.PathValue("id"), Key: in.Key, Name: in.Name, Description: strings.TrimSpace(in.Description), Vendor: strings.TrimSpace(in.Vendor), + IfFilter: in.IfFilter, + Prototypes: in.Prototypes, } for _, i := range in.Items { checkType := i.CheckType @@ -164,6 +222,14 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P "шаблон із ключем «"+t.Key+"» уже є — виберіть інший ключ") return } + // Фільтр, який зануляє склад портів на живому хості, — це не + // «внутрішня помилка», а рішення, яке треба переграти. Шаблон + // при цьому лишився таким, яким був: перевірка стоїть усередині + // тієї самої транзакції. + if errors.Is(err, store.ErrInterfaceFilterEmpty) { + writeError(w, http.StatusConflict, "filter_empty", err.Error()) + return + } s.writeStoreError(w, "збереження шаблону", err) return } @@ -391,8 +457,15 @@ func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Reque if t.Source == "" { t.Source = "metric" } - if !validSources[t.Source] { - writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело тригера: "+t.Source) + // Відмова стосується лише ввімкнених тригерів. + // + // Шаблон — документ, який редагують цілком: якби нереалізоване + // джерело блокувало збереження, людина з одним старим тригером + // на трапи не могла б виправити жоден інший. Вимкнений тригер + // нічого не обіцяє — він не розгортається в правило взагалі. + if reason := store.UnsupportedSourceReason(t.Source); reason != "" && t.Enabled { + writeError(w, http.StatusBadRequest, "bad_source", + "тригер «"+t.Name+"»: "+reason) return } if t.Severity == "" { @@ -407,6 +480,13 @@ func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Reque "тригер «"+t.Name+"» без умови") return } + if t.Enabled { + if err := store.ValidateRuleCondition(t.Source, t.Condition); err != nil { + writeError(w, http.StatusBadRequest, "bad_condition", + "тригер «"+t.Name+"»: "+err.Error()) + return + } + } if t.ForSeconds < 0 || t.ForSeconds > 86400 { writeError(w, http.StatusBadRequest, "bad_request", "тригер «"+t.Name+"»: витримка поза межами 0…86400 с") diff --git a/server/internal/httpapi/templates_iffilter.go b/server/internal/httpapi/templates_iffilter.go new file mode 100644 index 0000000..c50e034 --- /dev/null +++ b/server/internal/httpapi/templates_iffilter.go @@ -0,0 +1,77 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Попередній перегляд фільтра складу портів. +// +// Окремі ручки, а не поле у відповіді шаблону: зразок перевіряють ДО +// збереження й багато разів поспіль — по разу на кожну правку. Змусити +// зберігати шаблон, щоб побачити наслідок, означало б застосувати +// неперевірений зразок до всіх прив'язаних хостів саме для того, щоб +// дізнатись, чи він правильний. + +// handleInterfaceFilterDevices — на чому можна перевірити зразок. +func (s *Server) handleInterfaceFilterDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + list, err := s.store.ListInterfaceFilterDevices(r.Context(), p.TenantID, + r.URL.Query().Get("template_id")) + if err != nil { + s.writeStoreError(w, "хости з портами", err) + return + } + + // Хости поза видимістю оператора прибираємо тут, а не в SQL: + // правило доступу живе в Scope, і другий його примірник у запиті + // розійшовся б із першим на найближчій правці. + scope := p.Scope() + out := make([]store.InterfaceFilterDevice, 0, len(list)) + for _, d := range list { + if scope.CanRead(d.ID) { + out = append(out, d) + } + } + writeJSON(w, http.StatusOK, map[string]any{"devices": out}) +} + +// handleInterfaceFilterPreview проганяє зразок по справжньому хосту. +func (s *Server) handleInterfaceFilterPreview(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + + var in struct { + DeviceID string `json:"device_id"` + TemplateID string `json:"template_id"` + Filter store.InterfaceFilter `json:"filter"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту") + return + } + if in.DeviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "не вказано хост для перевірки") + return + } + if !p.Scope().CanRead(in.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + + // Помилку в зразку віддаємо як 400 з текстом RE2: людина зараз + // дивиться на поле, у якому її зробила, і повідомлення «внутрішня + // помилка» відправило б її шукати проблему деінде. + res, err := s.store.PreviewInterfaceFilter(r.Context(), p.TenantID, + in.DeviceID, in.TemplateID, in.Filter) + if err != nil { + s.writeStoreError(w, "перевірка фільтра портів", err) + return + } + writeJSON(w, http.StatusOK, res) +} diff --git a/server/internal/httpapi/traps.go b/server/internal/httpapi/traps.go new file mode 100644 index 0000000..87f3093 --- /dev/null +++ b/server/internal/httpapi/traps.go @@ -0,0 +1,223 @@ +package httpapi + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// SNMP-трапи: журнал, словник назв і перелік невідомих відправників. +// +// ПРО ПРАВА +// +// Дивитись — devices:read. Те саме рішення, що для сховища й дзеркала: +// трап — це подія з мережі, і бачити її має той, хто щодня дивиться на +// мережу, а не лише власник кабінету. +// +// Правити словник і прибирати рядки з переліку невідомих — +// settings:write. Словник міняє те, ЯК підписані події для всіх у +// кабінеті, а видалення з переліку невідомих означає «я з цим +// розібрався». І перше, і друге — рішення про кабінет, а не про свій +// екран. + +const ( + trapsReadPerm = "devices:read" + trapsWritePerm = "settings:write" +) + +// trapsMaxRange — стеля глибини вибірки. +// +// Та сама, що в аудиті, і з тих самих міркувань: журнал росте вічно, а +// «покажи все» — це не глибина, а відсутність питання. Строк зберігання +// трапів усе одно 90 діб (0005), тож рік тут — із запасом на кабінети, +// де його подовжили. +const trapsMaxRange = 366 * 24 * time.Hour + +func (s *Server) handleListTraps(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsReadPerm) { + return + } + + q := r.URL.Query() + f := store.TrapFilter{ + DeviceIDs: splitParam(q, "device"), + OIDs: splitParam(q, "oid"), + SourceIP: strings.TrimSpace(q.Get("source_ip")), + Query: strings.TrimSpace(q.Get("q")), + Cursor: q.Get("cursor"), + // «Тільки невідомі» — окремий фільтр, а не порожній device. + // Порожнє значення в адресному рядку губиться, а це питання + // ставлять достатньо часто, щоб на нього давали посилання. + OnlyUnknown: q.Get("unknown") == "1", + } + + var err error + if f.From, err = parseTimeParam(q.Get("from")); err != nil { + writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «від»") + return + } + if f.To, err = parseTimeParam(q.Get("to")); err != nil { + writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «до»") + return + } + if f.To.IsZero() { + f.To = time.Now().UTC() + } + if f.From.IsZero() { + f.From = f.To.Add(-24 * time.Hour) + } + if !f.From.Before(f.To) { + writeError(w, http.StatusBadRequest, "invalid", "початок періоду має бути раніше за кінець") + return + } + if f.To.Sub(f.From) > trapsMaxRange { + writeError(w, http.StatusBadRequest, "invalid", + "період не більший за рік — звузьте діапазон або скористайтесь фільтрами") + return + } + if n, err := strconv.Atoi(q.Get("limit")); err == nil { + f.Limit = n + } + + page, err := s.store.ListTraps(r.Context(), p.TenantID, f) + if err != nil { + s.writeStoreError(w, "журнал трапів", err) + return + } + writeJSON(w, http.StatusOK, page) +} + +// handleTrapsMeta — усе, що сторінці треба знати на момент відкриття. +// +// Одним викликом, а не трьома: словник, перелік невідомих відправників +// і перелік сліпих зон — це стан сторінки, і зібраний із трьох +// відповідей у різні секунди він нічого не виграє, зате додає два +// стани завантаження на екран. +func (s *Server) handleTrapsMeta(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsReadPerm) { + return + } + + custom, err := s.store.TrapNames(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "словник трапів", err) + return + } + unknown, err := s.store.ListUnknownTrapSources(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "невідомі джерела трапів", err) + return + } + + type namedOID struct { + OID string `json:"oid"` + store.TrapMeaning + } + names := []namedOID{} + for oid, m := range store.BuiltinTrapNames() { + names = append(names, namedOID{OID: oid, TrapMeaning: m}) + } + for oid, m := range custom { + names = append(names, namedOID{OID: oid, TrapMeaning: m}) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "names": names, + "unknown": unknown, + // Сліпі зони переліком, а не текстом на сторінці: порожній + // журнал без цього блоку читається як «трапів не було», хоча + // частіше означає «їх нікому приймати». Саме це й було станом + // продукту до 0065 — порт слухав ніхто, а екран мовчав. + "blind_spots": trapBlindSpots, + }) +} + +// trapBlindSpots — те, чого приймач трапів НЕ бачить. +// +// Перелік тут, а не в тексті сторінки, з тієї ж причини, що й у +// журналі аудиту: він мусить жити поруч із кодом, який його спростовує. +// Рядок, який перестав бути правдою, прибирає той, хто це полагодив. +var trapBlindSpots = []map[string]string{ + { + "title": "Трапи приймає лише зонд із увімкненим модулем «traps»", + "note": "Модуль вмикається переліком -modules на самому зонді, і йому потрібен " + + "порт 162/udp. Порт нижче 1024, тож у Linux зонду треба CAP_NET_BIND_SERVICE " + + "або перенаправлення 162 → 1162 правилом фаєрвола. Поки цього немає, " + + "пристрої шлють трапи в нікуди, і виглядає це як спокійна мережа.", + }, + { + "title": "SNMPv3 приймається, але не перевіряється", + "note": "Трап v3 розбирається й зберігається, проте підпис і шифрування зонд не " + + "звіряє: для цього потрібні облікові записи USM на кожен пристрій, і " + + "поки їх немає, «перевірено» було б неправдою. Ім'я користувача " + + "зберігається як контекст, а не як доказ.", + }, + { + "title": "Словник MIB — лише шість стандартних трапів плюс ваші записи", + "note": "Вбудовані назви є для coldStart, warmStart, linkDown, linkUp, " + + "authenticationFailure та egpNeighborLoss — їх визначає сам протокол. " + + "Усе інше показується сирим OID, доки ви не додасте назву самі. " + + "Ми навмисно не вгадуємо назви за схожістю OID.", + }, + { + "title": "Зонд обмежує потік", + "note": "Не більше 20 трапів за секунду з однієї адреси й 10 000 у черзі; " + + "надлишок відкидається, а його кількість видно в журналі зонда. " + + "Без цього порт, що фліпає, поклав би і зонд, і базу.", + }, +} + +// handleSaveTrapName додає або міняє власну назву трапа. +func (s *Server) handleSaveTrapName(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsWritePerm) { + return + } + var in struct { + OID string `json:"oid"` + Name string `json:"name"` + Description string `json:"description"` + } + if !decodeBody(w, r, &in) { + return + } + if err := s.store.SaveTrapName(r.Context(), p.TenantID, in.OID, in.Name, in.Description); err != nil { + s.writeStoreError(w, "запис словника трапів", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleDeleteTrapName(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsWritePerm) { + return + } + if err := s.store.DeleteTrapName(r.Context(), p.TenantID, r.PathValue("oid")); err != nil { + s.writeStoreError(w, "видалення зі словника трапів", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleForgetUnknownTrapSource прибирає адресу з переліку невідомих. +// +// Це не видалення даних: самі трапи лишаються в журналі, і якщо з цієї +// адреси прийде наступний, вона в переліку з'явиться знову. Прибирається +// саме ПИТАННЯ — «а що це таке», — на яке людина вже відповіла. +func (s *Server) handleForgetUnknownTrapSource(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsWritePerm) { + return + } + ip := strings.TrimSpace(r.PathValue("ip")) + if ip == "" { + writeError(w, http.StatusBadRequest, "invalid", "потрібна адреса") + return + } + if err := s.store.ForgetUnknownTrapSource(r.Context(), p.TenantID, ip); err != nil { + s.writeStoreError(w, "видалення невідомого джерела", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/server/internal/httpapi/tv.go b/server/internal/httpapi/tv.go index 21cae29..70bccbb 100644 --- a/server/internal/httpapi/tv.go +++ b/server/internal/httpapi/tv.go @@ -144,7 +144,8 @@ func (s *Server) handleTVDevices(w http.ResponseWriter, r *http.Request) { if !ok { return } - devices, err := s.store.ListDevices(r.Context(), tv.TenantID, store.Scope{Unrestricted: true}) + devices, err := s.store.ListDevices(r.Context(), tv.TenantID, + store.Scope{Unrestricted: true}, store.DeviceViewLive) if err != nil { s.log.Error("хости телевізора", "err", err) writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") diff --git a/server/internal/store/agents.go b/server/internal/store/agents.go index ea4bd6f..d1094b6 100644 --- a/server/internal/store/agents.go +++ b/server/internal/store/agents.go @@ -66,7 +66,7 @@ func (s *Store) AuthenticateAgent(ctx context.Context, token string) (*Agent, er limits map[string]any ) - err := s.pool.QueryRow(ctx, ` + err := s.bg.QueryRow(ctx, ` SELECT id::text, tenant_id::text, name, site_id::text, token_hash, status::text, enabled_modules, limits FROM core.agents @@ -184,21 +184,35 @@ func (s *Store) RecordHeartbeat(ctx context.Context, a *Agent, hb *npv1.Heartbea 'queue_depth', $4::int, 'dropped_samples', $5::bigint, 'tasks_running', $6::int, - 'clock_skew_ms', $7::bigint + 'clock_skew_ms', $7::bigint, + -- Черга планувальника зонда. Зонд шле її в кожному + -- heartbeat від початку, а в зведенні її не було: без + -- неї видно, скільки задач виконується, і не видно, + -- скільки не встигло початись. + 'tasks_queued', $8::int ) WHERE id = $1 AND tenant_id = $2 `, a.ID, a.TenantID, int64(h.GetRssBytes()), int32(h.GetQueueDepth()), int64(h.GetDroppedSamples()), int32(hb.GetTasksRunning()), - h.GetClockSkew().AsDuration().Milliseconds()) + h.GetClockSkew().AsDuration().Milliseconds(), int32(hb.GetTasksQueued())) - res := s.pool.SendBatch(ctx, batch) - defer res.Close() - for i := 0; i < batch.Len(); i++ { - if _, err := res.Exec(); err != nil { - return fmt.Errorf("heartbeat[%d]: %w", i, err) + // Пакет виконується в тенантній транзакції, хоча перший запит іде в + // гіпертаблицю, де RLS немає. Річ у другому: core.agents під + // tenant_isolation, і без app.tenant_id цей UPDATE зачіпав би нуль + // рядків — мовчки. Зонд слав би heartbeat кожні кілька секунд, а в + // інтерфейсі назавжди лишався б offline із застиглим часом + // останнього зв'язку. Помилки при цьому не було б ніде: SendBatch + // не вважає «0 рядків» збоєм. + return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error { + res := tx.SendBatch(ctx, batch) + defer res.Close() + for i := 0; i < batch.Len(); i++ { + if _, err := res.Exec(); err != nil { + return fmt.Errorf("heartbeat[%d]: %w", i, err) + } } - } - return nil + return nil + }) } // RecordTaskStatus оновлює core.checks за доповіддю агента. diff --git a/server/internal/store/alerts.go b/server/internal/store/alerts.go index 1b8b446..2de6a75 100644 --- a/server/internal/store/alerts.go +++ b/server/internal/store/alerts.go @@ -23,6 +23,12 @@ type Rule struct { Condition Condition ForSeconds int DependsOnTopology bool + + // Строк життя подієвого алерту й мінімальний проміжок між + // зверненнями до нього. Для опитуваних джерел обидва не мають + // сенсу й лишаються нулями. + AutoCloseSeconds int + MinIntervalSeconds int } // Selector — до чого застосовується правило. Порожній означає «до всього»: @@ -56,6 +62,44 @@ type Condition struct { Value float64 `json:"value"` Agg string `json:"agg"` MetricKey string `json:"metric_key"` // для source=metric: ts.series.metric_key + + // --- подієві джерела --- + // + // У них немає ні порогу, ні вікна: подія або сталася, або ні. + // Тому й поля інші — вони описують не «скільки», а «яка саме». + + // syslog: {"regex":"%LINK-3-UPDOWN.*down","severity_lte":4,"tag":"LINK"} + Regex string `json:"regex,omitempty"` + Tag string `json:"tag,omitempty"` + SeverityLTE *int `json:"severity_lte,omitempty"` + + // ncm: {"event":"changed"} | {"event":"backup_failed"} + // compliance: {"event":"violation"} + Event string `json:"event,omitempty"` + + // compliance: звузити до конкретних правил відповідності й до + // порога їхньої серйозності. Порожньо — усі. + RuleIDs []string `json:"rule_ids,omitempty"` + MinSeverity string `json:"min_severity,omitempty"` + + // trap: {"trap_oid":"1.3.6.1.6.3.1.1.5.3", + // "varbind_oid":"1.3.6.1.2.1.2.2.1.1","varbind_value":"7", + // "source_ip":"10.20.0.0/24"} + // + // Три питання, і рівно ті, які до трапа ставлять: ЩО сталося (OID), + // ЗВІДКИ прийшло (адреса — потрібна окремо від селектора, бо + // селектор оперує хостами, а трап приходить і з адрес, яких в + // інвентарі немає) і З ЯКИМ значенням (varbind). + // + // Чого тут немає й не буде мовчки: зразка (regex) по тексту трапа. + // Трап — це не рядок, а набір типізованих полів, і «пошук по + // трапу» довелося б визначати як пошук по конкатенації чогось із + // чимось. Форма про це каже вголос (ValidateRuleCondition), а не + // приймає regex і не використовує його. + TrapOID string `json:"trap_oid,omitempty"` + VarbindOID string `json:"varbind_oid,omitempty"` + VarbindValue string `json:"varbind_value,omitempty"` + SourceIP string `json:"source_ip,omitempty"` } // Candidate — об'єкт, який щойно задовольнив умову правила. @@ -91,11 +135,12 @@ func (c Candidate) DedupKey(ruleID string) string { // по горутині на тенант означало б платити з'єднанням до БД за кожного // клієнта, у якого може не бути жодного правила. func (s *Store) ActiveRules(ctx context.Context) ([]Rule, error) { - rows, err := s.pool.Query(ctx, ` + rows, err := s.bg.Query(ctx, ` SELECT r.id::text, r.tenant_id::text, r.name, COALESCE(r.description,''), r.source::text, r.severity::text, r.selector::text, r.condition::text, - r.for_seconds, r.depends_on_topology + r.for_seconds, r.depends_on_topology, + r.auto_close_seconds, r.min_interval_seconds FROM alr.rules r JOIN core.tenants t ON t.id = r.tenant_id WHERE r.enabled AND t.status NOT IN ('suspended','cancelled') @@ -112,7 +157,8 @@ func (s *Store) ActiveRules(ctx context.Context) ([]Rule, error) { var sel, cond string if err := rows.Scan(&r.ID, &r.TenantID, &r.Name, &r.Description, &r.Source, &r.Severity, &sel, &cond, - &r.ForSeconds, &r.DependsOnTopology); err != nil { + &r.ForSeconds, &r.DependsOnTopology, + &r.AutoCloseSeconds, &r.MinIntervalSeconds); err != nil { return nil, err } if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil { @@ -181,9 +227,13 @@ func (s *Store) EvaluateRule(ctx context.Context, r Rule) ([]Candidate, error) { case "metric": return s.evalSeries(ctx, r) default: - // Джерела syslog/trap/ncm/compliance обробляються не опитуванням, - // а подіями — цей движок їх свідомо не чіпає. - return nil, nil + // Джерела syslog/ncm/compliance обробляються не опитуванням, а + // в момент надходження події (alerts_events.go). Сюди вони + // доходити не мають: движок відсіює їх раніше, бо інакше + // ResolveMissing із порожнім списком кандидатів закривав би + // щойно піднятий подієвий алерт на наступному ж тіку. + return nil, fmt.Errorf("правило %s: джерело %s не обчислюється опитуванням", + r.Name, r.Source) } } diff --git a/server/internal/store/alerts_channels.go b/server/internal/store/alerts_channels.go index 3e99ebb..6f516b0 100644 --- a/server/internal/store/alerts_channels.go +++ b/server/internal/store/alerts_channels.go @@ -362,6 +362,11 @@ type RuleAction struct { ChannelIDs []string Schedule *RouteSchedule NotifyOnResolve bool + // Драбина ескалації правила. Порожньо — без ескалації. + EscalationPolicyID string + // Джерело правила. Потрібне рівно для одного рішення: подієвий + // алерт (0058) проходить драбину без повторів — див. PlanEscalation. + Source string } // LoadRuleActions читає маршрутизацію всіх увімкнених правил тенанта. @@ -373,7 +378,8 @@ func (s *Store) LoadRuleActions(ctx context.Context, tenantID string) (map[strin err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT id::text, channel_ids::text[], - COALESCE(notify_schedule::text,''), notify_on_resolve + COALESCE(notify_schedule::text,''), notify_on_resolve, + COALESCE(escalation_policy_id::text,''), source::text FROM alr.rules WHERE tenant_id = $1 `, tenantID) if err != nil { @@ -383,7 +389,8 @@ func (s *Store) LoadRuleActions(ctx context.Context, tenantID string) (map[strin for rows.Next() { var id, sched string var a RuleAction - if err := rows.Scan(&id, &a.ChannelIDs, &sched, &a.NotifyOnResolve); err != nil { + if err := rows.Scan(&id, &a.ChannelIDs, &sched, &a.NotifyOnResolve, + &a.EscalationPolicyID, &a.Source); err != nil { return err } if sched != "" { diff --git a/server/internal/store/alerts_escalation.go b/server/internal/store/alerts_escalation.go new file mode 100644 index 0000000..0bb227b --- /dev/null +++ b/server/internal/store/alerts_escalation.go @@ -0,0 +1,653 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------- +// Політики +// --------------------------------------------------------------------- + +// EscalationStep — одна сходинка драбини. +// +// AfterMin рахується від ПОЧАТКУ алерту, а не від попередньої сходинки. +// Людина проектує чергування абсолютними числами («через 15 хвилин — +// другий інженер, через 45 — керівник»), і відносні проміжки змушували б +// перераховувати всю драбину щоразу, коли посередині додається сходинка. +type EscalationStep struct { + AfterMin int `json:"after_min"` + ChannelIDs []string `json:"channel_ids"` +} + +// EscalationPolicy — драбина цілком. +type EscalationPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Steps []EscalationStep `json:"steps"` + // Через скільки хвилин після вичерпання драбини почати її спочатку. + // 0 — не повторювати. + RepeatAfterMin int `json:"repeat_after_min"` + MaxRepeats int `json:"max_repeats"` + // Скільки правил уже посилаються на цю політику — щоб видалення не + // було мовчазним вимкненням ескалації на десятку тригерів. + RuleCount int `json:"rule_count"` +} + +// Стелі драбини. Живуть тут, а не в HTTP-шарі, бо ту саму перевірку +// робить і збереження політики, і CHECK у 0066: три різні числа в трьох +// місцях розходяться на першій же правці. +const ( + MaxEscalationSteps = 10 + MaxEscalationRepeats = 10 + // Доба — стеля проміжку сходинки. Більше означає «розбудити + // завтра», а це вже не ескалація, а нагадування. + MaxEscalationAfterMin = 24 * 60 +) + +// ValidateEscalationSteps відмовляє в драбині, яка не робитиме того, що +// про неї думає людина. +// +// Головна перевірка тут — перша сходинка НЕ на нульовій хвилині. +// Сходинка «через 0 хвилин» пішла б одночасно зі звичайним сповіщенням +// про той самий алерт: людина отримала б два однакові повідомлення й +// вирішила, що система заїкається. Ескалація починається там, де +// закінчилось мовчання, тобто строго пізніше. +func ValidateEscalationSteps(steps []EscalationStep) error { + if len(steps) == 0 { + return fmt.Errorf("%w: драбина без жодної сходинки нікого не розбудить — "+ + "додайте принаймні одну", ErrInvalid) + } + if len(steps) > MaxEscalationSteps { + return fmt.Errorf("%w: сходинок не більше %d: довша драбина — це вже розсилка "+ + "на всю компанію з інтервалом", ErrInvalid, MaxEscalationSteps) + } + prev := 0 + for i, s := range steps { + if s.AfterMin <= 0 { + return fmt.Errorf("%w: сходинка %d стоїть на %d хв — перша ескалація має бути "+ + "пізніше за саме сповіщення, інакше вона його просто продублює", + ErrInvalid, i+1, s.AfterMin) + } + if s.AfterMin > MaxEscalationAfterMin { + return fmt.Errorf("%w: сходинка %d аж через %d хв — це вже нагадування, "+ + "а не ескалація (стеля %d хв)", ErrInvalid, i+1, s.AfterMin, MaxEscalationAfterMin) + } + if i > 0 && s.AfterMin <= prev { + return fmt.Errorf("%w: сходинка %d (%d хв) не пізніша за попередню (%d хв) — "+ + "драбина має вести вгору", ErrInvalid, i+1, s.AfterMin, prev) + } + if len(s.ChannelIDs) == 0 { + return fmt.Errorf("%w: сходинка %d не має жодного каналу — вона мовчатиме, "+ + "а драбина рахуватиме її пройденою", ErrInvalid, i+1) + } + prev = s.AfterMin + } + return nil +} + +// ListEscalationPolicies читає політики кабінету. +func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]EscalationPolicy, error) { + var out []EscalationPolicy + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT p.id::text, p.name, COALESCE(p.description,''), p.steps::text, + COALESCE(p.repeat_after_min, 0), p.max_repeats, + (SELECT count(*)::int FROM alr.rules r WHERE r.escalation_policy_id = p.id) + FROM alr.escalation_policies p + WHERE p.tenant_id = $1 + ORDER BY p.name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var p EscalationPolicy + var steps string + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &steps, + &p.RepeatAfterMin, &p.MaxRepeats, &p.RuleCount); err != nil { + return err + } + if err := json.Unmarshal([]byte(steps), &p.Steps); err != nil { + return fmt.Errorf("політика %s: сходинки: %w", p.Name, err) + } + if p.Steps == nil { + p.Steps = []EscalationStep{} + } + out = append(out, p) + } + return rows.Err() + }) + return out, err +} + +// SaveEscalationPolicy створює або замінює політику цілком. +// +// Цілком, а не полями: форма показує повну драбину, і часткове +// оновлення дало б комбінацію сходинок, якої людина не бачила. +func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p EscalationPolicy) (string, error) { + steps, err := json.Marshal(p.Steps) + if err != nil { + return "", err + } + var repeat any + if p.RepeatAfterMin > 0 { + repeat = p.RepeatAfterMin + } + + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if id == "" { + return tx.QueryRow(ctx, ` + INSERT INTO alr.escalation_policies + (tenant_id, name, description, steps, repeat_after_min, max_repeats) + VALUES ($1, $2, $3, $4::jsonb, $5, $6) + RETURNING id::text + `, tenantID, p.Name, nullString(p.Description), string(steps), + repeat, p.MaxRepeats).Scan(&id) + } + ct, err := tx.Exec(ctx, ` + UPDATE alr.escalation_policies + SET name = $3, description = $4, steps = $5::jsonb, + repeat_after_min = $6, max_repeats = $7, updated_at = now() + WHERE tenant_id = $1 AND id = $2 + `, tenantID, id, p.Name, nullString(p.Description), string(steps), + repeat, p.MaxRepeats) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) + return id, err +} + +// DeleteEscalationPolicy прибирає політику. +// +// Правила, що на неї посилались, лишаються без ескалації (ON DELETE SET +// NULL у 0066), а живі драбини зупиняє движок із причиною «політику +// видалено». Мовчазного продовження за старою копією немає навмисно: +// драбина, якої вже немає у формі, але яка ще будить людей, — найгірший +// із можливих станів. +func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + ct, err := tx.Exec(ctx, + `DELETE FROM alr.escalation_policies WHERE tenant_id = $1 AND id = $2`, tenantID, id) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) +} + +// --------------------------------------------------------------------- +// Взведення драбини +// --------------------------------------------------------------------- + +// EscalationGrace — запас до жорсткої стелі життя драбини. +// +// Стеля потрібна через заглушення: заглушена сходинка не витрачається, +// а відкладається (див. PlanEscalation), і без стелі відкладання ходило +// б по колу місяцями на алерті, який ніхто не закриє. Доба запасу +// означає «драбину, яку цілу добу не давали пройти, вже нема сенсу +// проходити»: за добу або аварію розібрали, або вона перестала бути +// новиною. +const EscalationGrace = 24 * time.Hour + +// ArmEscalation ставить драбину на бойовий звід. +// +// ON CONFLICT DO NOTHING — і це не оптимізація, а вимога. Повторна +// доставка того самого алерту трапляється (ретрай, другий інстанс, +// перезапуск між надсиланням і записом), і кожна з них інакше +// перезапускала б драбину з нуля: алерт висів би годинами, а «наступного» +// будили б щоп'ятнадцять хвилин заново. +func (s *Store) ArmEscalation(ctx context.Context, tenantID, alertID, policyID string, + isEvent bool, p EscalationPolicy, alertStarted time.Time) error { + + if len(p.Steps) == 0 { + return nil + } + passStart := alertStarted + next := passStart.Add(time.Duration(p.Steps[0].AfterMin) * time.Minute) + deadline := passStart.Add(escalationSpan(p, isEvent)).Add(EscalationGrace) + + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO alr.alert_escalations + (alert_id, tenant_id, policy_id, is_event, step_idx, repeat_idx, + pass_start, next_at, deadline) + VALUES ($1, $2, $3, $4, 0, 0, $5, $6, $7) + ON CONFLICT (alert_id) DO NOTHING + `, alertID, tenantID, policyID, isEvent, passStart, next, deadline) + return err + }) +} + +// escalationSpan — скільки триває драбина, якщо ніхто не втручається. +func escalationSpan(p EscalationPolicy, isEvent bool) time.Duration { + if len(p.Steps) == 0 { + return 0 + } + span := time.Duration(p.Steps[len(p.Steps)-1].AfterMin) * time.Minute + if !isEvent && p.RepeatAfterMin > 0 && p.MaxRepeats > 0 { + pass := time.Duration(p.RepeatAfterMin) * time.Minute + span += time.Duration(p.MaxRepeats) * (pass + span) + } + return span +} + +// --------------------------------------------------------------------- +// Рішення про одну сходинку +// --------------------------------------------------------------------- + +// EscalationSnapshot — усе, що потрібно, щоб вирішити долю однієї +// сходинки. Читається з бази одним запитом і в один момент часу: стан +// алерту, прочитаний окремо від стану драбини, встиг би застаріти рівно +// між двома запитами — тобто саме тоді, коли алерт підтвердили. +type EscalationSnapshot struct { + AlertID string + TenantID string + PolicyID string + PolicyName string + IsEvent bool + + StepIdx int + RepeatIdx int + PassStart time.Time + Deadline time.Time + + Steps []EscalationStep + RepeatAfterMin int + MaxRepeats int + + // Стан алерту на момент читання. Порожньо — алерту вже немає. + AlertState string + // Алерт у вигляді, придатному для тексту повідомлення. + Alert Alert +} + +// EscalationAction — що робити з цією сходинкою. +type EscalationAction int + +const ( + // EscFire — доставити сходинку. + EscFire EscalationAction = iota + // EscDefer — не доставляти й не витрачати: перевірити пізніше. + EscDefer + // EscStop — драбина закінчилась, доставляти нічого. + EscStop +) + +// EscalationDecision — рішення разом із новим станом драбини. +type EscalationDecision struct { + Action EscalationAction + // Код для журналу: sent | done | acked | closed | suppressed | + // deadline | no_policy. + Outcome string + Detail string + + // Яку саме сходинку доставляємо (для EscFire). + StepIdx int + RepeatIdx int + ChannelIDs []string + + // Новий стан. NextAt == nil означає «драбину зупинено». + NextStepIdx int + NextRepeatIdx int + NextPassStart time.Time + NextAt *time.Time +} + +// EscalationRecheck — через скільки перевірити відкладену драбину. +// +// Порівняно з тіком движка це довго й навмисно: заглушений алерт не +// потребує уваги щопівхвилини, а кожна перевірка — це рядок у журналі +// сходинок. +const EscalationRecheck = 5 * time.Minute + +// PlanEscalation вирішує долю однієї сходинки. +// +// Функція чиста, і це головне архітектурне рішення в усій ескалації. +// Причина проста: половина роботи ескалації — НЕ будити. «Сходинка +// спрацювала» перевіряється легко й доводить мало; «сходинка не +// спрацювала, бо алерт підтвердили / закрили / хост заглушено / вікно +// обслуговування / драбина протухла» — це п'ять різних гілок, кожна з +// яких коштує чийогось сну, і перевіряти їх треба без бази. +// +// Перевірка стану робиться ПЕРЕД КОЖНОЮ сходинкою, а не один раз на +// початку. Інакше драбина, взведена о 02:40, о 03:10 будила б людину +// через алерт, закритий о 02:45, — тобто ескалація воскрешала б мертве. +func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision { + stop := func(outcome, detail string) EscalationDecision { + return EscalationDecision{ + Action: EscStop, Outcome: outcome, Detail: detail, + StepIdx: s.StepIdx, RepeatIdx: s.RepeatIdx, + NextStepIdx: s.StepIdx, NextRepeatIdx: s.RepeatIdx, + NextPassStart: s.PassStart, + } + } + + switch s.AlertState { + case "", "resolved", "expired": + // Закритий алерт ескалації не потребує за визначенням. Окремо + // від 'acknowledged', бо це різні історії: тут проблеми більше + // немає, там нею зайнялись. + return stop("closed", "алерт закрито — далі будити нікого") + case "acknowledged": + // Рівно те, заради чого існує кнопка «Прийняти»: ack не гасить + // проблему, він зупиняє драбину. Без цього людина, яка вже + // дивиться на аварію, за 15 хвилин отримала б дзвінок від + // керівника про те, що вона й так чинить. + return stop("acked", "алерт підтверджено — драбину зупинено") + } + + if len(s.Steps) == 0 || s.StepIdx < 0 || s.StepIdx >= len(s.Steps) { + // Політику видалили або переписали коротшою, поки драбина йшла. + // Мовчки добивати за старою копією не можна: драбини, якої вже + // немає у формі, ніхто не знайде, коли питатиме «звідки дзвінок». + return stop("no_policy", "політику ескалації видалено або скорочено") + } + + if !now.Before(s.Deadline) { + return stop("deadline", "драбина протухла — стелю життя вичерпано") + } + + if s.AlertState == "suppressed" { + // Вікно обслуговування й ручне заглушення зупиняють ескалацію + // так само, як звичайне сповіщення: обидва означають «не + // турбувати», і драбина не має бути винятком. + // + // Але сходинка при цьому НЕ витрачається. Різниця принципова: + // заглушення — це «не зараз», а не «проблеми немає». Списана + // сходинка означала б, що півгодинне вікно обслуговування тихо + // роззброює драбину до кінця життя алерту — тобто рівно та + // мовчазна відмова, від якої ескалація й рятує. Тому чекаємо, + // а стелю життя (Deadline) поставлено саме для того, щоб це + // чекання колись закінчилось. + next := now.Add(EscalationRecheck) + if !next.Before(s.Deadline) { + return stop("deadline", "заглушення пережило стелю життя драбини") + } + return EscalationDecision{ + Action: EscDefer, Outcome: "suppressed", + Detail: "придушено (" + s.Alert.SuppressedBy + ") — сходинку відкладено", + StepIdx: s.StepIdx, + RepeatIdx: s.RepeatIdx, + NextStepIdx: s.StepIdx, + NextRepeatIdx: s.RepeatIdx, + NextPassStart: s.PassStart, + NextAt: &next, + } + } + + if s.AlertState != "firing" { + // Невідомий стан. Мовчати безпечніше, ніж будити за здогадкою. + return stop("closed", "невідомий стан алерту "+s.AlertState) + } + + d := EscalationDecision{ + Action: EscFire, + Outcome: "sent", + StepIdx: s.StepIdx, + RepeatIdx: s.RepeatIdx, + ChannelIDs: s.Steps[s.StepIdx].ChannelIDs, + NextPassStart: s.PassStart, + } + + // Є наступна сходинка в цьому проході. + if s.StepIdx+1 < len(s.Steps) { + cur := s.Steps[s.StepIdx].AfterMin + nxt := s.Steps[s.StepIdx+1].AfterMin + at := s.PassStart.Add(time.Duration(nxt) * time.Minute) + // Якщо сходинка спізнилась (процес стояв, драбина чекала кінця + // вікна обслуговування), наступна не має спрацювати негайно + // слідом: інакше після паузи вся драбина висиплеться в одну + // хвилину й розбудить одразу всіх. + gap := nxt - cur + if gap < 1 { + gap = 1 + } + if floor := now.Add(time.Duration(gap) * time.Minute); at.Before(floor) { + at = floor + } + d.NextStepIdx = s.StepIdx + 1 + d.NextRepeatIdx = s.RepeatIdx + d.NextAt = &at + return d + } + + // Драбина вичерпана. Повтор — це ставка на те, що проблема ще + // триває, і зробити її можна лише там, де існування алерту саме по + // собі є доказом: метричний алерт живий рівно доти, доки виконується + // умова, і зникає сам, щойно вона перестала. + // + // Подієвий алерт (0058) такого доказу не дає. Рядок журналу стався + // один раз і «перестати ставатись» не може: алерт висить, поки його + // не закриє людина або строк auto_close. Повторювати за ним драбину + // означало б будити всю зміну по колу через один нічний блимок + // порту, який давно припинився. Тому подієвий алерт проходить + // драбину рівно раз — і на цьому ескалація закінчується. + if !s.IsEvent && s.RepeatAfterMin > 0 && s.RepeatIdx+1 <= s.MaxRepeats { + at := now.Add(time.Duration(s.RepeatAfterMin) * time.Minute) + if !at.Before(s.Deadline) { + d.Outcome = "done" + d.Detail = "драбину пройдено; повтор не вміщається у стелю життя" + d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx + return d + } + d.NextStepIdx = 0 + d.NextRepeatIdx = s.RepeatIdx + 1 + // Відлік нового проходу зсуваємо так, щоб перша сходинка + // припала рівно на «через repeat_after_min», а не на + // «repeat_after_min + after_min першої сходинки». + d.NextPassStart = at.Add(-time.Duration(s.Steps[0].AfterMin) * time.Minute) + d.NextAt = &at + return d + } + + d.Outcome = "done" + if s.IsEvent { + d.Detail = "драбину пройдено; подієвий алерт не повторюється" + } else { + d.Detail = "драбину пройдено повністю" + } + d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx + return d +} + +// --------------------------------------------------------------------- +// Читання й запис стану +// --------------------------------------------------------------------- + +// EscalationLease — на скільки сходинка вважається взятою в роботу. +// +// Взяття, рішення й запис — три кроки, і між ними процес може впасти. +// Оренда закриває найгіршу з двох дір: доки вона не спливла, другий +// інстанс тієї самої сходинки не візьме, тож дубля не буде. Друга діра +// (падіння між записом і надсиланням) коштує однієї недоставленої +// сходинки — це той самий свідомий вибір, що вже зроблено для черги +// подієвих алертів у 0058: «спробували» не дорівнює «доставили», і +// краще не надіслати, ніж надіслати вдруге о третій ночі. +const EscalationLease = 2 * time.Minute + +// TakeDueEscalations забирає сходинки, час яких настав. +// +// Наскрізно по всіх кабінетах і робочим пулом — так само, як черга +// подієвих алертів: движок один на інсталяцію й крутиться під +// advisory-блокуванням. +func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.bg.Query(ctx, ` + WITH due AS ( + SELECT alert_id FROM alr.alert_escalations + WHERE stopped_at IS NULL + AND next_at IS NOT NULL AND next_at <= now() + AND (leased_until IS NULL OR leased_until <= now()) + ORDER BY next_at + LIMIT $1 + FOR UPDATE SKIP LOCKED + ), taken AS ( + UPDATE alr.alert_escalations e + SET leased_until = now() + $2::interval + FROM due d WHERE e.alert_id = d.alert_id + RETURNING e.alert_id, e.tenant_id, e.policy_id, e.is_event, + e.step_idx, e.repeat_idx, e.pass_start, e.deadline + ) + SELECT t.alert_id::text, t.tenant_id::text, COALESCE(t.policy_id::text,''), + t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline, + COALESCE(p.name,''), COALESCE(p.steps::text,'[]'), + COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0), + COALESCE(a.state::text,''), COALESCE(a.severity::text,'info'), + COALESCE(a.title,''), COALESCE(a.message,''), + COALESCE(a.device_id::text,''), COALESCE(d.name,''), + COALESCE(a.rule_id::text,''), COALESCE(a.suppressed_by,''), + COALESCE(a.started_at, t.pass_start) + FROM taken t + LEFT JOIN alr.alerts a ON a.id = t.alert_id + LEFT JOIN alr.escalation_policies p ON p.id = t.policy_id + LEFT JOIN inv.devices d ON d.id = a.device_id + ORDER BY t.pass_start + `, limit, EscalationLease.String()) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []EscalationSnapshot + for rows.Next() { + var s EscalationSnapshot + var steps string + if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent, + &s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline, + &s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats, + &s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message, + &s.Alert.DeviceID, &s.Alert.DeviceName, &s.Alert.RuleID, + &s.Alert.SuppressedBy, &s.Alert.StartedAt); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(steps), &s.Steps); err != nil { + // Політика з нечитабельними сходинками не має валити чергу: + // решта драбин у кабінеті ні в чому не винна. + s.Steps = nil + } + s.Alert.ID = s.AlertID + s.Alert.TenantID = s.TenantID + s.Alert.State = s.AlertState + out = append(out, s) + } + return out, rows.Err() +} + +// ApplyEscalation записує рішення й веде журнал. +// +// Записується ДО надсилання. Порядок той самий, що в RecordNotification, +// і з тієї ж причини: падіння між записом і надсиланням лишає слід +// «сходинку пройдено» на недоставленому повідомленні, а зворотний +// порядок лишав би драбину на місці — і після підйому вона надіслала б +// те саме вдруге. +func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) error { + var next any + if d.NextAt != nil { + next = *d.NextAt + } + fired := d.Action == EscFire + + return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + UPDATE alr.alert_escalations + SET step_idx = $2, + repeat_idx = $3, + pass_start = $4, + next_at = $5, + leased_until = NULL, + last_step_at = CASE WHEN $6 THEN now() ELSE last_step_at END, + stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END, + stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END + WHERE alert_id = $1 + `, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart, + next, fired, d.Outcome); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + INSERT INTO alr.escalation_steps + (tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID), + d.StepIdx, d.RepeatIdx, d.Outcome, nullString(d.Detail)) + return err + }) +} + +// AlertEscalation — стан драбини для картки алерту. +// +// Без цього ескалація перетворюється на невидиму магію: людину підняли +// о 03:10, а на екрані нічого не пояснює, звідки взявся дзвінок. Тут +// видно і те, скільки сходинок пройдено, і коли буде наступна, і — якщо +// драбину зупинено — чому саме. +type AlertEscalation struct { + PolicyName string `json:"policy_name"` + // Скільки сходинок цього проходу вже доставлено. + Step int `json:"step"` + // Скільки їх усього в драбині. + Total int `json:"total"` + Repeat int `json:"repeat"` + // Коли наступна. Порожньо — драбина зупинена або вичерпана. + NextAt *time.Time `json:"next_at,omitempty"` + StoppedAt *time.Time `json:"stopped_at,omitempty"` + StopReason string `json:"stop_reason,omitempty"` +} + +// PurgeEscalationLog прибирає журнал сходинок за строком. +// +// Той самий строк, що й у alr.notifications (0007). Таблиця не +// гіпертаблиця й політики ретеншену TimescaleDB не має, тож прибирає +// її прибиральник алертів. +func (s *Store) PurgeEscalationLog(ctx context.Context, olderThan time.Duration) (int64, error) { + tag, err := s.bg.Exec(ctx, + `DELETE FROM alr.escalation_steps WHERE ts < now() - $1::interval`, + olderThan.String()) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// stopEscalationTx зупиняє драбину в межах уже відкритої транзакції. +// +// Викликається з підтвердження й ручного закриття алерту. Це НЕ той +// механізм, який гарантує зупинку: гарантує її перевірка стану перед +// кожною сходинкою в PlanEscalation, і саме там зупиняються драбини +// алертів, закритих шляхами, до яких цей код не дотягується (гасіння +// прострочених подієвих, закриття різницею множин, вимкнення правила). +// Тут — лише щоб картка алерту сказала «ескалацію зупинено» одразу, а +// не за півхвилини, коли настане час наступної сходинки. +func stopEscalationTx(ctx context.Context, tx pgx.Tx, tenantID, alertID, reason, detail string) error { + // Одним оператором, щоб журнал не міг розійтися зі станом: окремий + // SELECT після UPDATE записав би рядок і тоді, коли драбини вже не + // було, — тобто вигадав би подію. + _, err := tx.Exec(ctx, ` + WITH stopped AS ( + UPDATE alr.alert_escalations + SET next_at = NULL, leased_until = NULL, + stopped_at = now(), stop_reason = $3 + WHERE tenant_id = $1 AND alert_id = $2 AND stopped_at IS NULL + RETURNING policy_id, step_idx, repeat_idx + ) + INSERT INTO alr.escalation_steps + (tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail) + SELECT $1, $2, policy_id, step_idx, repeat_idx, $3, $4 FROM stopped + `, tenantID, alertID, reason, nullString(detail)) + return err +} diff --git a/server/internal/store/alerts_escalation_db_test.go b/server/internal/store/alerts_escalation_db_test.go new file mode 100644 index 0000000..11a6a0c --- /dev/null +++ b/server/internal/store/alerts_escalation_db_test.go @@ -0,0 +1,238 @@ +package store + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +// Перевірка ескалації ПРОТИ БАЗИ. +// +// Чиста PlanEscalation покриває рішення, і це головна половина. Але є +// друга, яку рішенням не перевіриш узагалі: атомарність. «Сходинка не +// подвоїлась при перезапуску» — це властивість трьох запитів +// (взведення з ON CONFLICT, взяття з орендою, запис рішення), а не +// властивість функції. Тест, який їх не виконує, про них нічого не +// каже — скільки б зелених рядків він не давав. +// +// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має +// вимагати бази. Запускати треба на ОДНОРАЗОВІЙ базі — тест створює +// кабінет і видаляє його разом з усім вмістом: +// +// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \ +// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16 +// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate +// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \ +// go test ./internal/store/ -run EscalationAgainstDB -v +func TestEscalationAgainstDB(t *testing.T) { + dsn := os.Getenv("NETPULSE_TEST_DSN") + if dsn == "" { + t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається") + } + ctx := context.Background() + + st, err := New(ctx, dsn) + if err != nil { + t.Fatalf("підключення: %v", err) + } + t.Cleanup(st.Close) + + slug := "esc-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "") + var tenantID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text + `, slug, "Перевірка ескалацій").Scan(&tenantID); err != nil { + t.Fatalf("кабінет: %v", err) + } + t.Cleanup(func() { + _, _ = st.pool.Exec(context.Background(), + `DELETE FROM core.tenants WHERE id = $1`, tenantID) + }) + + var deviceID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO inv.devices (tenant_id, name, address, kind) + VALUES ($1, $2, '10.77.0.1', 'switch') RETURNING id::text + `, tenantID, slug+"-sw").Scan(&deviceID); err != nil { + t.Fatalf("хост: %v", err) + } + + policy := EscalationPolicy{ + Name: "Нічне чергування", + Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch"}}, {AfterMin: 45, ChannelIDs: []string{"ch"}}}, + } + policyID, err := st.SaveEscalationPolicy(ctx, tenantID, "", policy) + if err != nil { + t.Fatalf("політика: %v", err) + } + + var ruleID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO alr.rules (tenant_id, name, source, severity, condition, escalation_policy_id) + VALUES ($1, 'Пінг', 'icmp', 'high', '{"metric":"loss_pct","op":">","value":20}'::jsonb, $2) + RETURNING id::text + `, tenantID, policyID).Scan(&ruleID); err != nil { + t.Fatalf("правило: %v", err) + } + + newAlert := func(key string) string { + t.Helper() + var id string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, state, + title, dedup_key, started_at, last_seen_at) + VALUES ($1, $2, $3, 'high', 'firing', 'ядро не відповідає', $4, + now() - interval '20 minutes', now()) + RETURNING id::text + `, tenantID, ruleID, deviceID, key).Scan(&id); err != nil { + t.Fatalf("алерт: %v", err) + } + return id + } + + started := time.Now().Add(-20 * time.Minute) + + // --- Взведення не подвоюється ------------------------------------- + // + // Найтонше місце всієї ескалації: повторна доставка того самого + // алерту (ретрай, другий інстанс, перезапуск між надсиланням і + // записом) не має перезапускати драбину з нуля. + a1 := newAlert(ruleID + ":dev:" + deviceID) + for i := 0; i < 3; i++ { + if err := st.ArmEscalation(ctx, tenantID, a1, policyID, false, policy, started); err != nil { + t.Fatalf("взведення %d: %v", i, err) + } + } + var rows int + if err := st.pool.QueryRow(ctx, + `SELECT count(*)::int FROM alr.alert_escalations WHERE alert_id = $1`, a1).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("три взведення дали %d рядків драбини — драбина подвоїлась", rows) + } + + // --- Оренда не дає взяти сходинку двічі --------------------------- + due, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatalf("взяття сходинки: %v", err) + } + if len(due) != 1 { + t.Fatalf("очікували одну сходинку, отримали %d", len(due)) + } + if due[0].AlertState != "firing" || len(due[0].Steps) != 2 { + t.Fatalf("знімок прочитано неправильно: %+v", due[0]) + } + + again, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(again) != 0 { + t.Fatalf("та сама сходинка взята вдруге під орендою: %d", len(again)) + } + + // --- Запис рішення просуває драбину й веде журнал ------------------ + d := PlanEscalation(due[0], time.Now()) + if d.Action != EscFire { + t.Fatalf("сходинка мала спрацювати: %v/%s", d.Action, d.Outcome) + } + if err := st.ApplyEscalation(ctx, due[0], d); err != nil { + t.Fatalf("запис рішення: %v", err) + } + + var stepIdx int + var leased *time.Time + if err := st.pool.QueryRow(ctx, ` + SELECT step_idx, leased_until FROM alr.alert_escalations WHERE alert_id = $1 + `, a1).Scan(&stepIdx, &leased); err != nil { + t.Fatal(err) + } + if stepIdx != 1 { + t.Errorf("драбина не просунулась: step_idx = %d", stepIdx) + } + if leased != nil { + t.Error("оренда не знята після запису рішення") + } + + var logged int + if err := st.pool.QueryRow(ctx, + `SELECT count(*)::int FROM alr.escalation_steps WHERE alert_id = $1 AND outcome = 'sent'`, + a1).Scan(&logged); err != nil { + t.Fatal(err) + } + if logged != 1 { + t.Errorf("журнал сходинок порожній або задвоєний: %d", logged) + } + + // --- Підтвердження зупиняє драбину негайно ------------------------- + if _, err := st.AckAlert(ctx, tenantID, a1, "", "беру"); err != nil { + t.Fatalf("підтвердження: %v", err) + } + var stopReason string + var nextAt *time.Time + if err := st.pool.QueryRow(ctx, ` + SELECT COALESCE(stop_reason,''), next_at + FROM alr.alert_escalations WHERE alert_id = $1 + `, a1).Scan(&stopReason, &nextAt); err != nil { + t.Fatal(err) + } + if stopReason != "acked" { + t.Errorf("драбину не зупинено підтвердженням: причина %q", stopReason) + } + if nextAt != nil { + t.Error("у зупиненої драбини лишився час наступної сходинки") + } + + // І головне: зупинена драбина більше не потрапляє в чергу. + if _, err := st.pool.Exec(ctx, + `UPDATE alr.alert_escalations SET leased_until = NULL WHERE alert_id = $1`, a1); err != nil { + t.Fatal(err) + } + rest, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatal(err) + } + for _, r := range rest { + if r.AlertID == a1 { + t.Fatal("підтверджений алерт повернувся в чергу ескалації") + } + } + + // --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ---- + a2 := newAlert(ruleID + ":dev:" + deviceID + ":2") + if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil { + t.Fatal(err) + } + if err := st.CloseAlert(ctx, tenantID, a2); err != nil { + t.Fatalf("закриття: %v", err) + } + if _, err := st.pool.Exec(ctx, ` + UPDATE alr.alert_escalations + SET stopped_at = NULL, stop_reason = NULL, next_at = now() - interval '1 minute' + WHERE alert_id = $1 + `, a2); err != nil { + t.Fatal(err) + } + // Драбину штучно «воскресили» повз зупинку — рішення все одно має + // зупинити її, бо стан алерту читається перед кожною сходинкою. + dueClosed, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatal(err) + } + var found bool + for _, s := range dueClosed { + if s.AlertID != a2 { + continue + } + found = true + if got := PlanEscalation(s, time.Now()); got.Action != EscStop || got.Outcome != "closed" { + t.Fatalf("закритий алерт: %v/%s", got.Action, got.Outcome) + } + } + if !found { + t.Fatal("тест не дістав сходинку закритого алерту — перевірка нічого не довела") + } +} diff --git a/server/internal/store/alerts_escalation_test.go b/server/internal/store/alerts_escalation_test.go new file mode 100644 index 0000000..8daa653 --- /dev/null +++ b/server/internal/store/alerts_escalation_test.go @@ -0,0 +1,484 @@ +package store + +import ( + "strings" + "testing" + "time" +) + +// Тести написані навколо однієї думки: зелений тест доводить рівно те, +// що він перевіряє. «Сходинка спрацювала» — найлегший і найменш цінний +// із можливих тестів ескалації, бо він нічого не каже про другу +// половину роботи. А друга половина — НЕ будити: підтверджений алерт, +// закритий алерт, заглушений хост, вікно обслуговування, протухла +// драбина. Кожен із цих випадків коштує чийогось сну (або чиєїсь +// аварії, яку ніхто не побачив), і кожен перевіряється нижче окремо. + +// ladder — драбина «через 15 хв — черговий, через 45 — керівник». +func ladder() []EscalationStep { + return []EscalationStep{ + {AfterMin: 15, ChannelIDs: []string{"ch-duty"}}, + {AfterMin: 45, ChannelIDs: []string{"ch-lead"}}, + } +} + +// snap збирає знімок так, як його віддала б база. +func snap(state string, stepIdx int, mods ...func(*EscalationSnapshot)) EscalationSnapshot { + start := time.Date(2026, 8, 15, 2, 40, 0, 0, time.UTC) + s := EscalationSnapshot{ + AlertID: "a1", + TenantID: "t1", + PolicyID: "p1", + PolicyName: "Нічне чергування", + StepIdx: stepIdx, + PassStart: start, + Deadline: start.Add(48 * time.Hour), + Steps: ladder(), + AlertState: state, + Alert: Alert{ID: "a1", State: state, Severity: "high", StartedAt: start}, + } + for _, m := range mods { + m(&s) + } + return s +} + +func at(base time.Time, min int) time.Time { + return base.Add(time.Duration(min) * time.Minute) +} + +// --------------------------------------------------------------------- +// Контроль: без цього тесту решта нічого не доводить +// --------------------------------------------------------------------- + +// Ніхто не підтвердив за 15 хвилин — будимо чергового. +func TestEscalationFiresWhenNobodyAcked(t *testing.T) { + s := snap("firing", 0) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action != EscFire { + t.Fatalf("сходинка мала спрацювати, отримали %v (%s)", d.Action, d.Outcome) + } + if len(d.ChannelIDs) != 1 || d.ChannelIDs[0] != "ch-duty" { + t.Fatalf("не ті канали: %v", d.ChannelIDs) + } + if d.NextAt == nil { + t.Fatal("після першої сходинки має бути запланована друга") + } + if d.NextStepIdx != 1 { + t.Errorf("наступна сходинка мала стати 1, отримали %d", d.NextStepIdx) + } + if want := at(s.PassStart, 45); !d.NextAt.Equal(want) { + t.Errorf("друга сходинка о %s, очікували %s", d.NextAt, want) + } +} + +// --------------------------------------------------------------------- +// Друга половина: коли сходинка НЕ має спрацювати +// --------------------------------------------------------------------- + +// Підтвердження зупиняє драбину. Це вся суть кнопки «Прийняти»: людина +// вже дивиться на аварію, і дзвінок керівникові за 15 хвилин повідомить +// його рівно про те, що вона й так робить. +func TestEscalationStopsOnAcknowledged(t *testing.T) { + d := PlanEscalation(snap("acknowledged", 0), time.Now()) + + if d.Action != EscStop { + t.Fatalf("підтверджений алерт не має ескалювати, отримали %v", d.Action) + } + if d.Outcome != "acked" { + t.Errorf("причина %q, очікували acked", d.Outcome) + } + if len(d.ChannelIDs) != 0 { + t.Errorf("зупинена драбина не має каналів, отримали %v", d.ChannelIDs) + } + if d.NextAt != nil { + t.Error("після зупинки наступної сходинки бути не може") + } +} + +// Закритий алерт ескалація воскресити не може. +// +// Найдорожча помилка з можливих: аварію полагодили о 02:45, а о 03:10 +// драбина піднімає керівника через алерт, якого вже немає. Саме тому +// стан читається перед КОЖНОЮ сходинкою, а не один раз при взведенні. +func TestEscalationNeverResurrectsClosedAlert(t *testing.T) { + for _, st := range []string{"resolved", "expired", ""} { + d := PlanEscalation(snap(st, 0), time.Now()) + if d.Action != EscStop || d.Outcome != "closed" { + t.Errorf("стан %q: отримали %v/%s, очікували зупинку closed", st, d.Action, d.Outcome) + } + } +} + +// Те саме, але на СЕРЕДИНІ драбини — окремим тестом навмисно. +// +// Перевірка «на початку» проходить і в реалізації, яка дивиться на стан +// лише при взведенні. Ця — ні. +func TestEscalationChecksStateBeforeEveryStep(t *testing.T) { + // Перша сходинка вже пішла (step_idx = 1), алерт тим часом закрили. + d := PlanEscalation(snap("resolved", 1), time.Now()) + if d.Action != EscStop || d.Outcome != "closed" { + t.Fatalf("друга сходинка закритого алерту: %v/%s", d.Action, d.Outcome) + } + + // І те саме для підтвердження на середині драбини. + d = PlanEscalation(snap("acknowledged", 1), time.Now()) + if d.Action != EscStop || d.Outcome != "acked" { + t.Fatalf("друга сходинка підтвердженого алерту: %v/%s", d.Action, d.Outcome) + } +} + +// Заглушений хост і вікно обслуговування зупиняють сходинку так само, +// як звичайне сповіщення. +func TestEscalationDoesNotFireWhileSuppressed(t *testing.T) { + for _, why := range []string{"mute", "maintenance", "topology"} { + s := snap("suppressed", 0, func(s *EscalationSnapshot) { + s.Alert.SuppressedBy = why + }) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action == EscFire { + t.Fatalf("%s: придушений алерт розбудив людину", why) + } + if d.Action != EscDefer || d.Outcome != "suppressed" { + t.Fatalf("%s: отримали %v/%s", why, d.Action, d.Outcome) + } + if len(d.ChannelIDs) != 0 { + t.Errorf("%s: у придушеної сходинки з'явились канали %v", why, d.ChannelIDs) + } + if !strings.Contains(d.Detail, why) { + t.Errorf("%s: причина придушення не потрапила в журнал: %q", why, d.Detail) + } + } +} + +// Придушення не ВИТРАЧАЄ сходинку. +// +// Різниця принципова й непомітна на тесті «не надіслали»: якщо сходинку +// списати, півгодинне вікно обслуговування тихо роззброїть драбину до +// кінця життя алерту — тобто дасть рівно ту мовчазну відмову, від якої +// ескалація й рятує. +func TestSuppressionDefersStepInsteadOfSpendingIt(t *testing.T) { + s := snap("suppressed", 0) + now := at(s.PassStart, 15) + d := PlanEscalation(s, now) + + if d.NextStepIdx != 0 { + t.Fatalf("сходинку списано попри придушення: наступна %d", d.NextStepIdx) + } + if d.NextAt == nil { + t.Fatal("відкладена драбина має отримати час наступної перевірки") + } + if !d.NextAt.After(now) { + t.Errorf("перевірку призначено не в майбутнє: %s", d.NextAt) + } + + // Вікно скінчилось — сходинка, що чекала, спрацьовує тією самою. + back := snap("firing", d.NextStepIdx) + fired := PlanEscalation(back, *d.NextAt) + if fired.Action != EscFire || fired.StepIdx != 0 { + t.Fatalf("після вікна мала піти та сама перша сходинка, отримали %v/%d", + fired.Action, fired.StepIdx) + } +} + +// Вічно заглушений алерт не має ходити по колу вічно. +func TestSuppressionStopsAtDeadline(t *testing.T) { + s := snap("suppressed", 0, func(s *EscalationSnapshot) { + s.Deadline = s.PassStart.Add(20 * time.Minute) + }) + d := PlanEscalation(s, at(s.PassStart, 19)) + + if d.Action != EscStop || d.Outcome != "deadline" { + t.Fatalf("отримали %v/%s, очікували зупинку за стелею", d.Action, d.Outcome) + } +} + +// Протухла драбина не будить нікого, навіть якщо алерт ще горить. +func TestEscalationStopsAtDeadline(t *testing.T) { + s := snap("firing", 0, func(s *EscalationSnapshot) { + s.Deadline = s.PassStart.Add(10 * time.Minute) + }) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action != EscStop || d.Outcome != "deadline" { + t.Fatalf("отримали %v/%s", d.Action, d.Outcome) + } +} + +// Політику видалили, поки драбина йшла. +// +// Мовчки добивати за старою копією не можна: драбини, якої вже немає у +// формі, ніхто не знайде, коли питатиме «звідки дзвінок». +func TestEscalationStopsWhenPolicyDisappeared(t *testing.T) { + gone := snap("firing", 0, func(s *EscalationSnapshot) { s.Steps = nil }) + if d := PlanEscalation(gone, time.Now()); d.Action != EscStop || d.Outcome != "no_policy" { + t.Fatalf("зникла політика: %v/%s", d.Action, d.Outcome) + } + + // Політику скоротили — сходинки з таким номером більше немає. + short := snap("firing", 5) + if d := PlanEscalation(short, time.Now()); d.Action != EscStop || d.Outcome != "no_policy" { + t.Fatalf("скорочена політика: %v/%s", d.Action, d.Outcome) + } +} + +// --------------------------------------------------------------------- +// Перезапуск процесу +// --------------------------------------------------------------------- + +// Рішення залежить ВИКЛЮЧНО від рядка в базі, а не від чогось у пам'яті. +// +// Це і є перевірка на перезапуск: «процес підняли заново» означає, що +// той самий знімок читається вдруге, і сходинка не має ані загубитись, +// ані піти двічі. Загубитись — бо рішення однакове; піти двічі — бо +// після запису стану знімок уже інший, і та сама сходинка з нього не +// відтворюється. +func TestEscalationSurvivesRestartMidLadder(t *testing.T) { + before := snap("firing", 0) + now := at(before.PassStart, 15) + + first := PlanEscalation(before, now) + if first.Action != EscFire { + t.Fatal("перша сходинка мала спрацювати") + } + + // Процес упав і піднявся, стан прочитано з бази заново — рішення те + // саме, бо в пам'яті нічого не було. + again := PlanEscalation(before, now) + if again.Action != first.Action || again.StepIdx != first.StepIdx || + !equalTimes(again.NextAt, first.NextAt) { + t.Fatalf("рішення залежить від пам'яті процесу: %+v проти %+v", again, first) + } + + // А після того, як стан записано (ApplyEscalation), знімок уже + // інший — і перша сходинка з нього не повториться. + after := snap("firing", first.NextStepIdx, func(s *EscalationSnapshot) { + s.RepeatIdx = first.NextRepeatIdx + s.PassStart = first.NextPassStart + }) + second := PlanEscalation(after, *first.NextAt) + if second.Action != EscFire { + t.Fatalf("друга сходинка не спрацювала: %v/%s", second.Action, second.Outcome) + } + if second.StepIdx == first.StepIdx { + t.Fatal("після перезапуску повторилась та сама сходинка") + } + if second.ChannelIDs[0] != "ch-lead" { + t.Errorf("друга сходинка пішла не тому: %v", second.ChannelIDs) + } +} + +func equalTimes(a, b *time.Time) bool { + if a == nil || b == nil { + return a == b + } + return a.Equal(*b) +} + +// Драбина, яка чекала кінця вікна обслуговування, не має висипатись +// уся за одну хвилину: інакше після паузи будяться одразу всі. +func TestDelayedLadderKeepsSpacing(t *testing.T) { + s := snap("firing", 0) + // Перша сходинка спрацювала із запізненням на дві години. + late := at(s.PassStart, 135) + d := PlanEscalation(s, late) + + if d.Action != EscFire || d.NextAt == nil { + t.Fatalf("очікували спрацювання з наступною сходинкою, отримали %v", d.Action) + } + // Проміжок між сходинками — 30 хв (15 → 45), він і має зберегтися. + if got := d.NextAt.Sub(late); got < 30*time.Minute { + t.Fatalf("наступна сходинка через %s після спізнілої — проміжок з'їдено", got) + } +} + +// --------------------------------------------------------------------- +// Повтори й подієві алерти +// --------------------------------------------------------------------- + +// Метричний алерт: драбину можна пройти заново, бо саме існування +// алерту доводить, що умова досі виконується. +func TestMetricAlertRepeatsLadder(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.RepeatAfterMin = 60 + s.MaxRepeats = 2 + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("остання сходинка мала спрацювати: %v", d.Action) + } + if d.NextAt == nil { + t.Fatal("після вичерпання драбини мав бути запланований повтор") + } + if d.NextRepeatIdx != 1 || d.NextStepIdx != 0 { + t.Fatalf("повтор має починати драбину спочатку: крок %d, повтор %d", + d.NextStepIdx, d.NextRepeatIdx) + } + // Повтор «через 60 хв» означає рівно 60, а не 60 + 15 хвилин першої + // сходинки: зсув відліку проходу має це врахувати. + want := at(s.PassStart, 45).Add(60 * time.Minute) + if !d.NextAt.Equal(want) { + t.Errorf("повтор о %s, очікували %s", d.NextAt, want) + } + if got := d.NextPassStart.Add(15 * time.Minute); !got.Equal(*d.NextAt) { + t.Errorf("відлік нового проходу зсунуто неправильно: %s", got) + } +} + +// Стеля повторів дотримується. +func TestRepeatsStopAtMaxRepeats(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.RepeatAfterMin = 60 + s.MaxRepeats = 2 + s.RepeatIdx = 2 // два повтори вже зроблено + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("остання сходинка мала піти: %v", d.Action) + } + if d.NextAt != nil { + t.Fatal("після вичерпання повторів драбина має зупинитись") + } + if d.Outcome != "done" { + t.Errorf("причина %q, очікували done", d.Outcome) + } +} + +// Подієвий алерт (0058) проходить драбину рівно раз. +// +// Причина не в економії повідомлень. Метричний алерт живий рівно доти, +// доки виконується умова, і зникає сам — тому повтор є ставкою на те, +// що проблема триває. Подієвий алерт такого доказу не дає: рядок журналу +// стався один раз і «перестати ставатись» не може. Повторювати за ним +// драбину означало б будити всю зміну по колу через нічний блимок порту, +// який давно припинився. +func TestEventAlertLadderRunsOnce(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.IsEvent = true + s.RepeatAfterMin = 60 + s.MaxRepeats = 3 + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("остання сходинка подієвого алерту мала піти: %v", d.Action) + } + if d.NextAt != nil { + t.Fatal("подієвий алерт не повторює драбину — повтор усе одно заплановано") + } + if !strings.Contains(d.Detail, "подієв") { + t.Errorf("журнал не пояснює, чому повтору немає: %q", d.Detail) + } +} + +// Але сходинки в межах одного проходу подієвий алерт проходить як усі: +// «конфіг змінився на ядрі й ніхто не глянув» має піднімати наступного. +func TestEventAlertStillWalksItsLadder(t *testing.T) { + s := snap("firing", 0, func(s *EscalationSnapshot) { s.IsEvent = true }) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action != EscFire || d.NextStepIdx != 1 || d.NextAt == nil { + t.Fatalf("подієвий алерт має ескалюватись у межах проходу: %+v", d) + } +} + +// Повтор, який не вміщається у стелю життя, не планується. +func TestRepeatNotScheduledPastDeadline(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.RepeatAfterMin = 60 + s.MaxRepeats = 3 + s.Deadline = s.PassStart.Add(50 * time.Minute) + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("сходинка мала піти: %v", d.Action) + } + if d.NextAt != nil { + t.Fatal("повтор за межами стелі життя не має плануватись") + } +} + +// --------------------------------------------------------------------- +// Перевірка драбини при збереженні +// --------------------------------------------------------------------- + +func TestValidateEscalationStepsRejectsBrokenLadders(t *testing.T) { + cases := []struct { + name string + steps []EscalationStep + want string + }{ + {"порожня", nil, "жодної сходинки"}, + { + "нульова хвилина", + []EscalationStep{{AfterMin: 0, ChannelIDs: []string{"c"}}}, + "продублює", + }, + { + "не зростає", + []EscalationStep{ + {AfterMin: 30, ChannelIDs: []string{"c"}}, + {AfterMin: 30, ChannelIDs: []string{"c"}}, + }, + "вгору", + }, + { + "сходинка без каналів", + []EscalationStep{{AfterMin: 15}}, + "жодного каналу", + }, + { + "надто пізно", + []EscalationStep{{AfterMin: 5000, ChannelIDs: []string{"c"}}}, + "нагадування", + }, + } + for _, c := range cases { + err := ValidateEscalationSteps(c.steps) + if err == nil { + t.Errorf("%s: драбину прийнято, а мало бути відхилено", c.name) + continue + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: пояснення %q не містить %q", c.name, err, c.want) + } + } +} + +func TestValidateEscalationStepsRejectsTooLongLadder(t *testing.T) { + var steps []EscalationStep + for i := 1; i <= MaxEscalationSteps+1; i++ { + steps = append(steps, EscalationStep{AfterMin: i * 5, ChannelIDs: []string{"c"}}) + } + if err := ValidateEscalationSteps(steps); err == nil { + t.Fatal("драбина понад стелю має відхилятись") + } +} + +func TestValidateEscalationStepsAcceptsSaneLadder(t *testing.T) { + if err := ValidateEscalationSteps(ladder()); err != nil { + t.Fatalf("нормальну драбину відхилено: %v", err) + } +} + +// Стеля життя драбини має накривати саму драбину з усіма повторами — +// інакше вона обривала б ескалацію раніше, ніж та встигла спрацювати. +func TestEscalationSpanCoversRepeats(t *testing.T) { + p := EscalationPolicy{Steps: ladder(), RepeatAfterMin: 60, MaxRepeats: 2} + + withRepeats := escalationSpan(p, false) + if withRepeats <= 45*time.Minute { + t.Fatalf("стеля %s не накриває навіть один прохід із повторами", withRepeats) + } + + // Подієвий алерт повторів не має — і стеля в нього коротша. + if ev := escalationSpan(p, true); ev >= withRepeats { + t.Fatalf("подієвий алерт не повторюється, а стеля така сама: %s проти %s", + ev, withRepeats) + } +} diff --git a/server/internal/store/alerts_events.go b/server/internal/store/alerts_events.go new file mode 100644 index 0000000..6a80ead --- /dev/null +++ b/server/internal/store/alerts_events.go @@ -0,0 +1,579 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "net" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------- +// Класифікація джерел +// --------------------------------------------------------------------- + +// Джерела поділені не за темою, а за тим, звідки береться факт. +// +// Опитуване джерело має ряд вимірів: движок щотіку перепитує його й +// може відповісти і «так», і «ні». Подієве джерело ряду не має — є +// момент, коли щось сталося, і більше нічого. Уся різниця в поведінці +// алерту (як він гасне, як дедуплікується, чи можна його «не знайти») +// випливає саме звідси. +var ( + polledSources = map[string]bool{ + "metric": true, "icmp": true, "interface": true, + } + eventSources = map[string]bool{ + "syslog": true, "ncm": true, "compliance": true, "trap": true, + } +) + +// IsEventSource — чи обробляється джерело в момент надходження події. +func IsEventSource(source string) bool { return eventSources[source] } + +// UnsupportedSourceReason пояснює людині, чому джерело не працює. +// +// Порожній рядок означає «працює». Текст тут, а не в HTTP-шарі, бо ту +// саму відповідь має дати і збереження тригера шаблону: два різні +// пояснення тієї самої відмови розходяться на першій же правці. +func UnsupportedSourceReason(source string) string { + if polledSources[source] || eventSources[source] { + return "" + } + switch source { + case "link": + return "лінк на мапі не має власних вимірів — він живий рівно настільки, " + + "наскільки живі його кінці. Заведіть правило на пристрої або на інтерфейс" + case "agent": + return "«зонд не на звʼязку» — це стан, а не подія; він рахується опитуванням. " + + "Скористайтесь правилом «Пінг» з метрикою «Даних немає взагалі»" + default: + return "невідоме джерело правила" + } +} + +// --------------------------------------------------------------------- +// Перевірка умови при збереженні +// --------------------------------------------------------------------- + +// ValidateRuleCondition відмовляє у правилі, яке ніколи не спрацює. +// +// Перевірка стоїть на записі, а не на обчисленні, з тієї ж причини, що +// й у правил відповідності: про друкарську помилку в регулярному виразі +// людина має дізнатися з форми, а не з тригера, який рік мовчав. +func ValidateRuleCondition(source string, raw []byte) error { + if reason := UnsupportedSourceReason(source); reason != "" { + return fmt.Errorf("%w: %s", ErrInvalid, reason) + } + if !eventSources[source] { + return nil + } + + var c Condition + if len(raw) > 0 { + if err := json.Unmarshal(raw, &c); err != nil { + return fmt.Errorf("%w: умова не читається як JSON: %v", ErrInvalid, err) + } + } + + switch source { + case "syslog": + if strings.TrimSpace(c.Regex) == "" { + return fmt.Errorf("%w: правило на журнал без зразка підпало б під кожен рядок "+ + "— задайте, що саме шукати", ErrInvalid) + } + if _, err := regexp.Compile(c.Regex); err != nil { + return fmt.Errorf("%w: зразок не компілюється: %v", ErrInvalid, err) + } + if c.SeverityLTE != nil && (*c.SeverityLTE < 0 || *c.SeverityLTE > 7) { + return fmt.Errorf("%w: рівень syslog буває від 0 (emerg) до 7 (debug)", ErrInvalid) + } + case "ncm": + switch c.Event { + case "changed", "backup_failed": + case "": + return fmt.Errorf("%w: не вказано подію конфігу: changed або backup_failed", ErrInvalid) + default: + return fmt.Errorf("%w: невідома подія конфігу %q: буває changed або backup_failed", + ErrInvalid, c.Event) + } + case "compliance": + if c.Event != "" && c.Event != "violation" { + return fmt.Errorf("%w: для відповідності є лише подія violation", ErrInvalid) + } + if c.MinSeverity != "" && !validSeverity[c.MinSeverity] { + return fmt.Errorf("%w: невідома серйозність %q", ErrInvalid, c.MinSeverity) + } + case "trap": + return validateTrapCondition(c) + } + return nil +} + +// validateTrapCondition перевіряє умову правила на трапи. +// +// Головна відповідальність цієї функції — не пропустити правило, яке +// виглядатиме працюючим. 0058 з'явився саме через таке: джерело `trap` +// зберігалось мовчки, показувалось увімкненим і не спрацьовувало +// ніколи. Повернути джерело й лишити хоч одну мовчазну гілку означало б +// повторити ту саму помилку в дрібнішому масштабі, а це гірше — дрібну +// довше не помічають. +// +// Тому кожна відмова тут не просто відмовляє, а каже, що робити далі. +func validateTrapCondition(c Condition) error { + // Поля чужого джерела в умові — не дрібниця. Людина, яка + // переключила джерело правила з «Syslog» на «Трапи» й лишила в + // формі зразок, має дізнатись, що зразок більше не діє. Мовчазне + // ігнорування дало б правило, яке ловить УСІ трапи замість тих, що + // підпадають під зразок. + if strings.TrimSpace(c.Regex) != "" { + return fmt.Errorf("%w: зразок (regex) до трапів не застосовується — трап це не рядок "+ + "тексту, а набір типізованих полів. Задайте OID трапа, а за потреби "+ + "звузьте його конкретним varbind-ом", ErrInvalid) + } + if c.SeverityLTE != nil { + return fmt.Errorf("%w: у трапа немає рівня severity — його має syslog. Серйозність "+ + "алерту задається самим правилом", ErrInvalid) + } + if strings.TrimSpace(c.Tag) != "" { + return fmt.Errorf("%w: тега у трапа немає; те, «що саме сталося», задається OID трапа", + ErrInvalid) + } + + oid := NormalizeOID(c.TrapOID) + src := strings.TrimSpace(c.SourceIP) + if oid == "" && src == "" { + // Правило без жодного звуження підпадає під КОЖЕН трап у + // кабінеті. Формально воно робоче, практично — це спосіб + // отримати алерт на кожен linkUp кожного порту й вимкнути + // сповіщення назавжди через тиждень. + return fmt.Errorf("%w: правило без OID трапа й без адреси джерела підпало б під "+ + "кожен трап у мережі — вкажіть, що саме ловимо", ErrInvalid) + } + if oid != "" && !ValidOID(oid) { + return fmt.Errorf("%w: %q не схоже на OID. Очікуються числа через крапку "+ + "(наприклад 1.3.6.1.6.3.1.1.5.3 — linkDown); назву трапа зі свого словника "+ + "теж треба вказувати її OID-ом", ErrInvalid, c.TrapOID) + } + if src != "" && !validIPOrCIDR(src) { + return fmt.Errorf("%w: %q не схоже на адресу або підмережу (10.20.0.5 чи "+ + "10.20.0.0/24)", ErrInvalid, c.SourceIP) + } + + vbOID := NormalizeOID(c.VarbindOID) + if vbOID != "" && !ValidOID(vbOID) { + return fmt.Errorf("%w: %q не схоже на OID varbind-а", ErrInvalid, c.VarbindOID) + } + if strings.TrimSpace(c.VarbindValue) != "" && vbOID == "" { + // Порівнювати значення, не сказавши якого поля, ніде: у трапі + // їх десяток. Умова «будь-який varbind дорівнює 2» зривалась би + // на кожному другому трапі й виглядала б при цьому осмисленою. + return fmt.Errorf("%w: вказано значення varbind-а, але не вказано, якого саме. "+ + "Додайте OID varbind-а — наприклад 1.3.6.1.2.1.2.2.1.1 (ifIndex)", ErrInvalid) + } + return nil +} + +// validIPOrCIDR — поверхнева перевірка адреси джерела. +// +// Стоїть на записі, а не на спрацюванні, з тієї ж причини, що й +// перевірка регулярного виразу: про описку в адресі людина має +// дізнатись із форми, а не з правила, яке рік мовчало. +func validIPOrCIDR(s string) bool { + if _, _, err := net.ParseCIDR(s); err == nil { + return true + } + return net.ParseIP(s) != nil +} + +var validSeverity = map[string]bool{ + "info": true, "warning": true, "average": true, "high": true, "disaster": true, +} + +// SeverityRank — порядок серйозності, той самий, що в alr.severity. +func SeverityRank(s string) int { + switch s { + case "warning": + return 1 + case "average": + return 2 + case "high": + return 3 + case "disaster": + return 4 + default: + return 0 + } +} + +// --------------------------------------------------------------------- +// Читання подієвих правил +// --------------------------------------------------------------------- + +// EventRules читає увімкнені подієві правила одного тенанта. +// +// Окремо від ActiveRules навмисно: та вибірка наскрізна по всіх +// тенантах і робиться раз на тік одним процесом, а ця — гаряча. Її +// смикає приймач журналу, тобто найчастіший шлях у системі, і вона має +// віддавати рівно правила одного кабінету, щоб їх можна було закешувати +// поруч із його ж списком хостів. +func (s *Store) EventRules(ctx context.Context, tenantID string) ([]Rule, error) { + // Тенантна транзакція, хоча предикат r.tenant_id = $1 у запиті вже + // стоїть: alr.rules під tenant_isolation, і без app.tenant_id + // вибірка порожня. Наслідок був би тихий і найгірший з можливих — + // подієві алерти просто перестали б заводитись, а сторінка алертів + // виглядала б як «усе спокійно». + var out []Rule + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT r.id::text, r.name, r.source::text, r.severity::text, + r.selector::text, r.condition::text, + r.auto_close_seconds, r.min_interval_seconds + FROM alr.rules r + JOIN core.tenants t ON t.id = r.tenant_id + WHERE r.tenant_id = $1 + AND r.enabled + AND r.source IN ('syslog','ncm','compliance','trap') + AND t.status NOT IN ('suspended','cancelled') + ORDER BY r.name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + r := Rule{TenantID: tenantID} + var sel, cond string + if err := rows.Scan(&r.ID, &r.Name, &r.Source, &r.Severity, &sel, &cond, + &r.AutoCloseSeconds, &r.MinIntervalSeconds); err != nil { + return err + } + if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil { + return fmt.Errorf("правило %s: selector: %w", r.Name, err) + } + if err := json.Unmarshal([]byte(cond), &r.Condition); err != nil { + return fmt.Errorf("правило %s: condition: %w", r.Name, err) + } + out = append(out, r) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SelectorDevices розгортає селектор правила у перелік хостів. +// +// Подієвий шлях не може перевіряти належність хоста до селектора +// запитом на кожну подію: рядків журналу за секунду більше, ніж хостів +// у кабінеті. Тому селектор розгортається один раз і живе в кеші поруч +// із правилами — ціною того, що щойно доданий хост підпадає під правило +// не миттєво, а з наступним оновленням кешу. +func (s *Store) SelectorDevices(ctx context.Context, tenantID string, sel Selector) ([]string, error) { + var out []string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + ids, err := s.resolveSelectorDevices(ctx, tx, tenantID, sel) + out = ids + return err + }) + return out, err +} + +// --------------------------------------------------------------------- +// Підняття подієвого алерту +// --------------------------------------------------------------------- + +// EventAlert — подія, яка вже визнана такою, що підпадає під правило. +type EventAlert struct { + RuleID string + DeviceID string + DeviceName string + Severity string + Title string + Message string + DedupKey string + Context json.RawMessage + // Скільки однакових подій злилося в це звернення. Одиниця — звичайна + // подія; більше — пачка, зведена ще до звернення до бази. + Count int + SuppressedBy string +} + +// RaiseEventAlert створює подієвий алерт або доливає подію в наявний. +// +// Дедуплікація — той самий унікальний індекс, що й у метричних алертів, +// і це не економія коду. Ключ навмисно не містить нічого від самої +// події: один алерт на пару «правило + хост» незалежно від того, чи +// прийшов один рядок журналу, чи чотири тисячі. Інакше перший же +// мигаючий порт зробив би дошку алертів нечитабельною за хвилину, а +// саме дошка — те, заради чого все це існує. +// +// Ціна такого рішення чесна й видима: у алерті лишається останній текст +// і лічильник подій, а не весь їхній перелік. Перелік є в журналі, і +// шукати його треба там. +func (s *Store) RaiseEventAlert(ctx context.Context, tenantID string, e EventAlert) (FiredAlert, error) { + var out FiredAlert + ctxJSON := "{}" + if len(e.Context) > 0 { + ctxJSON = string(e.Context) + } + count := e.Count + if count < 1 { + count = 1 + } + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + WITH prev AS ( + SELECT id, state::text AS old_state + FROM alr.alerts + WHERE tenant_id = $1 AND dedup_key = $6 + AND state IN ('firing','acknowledged','suppressed') + ), ups AS ( + INSERT INTO alr.alerts + (tenant_id, rule_id, device_id, severity, state, title, message, + dedup_key, context, suppressed_by, event_count, notify_pending, + started_at, last_seen_at) + VALUES ($1, $2, $3, $4::alr.severity, + CASE WHEN $8::text IS NULL THEN 'firing' ELSE 'suppressed' END::alr.alert_state, + $5, $9, $6, $7::jsonb, $8, $10, + -- Придушений алерт не ставиться в чергу на + -- розсилку: заглушення означає «не турбувати», + -- а не «покажи пізніше». + $8::text IS NULL, + now(), now()) + ON CONFLICT (tenant_id, dedup_key) + WHERE state IN ('firing','acknowledged','suppressed') + DO UPDATE SET + last_seen_at = now(), + event_count = alr.alerts.event_count + EXCLUDED.event_count, + message = EXCLUDED.message, + context = EXCLUDED.context, + suppressed_by = $8, + state = CASE + WHEN alr.alerts.state = 'acknowledged' THEN 'acknowledged' + WHEN $8::text IS NULL THEN 'firing' + ELSE 'suppressed' END::alr.alert_state + -- notify_pending навмисно не чіпаємо: продовження + -- вже відомої події не є новиною, і сотий рядок + -- журналу не має слати сотого повідомлення. + RETURNING id, state::text AS new_state, started_at, last_seen_at, + event_count, notify_count, (xmax = 0) AS inserted + ) + SELECT ups.id::text, ups.new_state, ups.started_at, ups.last_seen_at, + ups.event_count, ups.notify_count, ups.inserted, + COALESCE(prev.old_state, '') + FROM ups LEFT JOIN prev ON prev.id = ups.id + `, tenantID, nullUUID(e.RuleID), nullUUID(e.DeviceID), e.Severity, + e.Title, e.DedupKey, ctxJSON, nullString(e.SuppressedBy), + nullString(e.Message), count, + ).Scan(&out.ID, &out.State, &out.StartedAt, &out.LastSeenAt, + &out.EventCount, &out.NotifyCount, &out.IsNew, &out.PrevState) + }) + if err != nil { + return out, fmt.Errorf("подієвий алерт %s: %w", e.DedupKey, err) + } + + out.RuleID, out.DeviceID = e.RuleID, e.DeviceID + out.DeviceName, out.Severity, out.Title = e.DeviceName, e.Severity, e.Title + out.Message, out.DedupKey, out.SuppressedBy = e.Message, e.DedupKey, e.SuppressedBy + return out, nil +} + +// ResolveEventAlerts закриває подієві алерти за їхніми ключами. +// +// Потрібно рівно там, де в події ВСЕ Ж таки є зворотний бік: +// відповідність перевіряється прогоном, і той самий прогін, у якому +// хост правило пройшов, — єдиний чесний сигнал «більше не порушено». +// Для журналу й конфігів такого сигналу не існує: рядок «конфіг +// змінився» ніщо не скасовує. +func (s *Store) ResolveEventAlerts(ctx context.Context, tenantID string, dedupKeys []string, reason string) (int, error) { + if len(dedupKeys) == 0 { + return 0, nil + } + var n int + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + WITH closed AS ( + UPDATE alr.alerts + SET state = 'resolved', resolved_at = now(), notify_pending = false + WHERE tenant_id = $1 + AND dedup_key = ANY($2::text[]) + AND state IN ('firing','acknowledged','suppressed') + RETURNING id, device_id, severity, title + ), published AS ( + INSERT INTO core.event_outbox (tenant_id, topic, payload) + SELECT $1, 'alert.resolved', + jsonb_build_object('alert_id', id::text, + 'device_id', COALESCE(device_id::text,''), + 'severity', severity::text, + 'title', title, + 'state', 'resolved', + 'reason', $3::text) + FROM closed + RETURNING 1 + ) + SELECT count(*)::int FROM published + `, tenantID, dedupKeys, reason).Scan(&n) + }) + return n, err +} + +// ExpireEventAlerts гасить подієві алерти, до яких давно не було подій. +// +// Це не прибирання й не косметика — це відповідь на питання «як +// закривається алерт, що не має умови». +// +// Метричний алерт закриває сама дійсність: умова перестала виконуватись +// — рядок зник із кандидатів. Подієвий такого шансу не має: рядок +// журналу стався, і «перестати ставатись» не може. Лишити його висіти +// назавжди означає за тиждень отримати дошку з двома сотнями старих +// подій, на яку ніхто не дивиться, — а тоді на ній не помітять і +// справжню аварію. +// +// Тому такий алерт має строк. Стан навмисно `expired`, а не `resolved`: +// ніхто не казав, що проблему полагодили, вона просто відстоялась. У +// журналі й на екрані це має виглядати по-різному, інакше «саме +// минулося» неможливо відрізнити від «розібрались». +func (s *Store) ExpireEventAlerts(ctx context.Context) (int64, error) { + tag, err := s.bg.Exec(ctx, ` + WITH aged AS ( + UPDATE alr.alerts a + SET state = 'expired', resolved_at = now(), notify_pending = false + FROM alr.rules r + WHERE r.id = a.rule_id + AND r.auto_close_seconds > 0 + AND a.state IN ('firing','acknowledged','suppressed') + AND a.last_seen_at < now() - make_interval(secs => r.auto_close_seconds) + RETURNING a.id, a.tenant_id, a.device_id, a.severity, a.title + ) + INSERT INTO core.event_outbox (tenant_id, topic, payload) + SELECT tenant_id, 'alert.resolved', + jsonb_build_object('alert_id', id::text, + 'device_id', COALESCE(device_id::text,''), + 'severity', severity::text, + 'title', title, + 'state', 'expired', + 'reason', 'подій більше не було') + FROM aged + `) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// TakeNotifyPending забирає подієві алерти, які ще нікуди не пішли. +// +// Забирає, а не читає: позначка знімається тією ж командою, що й +// повертає рядки. Інакше два процеси, які випадково опинились у тіку +// одночасно, розіслали б одне й те саме двічі — а телефон о третій ночі +// не розрізняє «дублікат» і «друга аварія». +// +// Знята позначка означає «спробували», а не «доставили». Це свідомо: +// повторні спроби доставки — робота каналу, і робити їх звідси означало +// б слати вдруге те, що вже дійшло, щоразу як мовчить один із трьох +// каналів. +func (s *Store) TakeNotifyPending(ctx context.Context, limit int) ([]Alert, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + rows, err := s.bg.Query(ctx, ` + WITH taken AS ( + UPDATE alr.alerts a + SET notify_pending = false + WHERE a.id IN ( + SELECT id FROM alr.alerts + WHERE notify_pending + ORDER BY started_at + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + RETURNING a.* + ) + SELECT t.tenant_id::text, t.id::text, COALESCE(t.rule_id::text,''), + COALESCE(t.device_id::text,''), COALESCE(d.name,''), + t.severity::text, t.state::text, t.title, COALESCE(t.message,''), + t.dedup_key, COALESCE(t.suppressed_by,''), t.started_at, t.last_seen_at, + t.event_count + FROM taken t + LEFT JOIN inv.devices d ON d.id = t.device_id + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Alert + for rows.Next() { + var a Alert + if err := rows.Scan(&a.TenantID, &a.ID, &a.RuleID, &a.DeviceID, &a.DeviceName, + &a.Severity, &a.State, &a.Title, &a.Message, &a.DedupKey, + &a.SuppressedBy, &a.StartedAt, &a.LastSeenAt, &a.EventCount); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// EventDedupKey — ключ подієвого алерту. +// +// Свідомо той самий вигляд, що й у Candidate.DedupKey: подія й вимір +// про той самий хост і те саме правило — це один алерт, і два різні +// формати ключа рано чи пізно дали б два. +func EventDedupKey(ruleID, deviceID string) string { + return ruleID + ":dev:" + deviceID +} + +// TrapDedupKey — ключ алерту за трапом. +// +// Для трапа від відомого хоста це той самий ключ, що й для решти +// подієвих джерел: подія про хост — один алерт на пару «правило+хост». +// +// Для трапа від адреси, якої немає в інвентарі, хоста немає взагалі, і +// ключ будується від адреси. Без цього всі невпізнані відправники +// злилися б у ОДИН алерт на правило — тобто «щось у мережі шле трапи», +// з чим неможливо нічого зробити. З адресою в ключі кожен незнайомець +// має власний рядок на дошці, і його видно як окреме питання. +func TrapDedupKey(ruleID, deviceID, sourceIP string) string { + if deviceID != "" { + return EventDedupKey(ruleID, deviceID) + } + return ruleID + ":ip:" + sourceIP +} + +// DeviceNames — імена всіх живих хостів кабінету. +// +// Одним запитом на весь кабінет, а не по хосту на подію: ім'я потрібне +// лише в заголовку алерту, а заголовок пишеться раз при створенні. +// Платити за нього окремим запитом на кожен рядок журналу означало б +// зробити найдорожчою частиною шляху найдешевшу його потребу. +func (s *Store) DeviceNames(ctx context.Context, tenantID string) (map[string]string, error) { + out := map[string]string{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, name FROM inv.devices + WHERE tenant_id = $1 AND deleted_at IS NULL + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + return err + } + out[id] = name + } + return rows.Err() + }) + return out, err +} diff --git a/server/internal/store/alerts_events_test.go b/server/internal/store/alerts_events_test.go new file mode 100644 index 0000000..1038bc8 --- /dev/null +++ b/server/internal/store/alerts_events_test.go @@ -0,0 +1,221 @@ +package store + +import ( + "errors" + "strings" + "testing" +) + +// Помилка, яку лікує ця гілка: джерело, якого движок не вміє, +// зберігалось мовчки й виглядало ввімкненим. +func TestUnsupportedSourcesAreRefusedWithReason(t *testing.T) { + for _, src := range []string{"link", "agent", "вигадане"} { + reason := UnsupportedSourceReason(src) + if reason == "" { + t.Errorf("%s: джерело не працює, а відмови немає", src) + } + if len(reason) < 40 { + // Відмова без пояснення — та сама мовчанка, тільки з кодом + // помилки. Людина має дізнатись, що робити натомість. + t.Errorf("%s: пояснення надто коротке: %q", src, reason) + } + } + // `trap` переїхав сюди з переліку вище: 0065 дала йому приймач, + // словник і перевірку умови. Тест лишається двобічним навмисно — + // джерело, яке працює, не має відмовляти, а джерело, якого немає, + // не має мовчати. + for _, src := range []string{"metric", "icmp", "interface", "syslog", "ncm", "compliance", "trap"} { + if r := UnsupportedSourceReason(src); r != "" { + t.Errorf("%s: працююче джерело відхилено: %s", src, r) + } + } +} + +func TestEventSourcesAreExactlyTheEventDriven(t *testing.T) { + for _, src := range []string{"syslog", "ncm", "compliance", "trap"} { + if !IsEventSource(src) { + t.Errorf("%s мало бути подієвим", src) + } + } + for _, src := range []string{"metric", "icmp", "interface"} { + if IsEventSource(src) { + t.Errorf("%s опитується, а не приходить подією", src) + } + } +} + +// Правило на журнал без зразка підпало б під кожен рядок — тобто дало б +// алерт на все підряд у першу ж хвилину. +func TestSyslogRuleNeedsPattern(t *testing.T) { + if err := ValidateRuleCondition("syslog", []byte(`{}`)); err == nil { + t.Fatal("правило без зразка мало бути відхилене") + } else if !errors.Is(err, ErrInvalid) { + t.Fatalf("очікували ErrInvalid, отримали %v", err) + } +} + +// Про друкарську помилку в регулярному виразі людина має дізнатись із +// форми, а не з тригера, який рік мовчав. +func TestSyslogRuleRejectsBrokenPattern(t *testing.T) { + err := ValidateRuleCondition("syslog", []byte(`{"regex":"%LINK-3-(UPDOWN"}`)) + if err == nil { + t.Fatal("незакрита дужка мала бути помічена") + } + if !strings.Contains(err.Error(), "компілю") { + t.Errorf("пояснення має вказувати на зразок: %v", err) + } +} + +func TestSyslogSeverityBounds(t *testing.T) { + if err := ValidateRuleCondition("syslog", []byte(`{"regex":"x","severity_lte":4}`)); err != nil { + t.Fatalf("рівень 4 припустимий: %v", err) + } + if err := ValidateRuleCondition("syslog", []byte(`{"regex":"x","severity_lte":9}`)); err == nil { + t.Fatal("рівнів syslog більше семи не буває") + } +} + +// Тригер на конфіг мусить сказати, ЯКУ саме подію ловить: «змінився» і +// «не зібрався» — новини для різних людей. +func TestNcmRuleNeedsEvent(t *testing.T) { + if err := ValidateRuleCondition("ncm", []byte(`{}`)); err == nil { + t.Fatal("умова без події мала бути відхилена") + } + if err := ValidateRuleCondition("ncm", []byte(`{"event":"вигадана"}`)); err == nil { + t.Fatal("невідома подія мала бути відхилена") + } + for _, ev := range []string{"changed", "backup_failed"} { + if err := ValidateRuleCondition("ncm", []byte(`{"event":"`+ev+`"}`)); err != nil { + t.Errorf("%s: %v", ev, err) + } + } +} + +// Відповідність — єдине подієве джерело, де порожня умова осмислена: +// «скажи мені про будь-яке порушення». +func TestComplianceRuleAllowsEmptyCondition(t *testing.T) { + if err := ValidateRuleCondition("compliance", []byte(`{}`)); err != nil { + t.Fatalf("порожня умова мала пройти: %v", err) + } + if err := ValidateRuleCondition("compliance", []byte(`{"min_severity":"страшна"}`)); err == nil { + t.Fatal("невідома серйозність мала бути відхилена") + } +} + +// Опитувані джерела через цю перевірку проходять без змін: їхню умову +// розбирає движок, і дублювати тут його whitelist означало б розійтися +// з ним на першій же правці. +func TestPolledSourcesPassValidationUntouched(t *testing.T) { + if err := ValidateRuleCondition("icmp", []byte(`{"metric":"loss_pct","op":">","value":20}`)); err != nil { + t.Fatalf("метричну умову тут не перевіряють: %v", err) + } +} + +// Ключ подієвого алерту має збігатися з ключем метричного для тієї ж +// пари: два формати рано чи пізно дали б два алерти про одне. +func TestEventDedupKeyMatchesCandidateKey(t *testing.T) { + c := Candidate{DeviceID: "d1"} + if EventDedupKey("r1", "d1") != c.DedupKey("r1") { + t.Fatalf("ключі розійшлись: %q проти %q", EventDedupKey("r1", "d1"), c.DedupKey("r1")) + } +} + +// --------------------------------------------------------------------- +// Трапи +// --------------------------------------------------------------------- + +// Правило без жодного звуження підпало б під КОЖЕН трап у мережі — і +// перший же linkUp кожного порту навчив би людину не читати сповіщення. +func TestTrapRuleNeedsSomethingToMatch(t *testing.T) { + err := ValidateRuleCondition("trap", []byte(`{}`)) + if err == nil { + t.Fatal("умова без OID і без адреси мала бути відхилена") + } + if !errors.Is(err, ErrInvalid) { + t.Fatalf("очікували ErrInvalid, отримали %v", err) + } +} + +// Дві форми звуження, і кожної окремо достатньо: «цей трап звідусіль» і +// «будь-який трап від цієї адреси» — обидва питання осмислені. +func TestTrapRuleAcceptsOIDOrSource(t *testing.T) { + for _, cond := range []string{ + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3"}`, + `{"trap_oid":".1.3.6.1.6.3.1.1.5.3"}`, + `{"source_ip":"10.20.0.0/24"}`, + `{"source_ip":"10.20.0.7"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_oid":"1.3.6.1.2.1.2.2.1.8","varbind_value":"2"}`, + // Varbind без значення — «щоб такий взагалі був». Теж питання. + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_oid":"1.3.6.1.2.1.2.2.1.1"}`, + } { + if err := ValidateRuleCondition("trap", []byte(cond)); err != nil { + t.Errorf("%s: %v", cond, err) + } + } +} + +// Описка в OID має бути помічена у формі, а не через півроку тиші. +func TestTrapRuleRejectsBrokenOID(t *testing.T) { + for _, cond := range []string{ + `{"trap_oid":"linkDown"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5,3"}`, + `{"trap_oid":"1..3.6"}`, + `{"source_ip":"10.20.0.300"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_oid":"ifIndex"}`, + } { + if err := ValidateRuleCondition("trap", []byte(cond)); err == nil { + t.Errorf("%s: описку не помічено", cond) + } + } +} + +// Значення varbind-а без OID varbind-а порівнювати ніде: у трапі їх +// десяток. Мовчазне ігнорування дало б умову, яка виглядає точною й не +// є такою. +func TestTrapRuleRejectsValueWithoutOID(t *testing.T) { + err := ValidateRuleCondition("trap", []byte(`{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_value":"2"}`)) + if err == nil { + t.Fatal("значення без OID varbind-а мало бути відхилене") + } + if !strings.Contains(err.Error(), "1.3.6.1.2.1.2.2.1.1") { + // Відмова має казати, що робити далі, а не лише «ні». + t.Errorf("у поясненні немає прикладу: %v", err) + } +} + +// Поля чужого джерела в умові — не дрібниця: людина, яка переключила +// джерело з «Syslog» на «Трапи», має дізнатися, що зразок більше не +// діє. Мовчазне ігнорування дало б правило, яке ловить УСЕ. +func TestTrapRuleRefusesSyslogLeftovers(t *testing.T) { + for _, cond := range []string{ + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","regex":"%LINK-3-UPDOWN"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","severity_lte":4}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","tag":"LINK"}`, + } { + err := ValidateRuleCondition("trap", []byte(cond)) + if err == nil { + t.Errorf("%s: залишок від syslog проковтнуто мовчки", cond) + continue + } + if len(err.Error()) < 60 { + t.Errorf("%s: відмова без пояснення: %v", cond, err) + } + } +} + +// Алерт про трап від адреси, яку не впізнали, не має зливатися з +// алертами про інших незнайомців: інакше на дошці буде один рядок +// «щось у мережі шле трапи», з яким нічого не зробиш. +func TestTrapDedupKeySeparatesUnknownSenders(t *testing.T) { + if got := TrapDedupKey("r1", "d1", "10.0.0.1"); got != EventDedupKey("r1", "d1") { + t.Fatalf("для відомого хоста ключ має бути звичайним: %q", got) + } + a := TrapDedupKey("r1", "", "10.0.0.1") + b := TrapDedupKey("r1", "", "10.0.0.2") + if a == b { + t.Fatalf("двоє незнайомців злилися в один алерт: %q", a) + } + if a == EventDedupKey("r1", "") { + t.Fatalf("ключ без хоста збігся з ключем «хост порожній»: %q", a) + } +} diff --git a/server/internal/store/alerts_query.go b/server/internal/store/alerts_query.go index 7699370..0cac4c1 100644 --- a/server/internal/store/alerts_query.go +++ b/server/internal/store/alerts_query.go @@ -53,11 +53,21 @@ func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter) a.dedup_key, a.value, a.threshold, a.context::text, COALESCE(a.root_alert_id::text,''), COALESCE(a.suppressed_by,''), a.started_at, a.acked_at, COALESCE(u.email::text,''), - COALESCE(a.ack_comment,''), a.resolved_at, a.last_seen_at, a.notify_count + COALESCE(a.ack_comment,''), a.resolved_at, a.last_seen_at, + a.notify_count, a.event_count, + COALESCE(ep.name,''), COALESCE(e.step_idx,0), COALESCE(e.repeat_idx,0), + COALESCE(jsonb_array_length(ep.steps),0), + e.next_at, e.stopped_at, COALESCE(e.stop_reason,''), + (e.alert_id IS NOT NULL) FROM alr.alerts a LEFT JOIN alr.rules r ON r.id = a.rule_id LEFT JOIN inv.devices d ON d.id = a.device_id LEFT JOIN core.users u ON u.id = a.acked_by + -- Стан драбини ескалації. LEFT JOIN, бо ескалація типово + -- вимкнена: більшість алертів рядка тут не має, і це + -- нормальний стан, а не відсутність даних. + LEFT JOIN alr.alert_escalations e ON e.alert_id = a.id + LEFT JOIN alr.escalation_policies ep ON ep.id = e.policy_id WHERE a.tenant_id = $1 AND a.state::text = ANY($2::text[]) AND ($3::uuid IS NULL OR a.device_id = $3::uuid) @@ -85,17 +95,25 @@ func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter) for rows.Next() { var a Alert var ctxJSON string + var esc AlertEscalation + var hasEsc bool if err := rows.Scan(&a.ID, &a.RuleID, &a.RuleName, &a.DeviceID, &a.DeviceName, &a.InterfaceID, &a.Severity, &a.State, &a.Title, &a.Message, &a.DedupKey, &a.Value, &a.Threshold, &ctxJSON, &a.RootAlertID, &a.SuppressedBy, &a.StartedAt, &a.AckedAt, &a.AckedByEmail, &a.AckComment, &a.ResolvedAt, &a.LastSeenAt, - &a.NotifyCount); err != nil { + &a.NotifyCount, &a.EventCount, + &esc.PolicyName, &esc.Step, &esc.Repeat, &esc.Total, + &esc.NextAt, &esc.StoppedAt, &esc.StopReason, &hasEsc); err != nil { return err } if minRank >= 0 && severityRank[a.Severity] < minRank { continue } + if hasEsc { + e := esc + a.Escalation = &e + } a.Context = []byte(ctxJSON) out = append(out, a) } @@ -156,6 +174,16 @@ func (s *Store) AckAlert(ctx context.Context, tenantID, alertID, userID, comment return err } + // Драбина зупиняється тут же, у тій самій транзакції: людина + // натиснула «Прийняти» рівно для цього, і побачити «ескалацію + // зупинено» вона має негайно, а не за півхвилини. Безпеку при + // цьому тримає не цей рядок, а перевірка стану перед кожною + // сходинкою — див. PlanEscalation. + if err := stopEscalationTx(ctx, tx, tenantID, a.ID, "acked", + "алерт підтверджено людиною"); err != nil { + return err + } + _, err = tx.Exec(ctx, ` INSERT INTO core.event_outbox (tenant_id, topic, payload) VALUES ($1, 'alert.acked', jsonb_build_object('alert_id', $2::text)) @@ -184,6 +212,11 @@ func (s *Store) CloseAlert(ctx context.Context, tenantID, alertID string) error if tag.RowsAffected() == 0 { return ErrAlertNotFound } + // Закритий алерт не має воскресати сходинкою ескалації. + if err := stopEscalationTx(ctx, tx, tenantID, alertID, "closed", + "алерт закрито людиною"); err != nil { + return err + } _, err = tx.Exec(ctx, ` INSERT INTO core.event_outbox (tenant_id, topic, payload) VALUES ($1, 'alert.resolved', jsonb_build_object('alert_id', $2::text)) @@ -192,6 +225,19 @@ func (s *Store) CloseAlert(ctx context.Context, tenantID, alertID string) error }) } +// MaxMute — стеля ручного заглушення. +// +// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути +// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень +// достатньо для будь-яких робіт і замало, щоб забути назовсім. +// +// Живе в шарі store, а не в обробнику HTTP, бо кнопку «Заглушити» +// натискають уже з двох місць — з інтерфейсу й з Telegram. Стеля, +// продубльована в другому місці, розійшлася б із першою на наступній +// же правці, і найгірше в цьому те, що розбіжність було б видно лише +// тому, хто відкриє обидва файли поруч. +const MaxMute = 7 * 24 * time.Hour + // MuteDevice глушить пристрій на визначений час. func (s *Store) MuteDevice(ctx context.Context, tenantID, deviceID, userID, reason string, until time.Time) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { @@ -203,6 +249,94 @@ func (s *Store) MuteDevice(ctx context.Context, tenantID, deviceID, userID, reas }) } +// ActiveMute каже, чи пристрій уже заглушений і до котрої. +// +// Потрібне не для показу, а для ідемпотентності: кнопка в Telegram +// приходить від пальця, а палець натискає двічі — і мережа теж уміє +// доставити те саме натискання повторно. Без цієї перевірки друге +// натискання клало б другий ряд у alr.mutes, і «заглушено на годину» +// тихо перетворювалось би на дві години. +func (s *Store) ActiveMute(ctx context.Context, tenantID, deviceID string) (time.Time, bool, error) { + var until time.Time + var found bool + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT until FROM alr.mutes + WHERE tenant_id = $1 AND device_id = $2 AND until > now() + ORDER BY until DESC LIMIT 1 + `, tenantID, deviceID).Scan(&until) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + found = true + return nil + }) + return until, found, err +} + +// AlertAck — стан підтвердження алерту. +// +// Окреме читання перед AckAlert, а не після: до підтвердження треба +// знати пристрій (щоб звірити його з доступом людини) і поточний стан +// (щоб повторне натискання відповіло «вже підтверджено Іваном о 03:12», +// а не помилкою «алерт не знайдено» — з телефона друге читається як +// поломка). +type AlertAck struct { + ID string + DeviceID string + DeviceName string + Title string + State string + AckedAt *time.Time + AckedByEmail string +} + +// AlertAckState читає алерт разом із тим, хто його вже підтвердив. +func (s *Store) AlertAckState(ctx context.Context, tenantID, alertID string) (AlertAck, error) { + var a AlertAck + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT a.id::text, COALESCE(a.device_id::text,''), COALESCE(d.name,''), + a.title, a.state::text, a.acked_at, COALESCE(u.email::text,'') + FROM alr.alerts a + LEFT JOIN inv.devices d ON d.id = a.device_id + LEFT JOIN core.users u ON u.id = a.acked_by + WHERE a.tenant_id = $1 AND a.id = $2 + `, tenantID, alertID).Scan(&a.ID, &a.DeviceID, &a.DeviceName, + &a.Title, &a.State, &a.AckedAt, &a.AckedByEmail) + if errors.Is(err, pgx.ErrNoRows) { + return ErrAlertNotFound + } + return err + }) + return a, err +} + +// DeviceNameInTenant повертає ім'я хоста, якщо він належить кабінету. +// +// Зовнішній ключ alr.mutes.device_id перевіряє лише те, що хост існує +// десь у базі, — не те, що він у цьому кабінеті. Для запиту з UI цього +// досить, бо там ідентифікатор уже пройшов через scope; для натискання +// з Telegram — ні: у callback_data лежить рівно те, що ми туди +// поклали, але прислати його може будь-хто, хто бачив формат. +func (s *Store) DeviceNameInTenant(ctx context.Context, tenantID, deviceID string) (string, error) { + var name string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT name FROM inv.devices + WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL + `, tenantID, deviceID).Scan(&name) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + }) + return name, err +} + // --------------------------------------------------------------------- // Правила: CRUD для UI // --------------------------------------------------------------------- @@ -221,6 +355,13 @@ type RuleInput struct { ChannelIDs []string NotifySchedule string NotifyOnResolve bool + // Тільки для подієвих джерел: через скільки алерт гасне сам (0 — + // тільки руками) і як рідко можна доливати в нього події. + AutoCloseSeconds int + MinIntervalSeconds int + // Драбина ескалації. Порожньо — без ескалації, і це типове + // значення: оновлення не має раптом почати будити людей. + EscalationPolicyID string } func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]any, error) { @@ -232,11 +373,14 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an r.for_seconds, r.depends_on_topology, r.enabled, r.channel_ids::text[], COALESCE(r.notify_schedule::text,''), r.notify_on_resolve, + r.auto_close_seconds, r.min_interval_seconds, + COALESCE(r.escalation_policy_id::text,''), COALESCE(ep.name,''), (SELECT count(*) FROM alr.alerts a WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged')), COALESCE(r.template_id::text,''), COALESCE(t.name,'') FROM alr.rules r LEFT JOIN tpl.templates t ON t.id = r.template_id + LEFT JOIN alr.escalation_policies ep ON ep.id = r.escalation_policy_id WHERE r.tenant_id = $1 ORDER BY r.name `, tenantID) @@ -246,12 +390,13 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an defer rows.Close() for rows.Next() { var id, name, desc, source, sev, sel, cond, sched string - var tplID, tplName string - var forSec, active int + var tplID, tplName, escID, escName string + var forSec, active, autoClose, minInterval int var topo, enabled, onResolve bool var channelIDs []string if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond, &forSec, &topo, &enabled, &channelIDs, &sched, &onResolve, + &autoClose, &minInterval, &escID, &escName, &active, &tplID, &tplName); err != nil { return err } @@ -264,7 +409,12 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an "for_seconds": forSec, "depends_on_topology": topo, "enabled": enabled, "active_alerts": active, "channel_ids": channelIDs, "notify_on_resolve": onResolve, - "notify_schedule": rawJSON(sched), + "notify_schedule": rawJSON(sched), + "auto_close_seconds": autoClose, "min_interval_seconds": minInterval, + // Порожньо — без ескалації. Ім'я віддаємо поруч із + // ідентифікатором, щоб перелік правил не мусив другим + // запитом з'ясовувати, як називається драбина. + "escalation_policy_id": escID, "escalation_policy_name": escName, // Непорожній шаблон означає, що правило породжене його // тригером: у списку його видно, але правлять його там, // де воно описане один раз на клас пристроїв. @@ -283,14 +433,16 @@ func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in Rule INSERT INTO alr.rules (tenant_id, name, description, source, severity, selector, condition, for_seconds, depends_on_topology, enabled, created_by, - channel_ids, notify_schedule, notify_on_resolve) + channel_ids, notify_schedule, notify_on_resolve, + auto_close_seconds, min_interval_seconds, escalation_policy_id) VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,$7::jsonb, - $8,$9,$10,$11,$12::uuid[],NULLIF($13,'')::jsonb,$14) + $8,$9,$10,$11,$12::uuid[],NULLIF($13,'')::jsonb,$14,$15,$16,$17) RETURNING id::text `, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity, in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology, in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule, - in.NotifyOnResolve).Scan(&id) + in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds, + nullUUID(in.EscalationPolicyID)).Scan(&id) }) return id, err } @@ -383,12 +535,16 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule channel_ids = $12::uuid[], notify_schedule = NULLIF($13,'')::jsonb, notify_on_resolve = $14, + auto_close_seconds = $15, + min_interval_seconds = $16, + escalation_policy_id = $17, updated_at = now() WHERE id = $1 AND tenant_id = $2 `, ruleID, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity, in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology, in.Enabled, in.ChannelIDs, in.NotifySchedule, - in.NotifyOnResolve) + in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds, + nullUUID(in.EscalationPolicyID)) if err != nil { return err } diff --git a/server/internal/store/alerts_state.go b/server/internal/store/alerts_state.go index a66953a..6c1e4e0 100644 --- a/server/internal/store/alerts_state.go +++ b/server/internal/store/alerts_state.go @@ -34,6 +34,17 @@ type Alert struct { ResolvedAt *time.Time `json:"resolved_at,omitempty"` LastSeenAt time.Time `json:"last_seen_at"` NotifyCount int `json:"notify_count"` + // Скільки подій злилося в цей алерт. Для метричних завжди 1: + // вимір не «стається», він просто триває. + EventCount int `json:"event_count,omitempty"` + // Стан драбини ескалації; порожньо — ескалації в цього алерту немає. + // Без цього поля людина, підняту о третій ночі, не має де побачити, + // звідки взявся дзвінок і коли буде наступний. + Escalation *AlertEscalation `json:"escalation,omitempty"` + // Тенант потрібен лише там, де алерти читаються наскрізь по всіх + // кабінетах (черга розсилки подієвих) — у решті місць він відомий + // із контексту запиту. + TenantID string `json:"-"` } // FiredAlert — результат підняття. @@ -167,7 +178,7 @@ func (s *Store) ResolveMissing(ctx context.Context, tenantID, ruleID string, kee // у тій самій таблиці зробила б кожен із цих запитів дорожчим без // жодної користі. func (s *Store) ArchiveResolved(ctx context.Context, olderThan time.Duration) (int64, error) { - tag, err := s.pool.Exec(ctx, ` + tag, err := s.bg.Exec(ctx, ` WITH moved AS ( DELETE FROM alr.alerts WHERE state IN ('resolved','expired') diff --git a/server/internal/store/apitokens.go b/server/internal/store/apitokens.go index fc72056..d809a04 100644 --- a/server/internal/store/apitokens.go +++ b/server/internal/store/apitokens.go @@ -52,7 +52,7 @@ func (s *Store) AuthenticateAPIToken(ctx context.Context, token string) (*APITok revokedAt *time.Time ) - err := s.pool.QueryRow(ctx, ` + err := s.bg.QueryRow(ctx, ` SELECT id::text, tenant_id::text, name, scopes, token_hash, expires_at, revoked_at FROM core.api_tokens WHERE token_hash = $1 @@ -79,7 +79,7 @@ func (s *Store) AuthenticateAPIToken(ctx context.Context, token string) (*APITok go func() { bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() - _, _ = s.pool.Exec(bg, + _, _ = s.bg.Exec(bg, `UPDATE core.api_tokens SET last_used_at = now() WHERE id = $1`, t.ID) }() diff --git a/server/internal/store/audit.go b/server/internal/store/audit.go new file mode 100644 index 0000000..032a87f --- /dev/null +++ b/server/internal/store/audit.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "encoding/json" + "net" +) + +// Журнал аудиту. +// +// Таблиця core.audit_log існує з першої міграції, але доти в неї ніхто +// не писав: усі дії, які встигли з'явитись, або оборотні (правка мапи, +// перейменування хоста), або й так видимі в самому об'єкті. Масове +// виконання команд — перша дія, яка нічого по собі не лишає: команда +// пішла, пристрій відповів, слід є тільки тут. +// +// Через це запис аудиту тут не «на всяк випадок», а частина функції: +// без нього питання «хто о третій ночі прогнав це по дільниці» не має +// відповіді взагалі. + +// AuditEntry — один рядок журналу. +// +// Actor окремими полями, а не одним «хто»: за токеном інтеграції немає +// людини, і зводити їх до рядка означало б втратити різницю між +// «інженер натиснув кнопку» і «скрипт ходить нашим API». +type AuditEntry struct { + ActorUserID string + ActorTokenID string + ActorIP net.IP + + // Дія у вигляді «об'єкт.дія»: ncm.command_run.create. + Action string + ObjectType string + ObjectID string + + // Meta несе те, що робить рядок зрозумілим без доступу до решти + // таблиць: самі команди, кількість хостів, їхні імена. Через рік + // хостів може вже не бути, а питання лишиться. + Meta map[string]any +} + +// WriteAudit кладе подію в журнал. +// +// Пише в пул напряму, а не в тенантній транзакції: core.audit_log — +// гіпертаблиця, RLS на ній вимкнено (див. 0011), тому ізоляцію тримає +// сам предикат tenant_id у запитах читання. +func (s *Store) WriteAudit(ctx context.Context, tenantID string, e AuditEntry) error { + meta := []byte("{}") + if len(e.Meta) > 0 { + b, err := json.Marshal(e.Meta) + if err != nil { + return err + } + meta = b + } + + var ip any + if e.ActorIP != nil { + ip = e.ActorIP.String() + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO core.audit_log + (tenant_id, actor_user_id, actor_token_id, actor_ip, + action, object_type, object_id, meta) + VALUES ($1, $2, $3, $4::inet, $5, $6, $7, $8::jsonb) + `, tenantID, nullUUID(e.ActorUserID), nullUUID(e.ActorTokenID), ip, + e.Action, nullString(e.ObjectType), nullUUID(e.ObjectID), string(meta)) + return err +} diff --git a/server/internal/store/audit_actions.go b/server/internal/store/audit_actions.go new file mode 100644 index 0000000..acdeb48 --- /dev/null +++ b/server/internal/store/audit_actions.go @@ -0,0 +1,349 @@ +package store + +import "strings" + +// Словник дій журналу аудиту. +// +// Навіщо він узагалі. Ключ `ncm.command_run.delete` читається лише тим, +// хто його писав; людина, яка шукає, хто стер результати прогону, +// шукає «видалення». Але сам ключ прибирати не можна: за ним фільтрують, +// його шлють у підтримку, за ним шукають у логах сервера. Тому в +// інтерфейсі є обидва — фраза великим, ключ поруч дрібним. +// +// Словник живе тут, а не в браузері, з однієї причини: перелік дій — це +// властивість того, що продукт ЗАПИСУЄ, а не того, як це показано. +// Наступний, хто додасть виклик WriteAudit, дописує рядок сюди, поруч +// із рештою, — і сторінка, вивантаження й будь-що майбутнє отримують +// однакову назву. Копія словника в TypeScript розійшлася б із цією на +// першому ж новому рядку, і розбіжність було б видно лише тому, хто +// відкриє обидва файли поруч. +// +// Ключа, якого тут немає, це не ламає: подія показується самим лише +// ключем. Журнал старший за словник — у ньому лежать дії збірок, яких +// уже немає, і мовчки ховати їх було б гірше, ніж показати як є. +// +// ЧОМУ КЛЮЧІ — КОНСТАНТИ, А НЕ РЯДКИ ПО МІСЦЯХ ВИКЛИКУ +// +// Прохання «не забудь дописати рядок сюди» цей файл уже програв: за +// півроку в журнал почали писати дзеркало Git, ролі й прив'язку хоста +// до зонда — жодна з цих дій до словника не потрапила, і адміністратор +// бачив у переліку сирі ключі. Помітити це неможливо ніяк, крім як +// відкрити журнал і впізнати відсутню назву. +// +// Тому ключ тепер має рівно одне місце оголошення — константу нижче, — +// а поруч стоїть тест (audit_actions_source_test.go), який читає ВЕСЬ +// server/internal, знаходить кожне присвоєння AuditEntry.Action і падає +// на двох речах: ключ не оголошено константою тут або в константи немає +// назви у словнику. Тобто наступна нова дія не має способу потрапити в +// журнал безіменною: збірка не пройде. + +// Ключі дій. Значення — те, що лягає в core.audit_log.action; змінювати +// їх заднім числом не можна, бо в журналі вже лежать рядки зі старим +// значенням, а журнал не переписують. +const ( + AuditActionDeviceBulkUpdate = "inv.device.bulk_update" + AuditActionDeviceBulkDelete = "inv.device.bulk_delete" + AuditActionDeviceBulkPurge = "inv.device.bulk_purge" + AuditActionDeviceBulkRestore = "inv.device.bulk_restore" + AuditActionDeviceSelfAgent = "inv.device.self_agent" + AuditActionDeviceSelfAgentUn = "inv.device.self_agent_clear" + + AuditActionCommandRunCreate = "ncm.command_run.create" + AuditActionCommandRunCancel = "ncm.command_run.cancel" + AuditActionCommandRunDelete = "ncm.command_run.delete" + AuditActionCommandRunReport = "ncm.command_run.report" + + AuditActionRollbackCreate = "ncm.rollback.create" + AuditActionRollbackApprove = "ncm.rollback.approve" + AuditActionRollbackReject = "ncm.rollback.reject" + AuditActionRollbackPolicy = "ncm.rollback_policy.update" + + AuditActionConfigDelete = "ncm.config.delete" + AuditActionRetentionUpdate = "ncm.retention.update" + AuditActionMirrorUpdate = "ncm.mirror.update" + AuditActionMirrorPush = "ncm.mirror.push" + + AuditActionRoleCreate = "core.role.create" + AuditActionRoleUpdate = "core.role.update" + AuditActionRoleDelete = "core.role.delete" + + AuditActionTelegramLink = "core.telegram.link" + AuditActionTelegramUnlink = "core.telegram.unlink" + + AuditActionRetentionSettings = "core.retention.update" +) + +// Ключі типів об'єктів — те, НАД ЧИМ зроблено дію. +const ( + AuditObjectDevice = "inv.device" + AuditObjectCommandRun = "ncm.command_run" + AuditObjectRollback = "ncm.rollback" + AuditObjectRollbackPolicy = "ncm.rollback_policy" + AuditObjectConfig = "ncm.config" + AuditObjectBackupDefaults = "ncm.backup_defaults" + AuditObjectMirror = "ncm.mirror" + AuditObjectRole = "core.role" + AuditObjectTelegram = "core.telegram_account" + AuditObjectRetention = "core.retention_settings" +) + +// AuditActionInfo — те, що словник знає про дію. +type AuditActionInfo struct { + Key string `json:"key"` + Label string `json:"label"` + Group string `json:"group"` + // Destructive — після цієї дії об'єкта більше немає. Не «важлива» + // й не «небезпечна»: важливість суб'єктивна, а «щось зникло» — + // факт, і саме за ним журнал переглядають найчастіше. + Destructive bool `json:"destructive,omitempty"` +} + +// auditActions — усе, що продукт уміє записувати СЬОГОДНІ. +// +// Перелік і є вся правда про покриття: аудит пишуть масові дії над +// хостами, виконання команд, руйнівні дії над архівом конфігів, +// налаштування дзеркала Git і зміни складу ролей. Решта продукту в +// журнал не пише нічого — див. AuditBlindSpots. +var auditActions = []AuditActionInfo{ + { + Key: AuditActionDeviceBulkUpdate, Group: "Інвентар", + Label: "Масова правка хостів", + }, + { + Key: AuditActionDeviceBulkDelete, Group: "Інвентар", + // Назва навмисно широка. Досі під цим ключем писались ОБИДВА + // режими видалення — і архівний, і повний (режим лежав у + // meta.mode), тож у журналі за ним стоять і ті, й ті рядки. + // Звузити назву до «архівування» означало б перейменувати + // заднім числом чужі події, яких ніхто вже не перевірить. + Label: "Масове видалення хостів", Destructive: true, + }, + { + Key: AuditActionDeviceBulkPurge, Group: "Інвентар", + Label: "Повне видалення хостів разом з історією", Destructive: true, + }, + { + Key: AuditActionDeviceBulkRestore, Group: "Інвентар", + Label: "Відновлення хостів з архіву", + }, + { + Key: AuditActionDeviceSelfAgent, Group: "Інвентар", + Label: "Прив'язка хоста до машини зонда", + }, + { + Key: AuditActionDeviceSelfAgentUn, Group: "Інвентар", + Label: "Зняття прив'язки хоста до машини зонда", + }, + { + Key: AuditActionCommandRunCreate, Group: "Команди", + Label: "Запуск команд на обладнанні", + }, + { + Key: AuditActionCommandRunCancel, Group: "Команди", + Label: "Зупинка прогону команд", + }, + { + Key: AuditActionCommandRunDelete, Group: "Команди", + Label: "Видалення прогону разом із виводом", Destructive: true, + }, + { + Key: AuditActionCommandRunReport, Group: "Команди", + Label: "Вивантаження звіту про прогін", + }, + { + Key: AuditActionRollbackCreate, Group: "Конфігурації", + // Створення наміру, а не сама заливка. Різниця важлива: намір + // може так і не поїхати на пристрій — його відхилять або він + // застаріє. Те, що на залізо справді писали, видно в самому + // відкаті (стани applying → verifying → applied), і дублювати + // це рядком у журналі означало б мати два джерела правди про + // подію, яку найгірше знати неточно. + Label: "Намір відкотити конфіг на пристрої", + }, + { + Key: AuditActionRollbackApprove, Group: "Конфігурації", + // Найважливіший рядок розділу. Відкат за політикою погоджує + // ДРУГА людина, і питання «хто дозволив залити старий конфіг на + // магістральний вузол» має мати відповідь з іменем і часом — + // незалежно від того, чи вцілів сам намір у базі. + Label: "Погодження відкату конфігу", + }, + { + Key: AuditActionRollbackReject, Group: "Конфігурації", + Label: "Відмова у відкаті конфігу", + }, + { + Key: AuditActionRollbackPolicy, Group: "Конфігурації", + // Зміна політики нічого не ламає в мить збереження — вона + // змінює правила для всього, що станеться далі. Саме тому + // рядок тут: «вимогу другої людини вимкнули за годину до + // аварії» інакше не з'ясувати ніяк. + Label: "Зміна правил погодження відкату", + }, + { + Key: AuditActionConfigDelete, Group: "Конфігурації", + Label: "Видалення збережених версій конфігів", Destructive: true, + }, + { + Key: AuditActionRetentionUpdate, Group: "Конфігурації", + Label: "Зміна політики очистки конфігів", + }, + { + Key: AuditActionMirrorUpdate, Group: "Конфігурації", + // Одна назва на три дії, і це не спрощення: налаштування + // дзеркала, видача ключа розгортання й вимкнення дзеркала + // міняють той самий об'єкт і той самий рядок налаштувань. + // Що саме змінилось, видно в meta (url, auth, deploy_key), а + // три окремі рядки у фільтрі означали б три способи спитати + // одне питання. + Label: "Налаштування дзеркала Git", + }, + { + Key: AuditActionMirrorPush, Group: "Конфігурації", + Label: "Примусовий пуш архіву на дзеркало", + }, + { + Key: AuditActionRoleCreate, Group: "Адміністрування", + Label: "Створення ролі", + }, + { + Key: AuditActionRoleUpdate, Group: "Адміністрування", + Label: "Зміна прав ролі", + }, + { + Key: AuditActionRoleDelete, Group: "Адміністрування", + Label: "Видалення ролі", Destructive: true, + }, + { + Key: AuditActionTelegramLink, Group: "Адміністрування", + // Прив'язка не видає нових прав, але дає новий СПОСІБ ними + // скористатися — з телефона, без входу в систему. Питання «чому + // алерт підтверджено о третій ночі акаунтом, який тоді нікуди + // не заходив» без цього рядка відповіді не має. + Label: "Прив'язка Telegram до облікового запису", + }, + { + Key: AuditActionTelegramUnlink, Group: "Адміністрування", + Label: "Зняття прив'язки Telegram", + }, + { + Key: AuditActionRetentionSettings, Group: "Адміністрування", + // Destructive не ставимо, і це не недогляд. Сама зміна нічого + // не стирає — стирає її наслідок, політика, яка вночі знесе + // чанки. Позначка «об'єкта більше немає» тут була б неправдою + // про мить події. Правду про наслідок несе meta: для кожного + // виду даних там стоїть shortened, тобто «строк скоротили», і + // саме за цим полем шукатимуть того, після кого зникла історія. + Label: "Зміна строків зберігання даних", + }, +} + +var auditActionByKey = func() map[string]AuditActionInfo { + m := make(map[string]AuditActionInfo, len(auditActions)) + for _, a := range auditActions { + m[a.Key] = a + } + return m +}() + +// AuditActions — словник для фільтра на сторінці. +func AuditActions() []AuditActionInfo { return auditActions } + +// auditObjectTypes — назви типів об'єктів. +// +// Окремо від дій, бо це різні питання: дія — що зробили, тип — над чим. +// Один тип зачіпають кілька дій, і зводити їх в одну таблицю означало б +// повторювати назву об'єкта в кожному рядку. +// +// Перелік, а не мапа: порядок тут значущий — типи йдуть у тому ж +// порядку, у якому людина зустрічає їх у фільтрі дій, а мапа порядку не +// має. Раніше поруч лежав окремий список `order`, і будь-який новий тип +// мовчки не потрапляв у фільтр доти, доки його не допишуть удруге. +var auditObjectTypes = []AuditActionInfo{ + {Key: AuditObjectDevice, Label: "Хост"}, + {Key: AuditObjectCommandRun, Label: "Прогін команд"}, + {Key: AuditObjectRollback, Label: "Відкат конфігу"}, + {Key: AuditObjectRollbackPolicy, Label: "Правила погодження відкату"}, + {Key: AuditObjectConfig, Label: "Версія конфігу"}, + {Key: AuditObjectBackupDefaults, Label: "Налаштування бекапів"}, + {Key: AuditObjectMirror, Label: "Дзеркало Git"}, + {Key: AuditObjectRole, Label: "Роль"}, + {Key: AuditObjectTelegram, Label: "Прив'язка Telegram"}, + {Key: AuditObjectRetention, Label: "Строки зберігання даних"}, +} + +var auditObjectTypeByKey = func() map[string]string { + m := make(map[string]string, len(auditObjectTypes)) + for _, o := range auditObjectTypes { + m[o.Key] = o.Label + } + return m +}() + +// AuditObjectTypes — словник типів для фільтра. +func AuditObjectTypes() []AuditActionInfo { return auditObjectTypes } + +// decorateAuditEvent дописує до події те, що знає словник. +func decorateAuditEvent(e *AuditEvent) { + if a, ok := auditActionByKey[e.Action]; ok { + e.ActionLabel = a.Label + e.ActionGroup = a.Group + e.Destructive = a.Destructive + } else { + // Ключа немає в словнику. Не вигадуємо назву з ключа — назва, + // зібрана з крапок, читається як фраза, але означає лише те, що + // хтось назвав змінну. Порожня мітка чесніша: сторінка покаже + // ключ і позначить, що назви для нього немає. + e.ActionGroup = auditActionGroupFromKey(e.Action) + } + if l, ok := auditObjectTypeByKey[e.ObjectType]; ok { + e.ObjectTypeLabel = l + } +} + +// auditActionGroupFromKey — до якого розділу віднести незнайому дію. +// +// Це єдине, що з ключа справді виводиться: перший сегмент — схема бази, +// і вона не змінюється разом із формулюванням. +func auditActionGroupFromKey(key string) string { + head, _, _ := strings.Cut(key, ".") + switch head { + case "inv": + return "Інвентар" + case "ncm": + return "Конфігурації" + case "topo": + return "Топологія" + case "alr": + return "Сповіщення" + case "core": + return "Адміністрування" + default: + return "" + } +} + +// AuditBlindSpots — чесний перелік того, чого журнал НЕ бачить. +// +// Сторінка без цього блоку створює хибне відчуття повноти: «тут нічого +// немає» читається як «нічого не робили», хоча означає лише «це місце +// продукту в журнал не пише». Найдорожча помилка журналу — не +// неправильний запис, а відсутній: неправильний помітно, відсутнього +// немає з чим порівняти. +// +// Перелік складено від протилежного до auditActions: усе, що не +// перелічено там, не записується. Рядки тримаються поруч зі словником +// саме тому, що правити їх треба разом — новий виклик WriteAudit має +// одночасно з'явитись у словнику й зникнути звідси. +func AuditBlindSpots() []string { + return []string{ + "Вхід у систему, вихід і невдалі спроби входу. Спроби входу — і вдалі, і ні — лягають в окрему таблицю core.login_attempts, а не сюди: журнал аудиту вимагає tenant_id, а на момент перевірки пароля кабінет ще невідомий. Тобто цієї сторінки для питання «хто заходив» замало.", + "Поодинокі зміни хостів: створення, правка й видалення одного хоста в його картці. У журнал пишуть лише масові дії — а «видалив один хост» і «видалив сорок» відрізняються масштабом, не суттю. Виняток — повернення хоста з архіву й повне видалення: обидві йдуть масовим шляхом навіть для одного хоста, тож записуються завжди.", + "Склад команди: запрошення, зміна ролі людини, вилучення з кабінету. САМІ ролі — створення, зміна набору прав, видалення — з 0053 у журналі є (core.role.*), а от «кому цю роль видали» — ні. Тобто на питання «звідки в цієї людини такий доступ» журнал відповідає лише наполовину: що дозволяє роль, видно, хто в ній опинився — ні.", + "Доступи до обладнання: створення, правка й видалення облікових даних, а також те, кому їх призначили.", + "Мапи, групи, шаблони, правила алертів і канали сповіщень. Правка мапи оборотна й видима в самій мапі, але «хто вимкнув правило, за яким приходив алерт» звідси не видно.", + "Ручний запуск збору конфігу. Відкат конфігурації журнал бачить з 0060 — намір, погодження, відмову й зміну правил погодження, — а от «зібрати зараз» лишається поза ним: збір нічого не змінює на пристрої.", + "Читання. Журнал записує зміни, а не перегляди: те, що хтось відкрив чужий конфіг або вивантажив архів, тут не з'явиться — окрім вивантаження звіту про прогін команд.", + "Дії зондів і колектора: усе, що система робить сама за розкладом, журналом не покривається — це не дії людини, і їхнє місце в логах служб.", + } +} diff --git a/server/internal/store/audit_actions_source_test.go b/server/internal/store/audit_actions_source_test.go new file mode 100644 index 0000000..ee28861 --- /dev/null +++ b/server/internal/store/audit_actions_source_test.go @@ -0,0 +1,409 @@ +package store + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// Тест, який не дає новій дії потрапити в журнал безіменною. +// +// ЧОМУ ЦЕ НЕ ПЕРЕВІРИТИ ІНАКШЕ +// +// Словник auditActions — єдине місце, де ключ `ncm.mirror.push` +// перетворюється на «Примусовий пуш архіву на дзеркало». Дописати туди +// рядок нічого не коштує; проблема в тому, що ЗАБУТИ його теж нічого не +// коштує й нічим не проявляється: запис у журнал іде, сторінка +// відкривається, фільтр працює. Єдиний, хто побачить пропуск, — людина, +// яка через місяць читає журнал і бачить у ньому сирий ключ замість +// назви. Саме так у продукті й опинились без назв дзеркало Git, ролі та +// прив'язка хоста до зонда — усі три додали правильно, просто в іншому +// файлі. +// +// Звідси вимога до перевірки: вона має брати перелік ключів НЕ з +// окремого списку (той розійшовся б із кодом так само), а з самого +// коду — з тих місць, де ключ реально лягає в AuditEntry.Action. Тому +// тест розбирає дерево server/ як текст програми й дивиться на кожне +// таке присвоєння. +// +// ЩО САМЕ ВИМАГАЄТЬСЯ ВІД АВТОРА НОВОЇ ДІЇ +// +// 1. Ключ оголошено константою в audit_actions.go (AuditAction… / +// AuditObject…). Літерал у місці виклику тест вважає помилкою — не +// з любові до констант, а тому, що інакше ключ має два місця +// оголошення й розходяться вони мовчки. +// 2. У константи є рядок у словнику. +// +// Обидві вимоги — це одна дія автора: дописати константу поруч із +// назвою. Тест лише робить її обов'язковою. +// +// ЧОГО ТЕСТ НЕ ВИМАГАЄ +// +// Зворотного — щоб кожен рядок словника десь писався. Журнал старший за +// код: у ньому лежать дії збірок, яких уже немає, і назви до них мають +// лишатись, поки лежать самі рядки. + +// auditScanRoot — корінь розбору. Не лише internal/: дію в журнал може +// писати й службова команда з cmd/, і пропустити її було б рівно тією +// помилкою, від якої цей тест і стоїть. +const auditScanRoot = "../.." + +// auditKeyUse — одне місце в коді, де ключ лягає в AuditEntry. +type auditKeyUse struct { + pos string // файл:рядок + field string // Action або ObjectType + key string // саме значення ключа + literal bool // ключ написано рядком просто в місці виклику +} + +func TestAuditDictionaryCoversEveryWrittenKey(t *testing.T) { + fset := token.NewFileSet() + files := parseAuditScanTree(t, fset) + consts := auditConstValues(files) + + uses := collectAuditKeyUses(t, fset, files, consts) + + // Захист від «тест зелений, бо нічого не знайшов». Перейменують + // AuditEntry — і мовчазний нуль знахідок виглядав би як успіх. + var actions int + for _, u := range uses { + if u.field == "Action" { + actions++ + } + } + if actions < 10 { + t.Fatalf("розбір знайшов лише %d присвоєнь AuditEntry.Action — "+ + "так мало бути не може; найімовірніше, змінилась назва типу "+ + "або структура виклику, і перевірка перестала щось перевіряти", actions) + } + + for _, u := range uses { + if u.literal { + t.Errorf("%s: ключ %q написано рядком просто в місці виклику. "+ + "Заведіть константу в store/audit_actions.go — інакше ключ має "+ + "два місця оголошення, і словник розійдеться з кодом мовчки", + u.pos, u.key) + // Далі все одно перевіряємо: відсутня назва — окрема біда. + } + switch u.field { + case "Action": + if _, ok := auditActionByKey[u.key]; !ok { + t.Errorf("%s: дія %q пишеться в журнал, але назви для неї немає. "+ + "Допишіть рядок у auditActions (store/audit_actions.go) — "+ + "інакше адміністратор побачить у журналі сирий ключ", + u.pos, u.key) + } + case "ObjectType": + if _, ok := auditObjectTypeByKey[u.key]; !ok { + t.Errorf("%s: тип об'єкта %q пишеться в журнал, але назви для нього "+ + "немає. Допишіть рядок у auditObjectTypes (store/audit_actions.go)", + u.pos, u.key) + } + } + } +} + +// Константи без назви — та сама діра з іншого боку. +// +// Ключ, оголошений константою й ще нікуди не підставлений, потрапить у +// журнал наступним комітом, і на той момент про словник ніхто вже не +// згадає. Дешевше вимагати назву одразу. +func TestAuditConstantsHaveLabels(t *testing.T) { + fset := token.NewFileSet() + files := parseAuditScanTree(t, fset) + + for name, val := range auditConstValues(files) { + switch { + case strings.HasPrefix(name, "AuditObject"): + if _, ok := auditObjectTypeByKey[val]; !ok { + t.Errorf("константа %s = %q оголошена, але назви в auditObjectTypes немає", + name, val) + } + case strings.HasPrefix(name, "AuditAction"): + if _, ok := auditActionByKey[val]; !ok { + t.Errorf("константа %s = %q оголошена, але назви в auditActions немає", name, val) + } + } + } +} + +// Сам словник теж має бути справним: порожня назва або два рядки з +// однаковим ключем ламають фільтр на сторінці мовчки. +func TestAuditDictionaryWellFormed(t *testing.T) { + seen := map[string]bool{} + for _, a := range auditActions { + if a.Key == "" || a.Label == "" || a.Group == "" { + t.Errorf("дія %+v: ключ, назва й розділ обов'язкові", a) + } + if seen[a.Key] { + t.Errorf("ключ %q описано двічі", a.Key) + } + seen[a.Key] = true + // Звіряти розділ із тим, що виводиться з ключа + // (auditActionGroupFromKey), тут НЕ можна: «Команди» — + // повноцінний розділ фільтра, а ключ у прогонів команд + // починається з ncm, тобто з ключа виводиться «Конфігурації». + // Розбіжність тут навмисна: з ключа розділ виводять лише для + // дії, назви якої немає взагалі, і це запасний варіант, а не + // друге джерело правди. + } + + seen = map[string]bool{} + for _, o := range auditObjectTypes { + if o.Key == "" || o.Label == "" { + t.Errorf("тип об'єкта %+v: ключ і назва обов'язкові", o) + } + if seen[o.Key] { + t.Errorf("тип об'єкта %q описано двічі", o.Key) + } + seen[o.Key] = true + } +} + +// --------------------------------------------------------------------- +// Розбір дерева +// --------------------------------------------------------------------- + +func parseAuditScanTree(t *testing.T, fset *token.FileSet) []*ast.File { + t.Helper() + + var out []*ast.File + err := filepath.WalkDir(auditScanRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "node_modules", "webui", "migrations": + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + f, err := parser.ParseFile(fset, path, src, 0) + if err != nil { + return err + } + out = append(out, f) + return nil + }) + if err != nil { + t.Fatalf("розбір дерева %s: %v", auditScanRoot, err) + } + if len(out) == 0 { + t.Fatalf("у %s не знайдено жодного файлу .go", auditScanRoot) + } + return out +} + +// auditConstValues — усі рядкові константи AuditAction… / AuditObject… +// разом з їхніми значеннями. +func auditConstValues(files []*ast.File) map[string]string { + out := map[string]string{} + for _, f := range files { + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range vs.Names { + if i >= len(vs.Values) { + continue + } + if !strings.HasPrefix(name.Name, "AuditAction") && + !strings.HasPrefix(name.Name, "AuditObject") { + continue + } + bl, ok := vs.Values[i].(*ast.BasicLit) + if !ok || bl.Kind != token.STRING { + continue + } + v, err := strconv.Unquote(bl.Value) + if err != nil { + continue + } + out[name.Name] = v + } + } + } + } + return out +} + +// collectAuditKeyUses — усі місця, де в AuditEntry кладуть Action або +// ObjectType. +// +// Окремий випадок, заради якого тут узагалі є другий прохід: половина +// дій пишеться не напряму, а через невеликий помічник — +// auditRole(r, p, "core.role.create", …), auditSelfAgent(…). У самому +// виклику WriteAudit там стоїть параметр, а не ключ, і взяти значення +// з того рядка неможливо. Тому такі місця відкладаються, а потім +// шукаються виклики самого помічника — саме там ключ і написано. +func collectAuditKeyUses(t *testing.T, fset *token.FileSet, files []*ast.File, + consts map[string]string) []auditKeyUse { + + t.Helper() + + type pending struct { + fn string // ім'я функції-помічника + param string // ім'я параметра, у якому їде ключ + field string + pos string + } + + var uses []auditKeyUse + var pend []pending + // Кожна функція дерева — на випадок, якщо ключ їде в неї параметром. + funcs := map[string]*ast.FuncDecl{} + + resolve := func(expr ast.Expr, field, pos string) (auditKeyUse, bool, string) { + switch v := expr.(type) { + case *ast.BasicLit: + if v.Kind != token.STRING { + return auditKeyUse{}, false, "не рядок" + } + s, err := strconv.Unquote(v.Value) + if err != nil { + return auditKeyUse{}, false, "не розібрати рядок" + } + return auditKeyUse{pos: pos, field: field, key: s, literal: true}, true, "" + case *ast.Ident: + if s, ok := consts[v.Name]; ok { + return auditKeyUse{pos: pos, field: field, key: s}, true, "" + } + return auditKeyUse{}, false, v.Name + case *ast.SelectorExpr: + if s, ok := consts[v.Sel.Name]; ok { + return auditKeyUse{pos: pos, field: field, key: s}, true, "" + } + return auditKeyUse{}, false, v.Sel.Name + } + return auditKeyUse{}, false, "вираз" + } + + for _, f := range files { + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Body == nil { + continue + } + funcs[fd.Name.Name] = fd + + ast.Inspect(fd.Body, func(n ast.Node) bool { + cl, ok := n.(*ast.CompositeLit) + if !ok || !isAuditEntryType(cl.Type) { + return true + } + for _, el := range cl.Elts { + kv, ok := el.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok || (key.Name != "Action" && key.Name != "ObjectType") { + continue + } + pos := fset.Position(kv.Pos()).String() + use, ok, what := resolve(kv.Value, key.Name, pos) + if ok { + uses = append(uses, use) + continue + } + // Не вивелось: або це параметр помічника, або щось + // таке, з чого ключ не дістати взагалі. + pend = append(pend, pending{ + fn: fd.Name.Name, param: what, field: key.Name, pos: pos, + }) + } + return true + }) + } + } + + for _, p := range pend { + fd := funcs[p.fn] + idx := -1 + if fd != nil && fd.Type.Params != nil { + i := 0 + for _, field := range fd.Type.Params.List { + for _, name := range field.Names { + if name.Name == p.param { + idx = i + } + i++ + } + } + } + if idx < 0 { + t.Errorf("%s: у %s.%s підставляють %s — значення ключа не виводиться з коду. "+ + "Передавайте константу store.AuditAction… (хай навіть через параметр "+ + "функції-помічника), інакше цю дію нічим не перевірити", + p.pos, p.fn, p.field, p.param) + continue + } + + found := 0 + for _, f := range files { + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || calleeName(call.Fun) != p.fn || idx >= len(call.Args) { + return true + } + pos := fset.Position(call.Pos()).String() + use, ok, what := resolve(call.Args[idx], p.field, pos) + if !ok { + t.Errorf("%s: у виклик %s ключ %s їде як %s — значення не виводиться "+ + "з коду; підставте константу store.AuditAction…", + pos, p.fn, p.field, what) + return true + } + found++ + uses = append(uses, use) + return true + }) + } + if found == 0 { + t.Errorf("%s: %s приймає ключ %s параметром, але жодного виклику не знайдено — "+ + "перевірити цю дію нічим", p.pos, p.fn, p.field) + } + } + + return uses +} + +func isAuditEntryType(expr ast.Expr) bool { + switch v := expr.(type) { + case *ast.Ident: + return v.Name == "AuditEntry" + case *ast.SelectorExpr: + return v.Sel.Name == "AuditEntry" + } + return false +} + +func calleeName(expr ast.Expr) string { + switch v := expr.(type) { + case *ast.Ident: + return v.Name + case *ast.SelectorExpr: + return v.Sel.Name + } + return "" +} diff --git a/server/internal/store/audit_read.go b/server/internal/store/audit_read.go new file mode 100644 index 0000000..f78551f --- /dev/null +++ b/server/internal/store/audit_read.go @@ -0,0 +1,815 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Читання журналу аудиту. +// +// Окремо від audit.go навмисно: там одна функція, яка кладе подію, і +// вона не має знати нічого про те, як журнал потім розглядають. Запис +// іде з десятка місць продукту й мусить лишатись дешевим і незмінним; +// читання — це фільтри, курсор і словник, який житиме своїм життям. +// +// Головне обмеження шару: RLS на core.audit_log НЕ ДІЄ й діяти не +// може — це гіпертаблиця, а TimescaleDB не поєднує row level security +// зі стисненням (0011). Тому предикат tenant_id стоїть у КОЖНОМУ запиті +// нижче явно, як єдиний механізм ізоляції, а не як дублювання. +// +// Виняток — два LEFT JOIN на core.users і core.api_tokens, якими +// журнал добирає імена акторів. Ці таблиці під політиками, тож обидва +// читання йдуть через InTenantTx. Без контексту вони не впали б, а +// повернули NULL: перелік подій лишився б, а колонка «хто» стала б +// порожньою. Це єдине місце в шарі, де забутий контекст дає не +// порожній екран, а правдоподібну неправду. + +// AuditFilter — питання, з яким приходять на сторінку журналу. +type AuditFilter struct { + // Період обов'язковий і завжди заданий клієнтом. Журнал росте + // вічно, і запит без нижньої межі — це запит по всій історії + // організації; те, що він зазвичай швидкий завдяки курсору, не + // робить його осмисленим. + From time.Time + To time.Time + + Actions []string + ObjectTypes []string + // Хто саме — двома окремими переліками, бо це два різні стовпці й + // два різні види діяча. Обрані разом вони складаються через АБО: + // питання «що робили Петро й наша інтеграція» — одне питання. + ActorUserIDs []string + ActorTokenIDs []string + // AnyToken — «будь-який машинний токен», тобто рядок без людини. + // Потрібне окремо від переліку токенів: відкликаний токен зі списку + // зникає, а його сліди в журналі лишаються назавжди — і саме до них + // найчастіше й буває питання. + AnyToken bool + // Адреса або підмережа: '10.20.0.5' і '10.20.0.0/24' обидві + // працюють через один оператор <<=. + ActorIP string + + // Пошук підрядком по before/after/meta. Саме там лежать імена + // хостів і тексти команд — тобто те, за чим журнал і шукають. + Query string + + Limit int + Cursor string +} + +// AuditActor — хто зробив дію. +// +// Користувач і токен окремими полями, а не одним «хто»: за токеном +// інтеграції немає людини, і зводити їх до рядка означало б втратити +// різницю між «інженер натиснув кнопку» і «скрипт ходить нашим API». +// Ім'я користувача підтягується на момент ЧИТАННЯ — якщо людину +// перейменували, у журналі має бути видно поточне ім'я, бо саме за ним +// її впізнають сьогодні. +type AuditActor struct { + UserID string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + TokenID string `json:"token_id,omitempty"` + TokenName string `json:"token_name,omitempty"` + IP string `json:"ip,omitempty"` +} + +// AuditEvent — рядок журналу в переліку. +// +// Без before/after/meta: у прогоні команд на п'ятсот хостів meta несе +// п'ятсот ідентифікаторів та імен, і п'ятдесят таких рядків — це +// мегабайти на кожне гортання. Замість вмісту тут його стислий переказ +// (Summary) і чесна вага (Bytes); повний вміст приїздить окремо, коли +// на запис натиснули. +type AuditEvent struct { + ID string `json:"id"` + TS time.Time `json:"ts"` + + Action string `json:"action"` + // Людська назва дії. Порожня для ключа, якого немає в словнику, — + // і це не помилка: журнал старший за словник, і рядок, назви якому + // ще не придумали, має бути видно як є. + ActionLabel string `json:"action_label,omitempty"` + ActionGroup string `json:"action_group,omitempty"` + // Руйнівна дія: те, після чого об'єкта більше немає. Саме за цим + // журнал переглядають найчастіше. + Destructive bool `json:"destructive,omitempty"` + + ObjectType string `json:"object_type,omitempty"` + ObjectTypeLabel string `json:"object_type_label,omitempty"` + ObjectID string `json:"object_id,omitempty"` + + Actor AuditActor `json:"actor"` + + Summary string `json:"summary,omitempty"` + // Скільки важать before/after/meta разом. Показується поруч із + // кнопкою розгортання: людина має бачити, що зараз завантажить. + Bytes int `json:"bytes"` + HasBefore bool `json:"has_before"` + HasAfter bool `json:"has_after"` + HasDetails bool `json:"has_details"` +} + +// AuditEventDetail — те, що показують по натисканню. +type AuditEventDetail struct { + AuditEvent + Before json.RawMessage `json:"before,omitempty"` + After json.RawMessage `json:"after,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` +} + +// AuditPage — сторінка журналу. +type AuditPage struct { + // Період, у якому відповідь насправді зібрана. Повертається назад + // тому, що межі мають типові значення («останні 30 днів»), а + // «останні» — це момент першого запиту. Клієнт закріплює отриману + // пару й шле її з кожною наступною сторінкою: інакше подія, що + // сталася під час гортання, зсунула б усю вибірку на рядок. + From time.Time `json:"from"` + To time.Time `json:"to"` + + Events []AuditEvent `json:"events"` + // Курсор наступної сторінки. Порожній означає «більше немає» — + // саме означає, а не «здається»: сторінка береться з limit+1 + // рядка, тож питання «чи є ще» відповідає сама вибірка, а не + // окремий COUNT по всьому журналу. + NextCursor string `json:"next_cursor,omitempty"` +} + +const ( + auditDefaultLimit = 50 + auditMaxLimit = 200 +) + +// --------------------------------------------------------------------- +// Курсор +// --------------------------------------------------------------------- + +// Курсор — це пара (ts, id) останнього показаного рядка, а не номер +// сторінки. +// +// OFFSET на журналі означає «прочитати сто тисяч рядків, щоб показати +// двадцять», і ціна росте разом із глибиною. Виміряно на 500 тис. +// рядків: курсор на 100 000-му рядку — 1.3 мс і 147 буферів, той самий +// рядок через OFFSET 100000 — 46 мс і 100 416 буферів. +// +// Пара, а не сам лише ts: дві події однієї дії отримують один ts із +// точністю до мікросекунди, і межа лише за часом або губила б другий +// рядок, або показувала б перший двічі. +// +// Мікросекунди, а не RFC3339 з наносекундами: timestamptz у Postgres +// має саме мікросекундну точність, тож так пара повертається в базу +// точно такою, якою звідти приїхала. +func encodeAuditCursor(ts time.Time, id string) string { + return strconv.FormatInt(ts.UnixMicro(), 10) + "." + id +} + +func decodeAuditCursor(s string) (time.Time, string, error) { + micros, id, ok := strings.Cut(s, ".") + if !ok { + return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid) + } + n, err := strconv.ParseInt(micros, 10, 64) + if err != nil { + return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid) + } + if !isUUID(id) { + return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid) + } + return time.UnixMicro(n).UTC(), id, nil +} + +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, c := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + continue + } + if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') { + return false + } + } + return true +} + +// --------------------------------------------------------------------- +// Вибірка +// --------------------------------------------------------------------- + +// ListAudit повертає сторінку журналу. +func (s *Store) ListAudit(ctx context.Context, tenantID string, f AuditFilter) (AuditPage, error) { + limit := f.Limit + if limit <= 0 { + limit = auditDefaultLimit + } + if limit > auditMaxLimit { + limit = auditMaxLimit + } + + where, args, err := auditWhere(tenantID, f) + if err != nil { + return AuditPage{}, err + } + + // Беремо на один рядок більше, ніж показуємо: саме він і є + // відповіддю на «чи є наступна сторінка». Окремий COUNT(*) по + // журналу коштував би повного проходу за фільтром — тобто рівно + // того, чого курсор і уникає. + args = append(args, limit+1) + limitArg := len(args) + + inner := ` + SELECT ts, id, action, object_type, object_id, + actor_user_id, actor_token_id, host(actor_ip) AS actor_ip, + (before IS NOT NULL) AS has_before, + (after IS NOT NULL) AS has_after, + coalesce(octet_length(before::text), 0) + + coalesce(octet_length(after::text), 0) + + coalesce(octet_length(meta::text), 0) AS bytes, + meta + FROM core.audit_log + WHERE ` + where + + if f.Query != "" { + // Пошук по вмісту ставиться в підзапит із OFFSET 0. + // + // Це бар'єр планувальника, і без нього план виходить + // катастрофічним саме в найчастішому випадку. З ORDER BY ts + // DESC + LIMIT планувальник обирає впорядкований індекс і + // розраховує зупинитись, щойно набере двадцять рядків, — а + // коли шуканого підрядка в журналі немає (а це і є типовий + // результат пошуку за іменем хоста), він доходить до кінця + // історії, жодного разу не заглянувши в триграмний індекс. + // + // Виміряно на 500 тис. рядків, пошук без збігів за два роки: + // 634 мс і 167 368 буферів проти 64 мс і 6 016 із бар'єром. + // Ціною є те, що широкий запит («Бровари») збирає всі збіги й + // сортує їх — 42 мс на тих самих даних; це вища й передбачувана + // стеля замість нижчої й випадкової. + inner += "\n\t\tOFFSET 0" + inner = "SELECT * FROM (" + inner + "\n\t\t) q" + } + + q := ` + WITH page AS (` + inner + ` + ORDER BY ts DESC, id DESC + LIMIT $` + strconv.Itoa(limitArg) + ` + ) + SELECT p.ts, p.id, p.action, p.object_type, p.object_id, + p.actor_user_id, p.actor_token_id, p.actor_ip, + p.has_before, p.has_after, p.bytes, p.meta, + u.username, u.email, t.name + FROM page p + LEFT JOIN core.users u ON u.id = p.actor_user_id + LEFT JOIN core.api_tokens t ON t.id = p.actor_token_id AND t.tenant_id = $1 + ORDER BY p.ts DESC, p.id DESC` + + // Тенантна транзакція потрібна не журналу, а двом LEFT JOIN у + // хвості запиту. core.audit_log — гіпертаблиця, RLS на ній немає, і + // сам перелік прочитався б і без контексту. А от core.users і + // core.api_tokens під політиками: без app.tenant_id обидва JOIN + // дали б NULL, і журнал показував би ті самі рядки з порожньою + // колонкою «хто». Це єдиний тут випадок, коли забутий контекст дає + // не порожній екран, а правдоподібну неправду — журнал, у якому всі + // дії анонімні. + out := AuditPage{From: f.From, To: f.To, Events: []AuditEvent{}} + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, q, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + e, err := scanAuditEvent(rows) + if err != nil { + return err + } + out.Events = append(out.Events, e) + } + return rows.Err() + }) + if err != nil { + return AuditPage{}, err + } + + if len(out.Events) > limit { + last := out.Events[limit-1] + out.Events = out.Events[:limit] + out.NextCursor = encodeAuditCursor(last.TS, last.ID) + } + return out, nil +} + +// auditWhere складає умову й аргументи. $1 завжди tenant_id. +func auditWhere(tenantID string, f AuditFilter) (string, []any, error) { + args := []any{tenantID} + // Предикат tenant_id стоїть тут, а не покладається на RLS, і це не + // перестраховка: політик на core.audit_log немає взагалі й не буде + // (гіпертаблиця). Прибрати цей рядок означає показати один кабінет + // іншому — і після 0063 це лишається правдою рівно для журналу й + // решти гіпертаблиць, тоді як усюди ще й спрацює політика. + cond := []string{"tenant_id = $1"} + + add := func(sql string, v any) { + args = append(args, v) + cond = append(cond, fmt.Sprintf(sql, len(args))) + } + + if !f.From.IsZero() { + add("ts >= $%d", f.From) + } + if !f.To.IsZero() { + add("ts < $%d", f.To) + } + if len(f.Actions) > 0 { + add("action = ANY($%d)", f.Actions) + } + if len(f.ObjectTypes) > 0 { + add("object_type = ANY($%d)", f.ObjectTypes) + } + + // Актори: обране з різних стовпців зводиться в одну умову через АБО. + // Окремими умовами воно означало б перетин («що робив Петро ЧЕРЕЗ + // цей токен»), а такого рядка не буває взагалі — актор у події + // рівно один. + var actorOr []string + if len(f.ActorUserIDs) > 0 { + args = append(args, f.ActorUserIDs) + actorOr = append(actorOr, fmt.Sprintf("actor_user_id = ANY($%d)", len(args))) + } + if len(f.ActorTokenIDs) > 0 { + args = append(args, f.ActorTokenIDs) + actorOr = append(actorOr, fmt.Sprintf("actor_token_id = ANY($%d)", len(args))) + } + if f.AnyToken { + actorOr = append(actorOr, "actor_user_id IS NULL") + } + if len(actorOr) > 0 { + cond = append(cond, "("+strings.Join(actorOr, " OR ")+")") + } + + if ip := strings.TrimSpace(f.ActorIP); ip != "" { + // <<= бере і точну адресу, і підмережу: одна умова замість + // двох режимів у інтерфейсі. + add("actor_ip <<= $%d::inet", ip) + } + + if q := strings.TrimSpace(f.Query); q != "" { + // Один вираз над трьома колонками — рівно той, на якому стоїть + // триграмний індекс із 0050. Три окремі ILIKE через OR дали б + // три сканування й BitmapOr замість одного. + add(`(coalesce(meta::text, '') || ' ' || + coalesce(before::text, '') || ' ' || + coalesce(after::text, '')) ILIKE $%d`, "%"+escapeLike(q)+"%") + } + + if f.Cursor != "" { + ts, id, err := decodeAuditCursor(f.Cursor) + if err != nil { + return "", nil, err + } + args = append(args, ts, id) + cond = append(cond, fmt.Sprintf("(ts, id) < ($%d, $%d)", len(args)-1, len(args))) + } + + return strings.Join(cond, "\n\t\t AND "), args, nil +} + +// escapeLike знешкоджує символи шаблону. +// +// Без цього набране в полі пошуку «%» знаходить геть усе, а «_» — +// будь-який символ: людина шукає підрядок, а не пише шаблон, і +// підсунути їй мову шаблонів мовчки означає давати неправильні +// відповіді на правильні питання. +func escapeLike(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + +func scanAuditEvent(rows pgx.Rows) (AuditEvent, error) { + var ( + e AuditEvent + objType *string + objID *string + userID *string + tokenID *string + ip *string + meta []byte + username *string + email *string + tokenName *string + ) + if err := rows.Scan(&e.TS, &e.ID, &e.Action, &objType, &objID, + &userID, &tokenID, &ip, + &e.HasBefore, &e.HasAfter, &e.Bytes, &meta, + &username, &email, &tokenName); err != nil { + return AuditEvent{}, err + } + + // deref живе в autoassign.go — той самий пакет, той самий сенс; + // друга копія на п'ять рядків розійшлася б із першою рівно тоді, + // коли одну з них комусь захочеться «покращити». + e.TS = e.TS.UTC() + e.ObjectType = deref(objType) + e.ObjectID = deref(objID) + e.Actor = AuditActor{ + UserID: deref(userID), + Username: deref(username), + Email: deref(email), + TokenID: deref(tokenID), + TokenName: deref(tokenName), + IP: deref(ip), + } + decorateAuditEvent(&e) + e.Summary = auditSummary(meta) + e.HasDetails = e.Bytes > 2 // «{}» — це не подробиці + return e, nil +} + +// GetAuditEvent повертає один запис разом із before/after/meta. +// +// ts у ключі не примха: первинний ключ гіпертаблиці — (ts, id), і без +// часу пошук за самим лише id означав би перебір усіх чанків. Клієнт +// його завжди має — він щойно бачив цей рядок у переліку. +func (s *Store) GetAuditEvent(ctx context.Context, tenantID, id string, ts time.Time) (AuditEventDetail, error) { + if !isUUID(id) { + return AuditEventDetail{}, fmt.Errorf("%w: некоректний ідентифікатор", ErrInvalid) + } + + var ( + d AuditEventDetail + objType *string + objID *string + userID *string + tokenID *string + ip *string + before []byte + after []byte + meta []byte + username *string + email *string + tokenName *string + ) + // Транзакція — заради тих самих двох LEFT JOIN, що й у ListAudit. + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT a.ts, a.id, a.action, a.object_type, a.object_id, + a.actor_user_id, a.actor_token_id, host(a.actor_ip), + (a.before IS NOT NULL), (a.after IS NOT NULL), + coalesce(octet_length(a.before::text), 0) + + coalesce(octet_length(a.after::text), 0) + + coalesce(octet_length(a.meta::text), 0), + a.before, a.after, a.meta, + u.username, u.email, t.name + FROM core.audit_log a + LEFT JOIN core.users u ON u.id = a.actor_user_id + LEFT JOIN core.api_tokens t ON t.id = a.actor_token_id AND t.tenant_id = $1 + WHERE a.tenant_id = $1 AND a.id = $2 AND a.ts = $3 + `, tenantID, id, ts).Scan(&d.TS, &d.ID, &d.Action, &objType, &objID, + &userID, &tokenID, &ip, + &d.HasBefore, &d.HasAfter, &d.Bytes, + &before, &after, &meta, + &username, &email, &tokenName) + }) + if errors.Is(err, pgx.ErrNoRows) { + return AuditEventDetail{}, ErrNotFound + } + if err != nil { + return AuditEventDetail{}, err + } + + d.TS = d.TS.UTC() + d.ObjectType = deref(objType) + d.ObjectID = deref(objID) + d.Actor = AuditActor{ + UserID: deref(userID), + Username: deref(username), + Email: deref(email), + TokenID: deref(tokenID), + TokenName: deref(tokenName), + IP: deref(ip), + } + decorateAuditEvent(&d.AuditEvent) + d.Summary = auditSummary(meta) + d.HasDetails = d.Bytes > 2 + d.Before = json.RawMessage(before) + d.After = json.RawMessage(after) + d.Meta = json.RawMessage(meta) + return d, nil +} + +// --------------------------------------------------------------------- +// Межі журналу +// --------------------------------------------------------------------- + +// AuditBounds — від якої дати в журналі взагалі щось є. +// +// Потрібне не для краси. Писати в journal почали недавно, і порожня +// відповідь на «покажи березень» означає «у березні ще не писали», а не +// «у березні нічого не робили». Різницю між цими двома реченнями +// сторінка мусить показати сама, інакше вона тихо бреше. +type AuditBounds struct { + First *time.Time `json:"first,omitempty"` + Last *time.Time `json:"last,omitempty"` +} + +func (s *Store) AuditBounds(ctx context.Context, tenantID string) (AuditBounds, error) { + var b AuditBounds + // Два окремі запити з LIMIT 1, а не min()/max() одним рядком: + // впорядкований обхід гіпертаблиці зупиняється на першому ж чанку, + // у якому щось є, а агрегат на кожному краю читав би всі. + err := s.pool.QueryRow(ctx, ` + SELECT ts FROM core.audit_log WHERE tenant_id = $1 + ORDER BY ts ASC, id ASC LIMIT 1 + `, tenantID).Scan(&b.First) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return AuditBounds{}, err + } + err = s.pool.QueryRow(ctx, ` + SELECT ts FROM core.audit_log WHERE tenant_id = $1 + ORDER BY ts DESC, id DESC LIMIT 1 + `, tenantID).Scan(&b.Last) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return AuditBounds{}, err + } + if b.First != nil { + t := b.First.UTC() + b.First = &t + } + if b.Last != nil { + t := b.Last.UTC() + b.Last = &t + } + return b, nil +} + +// AuditActorOption — рядок у списку «хто саме». +type AuditActorOption struct { + ID string `json:"id"` + Label string `json:"label"` + // Kind: user | token. + Kind string `json:"kind"` +} + +// AuditActors — список для фільтра «хто». +// +// Береться зі складу кабінету й з переліку токенів, а не з DISTINCT по +// самому журналу. DISTINCT по колонці, яка стоїть другою в індексі, +// Postgres 16 без skip-scan виконує повним проходом — тобто ціна списку +// для випадаючого меню дорівнювала б ціні найважчого запиту сторінки. +// А головне, список зі складу кабінету відповідає на потрібне питання +// («хто в нас є») навіть тоді, коли людина ще нічого не зробила. +func (s *Store) AuditActors(ctx context.Context, tenantID string) ([]AuditActorOption, error) { + out := []AuditActorOption{} + + // Обидві таблиці під RLS, тому без контексту список «хто» був би + // порожній, а фільтр журналу — непрацездатний. + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT u.id, u.username, coalesce(u.email::text, '') + FROM core.memberships m + JOIN core.users u ON u.id = m.user_id + WHERE m.tenant_id = $1 AND u.deleted_at IS NULL + ORDER BY u.username + `, tenantID) + if err != nil { + return err + } + for rows.Next() { + var id, username, email string + if err := rows.Scan(&id, &username, &email); err != nil { + rows.Close() + return err + } + label := username + if email != "" { + label = username + " · " + email + } + out = append(out, AuditActorOption{ID: id, Label: label, Kind: "user"}) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + trows, err := tx.Query(ctx, ` + SELECT id, name FROM core.api_tokens + WHERE tenant_id = $1 AND revoked_at IS NULL + ORDER BY name + `, tenantID) + if err != nil { + return err + } + defer trows.Close() + for trows.Next() { + var id, name string + if err := trows.Scan(&id, &name); err != nil { + return err + } + out = append(out, AuditActorOption{ID: id, Label: name, Kind: "token"}) + } + return trows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// --------------------------------------------------------------------- +// Стислий переказ вмісту +// --------------------------------------------------------------------- + +// auditSummary робить із meta один рядок для переліку. +// +// Узагальнено, без гілки на кожну дію. Гілка на дію означала б, що +// рядок, доданий у продукт завтра, у переліку виглядатиме порожнім +// доти, доки хтось не згадає дописати сюди ще один case — а помітити це +// можна лише випадково, дивлячись саме на цю дію. +// +// Порядок ключів заданий явно: у meta вони йдуть як у Go-мапі, тобто +// випадково, і без сортування той самий рядок при кожному читанні +// виглядав би інакше. Спочатку — те, що відповідає на «скільки й чого», +// решта за абеткою. +func auditSummary(meta []byte) string { + if len(meta) < 3 { + return "" + } + var m map[string]any + if err := json.Unmarshal(meta, &m); err != nil || len(m) == 0 { + return "" + } + + // Ключі, які найкраще переказують дію. Технічні переліки + // ідентифікаторів (device_ids) свідомо не показуються: у переліку + // вони витісняють імена, а імена й є те, що людина впізнає. Самі + // ідентифікатори нікуди не діваються — вони у повному вмісті, який + // приїздить по натисканню. + preferred := []string{ + "devices", "deleted", "updated", "stopped", "names", "commands", + "counts", "status", "versions", "format", "enabled", "freed_bytes", + "created_by", "filter", + } + skip := map[string]bool{"device_ids": true} + + seen := map[string]bool{} + order := make([]string, 0, len(m)) + for _, k := range preferred { + if _, ok := m[k]; ok && !skip[k] { + order = append(order, k) + seen[k] = true + } + } + rest := make([]string, 0, len(m)) + for k := range m { + if !seen[k] && !skip[k] { + rest = append(rest, k) + } + } + sort.Strings(rest) + order = append(order, rest...) + + parts := make([]string, 0, 4) + for _, k := range order { + if len(parts) == 4 { + break + } + if v := auditValueText(m[k]); v != "" { + parts = append(parts, auditKeyLabel(k)+": "+v) + } + } + return strings.Join(parts, " · ") +} + +// auditKeyLabel — назва ключа meta українською. +// +// Словник лише на ті ключі, які продукт справді кладе в meta сьогодні; +// невідомий ключ показується як є. Це навмисно: переказ, у якому +// незнайомий ключ мовчки зник би, гірший за переказ, у якому видно +// англійське слово. Повний вміст по натисканню показує сирі ключі +// завжди — саме там правда, а тут її переказ. +var auditKeyLabels = map[string]string{ + "devices": "хостів", + "names": "хости", + "commands": "команди", + "counts": "результат", + "status": "стан", + "deleted": "видалено", + "updated": "змінено", + "stopped": "не почато", + "versions": "версії", + "kept": "лишилось", + "freed_bytes": "звільнено байтів", + "format": "формат", + "enabled": "увімкнено", + "days": "днів", + "filter": "фільтр", + "concurrency": "паралельно", + "timeout_sec": "таймаут, с", + "created_by": "створив", + "created_at": "створено", + "dropped": "відкинуто", + "checks": "перевірки", + "success": "успішно", + "failed": "невдало", + "canceled": "скасовано", + "pending": "у черзі", +} + +func auditKeyLabel(k string) string { + if l, ok := auditKeyLabels[k]; ok { + return l + } + return k +} + +// auditValueText — коротке подання одного значення. +func auditValueText(v any) string { + switch t := v.(type) { + case nil: + return "" + case bool: + if t { + return "так" + } + return "ні" + case float64: + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'f', -1, 64) + case string: + return clip(t, 60) + case []any: + if len(t) == 0 { + return "" + } + head := make([]string, 0, 2) + for _, e := range t { + if len(head) == 2 { + break + } + if s := auditValueText(e); s != "" { + head = append(head, s) + } + } + s := strings.Join(head, ", ") + if len(t) > len(head) { + s += fmt.Sprintf(" (+%d)", len(t)-len(head)) + } + return clip(s, 80) + case map[string]any: + // Вкладений об'єкт розкривається на один рівень, а не рахується. + // «{1}» не каже нічого; «успішно 1» — це і є те, чим прогін + // закінчився, і саме за цим у перелік дивляться. + if len(t) == 0 { + return "" + } + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + head := make([]string, 0, 3) + for _, k := range keys { + if len(head) == 3 { + break + } + if s := auditValueText(t[k]); s != "" { + head = append(head, auditKeyLabel(k)+" "+s) + } + } + if len(head) == 0 { + return "" + } + s := strings.Join(head, ", ") + if len(keys) > len(head) { + s += fmt.Sprintf(" (+%d)", len(keys)-len(head)) + } + return clip(s, 60) + default: + return "" + } +} + +func clip(s string, max int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) <= max { + return string(r) + } + return string(r[:max]) + "…" +} diff --git a/server/internal/store/audit_read_test.go b/server/internal/store/audit_read_test.go new file mode 100644 index 0000000..827cb12 --- /dev/null +++ b/server/internal/store/audit_read_test.go @@ -0,0 +1,160 @@ +package store + +import ( + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +// Нумерація плейсхолдерів — те, що ламається мовчки. +// +// auditWhere складає умову з довільного набору фільтрів і сама рахує +// $1, $2, … Помилка на одиницю тут не падає й не помітна на око: запит +// виконується, просто порівнює не ту колонку не з тим значенням — а +// журнал аудиту саме тим і цінний, що йому вірять без перевірки. +func TestAuditWherePlaceholdersMatchArgs(t *testing.T) { + now := time.Now().UTC() + cases := map[string]AuditFilter{ + "порожній": {}, + "період": {From: now.Add(-24 * time.Hour), To: now}, + "усе разом": { + From: now.Add(-24 * time.Hour), + To: now, + Actions: []string{"ncm.config.delete"}, + ObjectTypes: []string{"ncm.config"}, + ActorUserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}, + ActorIP: "10.0.0.0/24", + Query: "Миронівка", + Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"), + }, + "лише машинні токени": {AnyToken: true}, + "люди, токени й будь-який токен": { + ActorUserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}, + ActorTokenIDs: []string{"3f1b0f5e-0000-0000-0000-000000000001"}, + AnyToken: true, + }, + "лише курсор": { + Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"), + }, + } + + ph := regexp.MustCompile(`\$(\d+)`) + for name, f := range cases { + t.Run(name, func(t *testing.T) { + where, args, err := auditWhere("00000000-0000-0000-0000-000000000001", f) + if err != nil { + t.Fatalf("auditWhere: %v", err) + } + + seen := map[int]bool{} + maxN := 0 + for _, m := range ph.FindAllStringSubmatch(where, -1) { + n, _ := strconv.Atoi(m[1]) + seen[n] = true + if n > maxN { + maxN = n + } + } + if maxN != len(args) { + t.Fatalf("найбільший плейсхолдер $%d, а аргументів %d\n%s", maxN, len(args), where) + } + for i := 1; i <= len(args); i++ { + if !seen[i] { + t.Fatalf("аргумент $%d не використано\n%s", i, where) + } + } + + // Предикат кабінету має стояти завжди й саме на $1: без + // нього сторінка показує чужі записи, а RLS на цій + // гіпертаблиці не діє й підстрахувати не може. + if !strings.Contains(where, "tenant_id = $1") { + t.Fatalf("немає предикату tenant_id:\n%s", where) + } + }) + } +} + +func TestAuditCursorRoundTrip(t *testing.T) { + // Мікросекунди, а не наносекунди: саме таку точність має + // timestamptz, і курсор мусить повертатись у базу тим самим + // значенням, яким звідти приїхав. + ts := time.Date(2026, 8, 25, 20, 45, 6, 395968000, time.UTC) + id := "bf903579-806d-4c59-a2e2-7418003578fd" + + got, gotID, err := decodeAuditCursor(encodeAuditCursor(ts, id)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !got.Equal(ts) { + t.Fatalf("час не збігся: %s != %s", got, ts) + } + if gotID != id { + t.Fatalf("id не збігся: %s != %s", gotID, id) + } + + for _, bad := range []string{"", "abc", "123", "123.не-uuid", ".x"} { + if _, _, err := decodeAuditCursor(bad); err == nil { + t.Fatalf("зіпсований курсор %q прийнято", bad) + } + } +} + +// Поле пошуку — це підрядок, а не мова шаблонів. +func TestEscapeLike(t *testing.T) { + if got := escapeLike("100%_готово"); got != `100\%\_готово` { + t.Fatalf("не екрановано: %s", got) + } +} + +// Переказ має бути однаковим при кожному читанні того самого рядка: +// ключі в JSON приходять у випадковому порядку, і без явного впорядкування +// той самий запис виглядав би щоразу інакше. +func TestAuditSummaryStableAndReadable(t *testing.T) { + meta := []byte(`{"counts":{"success":2},"status":"done","devices":2, + "commands":["show vlan"],"created_by":"admin", + "device_ids":["3f1b0f5e-0000-0000-0000-000000000001"]}`) + + first := auditSummary(meta) + for i := 0; i < 20; i++ { + if got := auditSummary(meta); got != first { + t.Fatalf("переказ нестабільний:\n%s\n%s", first, got) + } + } + if !strings.HasPrefix(first, "хостів: 2") { + t.Fatalf("очікували, що переказ почнеться з кількості хостів: %s", first) + } + if strings.Contains(first, "3f1b0f5e") { + t.Fatalf("сирі ідентифікатори не мають потрапляти в переказ: %s", first) + } + if strings.Contains(first, "success") { + t.Fatalf("вкладений об'єкт не перекладено: %s", first) + } + + if auditSummary([]byte(`{}`)) != "" { + t.Fatal("порожня meta має давати порожній переказ") + } + if auditSummary([]byte(`не json`)) != "" { + t.Fatal("зіпсована meta не має ламати перелік") + } +} + +// Ключ, якого немає в словнику, має лишатись видимим. +func TestUnknownActionKeepsKey(t *testing.T) { + e := AuditEvent{Action: "ncm.майбутня_дія"} + decorateAuditEvent(&e) + if e.ActionLabel != "" { + t.Fatalf("назву для незнайомої дії вигадано: %s", e.ActionLabel) + } + if e.ActionGroup != "Конфігурації" { + t.Fatalf("розділ мав вивестись зі схеми: %q", e.ActionGroup) + } + + // А відомий ключ має нести і назву, і позначку руйнівності. + d := AuditEvent{Action: "ncm.config.delete"} + decorateAuditEvent(&d) + if d.ActionLabel == "" || !d.Destructive { + t.Fatalf("словник не спрацював: %+v", d) + } +} diff --git a/server/internal/store/autoassign.go b/server/internal/store/autoassign.go index 6a95bbc..5e9beac 100644 --- a/server/internal/store/autoassign.go +++ b/server/internal/store/autoassign.go @@ -2,6 +2,8 @@ package store import ( "context" + "errors" + "regexp" "sort" "strings" @@ -15,6 +17,13 @@ type AutoAssignStat struct { Described int // Скільки хостів отримали новий шаблон. Assigned int + // Скільки хостів не вдалося розпізнати. + Failed int + // Скільки хостів відповіли іншим sysObjectID, ніж мали. + HardwareChanged int + // Остання помилка — щоб було що написати в журнал сервера, не + // ховаючи решту хостів за нею. + LastError error } // ApplySystemInfo зберігає системну групу й довішує шаблони за @@ -40,6 +49,16 @@ func (s *Store) ApplySystemInfo(ctx context.Context, tenantID string, devices [] return stat, err } + // Помилка на одному хості не має ронити весь звіт. + // + // Досі роняла — і саме через це хост DGS-3420 місяцями стояв би + // нерозпізнаним: запит до перейменованої таблиці падав, звіт + // обривався, системна група вже була записана, а причина не + // лишалась ніде. Ззовні це виглядало як «система просто не + // розпізнала», без жодної підказки, що робити. + // + // Тепер кожен хост обробляється окремо, а причина відмови осідає + // в inv.devices.identify_error — там, де її шукатимуть. for _, d := range devices { if d.GetDeviceId() == "" { // Знахідки сканування (без device_id) тут не обробляємо: @@ -48,26 +67,75 @@ func (s *Store) ApplySystemInfo(ctx context.Context, tenantID string, devices [] continue } - if err := s.saveSystemInfo(ctx, tenantID, d); err != nil { - return stat, err - } - stat.Described++ - - tplID := matchAutoAssign(rules, d.GetSysObjectId()) - if tplID == "" { + if err := s.identifyOne(ctx, tenantID, d, rules, &stat); err != nil { + stat.Failed++ + // Причину зберігаємо саме тут, а не в журналі сервера: + // журнал читає той, хто вже знає, що щось не так, а поле + // в картці бачить той, хто питає «чому порожньо». + if e := s.noteIdentifyError(ctx, tenantID, d.GetDeviceId(), err); e != nil { + stat.LastError = e + } else { + stat.LastError = err + } continue } - added, err := s.attachTemplate(ctx, tenantID, d.GetDeviceId(), tplID) - if err != nil { - return stat, err - } - if added { - stat.Assigned++ - } } return stat, nil } +// identifyOne — весь шлях розпізнавання одного хоста. +func (s *Store) identifyOne(ctx context.Context, tenantID string, + d *npv1.DiscoveredDevice, rules []autoAssignRule, stat *AutoAssignStat) error { + + changed, err := s.noteHardwareChange(ctx, tenantID, d) + if err != nil { + return err + } + if changed { + stat.HardwareChanged++ + } + + if err := s.saveSystemInfo(ctx, tenantID, d); err != nil { + return err + } + stat.Described++ + + // Заповнюємо «виробник», «тип», модель і версію. + // + // Поля самі собою нічого не збирають, але ними користується решта + // системи: фільтри, значки на мапі, звіти й запасний підбір профілю + // для хостів без SNMP. Уже задане не чіпаємо — людина могла + // написати точніше за наш довідник. Виняток один: заміна заліза, + // коли старе значення описує коробку, якої вже немає. + if err := s.fillFromOIDForce(ctx, tenantID, d.GetDeviceId(), changed); err != nil { + return err + } + + // Профіль збору закріплюємо за хостом, а не підбираємо щоразу + // наново: інакше в картці порожньо, і людина не бачить, чим її + // хост зніматимуть. + if err := s.pinAutoProfile(ctx, tenantID, d.GetDeviceId(), changed); err != nil { + return err + } + + if err := s.clearIdentifyError(ctx, tenantID, d.GetDeviceId()); err != nil { + return err + } + + tplID := matchAutoAssign(rules, d.GetSysObjectId()) + if tplID == "" { + return nil + } + added, err := s.attachTemplate(ctx, tenantID, d.GetDeviceId(), tplID) + if err != nil { + return err + } + if added { + stat.Assigned++ + } + return nil +} + type autoAssignRule struct { prefix string templateID string @@ -171,3 +239,259 @@ func (s *Store) attachTemplate(ctx context.Context, tenantID, deviceID, template } return true, nil } + +// fillFromOID проставляє виробника, тип, модель і версію ПЗ. +// +// Правило береться те саме, що підбирає профіль збору конфігу, і тим +// самим порядком: уточнення за описом перед загальним, довший префікс +// перед коротшим. Інакше хост міг би отримати тип від одного правила, а +// профіль від іншого — і пояснити людині, чому OLT підписаний +// комутатором, було б нічим. +// +// Модель і версію дістає Go, а не Postgres: потрібні групи захоплення. +// Зразки RE2 не мають відкату, тож правило з довідника не підвісить +// збір навіть якщо його напише людина. +// +// Уже задане не чіпаємо. `other` у типі й порожній рядок в решті — це +// «не задано»: типові значення колонок, яких людина не обирала. +func (s *Store) fillFromOIDForce(ctx context.Context, tenantID, deviceID string, force bool) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var ( + descr string + vendor, kind *string + modelRe, versionRe *string + ) + err := tx.QueryRow(ctx, ` + SELECT COALESCE(dev.sys_descr,''), r.vendor, r.kind::text, + r.model_regex, r.version_regex + FROM inv.ident_rules r, inv.devices dev + WHERE dev.id = $1 AND dev.tenant_id = $2 + AND r.enabled + AND (r.tenant_id IS NULL OR r.tenant_id = dev.tenant_id) + AND (dev.sys_object_id = r.oid_prefix + OR dev.sys_object_id LIKE r.oid_prefix || '.%') + AND (r.descr_regex IS NULL + OR COALESCE(dev.sys_descr,'') ~ r.descr_regex) + ORDER BY r.tenant_id NULLS LAST, + (r.descr_regex IS NULL), + length(r.oid_prefix) DESC + LIMIT 1 + `, deviceID, tenantID).Scan(&descr, &vendor, &kind, &modelRe, &versionRe) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + + model := firstGroup(modelRe, descr) + version := firstGroup(versionRe, descr) + + // $7 — «залізо замінили». Тоді старе значення описує коробку, + // якої вже немає, і берегти його — берегти неправду. У всіх + // інших випадках рука людини важить більше за довідник. + _, err = tx.Exec(ctx, ` + UPDATE inv.devices SET + vendor = CASE WHEN ($7 OR COALESCE(vendor,'') = '') AND $3 <> '' + THEN $3 ELSE vendor END, + kind = CASE WHEN ($7 OR kind = 'other') AND $4 <> '' + THEN $4::inv.device_kind ELSE kind END, + model = CASE WHEN ($7 OR COALESCE(model,'') = '') AND $5 <> '' + THEN $5 ELSE model END, + os_version = CASE WHEN ($7 OR COALESCE(os_version,'') = '') AND $6 <> '' + THEN $6 ELSE os_version END, + identified_at = now(), + updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID, deref(vendor), deref(kind), model, version, force) + return err + }) +} + +func deref(p *string) string { + if p == nil { + return "" + } + return *p +} + +// firstGroup витягує першу групу захоплення. Порожньо, якщо зразка +// немає, він не збігся або взагалі не компілюється. +// +// Зіпсований зразок мовчки нічого не дає, а не валить розпізнавання: +// правила редагує людина, і помилка в одному рядку довідника не має +// зупиняти онбординг усіх інших хостів. Помітно її буде там, де й +// має бути — у порожньому полі моделі. +func firstGroup(pattern *string, text string) string { + if pattern == nil || *pattern == "" || text == "" { + return "" + } + re, err := regexp.Compile(*pattern) + if err != nil { + return "" + } + m := re.FindStringSubmatch(text) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +// noteHardwareChange помічає, що в шафі стоїть інша коробка. +// +// Повертає true лише коли sysObjectID БУВ і став іншим. Перше +// розпізнавання зміною не рахується: воно не скасовує нічого, а +// заповнює порожнє. +// +// Навіщо окремо від решти. Заміна заліза знецінює все, що система про +// хост знала: модель, версію, тип, профіль збору конфігу. Мовчки +// підмінити ці поля — залишити людину з карткою, яка змінилась сама +// собою; не підмінити — залишити її з карткою, яка бреше. Тому мітка +// часу: поля оновлюються, і водночас видно, коли й чому. +func (s *Store) noteHardwareChange(ctx context.Context, tenantID string, d *npv1.DiscoveredDevice) (bool, error) { + oid := strings.TrimSpace(d.GetSysObjectId()) + if oid == "" { + return false, nil + } + + var changed bool + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + UPDATE inv.devices + SET hardware_changed_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = $2 + AND COALESCE(sys_object_id,'') NOT IN ('', $3) + RETURNING true + `, d.GetDeviceId(), tenantID, oid).Scan(&changed) + }) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return changed, err +} + +// pinAutoProfile закріплює за хостом підібраний профіль збору конфігу. +// +// Профіль і далі підбирається тими самими правилами — змінюється лише +// те, що результат тепер видно в картці й він не переграється щоразу +// наново. Обраний людиною не чіпається ніколи: прапорець profile_auto +// саме для того й заведений. +// +// Виняток — заміна заліза: автоматично підібраний профіль для коробки, +// якої вже немає, гарантовано хибний. +func (s *Store) pinAutoProfile(ctx context.Context, tenantID, deviceID string, hardwareChanged bool) error { + prof, err := s.ResolveProfile(ctx, tenantID, deviceID, nil) + if err != nil { + // Немає профілю — не привід валити розпізнавання: більшість + // решти роботи вже зроблена й корисна сама собою. + if errors.Is(err, ErrNoProfile) { + return nil + } + return err + } + if prof.ID == "" { + return nil + } + + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO ncm.device_policies (tenant_id, device_id, profile_id, profile_auto) + VALUES ($1, $2, $3, true) + ON CONFLICT (device_id) DO UPDATE + SET profile_id = EXCLUDED.profile_id, profile_auto = true + WHERE ncm.device_policies.profile_id IS NULL + OR (ncm.device_policies.profile_auto AND $4) + `, tenantID, deviceID, prof.ID, hardwareChanged) + return err + }) +} + +// noteIdentifyError лишає слід невдачі в картці хоста. +func (s *Store) noteIdentifyError(ctx context.Context, tenantID, deviceID string, cause error) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET identify_error = $3, updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID, cause.Error()) + return err + }) +} + +func (s *Store) clearIdentifyError(ctx context.Context, tenantID, deviceID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET identify_error = NULL + WHERE id = $1 AND tenant_id = $2 AND identify_error IS NOT NULL + `, deviceID, tenantID) + return err + }) +} + +// Скільки початку конфігу дивитись у пошуках версії. +// +// Шапка з моделлю й прошивкою стоїть на самому початку — так роблять +// усі, бо це заголовок файлу. Ганяти зразок по тридцяти тисячах рядків +// заради рядка, який завжди в перших двадцяти, означало б платити за +// кожен зібраний конфіг рівно нічим не виправдану ціну. +const configHeadBytes = 4096 + +// fillFromConfig дістає версію, модель і серійний номер із тіла конфігу. +// +// Запасний шлях до того, що не приїхало по SNMP. Уже відоме не +// перезаписується: SNMP — джерело свіжіше, конфіг міг бути зібраний +// учора. +func (s *Store) fillFromConfig(ctx context.Context, tenantID, deviceID string, body []byte) error { + if len(body) == 0 { + return nil + } + head := body + if len(head) > configHeadBytes { + head = head[:configHeadBytes] + } + text := string(head) + + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var modelRe, versionRe, serialRe *string + err := tx.QueryRow(ctx, ` + SELECT r.config_model_regex, r.config_version_regex, r.config_serial_regex + FROM inv.ident_rules r, inv.devices dev + WHERE dev.id = $1 AND dev.tenant_id = $2 + AND r.enabled + AND (r.tenant_id IS NULL OR r.tenant_id = dev.tenant_id) + AND (dev.sys_object_id = r.oid_prefix + OR dev.sys_object_id LIKE r.oid_prefix || '.%') + AND (r.descr_regex IS NULL + OR COALESCE(dev.sys_descr,'') ~ r.descr_regex) + ORDER BY r.tenant_id NULLS LAST, + (r.descr_regex IS NULL), + length(r.oid_prefix) DESC + LIMIT 1 + `, deviceID, tenantID).Scan(&modelRe, &versionRe, &serialRe) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + + model := firstGroup(modelRe, text) + version := firstGroup(versionRe, text) + serial := firstGroup(serialRe, text) + if model == "" && version == "" && serial == "" { + return nil + } + + _, err = tx.Exec(ctx, ` + UPDATE inv.devices SET + model = CASE WHEN COALESCE(model,'') = '' AND $3 <> '' + THEN $3 ELSE model END, + os_version = CASE WHEN COALESCE(os_version,'') = '' AND $4 <> '' + THEN $4 ELSE os_version END, + serial_number = CASE WHEN COALESCE(serial_number,'') = '' AND $5 <> '' + THEN $5 ELSE serial_number END, + updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID, model, version, serial) + return err + }) +} diff --git a/server/internal/store/autochecks.go b/server/internal/store/autochecks.go index 89ecf29..d647028 100644 --- a/server/internal/store/autochecks.go +++ b/server/internal/store/autochecks.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" @@ -21,6 +22,32 @@ import ( // поки що просто обрізаємо й пишемо про це в журнал. const MaxInterfacesPerCheck = 256 +// Скільки рядків inv.interfaces узагалі читати за раз. +// +// Стеля вище — про розмір PDU й лишається недоторканою. Ця — про те, що +// фільтр складу портів застосовує Go, а отже кандидатів треба спершу +// прочитати: LIMIT 257, який стояв тут раніше, відрізав би порти ще до +// того, як зразок їх побачив, і «xe-0/0/47» просто не існувало б для +// фільтра. Число з великим запасом: найбільша коробка на стенді має 100 +// портів, шасі з повною набивкою — близько тисячі. +const maxInterfaceScan = 4096 + +// ifEligibleSQL — базові правила складу портів, спільні для чека й +// попереднього перегляду. +// +// Одним рядком, а не двома копіями, саме тому, що перегляд мусить +// показувати те, що станеться насправді. Розійшовшись на одному «і», +// два переліки почали б відрізнятись рівно на ті порти, через які +// людина й прийшла дивитись. +// +// Loopback і відсутні порти графіка не дають, а місце в PDU займають; +// `monitored` — ручний вимикач людини на конкретному порту, і фільтр +// шаблону його не скасовує. +const ifEligibleSQL = `monitored + AND if_index IS NOT NULL + AND COALESCE(type, '') <> 'softwareLoopback' + AND oper_status <> 'notPresent'` + // InterfaceCheckInterval — типовий інтервал опитування лічильників. // // 60 секунд — компроміс: частіше не має сенсу для 32-бітних лічильників @@ -49,6 +76,25 @@ type ifCheckTarget struct { // Повертає задачу для TaskDelta, якщо щось змінилось. nil означає // «нічого робити»: або немає SNMP-креденшела, або немає інтерфейсів, // або список не змінився з минулого разу. +// +// Чому це й далі Go, а не прототип шаблону (0059). Порт — теж рядок +// таблиці зі змінним індексом, і спокуса виразити його прототипом +// очевидна. Але snmp.if не збирає метрики: він повертає +// InterfaceCounters, а для цього зонд тримає попередній замір, рахує +// швидкості за ФАКТИЧНИМ інтервалом, ловить перевертання лічильника й +// ділить на номінальну швидкість порту. Прототип віддав би сирі октети +// без жодного з цих чотирьох умінь. +// +// Далі — прив'язки. Лічильники лягають у ts.if_counters за +// interface_id, а не в ts.samples за міткою; на цьому interface_id +// тримаються анімація трафіку на мапі, інспектор лінка, filtered_out_at +// і тригери з джерелом «interface». Прототип породжує серії з мітками й +// без interface_id. +// +// Тобто спільного між ними рівно одне слово — «таблиця». Прототипи +// покривають випадок, якого раніше не було покрито нічим; цей випадок +// покритий, і переносити його заради симетрії означало б обміняти +// найпомітнішу частину продукту на мінус двісті рядків. func (s *Store) EnsureInterfaceChecks(ctx context.Context, a *Agent, deviceID string) (*npv1.Task, error) { var task *npv1.Task @@ -71,36 +117,11 @@ func (s *Store) EnsureInterfaceChecks(ctx context.Context, a *Agent, deviceID st return nil } - rows, err := tx.Query(ctx, ` - SELECT if_index, id::text, COALESCE(speed_bps, 0) - FROM inv.interfaces - WHERE device_id = $1 - AND tenant_id = $2 - AND monitored - AND if_index IS NOT NULL - -- Loopback і відсутні порти графіка не дають, а місце - -- в PDU займають. - AND COALESCE(type, '') <> 'softwareLoopback' - AND oper_status <> 'notPresent' - ORDER BY if_index - LIMIT $3 - `, deviceID, a.TenantID, MaxInterfacesPerCheck+1) + targets, err := selectInterfaceTargets(ctx, tx, a.TenantID, deviceID) if err != nil { return err } - defer rows.Close() - - params := ifCheckParams{UseHCCounters: true} - for rows.Next() { - var t ifCheckTarget - if err := rows.Scan(&t.IfIndex, &t.InterfaceID, &t.SpeedBps); err != nil { - return err - } - params.Interfaces = append(params.Interfaces, t) - } - if err := rows.Err(); err != nil { - return err - } + params := ifCheckParams{UseHCCounters: true, Interfaces: targets} truncated := false if len(params.Interfaces) > MaxInterfacesPerCheck { @@ -216,6 +237,229 @@ func sameInterfaceSet(oldJSON string, newJSON []byte) bool { return true } +// selectInterfaceTargets вирішує, які порти хоста потраплять у чек. +// +// Одне місце на два виклики (перебудова після автовиявлення й +// перебудова після зміни шаблонів) — саме тому, що правило «який порт +// опитувати» мусить бути одне. Два його примірники розійшлися б, і +// склад чека залежав би від того, що саме щойно сталося. +// +// Заразом розставляє позначки `filtered_out_at` на самих портах: без +// них обрив графіка нічим не відрізнити від аварії. +func selectInterfaceTargets(ctx context.Context, tx pgx.Tx, tenantID, deviceID string) ([]ifCheckTarget, error) { + // Фільтри читаємо ДО портів: якщо серед них є зіпсований, склад + // чека краще не чіпати взагалі, ніж перебудувати його за половиною + // правил. + set, err := deviceInterfaceFilters(ctx, tx, deviceID) + if err != nil { + return nil, err + } + + rows, err := tx.Query(ctx, ` + SELECT if_index, id::text, COALESCE(speed_bps, 0), + name, COALESCE(alias, ''), COALESCE(type, '') + FROM inv.interfaces + WHERE device_id = $1 + AND tenant_id = $2 + AND `+ifEligibleSQL+` + ORDER BY if_index + LIMIT $3 + `, deviceID, tenantID, maxInterfaceScan) + if err != nil { + return nil, err + } + defer rows.Close() + + var ( + targets []ifCheckTarget + // Порти, які пройшли базові правила, але не пройшли фільтр. + excluded []string + eligible int + ) + for rows.Next() { + var t ifCheckTarget + var in InterfaceCandidate + if err := rows.Scan(&t.IfIndex, &t.InterfaceID, &t.SpeedBps, + &in.Name, &in.Alias, &in.Type); err != nil { + return nil, err + } + eligible++ + if set.match(in) { + targets = append(targets, t) + } else { + excluded = append(excluded, t.InterfaceID) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Фільтр, який не пропустив НІЧОГО, — це майже завжди помилка в + // зразку, а не намір: порожній snmp.if-чек ззовні не відрізняється + // від справного, поки хтось не помітить, що метрик портів немає вже + // тиждень. + // + // Тому склад чека лишається таким, який був, а транзакція + // відкочується цілком — разом із позначками на портах. Викличник + // пише це в журнал, а людина бачить те саме число в попередньому + // перегляді ще до збереження. + if !set.empty() && eligible > 0 && len(targets) == 0 { + return nil, fmt.Errorf("%w: пристрій %s, портів під наглядом %d, фільтр шаблонів (%s) не пропустив жодного — склад чека лишено без змін", + ErrInterfaceFilterEmpty, deviceID, eligible, strings.Join(set.names, ", ")) + } + + if err := markFilteredOut(ctx, tx, tenantID, deviceID, excluded); err != nil { + return nil, err + } + return targets, nil +} + +// ApplyInterfaceFilters переписує склад уже наявного snmp.if-чека. +// +// Потрібна тоді, коли змінились не порти, а правила: шаблон причепили, +// відчепили або поправили в ньому зразок. Чекати наступного обходу +// автовиявлення означало б, що людина натиснула «Зберегти» й нічого не +// сталося — а «нічого» тут може тривати годинами. +// +// Чека НЕ створює навмисно: створення прив'язане до звіту +// автовиявлення, бо лише там відомо, який агент опитує цей хост і кому +// штовхати оновлений план. Тут ми лише приводимо у відповідність те, що +// вже є; агент забере новий склад разом із наступною звіркою плану. +func (s *Store) ApplyInterfaceFilters(ctx context.Context, tenantID, deviceID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return applyInterfaceFiltersTx(ctx, tx, tenantID, deviceID) + }) +} + +func applyInterfaceFiltersTx(ctx context.Context, tx pgx.Tx, tenantID, deviceID string) error { + var checkID string + if err := tx.QueryRow(ctx, ` + SELECT id::text FROM core.checks + WHERE device_id = $1 AND tenant_id = $2 AND check_type = 'snmp.if' + ORDER BY created_at LIMIT 1 + `, deviceID, tenantID).Scan(&checkID); err != nil { + if isNoRows(err) { + return nil + } + return err + } + + targets, err := selectInterfaceTargets(ctx, tx, tenantID, deviceID) + switch { + case errors.Is(err, ErrInterfaceFilterEmpty): + // Тут ця відмова не має нікуди підійматись. Реконсиляція + // шаблонів іде і в потоці розпізнавання хоста, і в масовій + // правці — там помилка одного фільтра поклала б увесь звіт, а + // цей самий факт і так буде сказаний тричі: 409 при збереженні + // шаблону, попередження в журналі автовиявлення й число «0 зі + // 100» у попередньому перегляді. + // + // Склад чека при цьому лишається попереднім — саме те, чого ми + // й хочемо: перестати збирати все відразу гірше, ніж збирати + // зайве. + return nil + case err != nil: + return err + } + if len(targets) > MaxInterfacesPerCheck { + targets = targets[:MaxInterfacesPerCheck] + } + // Порожній склад чека не пишемо ніколи: або фільтр помилковий (і + // тоді вище вже була помилка), або портів справді не лишилось — і + // тоді чек має дочекатись автовиявлення, яке одне знає, чи це + // назавжди. + if len(targets) == 0 { + return nil + } + + payload, err := json.Marshal(ifCheckParams{UseHCCounters: true, Interfaces: targets}) + if err != nil { + return err + } + _, err = tx.Exec(ctx, ` + UPDATE core.checks SET params = $2::jsonb, updated_at = now() + WHERE id = $1 + `, checkID, string(payload)) + return err +} + +// ErrInterfaceFilterEmpty — фільтри шаблонів не пропустили жодного порту. +// +// Окрема помилка, а не просто текст: її ловлять і сервер (щоб не +// перебудовувати чек), і HTTP-шар (щоб відмовити в збереженні шаблону +// зрозумілим кодом, а не «внутрішньою помилкою»). +var ErrInterfaceFilterEmpty = errors.New("фільтр портів не пропустив жодного порту") + +// deviceInterfaceFilters збирає фільтри всіх шаблонів хоста. +// +// Шаблони без фільтра пропускаються: відсутність думки про порти не є +// думкою «брати все» — інакше один шаблон загального призначення +// знецінював би фільтр іншого. +// +// Зіпсований фільтр (не компілюється) — це помилка, а не «беремо все». +// Розширити склад чека зі ста портів назад до ста через друкарську +// помилку в чужому шаблоні гірше, ніж лишити все як є й написати про це. +func deviceInterfaceFilters(ctx context.Context, tx pgx.Tx, deviceID string) (*interfaceFilterSet, error) { + rows, err := tx.Query(ctx, ` + SELECT t.name, t.if_filter::text + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.device_id = $1 + ORDER BY t.name + `, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + + set := &interfaceFilterSet{} + for rows.Next() { + var name, raw string + if err := rows.Scan(&name, &raw); err != nil { + return nil, err + } + f := parseInterfaceFilter(raw) + if f.IsEmpty() { + continue + } + cf, err := compileInterfaceFilter(f) + if err != nil { + return nil, fmt.Errorf("фільтр портів шаблону «%s»: %w", name, err) + } + set.names = append(set.names, name) + set.filters = append(set.filters, cf) + } + return set, rows.Err() +} + +// markFilteredOut ставить і знімає позначку «виключено фільтром». +// +// Двома запитами, а не одним UPDATE по всіх портах: обидва мають умову +// на поточне значення, тож порт, стан якого не змінився, не оновлюється +// взагалі. Це не мікрооптимізація — на кожному порту висить тригер +// touch_updated_at, і сліпий UPDATE ворушив би `updated_at` сотні портів +// щохвилини, роблячи це поле марним. +func markFilteredOut(ctx context.Context, tx pgx.Tx, tenantID, deviceID string, excluded []string) error { + if excluded == nil { + excluded = []string{} + } + if _, err := tx.Exec(ctx, ` + UPDATE inv.interfaces SET filtered_out_at = now() + WHERE device_id = $1 AND tenant_id = $2 + AND id = ANY($3::uuid[]) + AND filtered_out_at IS NULL + `, deviceID, tenantID, excluded); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + UPDATE inv.interfaces SET filtered_out_at = NULL + WHERE device_id = $1 AND tenant_id = $2 + AND NOT (id = ANY($3::uuid[])) + AND filtered_out_at IS NOT NULL + `, deviceID, tenantID, excluded) + return err +} + // PlanHash перераховує хеш плану без побудови самого плану. // // Потрібен після зміни чеків: агент має отримати новий хеш разом із @@ -228,3 +472,120 @@ func (s *Store) PlanHash(ctx context.Context, a *Agent) ([]byte, error) { } return plan.GetPlanHash(), nil } + +// Два інтервали, бо в чека розпізнавання дві різні ролі. +// +// Поки хост не розпізнано, від цього чека залежить усе інше: підбір +// шаблонів, підбір профілю збору конфігів. Людина щойно завела хост і +// дивиться на екран — тут дорога кожна хвилина. +// +// Щойно розпізнано, питання закрите й лишається тільки сторожова +// функція: залізо в шафі міняють, і картка має помітити це сама. +// Три OID раз на шість годин — нижче за рівень шуму на будь-якому +// пристрої. +// +// Розклад агента кладе задачі на сітку інтервалу: при шести годинах +// перший запуск міг би припасти й на п'яту годину очікування. Саме +// тому початковий інтервал малий, а не тому, що дані швидко старіють. +const ( + IdentifyProbeInterval = 2 * time.Minute + IdentifyCheckInterval = 6 * time.Hour +) + +// IdentifyCheckType — тип чека розпізнавання. +// +// Власний тип, а не полегшений `topology.discover`, хоч модуль той +// самий: у списку перевірок хоста рядок мусить називати те, що робить. +// «Neighbor Discovery», який не шукає жодного сусіда, — це підказка, +// що бреше. +// +// Префікс `topology.` обов'язковий: Registry.Register вимагає, щоб тип +// чека починався з ключа модуля. +const IdentifyCheckType = "topology.identify" + +// EnsureIdentifyChecks заводить чек розпізнавання хостам, які його +// потребують. +// +// Підбір профілю збору конфігів і шаблонів опитування спирається на +// sysObjectID — а він нізвідки не береться, доки хост нічим не +// опитують. Виходило замкнене коло: автоматика чекає на дані, дані +// чекають на те, що людина руками заведе чек автовиявлення. +// +// Розриваємо його тут: хост має SNMP-доступ — значить, дозволено його +// запитати, хто він такий. +// +// Чек не створюється, якщо автовиявлення вже налаштоване вручну: його +// системна група так само заповнить поля, а два чеки одного типу лише +// подвоїли б трафік. +func (s *Store) EnsureIdentifyChecks(ctx context.Context, a *Agent) (int, error) { + var made int + err := s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error { + // Приводимо інтервал до ролі: нерозпізнаним — часто, + // розпізнаним — рідко. В обидва боки, бо пристрій може і + // зникнути з обліку, і повернутись іншою моделлю. + if _, err := tx.Exec(ctx, ` + UPDATE core.checks ch + SET interval_sec = want.sec, updated_at = now() + FROM inv.devices d, + LATERAL (SELECT CASE WHEN d.sys_object_id IS NULL + THEN $3::int ELSE $4::int END) AS want(sec) + WHERE d.id = ch.device_id + AND ch.tenant_id = $1 + AND d.agent_id = $2 + AND ch.check_type = $5 + AND ch.interval_sec <> want.sec + `, a.TenantID, a.ID, + int32(IdentifyProbeInterval/time.Second), + int32(IdentifyCheckInterval/time.Second), + IdentifyCheckType); err != nil { + return err + } + + // Чек заводиться будь-якому хосту зі SNMP-доступом, а не лише + // нерозпізнаному. + // + // Спершу умова була «ще не розпізнаний»: чек потрібен, щоб + // хост розпізнати, а далі він просто лишається. Хибність цього + // міркування показав живий стенд: коли чек зникав з іншої + // причини, він не повертався НІКОЛИ — хост уже розпізнаний, під + // умову не підпадає, і сторожова функція (помітити заміну + // заліза) мовчки припинялась назавжди. + // + // Різниця між «розпізнати» і «стежити» лишилась там, де їй + // місце, — в інтервалі вище: дві хвилини проти шести годин. + return tx.QueryRow(ctx, ` + WITH candidates AS ( + SELECT d.id + FROM inv.devices d + WHERE d.tenant_id = $1 + AND d.agent_id = $2 + AND d.enabled + AND d.deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE dc.device_id = d.id + AND c.tenant_id = d.tenant_id + AND c.proto IN ('snmp_v2c','snmp_v3') + ) + -- Ні свого чека, ні вручну заведеного автовиявлення: + -- воно знімає ту саму системну групу, і другий чек + -- лише подвоїв би трафік. + AND NOT EXISTS ( + SELECT 1 FROM core.checks ch + WHERE ch.device_id = d.id + AND ch.tenant_id = d.tenant_id + AND ch.check_type IN ($3, 'topology.discover') + ) + ), ins AS ( + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries) + SELECT $1, candidates.id, $3, '{}'::jsonb, $4, 10000, 1 + FROM candidates + RETURNING 1 + ) + SELECT count(*) FROM ins + `, a.TenantID, a.ID, IdentifyCheckType, int32(IdentifyProbeInterval/time.Second)).Scan(&made) + }) + return made, err +} diff --git a/server/internal/store/checks.go b/server/internal/store/checks.go index 171070d..aef366d 100644 --- a/server/internal/store/checks.go +++ b/server/internal/store/checks.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/netpulse/netpulse/server/internal/crypto" ) @@ -24,6 +25,11 @@ type CheckType struct { Metrics json.RawMessage `json:"metrics"` /** Чи ввімкнений плагін у цього тенанта. */ Available bool `json:"available"` + // Чек заводить і планує система. З довідника не прибирається — + // перевірка типів при збереженні має його знати, — але в переліку + // для ручного додавання йому не місце: система вже завела свій, + // і другий такий самий упирався б у checks_uniq. + AutoManaged bool `json:"auto_managed"` } // Check — одна перевірка на хості. @@ -52,7 +58,8 @@ func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckTyp -- тенанту або він базовий: вимагати «встановлення» -- для пінга означало б зустрічати кожного клієнта -- порожнім списком перевірок. - (p.is_core OR COALESCE(pi.enabled, false)) + (p.is_core OR COALESCE(pi.enabled, false)), + ct.auto_managed FROM core.check_types ct JOIN core.plugins p ON p.key = ct.plugin_key LEFT JOIN core.plugin_installs pi @@ -67,7 +74,7 @@ func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckTyp var c CheckType var schema, metrics string if err := rows.Scan(&c.Key, &c.PluginKey, &c.Name, &schema, &metrics, - &c.Available); err != nil { + &c.Available, &c.AutoManaged); err != nil { return err } c.ParamsSchema = json.RawMessage(schema) @@ -194,6 +201,16 @@ func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, RETURNING id::text `, tenantID, deviceID, c.CheckType, params, c.IntervalSec, c.TimeoutMs, c.Retries, c.Enabled).Scan(&id); err != nil { + // Дубль ловимо окремо: checks_uniq спрацьовує на + // (хост, тип, інтерфейс, параметри), і для людини це + // не збій сервера, а «такий чек уже є». Без цієї + // гілки вона бачила б «внутрішню помилку» й не мала + // жодної підказки, що робити далі. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return fmt.Errorf("%w: перевірка %s з такими ж параметрами "+ + "у цього хоста вже є", ErrInvalid, c.CheckType) + } return fmt.Errorf("перевірка %s: %w", c.CheckType, err) } } @@ -205,12 +222,26 @@ func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, // Породжені шаблоном — так само: ними володіє реконсиляція, і // видалення тут означало б, що вони зникають на кожне // збереження форми, щоб за секунду з'явитися знову. + // + // І окремо — автокеровані типи. Їх ховають від переліку для + // ручного додавання, і саме тому вони НЕ приходять назад у + // цьому запиті: форма про них не знає, а отже не може їх + // «лишити». Без цієї умови кожне збереження форми тихо зносило + // б розпізнавання хоста й опитування портів — тобто ту саму + // автоматику, заради якої їх і сховали. + // + // Помилку внесено разом із auto_managed і знайдено на живому + // стенді: у п'яти хостів із шести зник topology.identify. _, err := tx.Exec(ctx, ` - DELETE FROM core.checks - WHERE tenant_id = $1 AND device_id = $2 - AND interface_id IS NULL - AND template_id IS NULL - AND NOT (id = ANY($3::uuid[])) + DELETE FROM core.checks c + WHERE c.tenant_id = $1 AND c.device_id = $2 + AND c.interface_id IS NULL + AND c.template_id IS NULL + AND NOT (c.id = ANY($3::uuid[])) + AND NOT EXISTS ( + SELECT 1 FROM core.check_types ct + WHERE ct.key = c.check_type AND ct.auto_managed + ) `, tenantID, deviceID, nonNilIDs(keep)) return err }) diff --git a/server/internal/store/dashboards_public.go b/server/internal/store/dashboards_public.go index a584273..542743b 100644 --- a/server/internal/store/dashboards_public.go +++ b/server/internal/store/dashboards_public.go @@ -46,7 +46,7 @@ func (s *Store) DashboardByPublicToken(ctx context.Context, token string) (strin tenantID string d Dashboard ) - err := s.pool.QueryRow(ctx, ` + err := s.bg.QueryRow(ctx, ` SELECT tenant_id::text, id::text, name, slug, kind::text, refresh_sec, is_default FROM core.dashboards WHERE public_token = $1 `, token).Scan(&tenantID, &d.ID, &d.Name, &d.Slug, &d.Kind, &d.RefreshSec, &d.IsDefault) diff --git a/server/internal/store/device_detail.go b/server/internal/store/device_detail.go new file mode 100644 index 0000000..5090940 --- /dev/null +++ b/server/internal/store/device_detail.go @@ -0,0 +1,708 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Картка хоста: усе, що система про нього знає, розкладене по полицях. +// +// Головне питання, на яке відповідає цей файл, — «звідки взялась ця +// метрика». Досі відповіді не існувало ніде: перелік рядів +// (`ListSeries`) показував ЩО зібрано, перелік перевірок +// (`ListDeviceChecks`) показував лише РУЧНІ, а шаблонні — ті, що й +// збирають майже все, — не показував узагалі. Між двома половинами +// зяяла діра, і саме в неї провалювались чотириста рядів, які +// збираються без жодного видимого чека. + +// MetricOrigin — походження одного ряду метрик. +// +// Kind відповідає на питання «хто це завів і де це міняти»: +// +// template — чек прийшов із шаблону; правити треба в шаблоні +// auto — чек завела сама система (порти, розпізнавання) +// manual — чек завела людина в картці хоста +// none — чека немає; ряд лишився від колишнього збору +// +// Останній випадок не теоретичний: на стенді 466 рядів `if.*` живуть +// без жодного snmp.if-чека й не оновлюються з учора. Без цього поля +// вони виглядають як звичайні метрики зі старим значенням — тобто як +// збій збору, а не як історія. +type MetricOrigin struct { + Kind string `json:"kind"` + CheckID string `json:"check_id,omitempty"` + CheckType string `json:"check_type,omitempty"` + CheckName string `json:"check_name,omitempty"` + Enabled bool `json:"enabled"` + IntervalSec int `json:"interval_sec,omitempty"` + TemplateID string `json:"template_id,omitempty"` + TemplateName string `json:"template_name,omitempty"` + ItemName string `json:"item_name,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// LatestMetric — рядок розділу «Останні дані». +type LatestMetric struct { + SeriesID int64 `json:"series_id"` + MetricKey string `json:"metric_key"` + Unit string `json:"unit,omitempty"` + PluginKey string `json:"plugin_key,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + InterfaceID string `json:"interface_id,omitempty"` + InterfaceName string `json:"interface_name,omitempty"` + LastValue *float64 `json:"last_value,omitempty"` + LastAt *time.Time `json:"last_at,omitempty"` + // Stale рахує сервер, а не браузер: поріг залежить від інтервалу + // чека, а інтервал знає лише той бік, який щойно зіставив ряд із + // чеком. Порахований у браузері, він розійшовся б із цим + // зіставленням при першій же зміні правила. + Stale bool `json:"stale"` + Origin MetricOrigin `json:"origin"` +} + +// DeviceCheck — перевірка хоста разом із тим, хто її завів. +// +// Окремо від store.Check навмисно: той тип обслуговує РЕДАКТОР ручних +// перевірок, і додати до нього шаблонні означало б показати в редакторі +// те, що з нього не редагується. Тут — перелік на читання, і в нього +// входить усе, що справді опитує хост. +type DeviceCheck struct { + ID string `json:"id"` + CheckType string `json:"check_type"` + TypeName string `json:"type_name,omitempty"` + Params json.RawMessage `json:"params"` + IntervalSec int `json:"interval_sec"` + TimeoutMs int `json:"timeout_ms"` + Retries int `json:"retries"` + Enabled bool `json:"enabled"` + AutoManaged bool `json:"auto_managed"` + Origin string `json:"origin"` + TemplateID string `json:"template_id,omitempty"` + TemplateName string `json:"template_name,omitempty"` + ItemKey string `json:"template_item_key,omitempty"` + InterfaceID string `json:"interface_id,omitempty"` + InterfaceName string `json:"interface_name,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + NextRunAt *time.Time `json:"next_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` + // Метрики, які цей ТИП уміє віддавати (довідник core.check_types). + Metrics []string `json:"metrics"` + // Скільки рядів система віднесла саме до цієї перевірки. Нуль у + // ввімкненого чека — найцікавіший рядок у переліку: опитування йде, + // а даних немає. + SeriesCount int `json:"series_count"` +} + +// DeviceLatest — відповідь розділу «Останні дані». +type DeviceLatest struct { + Metrics []LatestMetric `json:"metrics"` + Checks []DeviceCheck `json:"checks"` + // Ряди, для яких чека не знайшлось. Дублює лічильник у Metrics, + // але потрібен окремо: саме він відповідає на питання «чому в + // хоста без шаблонів усе одно є метрики». + Orphans int `json:"orphans"` +} + +// staleFloor — мінімальний поріг «не оновлюється». +// +// Навіть у хвилинного чека між тактами буває пропуск: зонд перечитує +// чергу, пристрій відповідає з другої спроби. Позначати таке червоним +// означало б привчити не дивитись на позначку взагалі. +const staleFloor = 5 * time.Minute + +// staleNoCheck — поріг для ряду, за яким не стоїть жодна перевірка. +// +// Такий ряд не оновиться вже ніколи, але кричати про це в першу ж +// хвилину після зняття шаблону — зайве: людина щойно це й зробила. +const staleNoCheck = 15 * time.Minute + +// DeviceLatestData — усі метрики хоста з останнім значенням і походженням. +// +// Два запити, а не N+1: один по рядах (з LATERAL на останню точку) і +// один по перевірках. Зіставлення робиться в Go — перевірок у хоста +// одиниці, а виражати пріоритет зіставлення в SQL означало б написати +// CASE на чотири гілки й потім не мати змоги його перевірити тестом. +// +// Про LATERAL замість DISTINCT ON: заміряно на стенді, хост зі 200 +// рядами, 63 тис. вибірок. DISTINCT ON читає гіпертаблицю ЦІЛКОМ +// (Seq Scan по всіх чанках) — 20.1 мс, і зростає разом з історією. +// LATERAL робить один індексний доторк на ряд у кожному чанку — +// 0.78 мс, і зростає разом із кількістю рядів, тобто з тим, що +// показуємо. Через рік retention різниця буде не в 25 разів. +func (s *Store) DeviceLatestData(ctx context.Context, tenantID, deviceID string) (*DeviceLatest, error) { + out := &DeviceLatest{Metrics: []LatestMetric{}, Checks: []DeviceCheck{}} + + checks, err := s.DeviceChecksAll(ctx, tenantID, deviceID) + if err != nil { + return nil, err + } + out.Checks = checks + + // Назви елементів шаблонів: metric_key → людська назва. Саме вона + // відповідає на «що це число означає» — ключ `sys.uptime_sec` це + // пояснює, а `1.3.6.1.2.1.1.3.0` вже ні. + itemNames, err := s.deviceTemplateItemNames(ctx, tenantID, deviceID) + if err != nil { + return nil, err + } + + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT s.id, s.metric_key, COALESCE(s.unit,''), COALESCE(s.plugin_key::text,''), + s.labels::text, + COALESCE(s.interface_id::text,''), COALESCE(i.name,''), + l.value, l.ts + FROM ts.series s + LEFT JOIN inv.interfaces i ON i.id = s.interface_id + LEFT JOIN LATERAL ( + SELECT m.value, m.ts FROM ts.samples m + WHERE m.series_id = s.id ORDER BY m.ts DESC LIMIT 1 + ) l ON true + WHERE s.tenant_id = $1 AND s.device_id = $2 + ORDER BY s.metric_key, i.if_index NULLS FIRST, i.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var m LatestMetric + var labels string + if err := rows.Scan(&m.SeriesID, &m.MetricKey, &m.Unit, &m.PluginKey, + &labels, &m.InterfaceID, &m.InterfaceName, + &m.LastValue, &m.LastAt); err != nil { + return err + } + m.Labels = decodeLabels(labels) + out.Metrics = append(out.Metrics, m) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + + now := time.Now() + seriesPerCheck := map[string]int{} + for i := range out.Metrics { + m := &out.Metrics[i] + m.Origin = matchOrigin(m, checks, itemNames) + if m.Origin.CheckID != "" { + seriesPerCheck[m.Origin.CheckID]++ + } else { + out.Orphans++ + } + m.Stale = isStale(m, now) + } + for i := range out.Checks { + out.Checks[i].SeriesCount = seriesPerCheck[out.Checks[i].ID] + } + return out, nil +} + +// isStale — чи перестав ряд оновлюватись. +func isStale(m *LatestMetric, now time.Time) bool { + if m.LastAt == nil { + return true + } + limit := staleNoCheck + if m.Origin.IntervalSec > 0 && m.Origin.Enabled { + // Три такти: один пропуск — це життя, три поспіль — це збій. + if d := 3 * time.Duration(m.Origin.IntervalSec) * time.Second; d > staleFloor { + limit = d + } else { + limit = staleFloor + } + } + return now.Sub(*m.LastAt) > limit +} + +// checkParams — те з params, що дозволяє впізнати «свої» ряди. +type checkParams struct { + // snmp.get: людина (або шаблон) прямо назвала ключ метрики. + OIDs []struct { + MetricKey string `json:"metric_key"` + } `json:"oids"` + // snmp.if: перелік портів складає автовиявлення. + Interfaces []struct { + InterfaceID string `json:"interface_id"` + } `json:"interfaces"` +} + +// matchOrigin зіставляє ряд із перевіркою, яка його наповнює. +// +// Прив'язки ряду до чека в схемі немає: агент реєструє серію по +// (пристрій, ключ метрики, мітки), а не по чеку, і правильно робить — +// одна серія переживає перескладання пачки OID. Тож зіставлення — +// висновок, і робиться воно за спаданням надійності: +// +// 1. ключ метрики прямо названий у params чека (snmp.get) — точно він; +// 2. порт ряду є в переліку портів чека (snmp.if) — точно він; +// 3. ключ метрики стоїть у довіднику типу (core.check_types.metrics); +// 4. збігається лише плагін — це вже здогад, але кращий за «невідомо». +// +// Порядок важливіший за повноту: помилитись тут означає показати +// людині не той шаблон, у який іти щось міняти. +func matchOrigin(m *LatestMetric, checks []DeviceCheck, itemNames map[string]string) MetricOrigin { + best := -1 + bestPri := 99 + + for i := range checks { + c := &checks[i] + pri := 99 + + var p checkParams + if len(c.Params) > 0 { + _ = json.Unmarshal(c.Params, &p) + } + + for _, o := range p.OIDs { + if o.MetricKey != "" && o.MetricKey == m.MetricKey { + pri = 1 + break + } + } + if pri > 2 && m.InterfaceID != "" { + for _, iface := range p.Interfaces { + if iface.InterfaceID == m.InterfaceID { + pri = 2 + break + } + } + } + if pri > 3 { + for _, mk := range c.Metrics { + if mk == m.MetricKey { + pri = 3 + break + } + } + } + if pri > 4 && m.PluginKey != "" && pluginOf(c.CheckType) == m.PluginKey { + pri = 4 + } + + if pri < bestPri { + best, bestPri = i, pri + } + } + + if best < 0 || bestPri == 99 { + return MetricOrigin{Kind: "none", ItemName: itemNames[m.MetricKey]} + } + + c := &checks[best] + return MetricOrigin{ + Kind: c.Origin, + CheckID: c.ID, + CheckType: c.CheckType, + CheckName: c.TypeName, + Enabled: c.Enabled, + IntervalSec: c.IntervalSec, + TemplateID: c.TemplateID, + TemplateName: c.TemplateName, + ItemName: itemNames[m.MetricKey], + LastRunAt: c.LastRunAt, + LastError: c.LastError, + } +} + +// pluginOf — «icmp.ping» → «icmp». Обмеження check_types_prefix_matches_plugin +// гарантує, що ключ типу завжди починається з ключа плагіна. +func pluginOf(checkType string) string { + if i := strings.IndexByte(checkType, '.'); i > 0 { + return checkType[:i] + } + return checkType +} + +// DeviceChecksAll — УСІ перевірки хоста, включно зі шаблонними. +// +// ListDeviceChecks навмисно віддає лише ручні: він годує редактор, а +// шаблонний чек звідти не редагується. Але людині, яка питає «що взагалі +// опитує цей хост», потрібні саме всі — інакше на екрані стоїть «одна +// перевірка», а метрик приїжджає шість. +func (s *Store) DeviceChecksAll(ctx context.Context, tenantID, deviceID string) ([]DeviceCheck, error) { + out := []DeviceCheck{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT c.id::text, c.check_type, COALESCE(ct.name,''), c.params::text, + c.interval_sec, c.timeout_ms, c.retries, c.enabled, + COALESCE(ct.auto_managed,false), + COALESCE(c.template_id::text,''), COALESCE(t.name,''), + COALESCE(c.template_item_key,''), + COALESCE(c.interface_id::text,''), COALESCE(i.name,''), + c.last_run_at, c.next_run_at, COALESCE(c.last_error,''), + COALESCE(ct.metrics::text,'[]') + FROM core.checks c + LEFT JOIN core.check_types ct ON ct.key = c.check_type + LEFT JOIN tpl.templates t ON t.id = c.template_id + LEFT JOIN inv.interfaces i ON i.id = c.interface_id + WHERE c.tenant_id = $1 AND c.device_id = $2 + ORDER BY c.check_type, t.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var c DeviceCheck + var params, metrics string + if err := rows.Scan(&c.ID, &c.CheckType, &c.TypeName, ¶ms, + &c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled, + &c.AutoManaged, &c.TemplateID, &c.TemplateName, &c.ItemKey, + &c.InterfaceID, &c.InterfaceName, + &c.LastRunAt, &c.NextRunAt, &c.LastError, &metrics); err != nil { + return err + } + c.Params = json.RawMessage(params) + c.Metrics = []string{} + _ = json.Unmarshal([]byte(metrics), &c.Metrics) + switch { + case c.TemplateID != "": + c.Origin = "template" + case c.AutoManaged: + c.Origin = "auto" + default: + c.Origin = "manual" + } + out = append(out, c) + } + return rows.Err() + }) + return out, err +} + +// deviceTemplateItemNames — назви елементів шаблонів, прив'язаних до хоста. +// +// Ключ — metric_key, бо саме він є в ряді. Збіг ключа в двох шаблонах +// означає одну й ту саму метрику, названу двічі; беремо першу за +// алфавітом шаблону, щоб підпис не стрибав між перезавантаженнями. +func (s *Store) deviceTemplateItemNames(ctx context.Context, tenantID, deviceID string) (map[string]string, error) { + out := map[string]string{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT COALESCE(it.metric_key, ''), COALESCE(it.name, '') + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + JOIN tpl.items it ON it.template_id = dt.template_id + WHERE dt.tenant_id = $1 AND dt.device_id = $2 + ORDER BY t.name, it.key + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var key, name string + if err := rows.Scan(&key, &name); err != nil { + return err + } + // Порожній ключ трапляється: елемент icmp-шаблону описує + // перевірку, а не окрему метрику (їх у неї три). Такий + // запис зіставляти нема з чим, і в мапі він лише зайняв би + // ключ "" — тобто підписав би собою перший-ліпший ряд без + // ключа, якби такий колись з'явився. + // + // COALESCE у запиті обов'язковий: у tpl.items колонка + // NULLABLE, і в живих даних там саме NULL, а не порожній + // рядок. Без нього сканування падає, і сторінка хоста + // віддає «внутрішню помилку» замість усіх своїх даних — + // через один рядок довідника, який до цих даних не має + // стосунку. + if key == "" { + continue + } + if _, ok := out[key]; !ok { + out[key] = name + } + } + return rows.Err() + }) + return out, err +} + +// DeviceInterface — порт із автовиявлення разом з останнім трафіком. +type DeviceInterface struct { + ID string `json:"id"` + IfIndex *int64 `json:"if_index,omitempty"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Type string `json:"type,omitempty"` + MAC string `json:"mac,omitempty"` + MTU *int `json:"mtu,omitempty"` + SpeedBps *int64 `json:"speed_bps,omitempty"` + Duplex string `json:"duplex,omitempty"` + AdminStatus string `json:"admin_status"` + OperStatus string `json:"oper_status"` + IsUplink bool `json:"is_uplink"` + Monitored bool `json:"monitored"` + LastChange *time.Time `json:"last_change_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + // Останні лічильники — те, заради чого на порт і дивляться. + CountersAt *time.Time `json:"counters_at,omitempty"` + InBps *float64 `json:"in_bps,omitempty"` + OutBps *float64 `json:"out_bps,omitempty"` + UtilIn *float64 `json:"util_in_pct,omitempty"` + UtilOut *float64 `json:"util_out_pct,omitempty"` + InErrors *int64 `json:"in_errors,omitempty"` + OutErrors *int64 `json:"out_errors,omitempty"` + // Скільки рядів метрик прив'язано саме до цього порту: звідси + // людина переходить у «Останні дані», а не гадає, чи щось збирається. + SeriesCount int `json:"series_count"` +} + +// DeviceInterfaces — порти хоста з останніми лічильниками. +// +// Один запит на весь перелік: у JUN.QFX сто портів, і окремий запит на +// кожен перетворив би відкриття вкладки на сотню походів у базу. Заміряно +// на стенді: 0.90 мс на сто портів. +func (s *Store) DeviceInterfaces(ctx context.Context, tenantID, deviceID string) ([]DeviceInterface, error) { + out := []DeviceInterface{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT i.id::text, i.if_index, i.name, COALESCE(i.alias,''), + COALESCE(i.type,''), COALESCE(i.mac::text,''), i.mtu, + i.speed_bps, COALESCE(i.duplex,''), + i.admin_status::text, i.oper_status::text, + i.is_uplink, i.monitored, i.last_change_at, i.updated_at, + f.ts, f.in_bps, f.out_bps, f.util_in_pct, f.util_out_pct, + f.in_errors, f.out_errors, + COALESCE(sc.n, 0) + FROM inv.interfaces i + LEFT JOIN LATERAL ( + -- util_* лежать як real; приводимо явно, щоб не залежати + -- від того, чи вміє драйвер класти float4 у float64. + SELECT c.ts, c.in_bps, c.out_bps, + c.util_in_pct::double precision AS util_in_pct, + c.util_out_pct::double precision AS util_out_pct, + c.in_errors, c.out_errors + FROM ts.if_counters c + WHERE c.interface_id = i.id ORDER BY c.ts DESC LIMIT 1 + ) f ON true + LEFT JOIN ( + SELECT interface_id, count(*) AS n FROM ts.series + WHERE tenant_id = $1 AND device_id = $2 AND interface_id IS NOT NULL + GROUP BY interface_id + ) sc ON sc.interface_id = i.id + WHERE i.tenant_id = $1 AND i.device_id = $2 + ORDER BY i.if_index NULLS LAST, i.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var d DeviceInterface + if err := rows.Scan(&d.ID, &d.IfIndex, &d.Name, &d.Alias, &d.Type, + &d.MAC, &d.MTU, &d.SpeedBps, &d.Duplex, + &d.AdminStatus, &d.OperStatus, &d.IsUplink, &d.Monitored, + &d.LastChange, &d.UpdatedAt, + &d.CountersAt, &d.InBps, &d.OutBps, &d.UtilIn, &d.UtilOut, + &d.InErrors, &d.OutErrors, &d.SeriesCount); err != nil { + return err + } + out = append(out, d) + } + return rows.Err() + }) + return out, err +} + +// DeviceCounts — скільки чого система про хост зберігає. +// +// Це зміст картки, а не статистика: кожне число — це підпис під +// вкладкою, у яку по ньому й переходять. Порожня вкладка, про яку не +// сказано заздалегідь, читається як зламана. +type DeviceCounts struct { + Checks int `json:"checks"` + ChecksTemplate int `json:"checks_template"` + ChecksManual int `json:"checks_manual"` + ChecksAuto int `json:"checks_auto"` + Series int `json:"series"` + Interfaces int `json:"interfaces"` + InterfacesUp int `json:"interfaces_up"` + Configs int `json:"configs"` + Alerts int `json:"alerts"` +} + +// TemplateRef — шаблон, прив'язаний до хоста. +type TemplateRef struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Items int `json:"items"` +} + +// CredentialRef — доступ, прив'язаний до хоста. +type CredentialRef struct { + ID string `json:"id"` + Name string `json:"name"` + Proto string `json:"proto"` +} + +// DeviceOverview — вкладка «Огляд»: паспорт хоста й зміст решти вкладок. +type DeviceOverview struct { + // Розпізнане по SNMP. Разом із помилкою й часом: «модель порожня» + // і «розпізнавання впало годину тому» — різні діагнози, і лікуються + // вони по-різному. + SysObjectID string `json:"sys_object_id,omitempty"` + SysDescr string `json:"sys_descr,omitempty"` + SystemName string `json:"system_name,omitempty"` + FQDN string `json:"fqdn,omitempty"` + Serial string `json:"serial_number,omitempty"` + ChassisID string `json:"chassis_id,omitempty"` + BaseMAC string `json:"base_mac,omitempty"` + Source string `json:"source,omitempty"` + IdentifiedAt *time.Time `json:"identified_at,omitempty"` + IdentifyRequestedAt *time.Time `json:"identify_requested_at,omitempty"` + HardwareChangedAt *time.Time `json:"hardware_changed_at,omitempty"` + IdentifyError string `json:"identify_error,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` + Notes string `json:"notes,omitempty"` + + AgentID string `json:"agent_id,omitempty"` + AgentName string `json:"agent_name,omitempty"` + AgentStatus string `json:"agent_status,omitempty"` + + Templates []TemplateRef `json:"templates"` + Credentials []CredentialRef `json:"credentials"` + Counts DeviceCounts `json:"counts"` + + LastConfigAt *time.Time `json:"last_config_at,omitempty"` + LastJobStatus string `json:"last_job_status,omitempty"` + LastJobAt *time.Time `json:"last_job_at,omitempty"` + LastJobError string `json:"last_job_error,omitempty"` + // Остання успішна відповідь пінга — окремо від last_seen_at, бо + // той оновлює будь-який успішний чек. + LastPingAt *time.Time `json:"last_ping_at,omitempty"` +} + +// DeviceOverviewData — паспорт хоста одним запитом плюс два дрібні переліки. +// +// Скалярні підзапити, а не окремі походи: усі вони — count по індексу +// device_id, і планувальник рахує їх у тому самому проході. +func (s *Store) DeviceOverviewData(ctx context.Context, tenantID, deviceID string) (*DeviceOverview, error) { + o := &DeviceOverview{Templates: []TemplateRef{}, Credentials: []CredentialRef{}} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT COALESCE(d.sys_object_id,''), COALESCE(d.sys_descr,''), + COALESCE(d.system_name,''), COALESCE(d.fqdn,''), + COALESCE(d.serial_number,''), COALESCE(d.chassis_id,''), + COALESCE(d.base_mac::text,''), COALESCE(d.source,''), + d.identified_at, d.identify_requested_at, d.hardware_changed_at, + COALESCE(d.identify_error,''), d.status_changed_at, + COALESCE(d.notes,''), + COALESCE(d.agent_id::text,''), COALESCE(ag.name,''), + COALESCE(ag.status::text,''), + (SELECT count(*) FROM core.checks c WHERE c.device_id = d.id), + (SELECT count(*) FROM core.checks c + WHERE c.device_id = d.id AND c.template_id IS NOT NULL), + (SELECT count(*) FROM core.checks c + JOIN core.check_types ct ON ct.key = c.check_type + WHERE c.device_id = d.id AND c.template_id IS NULL AND ct.auto_managed), + (SELECT count(*) FROM ts.series s WHERE s.device_id = d.id), + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id), + (SELECT count(*) FROM inv.interfaces i + WHERE i.device_id = d.id AND i.oper_status = 'up'), + (SELECT count(*) FROM ncm.configs cf WHERE cf.device_id = d.id), + (SELECT max(cf.collected_at) FROM ncm.configs cf WHERE cf.device_id = d.id), + (SELECT count(*) FROM alr.alerts a + WHERE a.device_id = d.id AND a.state IN ('firing','acknowledged','suppressed')), + (SELECT max(ic.ts) FROM ts.icmp_samples ic + WHERE ic.device_id = d.id AND ic.reachable) + FROM inv.devices d + LEFT JOIN core.agents ag ON ag.id = d.agent_id + WHERE d.tenant_id = $1 AND d.id = $2 AND d.deleted_at IS NULL + `, tenantID, deviceID).Scan( + &o.SysObjectID, &o.SysDescr, &o.SystemName, &o.FQDN, + &o.Serial, &o.ChassisID, &o.BaseMAC, &o.Source, + &o.IdentifiedAt, &o.IdentifyRequestedAt, &o.HardwareChangedAt, + &o.IdentifyError, &o.StatusChangedAt, &o.Notes, + &o.AgentID, &o.AgentName, &o.AgentStatus, + &o.Counts.Checks, &o.Counts.ChecksTemplate, &o.Counts.ChecksAuto, + &o.Counts.Series, &o.Counts.Interfaces, &o.Counts.InterfacesUp, + &o.Counts.Configs, &o.LastConfigAt, &o.Counts.Alerts, &o.LastPingAt) + if err != nil { + return err + } + o.Counts.ChecksManual = o.Counts.Checks - o.Counts.ChecksTemplate - o.Counts.ChecksAuto + + trows, err := tx.Query(ctx, ` + SELECT t.id::text, t.key::text, t.name, + (SELECT count(*) FROM tpl.items it WHERE it.template_id = t.id) + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.tenant_id = $1 AND dt.device_id = $2 + ORDER BY t.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer trows.Close() + for trows.Next() { + var t TemplateRef + if err := trows.Scan(&t.ID, &t.Key, &t.Name, &t.Items); err != nil { + return err + } + o.Templates = append(o.Templates, t) + } + if err := trows.Err(); err != nil { + return err + } + + crows, err := tx.Query(ctx, ` + SELECT c.id::text, c.name, c.proto::text + FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE c.tenant_id = $1 AND dc.device_id = $2 + ORDER BY c.proto, c.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer crows.Close() + for crows.Next() { + var c CredentialRef + if err := crows.Scan(&c.ID, &c.Name, &c.Proto); err != nil { + return err + } + o.Credentials = append(o.Credentials, c) + } + if err := crows.Err(); err != nil { + return err + } + + // Останнє завдання збору конфігу — окремо від самих версій: + // найцікавіший стан це «версій три, а останній збір упав». + var status, jobErr string + var at *time.Time + err = tx.QueryRow(ctx, ` + SELECT status::text, COALESCE(error,''), COALESCE(finished_at, created_at) + FROM ncm.jobs + WHERE tenant_id = $1 AND device_id = $2 + ORDER BY created_at DESC LIMIT 1 + `, tenantID, deviceID).Scan(&status, &jobErr, &at) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + o.LastJobStatus, o.LastJobError, o.LastJobAt = status, jobErr, at + } + return nil + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return o, nil +} diff --git a/server/internal/store/device_detail_test.go b/server/internal/store/device_detail_test.go new file mode 100644 index 0000000..f235f89 --- /dev/null +++ b/server/internal/store/device_detail_test.go @@ -0,0 +1,181 @@ +package store + +import ( + "encoding/json" + "testing" + "time" +) + +// Зіставлення ряду з перевіркою — висновок, а не факт зі схеми. Тест +// стереже саме ПОРЯДОК висновків: помилка тут не падає й не світиться, +// вона тихо показує людині не той шаблон, у який іти щось міняти. +func TestMatchOriginPriority(t *testing.T) { + checks := []DeviceCheck{ + { + ID: "icmp", CheckType: "icmp.ping", Origin: "template", + TemplateID: "t1", TemplateName: "Доступність (ICMP)", Enabled: true, + IntervalSec: 60, Params: json.RawMessage(`{"count":3}`), + Metrics: []string{"icmp.rtt_avg", "icmp.loss_pct", "icmp.jitter"}, + }, + { + ID: "get", CheckType: "snmp.get", Origin: "template", + TemplateID: "t2", TemplateName: "Загальне (SNMP)", Enabled: true, + IntervalSec: 60, + Params: json.RawMessage( + `{"oids":[{"oid":".1.3.6.1.2.1.1.3.0","metric_key":"sys.uptime_sec"}]}`), + Metrics: []string{}, + }, + { + ID: "if", CheckType: "snmp.if", Origin: "auto", AutoManaged: true, + Enabled: true, IntervalSec: 60, + Params: json.RawMessage(`{"interfaces":[{"if_index":1,"interface_id":"IF-1"}]}`), + Metrics: []string{"if.in_bps", "if.out_bps"}, + }, + } + + cases := []struct { + name string + metric LatestMetric + wantID string + wantKnd string + }{ + { + // Ключ прямо названий у params — найнадійніше джерело. + name: "ключ метрики з params", + metric: LatestMetric{MetricKey: "sys.uptime_sec", PluginKey: "snmp"}, + wantID: "get", + wantKnd: "template", + }, + { + // Порт у переліку чека переважає збіг за довідником: обидва + // вказують на snmp.if, але перший знає ще й ЯКИЙ порт. + name: "порт із переліку чека", + metric: LatestMetric{ + MetricKey: "if.in_bps", PluginKey: "snmp", InterfaceID: "IF-1", + }, + wantID: "if", + wantKnd: "auto", + }, + { + // Той самий плагін snmp, що й у snmp.get, але ключ стоїть у + // довіднику саме snmp.if — довідник має бути сильнішим за + // збіг плагіна, інакше метрики портів приписались би до + // шаблонного snmp.get. + name: "довідник типу переважає плагін", + metric: LatestMetric{ + MetricKey: "if.out_bps", PluginKey: "snmp", InterfaceID: "IF-9", + }, + wantID: "if", + wantKnd: "auto", + }, + { + name: "довідник icmp", + metric: LatestMetric{MetricKey: "icmp.jitter", PluginKey: "icmp"}, + wantID: "icmp", + wantKnd: "template", + }, + { + // Плагін збігається, ключа немає ніде — здогад, але кращий + // за «невідомо»: чек icmp.ping у хоста один. + name: "тільки плагін", + metric: LatestMetric{MetricKey: "icmp.rtt_p95", PluginKey: "icmp"}, + wantID: "icmp", + wantKnd: "template", + }, + { + // Найважливіший випадок: серія є, чека немає. Саме такий + // стан на стенді має 466 рядів if.* — і саме він виглядав + // як «метрики беруться нізвідки». + name: "чека немає", + metric: LatestMetric{MetricKey: "cpu.util", PluginKey: "unknown"}, + wantID: "", + wantKnd: "none", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := matchOrigin(&c.metric, checks, map[string]string{}) + if got.CheckID != c.wantID { + t.Errorf("check_id = %q, очікували %q", got.CheckID, c.wantID) + } + if got.Kind != c.wantKnd { + t.Errorf("kind = %q, очікували %q", got.Kind, c.wantKnd) + } + }) + } +} + +func TestIsStale(t *testing.T) { + now := time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC) + at := func(d time.Duration) *time.Time { t := now.Add(-d); return &t } + + cases := []struct { + name string + m LatestMetric + want bool + }{ + { + // Один пропуск у хвилинного чека — це життя, а не збій. + name: "пропуск такту", + m: LatestMetric{ + LastAt: at(90 * time.Second), + Origin: MetricOrigin{IntervalSec: 60, Enabled: true}, + }, + want: false, + }, + { + // П'ятихвилинний чек: три такти — це чверть години, і саме + // вони, а не нижня межа, вирішують. + name: "три такти поспіль", + m: LatestMetric{ + LastAt: at(20 * time.Minute), + Origin: MetricOrigin{IntervalSec: 300, Enabled: true}, + }, + want: true, + }, + { + // Нижня межа тримає хвилинний чек у спокої довше, ніж три + // такти: пропуск на такій частоті — не подія. + name: "хвилинний чек, п'ять хвилин мовчання", + m: LatestMetric{ + LastAt: at(5 * time.Minute), + Origin: MetricOrigin{IntervalSec: 60, Enabled: true}, + }, + want: false, + }, + { + // Секундний чек не має ставати червоним щохвилини — нижня + // межа порога тримає його в спокої. + name: "дуже частий чек", + m: LatestMetric{ + LastAt: at(2 * time.Minute), + Origin: MetricOrigin{IntervalSec: 5, Enabled: true}, + }, + want: false, + }, + { + name: "ряд без чека, дані свіжі", + m: LatestMetric{LastAt: at(time.Minute), Origin: MetricOrigin{Kind: "none"}}, + want: false, + }, + { + name: "ряд без чека, дані вчорашні", + m: LatestMetric{LastAt: at(20 * time.Hour), Origin: MetricOrigin{Kind: "none"}}, + want: true, + }, + { + name: "жодної точки", + m: LatestMetric{Origin: MetricOrigin{IntervalSec: 60, Enabled: true}}, + want: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isStale(&c.m, now); got != c.want { + t.Errorf("isStale = %v, очікували %v", got, c.want) + } + }) + } +} diff --git a/server/internal/store/device_filter.go b/server/internal/store/device_filter.go new file mode 100644 index 0000000..029f35a --- /dev/null +++ b/server/internal/store/device_filter.go @@ -0,0 +1,102 @@ +package store + +import ( + "fmt" + "strings" +) + +// Одна умова DeviceFilter — на всі запити, що ним відбирають. +// +// Навіщо окремий файл. Той самий шматок WHERE вже стояв у двох місцях: +// у доборі цілей для масових команд і в доборі цілей для масової правки +// хостів. Третє місце (добір хостів на сторінці «Конфіги») перетворило б +// збіг на систему: три копії умови розходяться не одразу, а на першому ж +// новому полі — і тоді однаково заповнений фільтр дає різні набори на +// різних сторінках. Помітити це неможливо інакше, ніж звірянням двох +// переліків руками. +// +// Тому умова живе тут одна, а запити вставляють її в себе. Ціна — SQL +// збирається рядками; вигода — нове поле фільтра з'являється відразу +// скрізь, і забути одне з місць нема де. +// +// Обмеження, яке треба тримати в голові: вставка розраховує на +// конкретні псевдоніми таблиць — `d` для inv.devices і `st` для +// inv.sites (LEFT JOIN, бо майданчик не обов'язковий). Запит, який хоче +// цю умову, називає таблиці так само. + +// deviceFilterSQL повертає умови фільтра й значення до них. +// +// `n` — номер першого вільного плейсхолдера в запиті: умова нумерує +// свої $-параметри від нього й далі, тож викликач лишається вільним у +// тому, скільки власних параметрів поставив перед нею. +func deviceFilterSQL(f DeviceFilter, n int) (cond string, args []any) { + verOp, verVal := versionCond(f) + + // Плейсхолдери рахуються від n, а не пишуться числами: інакше будь-яка + // вставка нового параметра посеред умови мовчки зсунула б решту. + p := func(i int) string { return fmt.Sprintf("$%d", n+i) } + + var b strings.Builder + fmt.Fprintf(&b, ` + AND (NOT %[1]s::boolean OR d.enabled) + AND (cardinality(%[2]s::text[]) = 0 + OR lower(COALESCE(d.vendor,'')) = ANY(%[2]s::text[])) + AND (cardinality(%[3]s::text[]) = 0 OR d.kind::text = ANY(%[3]s::text[])) + AND (cardinality(%[4]s::text[]) = 0 OR COALESCE(st.name,'') = ANY(%[4]s::text[])) + AND (cardinality(%[5]s::text[]) = 0 OR d.status::text = ANY(%[5]s::text[])) + AND (cardinality(%[6]s::uuid[]) = 0 OR EXISTS ( + SELECT 1 FROM inv.device_group_members m + WHERE m.device_id = d.id AND m.group_id = ANY(%[6]s::uuid[]) + )) + -- Швидкий пошук іде по всьому, що видно в переліку хостів. + -- + -- Адреса тут не для повноти: «айпі» — це те, чим інженер + -- називає хост уголос, і поле, у яке він його вводить, має + -- його знайти. Майданчик і версія ПЗ теж у цьому переліку, + -- хоч і мають власні поля фільтра: людина, яка набирає + -- «Миронівка», не має спершу здогадатись, що це майданчик, + -- а не частина імені. + AND (%[7]s = '' OR d.name ILIKE '%%' || %[7]s || '%%' + OR COALESCE(host(d.address),'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(d.vendor,'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(d.model,'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(d.os_version,'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(st.name,'') ILIKE '%%' || %[7]s || '%%') + AND (cardinality(%[8]s::text[]) = 0 + OR lower(COALESCE(d.model,'')) = ANY(%[8]s::text[])) + -- Умова на версію. Порівняння без регістру: те саме поле + -- заповнюють і розпізнавання по sysDescr, і людина руками. + AND (CASE %[9]s::text + WHEN 'eq' THEN lower(COALESCE(d.os_version,'')) = lower(%[10]s::text) + -- 'ne' навмисно захоплює й порожню версію: хост, про + -- прошивку якого ми нічого не знаємо, точно не на + -- цільовій, і зі списку «що лишилось оновити» він + -- випадати не має. + WHEN 'ne' THEN lower(COALESCE(d.os_version,'')) <> lower(%[10]s::text) + WHEN 'contains' THEN COALESCE(d.os_version,'') ILIKE '%%' || %[10]s::text || '%%' + WHEN 'ncontains' THEN COALESCE(d.os_version,'') NOT ILIKE '%%' || %[10]s::text || '%%' + WHEN 'empty' THEN COALESCE(d.os_version,'') = '' + ELSE true + END)`, + p(0), p(1), p(2), p(3), p(4), p(5), p(6), p(7), p(8), p(9)) + + return b.String(), []any{ + f.OnlyEnabled, + lowerAll(f.Vendors), + nonNilIDs(f.Kinds), + nonNilIDs(f.Sites), + nonNilIDs(f.Statuses), + nonNilIDs(f.GroupIDs), + strings.TrimSpace(f.Query), + lowerAll(f.Models), + verOp, + verVal, + } +} + +// deviceFilterArgs — скільки плейсхолдерів займає умова. +// +// Потрібне тим запитам, які ставлять власні параметри ПІСЛЯ фільтра: +// без цього числа довелось би рахувати $-и очима щоразу, коли у фільтрі +// з'явиться поле. +const deviceFilterArgs = 10 diff --git a/server/internal/store/device_filter_test.go b/server/internal/store/device_filter_test.go new file mode 100644 index 0000000..dd7050e --- /dev/null +++ b/server/internal/store/device_filter_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "fmt" + "strings" + "testing" +) + +// Умову фільтра вставляють у три різні запити, і кожен ставить її після +// свого числа власних параметрів. Зсув на одиницю тут не падає й не +// помиляється помітно — він мовчки порівнює не ті колонки. Тому тест +// саме на нумерацію. +func TestDeviceFilterSQLNumbersPlaceholdersFromOffset(t *testing.T) { + for _, start := range []int{2, 4, 5, 11} { + cond, args := deviceFilterSQL(DeviceFilter{}, start) + + if len(args) != deviceFilterArgs { + t.Fatalf("значень %d, а deviceFilterArgs каже %d", len(args), deviceFilterArgs) + } + // Жодного плейсхолдера до початку діапазону й жодного після кінця. + if before := fmt.Sprintf("$%d", start-1); strings.Contains(cond, before+" ") || + strings.Contains(cond, before+":") { + t.Fatalf("умова з %d чіпає чужий %s", start, before) + } + after := fmt.Sprintf("$%d", start+deviceFilterArgs) + if strings.Contains(cond, after) { + t.Fatalf("умова з %d вийшла за свій діапазон: %s", start, after) + } + // І кожен свій — на місці. + for i := 0; i < deviceFilterArgs; i++ { + if p := fmt.Sprintf("$%d", start+i); !strings.Contains(cond, p) { + t.Fatalf("умова з %d не використовує %s", start, p) + } + } + } +} + +// «Айпі» — те, чим інженер називає хост уголос. Поле швидкого пошуку +// мусить його знаходити, інакше людина шукає адресу очима в переліку. +func TestDeviceFilterSQLQuerySearchesAddress(t *testing.T) { + cond, _ := deviceFilterSQL(DeviceFilter{Query: "10.1.0."}, 4) + for _, col := range []string{ + "host(d.address)", "d.name", "d.vendor", "d.model", "d.os_version", "st.name", + } { + if !strings.Contains(cond, col+"") { + t.Fatalf("швидкий пошук не дивиться в %s", col) + } + } +} + +// Значення йдуть рівно в тому порядку, у якому умова їх нумерує. +func TestDeviceFilterSQLArgOrder(t *testing.T) { + _, args := deviceFilterSQL(DeviceFilter{ + Query: " Миронівка ", + Vendors: []string{"Huawei"}, + OnlyEnabled: true, + VersionOp: VersionOpNe, + VersionValue: " 5.70 ", + }, 4) + + if args[0] != true { + t.Fatalf("перше значення мало бути only_enabled, отримали %#v", args[0]) + } + if v, ok := args[1].([]string); !ok || len(v) != 1 || v[0] != "huawei" { + t.Fatalf("виробники мали приїхати в нижньому регістрі, отримали %#v", args[1]) + } + if args[6] != "Миронівка" { + t.Fatalf("пошук мав приїхати обрізаним, отримали %#v", args[6]) + } + if args[8] != VersionOpNe || args[9] != "5.70" { + t.Fatalf("умова версії приїхала як %#v / %#v", args[8], args[9]) + } +} + +// Порожнє значення при заданій операції означає «умови немає», а не +// «версія дорівнює порожньому рядку»: фільтр складають на льоту, і +// стерте поле має повернути повний перелік, а не порожній. +func TestDeviceFilterSQLEmptyVersionValueDropsCondition(t *testing.T) { + _, args := deviceFilterSQL(DeviceFilter{VersionOp: VersionOpEq, VersionValue: " "}, 4) + if args[8] != "" { + t.Fatalf("операція мала зникнути разом зі значенням, отримали %#v", args[8]) + } +} diff --git a/server/internal/store/devices_bulk.go b/server/internal/store/devices_bulk.go new file mode 100644 index 0000000..f14d6a3 --- /dev/null +++ b/server/internal/store/devices_bulk.go @@ -0,0 +1,864 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Масова правка й масове видалення хостів. +// +// Дві дії в одному файлі, бо в них спільне найважче — добір набору. +// Оператор бачить перелік, відмічає в ньому рядки й очікує, що діятиме +// саме на них. Між тим, як він подивився, і тим, як натиснув, минає +// час: хтось міг завести хост, забрати доступ, видалити інший. Тому +// набір тут резолвиться так само, як у масовому виконанні команд +// (див. ResolveCommandTargets): клієнт шле перелік id РАЗОМ із +// фільтром, сервер бере перетин, а те, що відпало, повертає окремо. +// +// Друга спільна річ — межі доступу. Береться Scope.Writable, а не +// Readable: «бачу хост» і «можу його змінити» — різні права, і хост, +// до якого людині дали лише читання, не має мовчки потрапити в набір +// масової дії. Мовчки — ключове слово: він не просто відсіюється, а +// повертається у списку відсіяних, інакше людина рахувала б, що +// змінила п'ятдесят, а змінила сорок. + +// MaxBulkDevices — стеля на одну масову дію. +// +// Двісті, а не «скільки влізе». Причина не в продуктивності запиту: +// UPDATE на десять тисяч рядків Postgres зробить швидко. Причина в +// тому, що вся правка йде однією транзакцією, а разом із хостами вона +// чіпає членство в групах, доступи й розклади бекапів — тобто тримає +// блокування на пів-інвентарю, поки диспетчер колектора намагається +// прочитати з тих самих таблиць свій план на наступний такт. +// +// Двісті — це також приблизно те, що людина здатна побачити в +// підтвердженні. Масова дія, перелік якої не вміщається в жоден екран, +// підтверджується наосліп, а «підтверджено наосліп» і «підтверджено» — +// різні речі. Хто справді міняє тисячу хостів, зробить це п'ятьма +// заходами й побачить результат кожного. +const MaxBulkDevices = 200 + +// BulkDeviceTarget — хост у наборі масової дії. +// +// Разом із тим, що до нього прив'язане. Ці числа потрібні саме тут, а +// не після дії: питання «що станеться з перевірками, розкладом і +// вузлом на мапі» людина ставить ДО натискання, і відповідь має бути +// на тому ж екрані, де кнопка. +type BulkDeviceTarget struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + Kind string `json:"kind"` + Vendor string `json:"vendor,omitempty"` + SiteName string `json:"site_name,omitempty"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + + // Скільки чеків опитує цей хост. Після видалення вони лишаються в + // базі, але планувальник їх не бачить — він бере лише живі хости. + Checks int `json:"checks"` + // Активні алерти: firing / acknowledged / suppressed. + ActiveAlerts int `json:"active_alerts"` + // Скільки вузлів на мапах посилаються на цей хост. + MapNodes int `json:"map_nodes"` + // Чи є власний розклад збору конфігів. + HasBackupPolicy bool `json:"has_backup_policy"` + // Скільки версій конфігу вже зібрано. Архівне видалення їх не + // чіпає, повне — стирає разом із зашифрованими тілами. + Configs int `json:"configs"` + + // Далі — числа, які має сенс читати саме перед ПОВНИМ видаленням. + // + // Вони тут, а не окремою ручкою, з тієї ж причини, з якої тут + // стоять перевірки й вузли мап: людина обирає між «прибрати з + // переліку» й «видалити назавжди» в одному вікні, і різниця між + // цими двома діями виражається саме числами. Дві ручки означали б, + // що половина відповіді приходить після того, як вибір уже зроблено. + + // Сумарний розмір збережених конфігів. Байти, а не версії: «12 + // версій» нічого не каже про те, скільки місця звільниться. + ConfigBytes int64 `json:"config_bytes"` + // Рядів метрик — те, що на сторінці «Метрики» виглядає як графіки. + Series int `json:"series"` + // Вимірювань у цих рядах. Рахується зі стелею (див. + // purgeCountCap): точне число тут не варте повного перегляду + // гіпертаблиці, а «понад мільйон» лякає рівно так само. + Samples int64 `json:"samples"` + // SamplesCapped — лічильник уперся в стелю, справжнє число більше. + SamplesCapped bool `json:"samples_capped,omitempty"` + // Усі алерти, а не лише активні: історія розбору інцидентів по + // цьому хосту зникає разом із ним. + Alerts int `json:"alerts"` + // Портів у інвентарі. + Interfaces int `json:"interfaces"` + // Гілка в Git-архіві. Порожньо — хост жодного разу не комітився, + // тобто прибирати на дзеркалі нема чого. + Branch string `json:"branch,omitempty"` +} + +// purgeCountCap — стеля лічильника вимірювань. +// +// Точний count(*) по гіпертаблиці за місяць даних для двохсот хостів — +// це хвилини читання заради числа у вікні підтвердження. Питання, на +// яке людина відповідає цим вікном, звучить «багато чи мало», а не +// «скільки саме»: «понад 1 000 000» відповідає на нього так само точно, +// як 1 348 512, і коштує рівно стелю. +const purgeCountCap = 1_000_000 + +// ResolveBulkTargets — перетин «що обрала людина» і «що їй дозволено». +// +// Фільтр той самий DeviceFilter, що й у масовому виконанні команд. +// Другої мови фільтрів у продукті бути не повинно: людина, яка +// відібрала в переліку хостів «усі проблемні в групі Миронівка», має +// відібрати те саме будь-де ще тими самими полями. +// +// На відміну від ResolveCommandTargets, тут перелік id обов'язковий. +// Різниця не косметична: команду запускають ПО ФІЛЬТРУ («прогнати на +// всіх Huawei»), а хости правлять і видаляють ПО ВІДМІЧЕНИХ РЯДКАХ. +// Дозволити тут порожній перелік означало б дати спосіб видалити +// півінвентарю запитом, у якому не названо жодного хоста. +func (s *Store) ResolveBulkTargets(ctx context.Context, tenantID string, + sc Scope, f DeviceFilter, ids []string) ([]BulkDeviceTarget, error) { + + return s.resolveBulkTargets(ctx, tenantID, sc, f, ids, false) +} + +// ResolveBulkPurgeTargets — те саме, але бачить і прибрані хости. +// +// Без цього «прибрати з переліку» перетворювалось на пастку: хост +// зникав з усіх переліків, а разом із ним зникала й будь-яка можливість +// його добити. Зібране лишалось у базі назавжди — рівно те сміття, +// заради якого повне видалення й заводили. +// +// Стосується ЛИШЕ повного видалення. Масова правка прибраного хоста +// сенсу не має: міняти налаштування тому, кого вже немає, — це спосіб +// повернути його наполовину. +func (s *Store) ResolveBulkPurgeTargets(ctx context.Context, tenantID string, + sc Scope, f DeviceFilter, ids []string) ([]BulkDeviceTarget, error) { + + return s.resolveBulkTargets(ctx, tenantID, sc, f, ids, true) +} + +func (s *Store) resolveBulkTargets(ctx context.Context, tenantID string, + sc Scope, f DeviceFilter, ids []string, withDeleted bool) ([]BulkDeviceTarget, error) { + + if len(ids) == 0 { + return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, fmt.Errorf("%w: за раз можна взяти не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + var out []BulkDeviceTarget + + // Умова фільтра — та сама вставка, що й у масовому виконанні команд + // (device_filter.go): один фільтр — одне тлумачення. Хост, який там + // потрапив під «усе, що не на цій прошивці», має потрапити під нього + // й тут. + // $2 — стеля лічильника вимірювань; далі перелік id, межі доступу, + // ознака «бачити прибраних» і лише потім умови фільтра. + cond, condArgs := deviceFilterSQL(f, 7) + args := append([]any{ + tenantID, purgeCountCap, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable), + withDeleted, + }, condArgs...) + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT d.id::text, d.name, COALESCE(host(d.address),''), d.kind::text, + COALESCE(d.vendor,''), COALESCE(st.name,''), + d.status::text, d.enabled, + (SELECT count(*) FROM core.checks c WHERE c.device_id = d.id), + (SELECT count(*) FROM alr.alerts a + WHERE a.device_id = d.id + AND a.state IN ('firing','acknowledged','suppressed')), + (SELECT count(*) FROM topo.map_nodes n WHERE n.device_id = d.id), + EXISTS (SELECT 1 FROM ncm.device_policies p WHERE p.device_id = d.id), + (SELECT count(*) FROM ncm.configs cf WHERE cf.device_id = d.id), + (SELECT COALESCE(sum(cf.size_bytes),0) FROM ncm.configs cf + WHERE cf.device_id = d.id), + (SELECT count(*) FROM ts.series s WHERE s.device_id = d.id), + -- Стеля в підзапиті, а не count(*) по всьому: див. + -- purgeCountCap. LIMIT усередині — саме те, що + -- перетворює повний перегляд на обмежений. + (SELECT count(*) FROM ( + SELECT 1 FROM ts.samples sm + WHERE sm.series_id IN (SELECT s2.id FROM ts.series s2 + WHERE s2.device_id = d.id) + LIMIT $2::int) q), + (SELECT count(*) FROM alr.alerts a2 WHERE a2.device_id = d.id), + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id), + COALESCE((SELECT cf.branch FROM ncm.configs cf + WHERE cf.device_id = d.id AND cf.branch <> '' + ORDER BY cf.collected_at DESC LIMIT 1), '') + FROM inv.devices d + LEFT JOIN inv.sites st ON st.id = d.site_id + WHERE d.tenant_id = $1 AND (d.deleted_at IS NULL OR $6::boolean) + AND d.id = ANY($3::uuid[]) + AND ($4::boolean OR d.id = ANY($5::uuid[]))`+cond+` + ORDER BY d.name + `, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var t BulkDeviceTarget + if err := rows.Scan(&t.DeviceID, &t.Name, &t.Address, &t.Kind, &t.Vendor, + &t.SiteName, &t.Status, &t.Enabled, + &t.Checks, &t.ActiveAlerts, &t.MapNodes, &t.HasBackupPolicy, + &t.Configs, &t.ConfigBytes, &t.Series, &t.Samples, + &t.Alerts, &t.Interfaces, &t.Branch); err != nil { + return err + } + // Гілка, під якою хост комітився, могла ще не оновитись + // після перейменування — тоді показуємо ту, яку він мав би + // зараз. Видалення прибирає обидві (див. purgeOne), але + // показати треба ту, що впізнається в дзеркалі. + if t.Branch == "" && t.Configs > 0 { + t.Branch = DeviceBranch(t.DeviceID, t.Name, t.Address) + } + t.SamplesCapped = t.Samples >= purgeCountCap + out = append(out, t) + } + return rows.Err() + }) + return out, err +} + +// --------------------------------------------------------------------- +// Форма масової правки +// +// Головне рішення всієї функції: «не чіпати» — це не «стерти». +// +// Форма на десять полів, застосована до п'ятдесяти хостів, не має +// занулити те, чого людина не заповнювала. Тому кожне поле має три +// стани, і всі три названі явно: +// +// поля немає в JSON — не чіпати (значення кожного хоста лишається своїм) +// {"op":"set", ...} — задати всім однакове +// {"op":"clear"} — очистити всім +// +// Порожній рядок як ознака «очистити» тут не годиться, хоч так і +// зроблено в одиничній формі: там людина бачить поточне значення в +// полі й стирає його свідомо, а тут поле порожнє ЗАВЖДИ — у п'ятдесяти +// хостів немає спільного «поточного значення», яке можна було б +// показати. Порожнє поле в масовій формі означає «я про це не +// говорив», і трактувати його як «зітри в усіх» — найдорожча помилка, +// яку ця функція може зробити. +// +// Той самий підхід уже несе патч мапи (див. maps_write.go): там nil +// означає «не чіпати», бо перетягування вузла шле лише координати. +// Різниця в тому, що там третього стану не потрібно, а тут потрібен. +// --------------------------------------------------------------------- + +// StringOp — текстове або посилальне поле. +type StringOp struct { + Op string `json:"op"` + Value string `json:"value,omitempty"` +} + +// BoolOp — прапорець. Очищати нема чого: false — це значення, а не +// відсутність значення. +type BoolOp struct { + Op string `json:"op"` + Value bool `json:"value"` +} + +// ListOp — набір зв'язків (групи, шаблони, доступи). +// +// Тут трьох станів мало, і це не примха форми, а суть множини. +// «Додати до груп» і «прибрати з груп» — різні дії з різними +// наслідками, і жодна з них не виражається через «задати перелік»: +// хости в наборі мають РІЗНІ поточні групи, і спільного «задати» для +// них не існує, поки людина не скаже, що старе можна викинути. +// +// add — дописати, наявне лишити +// remove — прибрати саме ці, решту лишити +// replace — викинути все й поставити рівно це +// +// replace лишено навмисно, попри те що воно найнебезпечніше: без нього +// неможливо навести лад там, де групи вже наставлені абияк. Але в +// інтерфейсі воно окремим вибором зі своїм попередженням, а не +// поведінкою за замовчуванням. +type ListOp struct { + Op string `json:"op"` + IDs []string `json:"ids"` +} + +// BulkBackupOp — розклад збору конфігів. +// +// Тут «задати» діє на весь об'єкт розкладу одразу, а не на кожне його +// поле окремо. Розклад — це одне рішення («щодня о третій, профілем +// huawei-vrp, доступом noc-ssh»), а не набір незалежних дрібниць: +// увімкнути збір, не сказавши чим і коли, означає завести політику, +// яка мовчки нічого не збирає. Три стани лишаються на рівні об'єкта: +// поля немає — розклади хостів не чіпаємо, set — ставимо всім один, +// clear — прибираємо розклад зовсім. +type BulkBackupOp struct { + Op string `json:"op"` + ProfileID string `json:"profile_id,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + Enabled bool `json:"enabled"` + Cron string `json:"cron,omitempty"` + OnSyslog bool `json:"on_syslog"` + FollowsDefault bool `json:"follows_default"` +} + +// BulkDeviceChanges — що саме міняється. +// +// Чого тут немає й не буде: імені та адреси. Вони унікальні на хост +// (ім'я — унікальним індексом, адреса — фізично), і масова зміна тут +// означала б або помилку оператора, або спробу зламати інвентар. +// Немає й нотаток: це людський текст про конкретний хост, і затерти +// п'ятдесят різних приміток одним абзацом — те саме, що затерти імена. +type BulkDeviceChanges struct { + // Зонд-опитувач. Найчастіша причина масової правки взагалі: + // дільницю перевели на інший зонд. + AgentID *StringOp `json:"agent_id,omitempty"` + // Тип хоста. Очищення немає: kind — NOT NULL з типовим 'other', і + // «очистити» тут означало б «поставити other», тобто те саме set. + Kind *StringOp `json:"kind,omitempty"` + // Виробник. Очищення осмислене: порожній виробник повертає хост + // під автоматичне розпізнавання, яке своє значення не перезаписує. + Vendor *StringOp `json:"vendor,omitempty"` + Enabled *BoolOp `json:"enabled,omitempty"` + + Groups *ListOp `json:"groups,omitempty"` + Templates *ListOp `json:"templates,omitempty"` + Credentials *ListOp `json:"credentials,omitempty"` + Backup *BulkBackupOp `json:"backup,omitempty"` + // Ручні перевірки — див. devices_bulk_checks.go. Тут вони окремим + // об'єктом, а не переліком, бо «додати» і «прибрати» називають тип, + // якого стосуються, і змішувати їх в одну дію нема сенсу. + Checks *BulkChecksOp `json:"checks,omitempty"` +} + +// Empty — чи форма взагалі щось каже. +// +// Порожня масова правка не є безпечним «нічого не сталось»: людина +// натиснула кнопку й чекає на результат, і мовчазне «готово» вона +// прочитає як «застосовано». Тому це помилка, а не успіх. +func (c BulkDeviceChanges) Empty() bool { + return c.AgentID == nil && c.Kind == nil && c.Vendor == nil && + c.Enabled == nil && c.Groups == nil && c.Templates == nil && + c.Credentials == nil && c.Backup == nil && c.Checks == nil +} + +func (o *StringOp) valid() error { + if o == nil { + return nil + } + switch o.Op { + case "set", "clear": + return nil + } + return fmt.Errorf("%w: невідома дія над полем: %q", ErrInvalid, o.Op) +} + +func (o *ListOp) valid() error { + if o == nil { + return nil + } + switch o.Op { + case "add", "remove", "replace": + default: + return fmt.Errorf("%w: невідома дія над переліком: %q", ErrInvalid, o.Op) + } + // «Додати нічого» і «прибрати нічого» — не помилка бази, а + // незаповнена форма. Мовчки виконати її означає показати «готово» + // там, де не сталось нічого. + if o.Op != "replace" && len(o.IDs) == 0 { + return fmt.Errorf("%w: не обрано жодного елемента для дії %q", ErrInvalid, o.Op) + } + return nil +} + +// BulkUpdateDevices застосовує правку до набору хостів. +// +// Одна транзакція на скалярні поля, групи, доступи й розклад: це або +// сталося з усіма обраними хостами, або не сталося ні з ким. Половина +// дільниці, переведена на новий зонд, — гірший стан, ніж жодного +// переведеного, бо про неї ніхто не знає. +// +// Шаблони — окремо й після транзакції. Призначення шаблону тягне за +// собою перерахунок чеків і правил алертів (ReconcileDeviceTemplates, +// ReconcileTemplateRules), кожен зі своєю транзакцією. Затягнути це +// всередину означало б тримати блокування на core.checks і alr.rules +// стільки, скільки триває перерахунок для всіх двохсот хостів. +// +// Ручні перевірки — теж усередині транзакції (див. +// devices_bulk_checks.go): це кілька простих операторів над +// core.checks, а не перерахунок, і атомарність тут важить більше за +// час блокування. Результат по кожному хосту повертається окремо: на +// відміну від решти полів, «додати перевірку» має три різні наслідки — +// додано, уже було, не вдалося, — і жодного з них не видно в числі +// змінених хостів. +func (s *Store) BulkUpdateDevices(ctx context.Context, tenantID string, sc Scope, + ids []string, ch BulkDeviceChanges) ([]string, []string, *BulkChecksResult, error) { + + if ch.Empty() { + return nil, nil, nil, fmt.Errorf("%w: не задано жодної зміни", ErrInvalid) + } + if len(ids) == 0 { + return nil, nil, nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, nil, nil, fmt.Errorf("%w: за раз можна змінити не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + for _, o := range []*StringOp{ch.AgentID, ch.Kind, ch.Vendor} { + if err := o.valid(); err != nil { + return nil, nil, nil, err + } + } + if ch.Kind != nil && ch.Kind.Op == "clear" { + return nil, nil, nil, fmt.Errorf("%w: тип хоста не можна очистити — його можна лише задати", + ErrInvalid) + } + for _, o := range []*ListOp{ch.Groups, ch.Templates, ch.Credentials} { + if err := o.valid(); err != nil { + return nil, nil, nil, err + } + } + if ch.Backup != nil && ch.Backup.Op != "set" && ch.Backup.Op != "clear" { + return nil, nil, nil, fmt.Errorf("%w: невідома дія над розкладом: %q", ErrInvalid, ch.Backup.Op) + } + if err := ch.Checks.valid(); err != nil { + return nil, nil, nil, err + } + + var ( + final []string + names []string + targets []bulkCheckDevice + checkRes *BulkChecksResult + ) + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Набір звужується ще раз, уже під блокуванням транзакції: + // перевірка прав у обробнику відповідає на питання «чи можна + // було», а цей запит — на питання «чи ще існує». + // + // Адреса й доменне ім'я їдуть звідси ж: підстановка в + // параметри перевірки має брати їх у тому ж стані, у якому + // хост зафіксовано в наборі, а не окремим запитом після. + rows, err := tx.Query(ctx, ` + SELECT id::text, name, COALESCE(host(address),''), COALESCE(fqdn,'') + FROM inv.devices + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL + AND ($3::boolean OR id = ANY($4::uuid[])) + ORDER BY name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + for rows.Next() { + var d bulkCheckDevice + if err := rows.Scan(&d.ID, &d.Name, &d.Address, &d.FQDN); err != nil { + rows.Close() + return err + } + final = append(final, d.ID) + names = append(names, d.Name) + targets = append(targets, d) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if len(final) == 0 { + return fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис", + ErrInvalid) + } + + if err := bulkSetFields(ctx, tx, tenantID, final, ch); err != nil { + return err + } + if err := bulkSetGroups(ctx, tx, tenantID, final, ch.Groups); err != nil { + return err + } + if err := bulkSetCredentials(ctx, tx, tenantID, final, ch.Credentials); err != nil { + return err + } + if err := bulkSetTemplateLinks(ctx, tx, tenantID, final, ch.Templates); err != nil { + return err + } + if err := bulkSetBackup(ctx, tx, tenantID, final, ch.Backup); err != nil { + return err + } + checkRes, err = bulkApplyChecks(ctx, tx, tenantID, targets, ch.Checks) + return err + }) + if err != nil { + return nil, nil, nil, err + } + + // Перерахунок після коміту. Помилка тут не скасовує правку — вона + // вже в базі; і мовчати про неї теж не можна, бо хост із + // призначеним, але не розгорнутим шаблоном виглядає налаштованим і + // нічого не опитує. + if ch.Templates != nil { + for _, id := range final { + if err := s.ReconcileDeviceTemplates(ctx, tenantID, id); err != nil { + return final, names, checkRes, fmt.Errorf("розгортання шаблонів: %w", err) + } + } + // Правила алертів — раз на шаблон, а не раз на хост: вони + // належать шаблону, і перерахунок для другого хоста зробив би + // ту саму роботу вдруге. + for _, tplID := range ch.Templates.IDs { + if err := s.ReconcileTemplateRules(ctx, tenantID, tplID); err != nil { + return final, names, checkRes, fmt.Errorf("правила шаблону: %w", err) + } + } + } + + return final, names, checkRes, nil +} + +// bulkSetFields — скалярні поля одним UPDATE. +// +// CASE на кожне поле замість збирання SQL рядками: запит лишається +// одним і тим самим текстом для планувальника, а «не чіпати» +// перетворюється на присвоєння полю його ж значення — тобто на +// відсутність зміни, а не на її імітацію. +func bulkSetFields(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, ch BulkDeviceChanges) error { + + if ch.AgentID == nil && ch.Kind == nil && ch.Vendor == nil && ch.Enabled == nil { + return nil + } + + op := func(o *StringOp) (string, string) { + if o == nil { + return "", "" + } + return o.Op, o.Value + } + agentOp, agentVal := op(ch.AgentID) + kindOp, kindVal := op(ch.Kind) + vendorOp, vendorVal := op(ch.Vendor) + + enabledOp, enabledVal := "", false + if ch.Enabled != nil { + enabledOp, enabledVal = "set", ch.Enabled.Value + } + + // Права тут уже не перевіряються: ids — це той перелік, який + // щойно повернув запит на початку транзакції, тобто вже звужений + // і тенантом, і Scope.Writable. + _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET + agent_id = CASE $3::text + WHEN 'set' THEN NULLIF($4::text,'')::uuid + WHEN 'clear' THEN NULL + ELSE agent_id END, + kind = CASE $5::text + WHEN 'set' THEN $6::text::inv.device_kind + ELSE kind END, + vendor = CASE $7::text + WHEN 'set' THEN NULLIF(btrim($8::text),'') + WHEN 'clear' THEN NULL + ELSE vendor END, + enabled = CASE $9::text + WHEN 'set' THEN $10::boolean + ELSE enabled END, + updated_at = now() + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL + `, tenantID, ids, + agentOp, agentVal, kindOp, kindVal, vendorOp, vendorVal, + enabledOp, enabledVal) + return err +} + +// bulkSetGroups — членство в групах. +// +// Групи звіряються з тенантом усередині кожного запиту, а не перед +// ними: інакше чужий id у списку тихо переклав би хости в групу +// сусіда. Так само зроблено в SetDeviceGroups для одного хоста. +func bulkSetGroups(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *ListOp) error { + + if op == nil { + return nil + } + + if op.Op == "replace" || op.Op == "remove" { + q := ` + DELETE FROM inv.device_group_members m + USING inv.device_groups g + WHERE m.group_id = g.id AND g.tenant_id = $1 + AND m.device_id = ANY($2::uuid[]) + ` + args := []any{tenantID, ids} + if op.Op == "remove" { + q += ` AND m.group_id = ANY($3::uuid[])` + args = append(args, nonNilIDs(op.IDs)) + } + if _, err := tx.Exec(ctx, q, args...); err != nil { + return err + } + if op.Op == "remove" { + return nil + } + } + + if len(op.IDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO inv.device_group_members (group_id, device_id) + SELECT g.id, d.id + FROM inv.device_groups g + CROSS JOIN unnest($2::uuid[]) AS d(id) + WHERE g.tenant_id = $1 AND g.id = ANY($3::uuid[]) + ON CONFLICT DO NOTHING + `, tenantID, ids, nonNilIDs(op.IDs)) + return err +} + +// bulkSetCredentials — доступи до обладнання. +// +// Пріоритет 100 — той самий, що ставить одинична форма: масова правка +// не має заводити хостам порядок перебору, якого людина не бачила. +func bulkSetCredentials(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *ListOp) error { + + if op == nil { + return nil + } + + if op.Op == "replace" || op.Op == "remove" { + q := ` + DELETE FROM inv.device_credentials dc + USING inv.credentials c + WHERE dc.credential_id = c.id AND c.tenant_id = $1 + AND dc.device_id = ANY($2::uuid[]) + ` + args := []any{tenantID, ids} + if op.Op == "remove" { + q += ` AND dc.credential_id = ANY($3::uuid[])` + args = append(args, nonNilIDs(op.IDs)) + } + if _, err := tx.Exec(ctx, q, args...); err != nil { + return err + } + if op.Op == "remove" { + return nil + } + } + + if len(op.IDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO inv.device_credentials (device_id, credential_id, priority) + SELECT d.id, c.id, 100 + FROM inv.credentials c + CROSS JOIN unnest($2::uuid[]) AS d(id) + WHERE c.tenant_id = $1 AND c.id = ANY($3::uuid[]) + ON CONFLICT DO NOTHING + `, tenantID, ids, nonNilIDs(op.IDs)) + return err +} + +// bulkSetTemplateLinks — самі зв'язки хост↔шаблон. +// +// Перерахунок чеків тут не робиться навмисно, він іде після коміту: +// див. коментар у BulkUpdateDevices. +// +// Шаблон звіряється не лише з тенантом: вбудовані шаблони спільні й +// мають tenant_id IS NULL, тож умова саме така, як у переліку шаблонів +// для форми. Без неї масова правка стала б способом причепити хосту +// чужий шаблон за вгаданим id. +func bulkSetTemplateLinks(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *ListOp) error { + + if op == nil { + return nil + } + + if op.Op == "replace" || op.Op == "remove" { + q := `DELETE FROM tpl.device_templates WHERE device_id = ANY($1::uuid[])` + args := []any{ids} + if op.Op == "remove" { + q += ` AND template_id = ANY($2::uuid[])` + args = append(args, nonNilIDs(op.IDs)) + } + if _, err := tx.Exec(ctx, q, args...); err != nil { + return err + } + if op.Op == "remove" { + return nil + } + } + + if len(op.IDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO tpl.device_templates (device_id, template_id, tenant_id) + SELECT d.id, t.id, $1 + FROM tpl.templates t + CROSS JOIN unnest($2::uuid[]) AS d(id) + WHERE t.id = ANY($3::uuid[]) + AND (t.tenant_id IS NULL OR t.tenant_id = $1) + ON CONFLICT DO NOTHING + `, tenantID, ids, nonNilIDs(op.IDs)) + return err +} + +// bulkSetBackup — розклад збору конфігів. +// +// next_backup_at обнуляється так само, як в одиничному SetBackupPolicy: +// розклад міг стати частішим, і лишити стару позначку означало б +// чекати за старим розкладом до першого спрацювання нового. +func bulkSetBackup(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *BulkBackupOp) error { + + if op == nil { + return nil + } + + if op.Op == "clear" { + // Саме DELETE, а не enabled = false. «Вимкнений розклад» і + // «розкладу немає» розрізняються в картці хоста, і масова дія + // не має лишати по собі стан, якого людина не просила. + _, err := tx.Exec(ctx, ` + DELETE FROM ncm.device_policies + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) + `, tenantID, ids) + return err + } + + cron := strings.TrimSpace(op.Cron) + if cron == "" { + cron = "0 3 * * *" + } + _, err := tx.Exec(ctx, ` + INSERT INTO ncm.device_policies + (device_id, tenant_id, profile_id, credential_id, enabled, cron, + on_syslog, follows_default, next_backup_at) + SELECT d.id, $1, $3, $4, $5, $6, $7, $8, NULL + FROM unnest($2::uuid[]) AS d(id) + ON CONFLICT (device_id) DO UPDATE SET + profile_id = EXCLUDED.profile_id, + credential_id = EXCLUDED.credential_id, + enabled = EXCLUDED.enabled, + cron = EXCLUDED.cron, + on_syslog = EXCLUDED.on_syslog, + follows_default = EXCLUDED.follows_default, + next_backup_at = NULL, + updated_at = now() + `, tenantID, ids, nullUUID(op.ProfileID), nullUUID(op.CredentialID), + op.Enabled, cron, op.OnSyslog, op.FollowsDefault) + return err +} + +// --------------------------------------------------------------------- +// Масове видалення +// --------------------------------------------------------------------- + +// BulkDeleteDevices — АРХІВНЕ видалення набору хостів. +// +// Одна з двох дій кнопки «Видалити»; друга — PurgeDevices +// (devices_purge.go), яка стирає хост назавжди. Тут — рівно те саме, +// що робить DeleteDevice для одного: deleted_at = now() +// і enabled = false. Розходитись із ним не можна принципово — «видалив +// п'ятдесят» і «видалив по одному п'ятдесят разів» мусять давати той +// самий стан бази, інакше половина системи знатиме про хост, а +// половина ні. +// +// Що з цього випливає для всього прив'язаного, і чому це саме так: +// +// - чеки лишаються рядками в core.checks, але планувальник бере лише +// живі й увімкнені хости — опитування спиняється; +// - розклад бекапів лишається, але DuePolicies так само дивиться на +// deleted_at — збір спиняється; +// - активні алерти закриються самі: рушій рахує кандидатів по живих +// хостах і закриває різницею множин (ResolveMissing), тобто на +// найближчому такті оцінки правила; +// - вузли на мапах ЛИШАЮТЬСЯ. Читання мапи не фільтрує видалені +// хости, тож вузол стоїть на місці з останнім відомим станом. Це +// не забутий випадок: мапа — це креслення, і дірка в ньому має +// з'явитись рішенням людини, а не наслідком дії в іншому розділі; +// - метрики й зібрані конфіги не чіпаються взагалі. Заради цього +// архівне видалення й існує окремо від повного; +// - гілка в Git-архіві лишається як була, і на дзеркалі теж. Хост, +// який колись повернуть, має продовжити СВОЮ історію конфігів, а +// не почати другу. +// +// Усе перелічене показується в підтвердженні числами по кожному хосту +// (див. BulkDeviceTarget) — щоб це читали ДО натискання. +func (s *Store) BulkDeleteDevices(ctx context.Context, tenantID string, sc Scope, + ids []string) ([]string, []string, error) { + + if len(ids) == 0 { + return nil, nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, nil, fmt.Errorf("%w: за раз можна видалити не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + var ( + deleted []string + names []string + ) + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + UPDATE inv.devices + SET deleted_at = now(), enabled = false, archived_enabled = enabled + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL + AND ($3::boolean OR id = ANY($4::uuid[])) + RETURNING id::text, name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + return err + } + deleted = append(deleted, id) + names = append(names, name) + } + if err := rows.Err(); err != nil { + return err + } + + // Те саме, що робить DeleteDevice для одного хоста: увімкнений + // чек видаленого хоста план не бере, зате він назавжди лишається + // «таким, що пропустив свій такт» на сторінці «Черги». + // + // archived_off — щоб RestoreDevices знало, які саме чеки + // вимкнула ця дія, а які були вимкнені й до неї. + if len(deleted) > 0 { + if _, err := tx.Exec(ctx, ` + UPDATE core.checks + SET enabled = false, archived_off = true, updated_at = now() + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) AND enabled + `, tenantID, nonNilIDs(deleted)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, nil, err + } + if len(deleted) == 0 { + return nil, nil, fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис", + ErrInvalid) + } + return deleted, names, nil +} diff --git a/server/internal/store/devices_bulk_checks.go b/server/internal/store/devices_bulk_checks.go new file mode 100644 index 0000000..bc75853 --- /dev/null +++ b/server/internal/store/devices_bulk_checks.go @@ -0,0 +1,434 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Масова дія над ручними перевірками хостів. +// +// Ручні перевірки колись лишили поза масовою формою з міркуванням, що +// вони поодинокі за визначенням — те, чого немає в шаблонах. Насправді +// поодинока тут причина, а не кількість: «додати http.status усім +// вебсерверам дільниці» і «прибрати icmp.ping із сорока хостів, яким +// його колись наставили руками» — це одне рішення на сорок рядків, тобто +// рівно те, заради чого масова форма й існує. Без неї людина або робить +// сорок однакових рухів, або не робить нічого — і другий варіант +// трапляється частіше. +// +// --------------------------------------------------------------------- +// Чому немає «замінити весь набір» +// +// Та сама межа, що проведена для груп, і з тієї самої причини, лише +// гострішої. У груп replace лишили, бо без нього неможливо навести лад +// там, де наставлено абияк, і бо групи видно в переліку хостів. Набори +// перевірок не видно ніде: людина, яка натисне «замінити», зітре те, +// чого не бачила й побачити не могла, — і дізнається про це тоді, коли +// перестане приходити алерт. Тому дій рівно три, і кожна називає тип, +// якого стосується. +// +// --------------------------------------------------------------------- +// Що таке «ручна» перевірка +// +// template_id IS NULL AND interface_id IS NULL — рівно та ж межа, яку +// проводить SetDeviceChecks, коли прибирає зняте у формі хоста. +// Перевірку, породжену шаблоном, міняють у шаблоні: правка тут зникла б +// на найближчій реконсиляції. Перевірку на інтерфейсі заводить +// автовиявлення, і вона взагалі не про хост, а про порт. + +// BulkChecksOp — одна дія над одним типом перевірки. +// +// Тип називається явно й завжди один. Це не обмеження реалізації: +// «додати перевірку» без назви типу не є висловлюванням, а дозволити +// кілька типів за раз означало б показувати результат («додано / +// уже було / не вдалося») перехресною таблицею, яку ніхто не читає. +type BulkChecksOp struct { + // add — завести перевірку тим, у кого її немає + // remove — прибрати перевірку цього типу + // tune — змінити розклад наявної: інтервал, таймаут, повтори, + // увімкнено/вимкнено + Op string `json:"op"` + CheckType string `json:"check_type"` + + // Параметри — тільки для add. Для tune їх свідомо немає: у + // http.status параметр — це URL, і масово задати той самий URL + // сорока хостам, у яких він уже різний, означає зламати сорок + // перевірок одним рухом. Змінити параметр — це рішення про + // конкретний хост, і робиться воно в його картці. + Params json.RawMessage `json:"params,omitempty"` + + // nil означає «не чіпати» — той самий тристановий принцип, що й у + // решті масової форми, лише всередині однієї дії. Для add nil — це + // «взяти типове». + IntervalSec *int `json:"interval_sec,omitempty"` + TimeoutMs *int `json:"timeout_ms,omitempty"` + Retries *int `json:"retries,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// BulkCheckOutcome — хост, з яким нічого не сталося, і чому. +type BulkCheckOutcome struct { + Device string `json:"device"` + Reason string `json:"reason"` +} + +// BulkChecksResult — що вийшло по кожному хосту. +// +// Три списки, а не одне число. Масове додавання ГАРАНТОВАНО натрапляє +// на хости, у яких така перевірка вже є, і це не помилка, а звичайний +// стан: людина відібрала дільницю, а половину дільниці колись уже +// налаштували руками. Показати «додано 12» і промовчати про решту +// означає збрехати; показати помилку означає злякати на рівному місці. +// Тому те саме розрізнення, що й у dropped: зроблено / не було чого +// робити / не вдалося. +type BulkChecksResult struct { + Op string `json:"op"` + CheckType string `json:"check_type"` + // Хости, де дія справді щось змінила. + Done []string `json:"done"` + // Хости, де робити не було чого: перевірка вже є (add) або її + // немає (remove, tune). + Skipped []BulkCheckOutcome `json:"skipped"` + // Хости, де дію зробити не вдалося. Зараз єдина причина — + // підстановка, якій нема чим підставитись (хост без адреси). + Failed []BulkCheckOutcome `json:"failed"` +} + +func (o *BulkChecksOp) valid() error { + if o == nil { + return nil + } + switch o.Op { + case "add", "remove", "tune": + default: + return fmt.Errorf("%w: невідома дія над перевірками: %q", ErrInvalid, o.Op) + } + if strings.TrimSpace(o.CheckType) == "" { + return fmt.Errorf("%w: не названо тип перевірки", ErrInvalid) + } + // «Змінити наявні», не сказавши що саме, — незаповнена форма, а не + // дія без наслідків. Мовчазне «готово» тут прочиталося б як + // «застосовано». + if o.Op == "tune" && o.IntervalSec == nil && o.TimeoutMs == nil && + o.Retries == nil && o.Enabled == nil { + return fmt.Errorf("%w: не задано, що саме змінити в перевірці %s", + ErrInvalid, o.CheckType) + } + return nil +} + +// --------------------------------------------------------------------- +// Підстановка +// +// Головне питання масової роботи з перевірками — параметри. Вони різні +// за типом: у icmp.ping це кількість пакетів, у http.status — URL. +// Кількість пакетів у сорока хостів однакова, і задати її масово +// осмислено. URL — ні: сорок хостів мають сорок різних URL, і спільного +// значення для них не існує. +// +// Заборонити типи з обов'язковими параметрами означало б заборонити +// рівно той випадок, з якого все почалося: у http.status url +// обов'язковий. Тому дозволено, але з єдиною підстановкою — на те, чим +// хости в наборі законно різняться, тобто на них самих: +// +// {{address}} — адреса хоста +// {{name}} — ім'я хоста +// {{fqdn}} — доменне ім'я, якщо задане +// +// Трьох достатньо й більше не буде. Будь-яке «різне», що не є самим +// хостом, — це не масова дія, а сорок окремих рішень, і робити їх треба +// в сорока картках. +// --------------------------------------------------------------------- + +// bulkCheckDevice — хост у масовій дії над перевірками. +type bulkCheckDevice struct { + ID string + Name string + Address string + FQDN string +} + +// expandCheckParams підставляє значення хоста в рядкові параметри. +// +// Лише рядки й елементи рядкових масивів: підставляти в число нема чого, +// а лізти в довільно вкладені структури означало б підмінювати те, про +// що форма не питала. +// +// Порожнє значення — помилка, а не порожня підстановка. URL +// «http:///status» ліг би в базу мовчки, а помітили б його через добу +// в переліку впалих перевірок — за кілометр від того місця, де його +// створили. +func expandCheckParams(raw json.RawMessage, d bulkCheckDevice) (string, error) { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return "{}", nil + } + if !strings.Contains(text, "{{") { + return text, nil + } + + var params map[string]any + if err := json.Unmarshal([]byte(text), ¶ms); err != nil { + return "", fmt.Errorf("%w: параметри перевірки не є об'єктом JSON", ErrInvalid) + } + + subst := func(s string) (string, error) { + for _, t := range []struct { + token string + value string + }{ + {"{{address}}", d.Address}, + {"{{name}}", d.Name}, + {"{{fqdn}}", d.FQDN}, + } { + if !strings.Contains(s, t.token) { + continue + } + if strings.TrimSpace(t.value) == "" { + return "", fmt.Errorf("немає чим підставити %s", t.token) + } + s = strings.ReplaceAll(s, t.token, t.value) + } + return s, nil + } + + for k, v := range params { + switch val := v.(type) { + case string: + s, err := subst(val) + if err != nil { + return "", err + } + params[k] = s + case []any: + for i, item := range val { + s, ok := item.(string) + if !ok { + continue + } + out, err := subst(s) + if err != nil { + return "", err + } + val[i] = out + } + } + } + + out, err := json.Marshal(params) + if err != nil { + return "", err + } + return string(out), nil +} + +// --------------------------------------------------------------------- +// Виконання +// --------------------------------------------------------------------- + +// bulkApplyChecks виконує дію над ручними перевірками набору хостів. +// +// Усередині тієї ж транзакції, що й решта масової правки: «половина +// дільниці отримала перевірку» — гірший стан, ніж «не отримав ніхто», +// бо про нього ніхто не дізнається. Хости, яким не вдалося, транзакцію +// не валять: «немає чим підставити адресу» — це властивість хоста, а не +// збій запиту, і скасовувати через неї роботу для решти означало б +// зробити один недооформлений хост стопором для всієї дільниці. +func bulkApplyChecks(ctx context.Context, tx pgx.Tx, tenantID string, + devices []bulkCheckDevice, op *BulkChecksOp) (*BulkChecksResult, error) { + + if op == nil { + return nil, nil + } + + res := &BulkChecksResult{ + Op: op.Op, + CheckType: op.CheckType, + Done: []string{}, + Skipped: []BulkCheckOutcome{}, + Failed: []BulkCheckOutcome{}, + } + + switch op.Op { + case "add": + return res, bulkAddChecks(ctx, tx, tenantID, devices, op, res) + case "remove": + return res, bulkTouchChecks(ctx, tx, tenantID, devices, op, res, true) + default: + return res, bulkTouchChecks(ctx, tx, tenantID, devices, op, res, false) + } +} + +// bulkAddChecks — завести перевірку тим, у кого її ще немає. +// +// По хосту, а не одним запитом: параметри після підстановки в кожного +// свої, та й результат однаково потрібен поштучно. Двісті окремих +// INSERT-ів усередині однієї транзакції коштують менше, ніж один рядок +// невиразної відповіді. +func bulkAddChecks(ctx context.Context, tx pgx.Tx, tenantID string, + devices []bulkCheckDevice, op *BulkChecksOp, res *BulkChecksResult) error { + + interval := 60 + if op.IntervalSec != nil { + interval = clampInterval(*op.IntervalSec) + } + timeout := 3000 + if op.TimeoutMs != nil && *op.TimeoutMs > 0 { + timeout = *op.TimeoutMs + } + retries := 2 + if op.Retries != nil && *op.Retries >= 0 { + retries = *op.Retries + } + enabled := op.Enabled == nil || *op.Enabled + + for _, d := range devices { + params, err := expandCheckParams(op.Params, d) + if err != nil { + res.Failed = append(res.Failed, BulkCheckOutcome{Device: d.Name, Reason: err.Error()}) + continue + } + + // NOT EXISTS по ТИПУ, а не по параметрах. Унікальний індекс + // checks_uniq рахує ще й md5(params), тобто пропустив би другий + // http.status з іншим URL — і хост лишився б із двома + // перевірками того самого типу, яких у формі картки завести + // неможливо. ON CONFLICT DO NOTHING поруч лишається як остання + // сітка: перевірка на інтерфейсі з такими ж параметрами в + // NOT EXISTS не потрапляє, а в індекс — потрапляє, і без цієї + // гілки 23505 обірвав би всю транзакцію на одному хості. + var id string + err = tx.QueryRow(ctx, ` + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, + timeout_ms, retries, enabled) + SELECT $1, $2::uuid, $3, $4::jsonb, $5, $6, $7, $8 + WHERE NOT EXISTS ( + SELECT 1 FROM core.checks c + WHERE c.tenant_id = $1 AND c.device_id = $2::uuid + AND c.check_type = $3 + AND c.interface_id IS NULL AND c.template_id IS NULL + ) + ON CONFLICT DO NOTHING + RETURNING id::text + `, tenantID, d.ID, op.CheckType, params, interval, timeout, retries, enabled).Scan(&id) + + switch { + case err == nil: + res.Done = append(res.Done, d.Name) + case errors.Is(err, pgx.ErrNoRows): + res.Skipped = append(res.Skipped, BulkCheckOutcome{ + Device: d.Name, Reason: "перевірка цього типу вже є", + }) + default: + return fmt.Errorf("перевірка %s на хості %s: %w", op.CheckType, d.Name, err) + } + } + return nil +} + +// bulkTouchChecks — прибрати або змінити наявні перевірки типу. +// +// Одним запитом на весь набір: тут нема чого підставляти, а RETURNING +// device_id сам розділяє «зроблено» і «не було чого робити» — саме те, +// що треба показати людині. +func bulkTouchChecks(ctx context.Context, tx pgx.Tx, tenantID string, + devices []bulkCheckDevice, op *BulkChecksOp, res *BulkChecksResult, remove bool) error { + + ids := make([]string, 0, len(devices)) + for _, d := range devices { + ids = append(ids, d.ID) + } + + var ( + rows pgx.Rows + err error + ) + if remove { + rows, err = tx.Query(ctx, ` + DELETE FROM core.checks + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) + AND check_type = $3 + AND interface_id IS NULL AND template_id IS NULL + RETURNING device_id::text + `, tenantID, nonNilIDs(ids), op.CheckType) + } else { + // Саме типовані вказівники, а не any(nil): nil тут доїжджає до + // Postgres як NULL потрібного типу, і COALESCE нижче читає його + // як «не чіпати» без здогадок про тип параметра. + var interval *int + if op.IntervalSec != nil { + v := clampInterval(*op.IntervalSec) + interval = &v + } + var timeout *int + if op.TimeoutMs != nil && *op.TimeoutMs > 0 { + timeout = op.TimeoutMs + } + var retries *int + if op.Retries != nil && *op.Retries >= 0 { + retries = op.Retries + } + // COALESCE, а не CASE: NULL тут і є «не чіпати», і кожне поле + // відповідає на це питання окремо — рівно як решта масової + // форми відповідає на нього окремо по кожному полю хоста. + rows, err = tx.Query(ctx, ` + UPDATE core.checks SET + interval_sec = COALESCE($4::int, interval_sec), + timeout_ms = COALESCE($5::int, timeout_ms), + retries = COALESCE($6::int, retries), + enabled = COALESCE($7::boolean, enabled), + updated_at = now() + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) + AND check_type = $3 + AND interface_id IS NULL AND template_id IS NULL + RETURNING device_id::text + `, tenantID, nonNilIDs(ids), op.CheckType, interval, timeout, retries, op.Enabled) + } + if err != nil { + return err + } + defer rows.Close() + + touched := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + touched[id] = true + } + if err := rows.Err(); err != nil { + return err + } + + reason := "такої ручної перевірки немає" + for _, d := range devices { + if touched[d.ID] { + res.Done = append(res.Done, d.Name) + } else { + res.Skipped = append(res.Skipped, BulkCheckOutcome{Device: d.Name, Reason: reason}) + } + } + return nil +} + +// clampInterval тримає інтервал у межах CHECK-обмеження core.checks. +// +// Обрізаємо так само, як SetDeviceChecks: масова форма не має падати +// там, де одинична мовчки виправляє, — інакше та сама цифра в двох +// місцях означала б різне. +func clampInterval(v int) int { + if v < 5 { + return 60 + } + if v > 86400 { + return 86400 + } + return v +} diff --git a/server/internal/store/devices_bulk_checks_test.go b/server/internal/store/devices_bulk_checks_test.go new file mode 100644 index 0000000..7311ae6 --- /dev/null +++ b/server/internal/store/devices_bulk_checks_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "encoding/json" + "strings" + "testing" +) + +// Перевіряється єдине рішення, яке в цій частині можна ухвалити +// неправильно й не помітити: підстановка. Помилка тут не впаде +// складанням — вона мовчки покладе в базу сорок перевірок із +// «http:///», а помітять їх через добу в переліку впалих. + +func devFixture() bulkCheckDevice { + return bulkCheckDevice{ + ID: "00000000-0000-0000-0000-000000000001", + Name: "ПОЛЬОВЕ.7", + Address: "10.1.0.75", + FQDN: "polove7.example.net", + } +} + +func TestExpandLeavesPlainParamsAlone(t *testing.T) { + in := json.RawMessage(`{"count":3,"packet_size":56}`) + out, err := expandCheckParams(in, devFixture()) + if err != nil { + t.Fatalf("несподівана помилка: %v", err) + } + // Байт-у-байт: параметри без підстановки не мають переживати + // перепакування JSON, бо md5(params) входить у checks_uniq, і + // перевпорядковані ключі зробили б «ту саму» перевірку іншою. + if out != string(in) { + t.Fatalf("параметри без підстановки змінились: %s", out) + } +} + +func TestExpandFillsHostTokens(t *testing.T) { + out, err := expandCheckParams( + json.RawMessage(`{"url":"http://{{address}}/health","keyword":"{{name}}"}`), devFixture()) + if err != nil { + t.Fatalf("несподівана помилка: %v", err) + } + if !strings.Contains(out, "http://10.1.0.75/health") { + t.Fatalf("адресу не підставлено: %s", out) + } + if !strings.Contains(out, "ПОЛЬОВЕ.7") { + t.Fatalf("імʼя не підставлено: %s", out) + } +} + +// Хост без адреси — не збій, а звичайний недооформлений рядок +// інвентарю. Але перевірка з порожнім URL не запуститься ніколи, тож +// такий хост має чесно потрапити в «не вдалося», а не отримати +// зіпсований чек. +func TestExpandRefusesWhenNothingToSubstitute(t *testing.T) { + d := devFixture() + d.Address = "" + _, err := expandCheckParams(json.RawMessage(`{"url":"http://{{address}}/"}`), d) + if err == nil { + t.Fatal("порожня адреса мала б дати помилку") + } + if !strings.Contains(err.Error(), "{{address}}") { + t.Fatalf("з помилки не видно, чого бракує: %v", err) + } +} + +func TestExpandWalksStringArrays(t *testing.T) { + out, err := expandCheckParams( + json.RawMessage(`{"hosts":["{{fqdn}}","static.example"]}`), devFixture()) + if err != nil { + t.Fatalf("несподівана помилка: %v", err) + } + if !strings.Contains(out, "polove7.example.net") || !strings.Contains(out, "static.example") { + t.Fatalf("масив рядків оброблено неправильно: %s", out) + } +} + +// «Змінити наявні», не сказавши що саме, — незаповнена форма. Мовчазне +// «готово» тут прочиталося б як «застосовано до сорока хостів». +func TestTuneWithoutFieldsIsRefused(t *testing.T) { + op := &BulkChecksOp{Op: "tune", CheckType: "icmp.ping"} + if err := op.valid(); err == nil { + t.Fatal("порожня дія tune мала б бути відхилена") + } + sec := 30 + op.IntervalSec = &sec + if err := op.valid(); err != nil { + t.Fatalf("заповнена дія tune відхилена: %v", err) + } +} + +func TestUnknownChecksOpIsRefused(t *testing.T) { + if err := (&BulkChecksOp{Op: "replace", CheckType: "icmp.ping"}).valid(); err == nil { + t.Fatal("replace над перевірками свідомо не існує — має бути відхилено") + } +} diff --git a/server/internal/store/devices_purge.go b/server/internal/store/devices_purge.go new file mode 100644 index 0000000..3519395 --- /dev/null +++ b/server/internal/store/devices_purge.go @@ -0,0 +1,542 @@ +package store + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" +) + +// Повне видалення хоста — те, після якого від нього не лишається нічого. +// +// ЧОМУ ЦЕ З'ЯВИЛОСЬ ПОРУЧ ІЗ М'ЯКИМ, А НЕ ЗАМІСТЬ НЬОГО +// +// М'яке видалення (deleted_at = now()) завели з правильного міркування: +// жорстке зняло б за собою телеметрію й історію алертів, а їх тримають +// заради розбору аварій і заради вимог відповідності. Міркування +// лишається правильним — і саме тому архівне видалення нікуди не +// зникло. +// +// Хибним виявився висновок, що воно може бути ЄДИНИМ. Три факти проти: +// +// 1. «Видалити» в інтерфейсі означає видалити. Людина, яка натиснула +// кнопку й побачила, що хост зник зі списку, не має підстав +// здогадуватись, що його метрики далі займають місце, а гілка з +// конфігами далі їде на зовнішній Git. +// 2. Архів, до якого немає дороги, архівом не є. Переліку видалених +// хостів у продукті немає, відновлення теж немає: рядок із +// deleted_at недосяжний нізвідки, крім SQL. Тобто «зберігаємо для +// розбору» на практиці означає «займає місце й ніколи не +// знадобиться». +// 3. Половина прибраного гірша за нуль прибраного. На цьому стенді від +// двох м'яко видалених хостів лишились 7 рядів метрик і 3 +// перевірки, які не належать жодному видимому хосту, — рівно ті +// «ряди без перевірки» й «чеки, що мовчать», за якими потім ходять +// із питанням «звідки це». +// +// Тому вибір робить людина у вікні видалення, і обидві дії названі +// своїми словами: «прибрати з переліку» й «видалити назавжди». Різниця +// незворотна, і ховати її за одним словом «видалити» не можна в жоден +// бік. +// +// ЩО САМЕ ПРИБИРАЄТЬСЯ +// +// Каскади бази роблять більшу частину: на inv.devices стоїть 21 +// зовнішній ключ, і всі, крім topo.neighbors.resolved_device_id +// (SET NULL), — CASCADE. Одного DELETE досить, щоб зникли перевірки, +// алерти, мовчання, періоди SLA, доступи, членство в групах, теги, +// порти, цілі команд, результати відповідності, версії конфігів, +// розклад збору, завдання, відкати, лінки, вузли мап, сусіди, шаблони +// й ряди метрик. +// +// Каскад НЕ дістає трьох речей, і кожна з них тут прибирається руками: +// +// - Зашифровані тіла конфігів у core.secrets. ncm.configs посилається +// на них через body_secret_id з ON DELETE SET NULL, тож каскадне +// видалення версій лишило б шифротекст у базі назавжди й без жодного +// способу до нього дійти. Саме там лежать мегабайти. Розв'язано тим +// самим deleteConfigRows, що й ручне видалення версій. +// - Телеметрія в гіпертаблицях. TimescaleDB не дозволяє посилатись на +// гіпертаблицю зовнішнім ключем, тому ts.icmp_samples, +// ts.if_counters, ts.syslog, ts.snmp_traps, ts.device_status_history +// й alr.alerts_history тримають device_id БЕЗ каскаду, а +// alr.notifications і ts.link_status — alert_id та link_id, які +// каскадом зникають самі. Перевірено на живій схемі (21 ключ на +// inv.devices, жодного з цих шести серед них), а не з опису таблиць. +// - Гілка в Git — локальна й на дзеркалі. Черга видалень посилань, +// див. ncm_refqueue.go. +// +// ЩО ЛИШАЄТЬСЯ, І ЦЕ НАВМИСНО +// +// - Журнал аудиту (core.audit_log). Запис про видалення має пережити +// видалення, інакше його нема сенсу писати; тому в ньому лежать +// ІМ'Я й адреса, а не лише id, якого вже не існує. +// - Спільні об'єкти: доступи (inv.credentials), майданчики, групи, +// шаблони, профілі. Зникає членство хоста в них, а не вони самі. +// - Об'єкти Git. Знявши посилання, ми не стираємо коміти з диска — +// їх збере gc. Тіла конфігів прибираються там, де вони справді +// лежать: у core.secrets. +// - Матеріалізовані згортки метрик (ts.samples_5m і решта). Вони +// ключуються series_id, ряд якого вже немає, тож у продукті вони +// недосяжні; фізично зникнуть із вікном ретеншену згортки. + +// PurgedDevice — хост, якого більше немає, і що саме з ним пішло. +// +// Ім'я й адреса тут не для краси: це те, що поїде в аудит і в +// відповідь. Після коміту жодним запитом їх уже не дістати. +type PurgedDevice struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + + Configs int `json:"configs"` + FreedBytes int64 `json:"freed_bytes"` + Series int `json:"series"` + Samples int64 `json:"samples"` + Branches []string `json:"branches,omitempty"` +} + +// PurgeFailure — хост, який видалити не вдалось. +// +// Окремим списком, а не помилкою всієї дії: видалення йде по одному +// хосту в своїй транзакції, і збій на тридцять сьомому не має скасувати +// тридцять шість уже зроблених. Скасувати їх усе одно неможливо — +// «відкотити» повне видалення нема з чого. +type PurgeFailure struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Error string `json:"error"` +} + +// PurgeResult — підсумок повного видалення. +type PurgeResult struct { + Purged []PurgedDevice `json:"purged"` + Failed []PurgeFailure `json:"failed,omitempty"` + + Configs int `json:"configs"` + FreedBytes int64 `json:"freed_bytes"` + Series int `json:"series"` + Samples int64 `json:"samples"` + Branches int `json:"branches"` +} + +// telemetryBatch — скільки рядків телеметрії прибирати за одну +// транзакцію. +// +// Не про швидкість. Хост із сотнею портів за місяць накопичує мільйони +// рядків у ts.if_counters, і один DELETE на всі тримав би блокування й +// ріс би в WAL стільки, скільки триває видалення. Партія розміром зі +// сто тисяч робить кожну транзакцію короткою, а перерваний посеред +// роботи процес лишає хост уже архівованим (див. порядок кроків нижче) +// — тобто повтор просто доробляє почате, а не починає спочатку. +const telemetryBatch = 100_000 + +// PurgeDevices видаляє хости назавжди. +// +// Стеля та сама, що й у решти масових дій (MaxBulkDevices): підтвердити +// наосліп можна що завгодно, але тут ціна помилки не «переналаштували +// не те», а «немає більше нічого». +// +// Кожен хост — окремо, і це головна відмінність від BulkUpdateDevices, +// де одна транзакція на весь набір. Там половина переведеної дільниці +// гірша за жодної переведеної, бо про неї ніхто не знає. Тут навпаки: +// «видалено 36 з 50» — зрозумілий стан, який доробляється повтором, а +// одна транзакція на п'ятдесят хостів означала б каскад на пів +// інвентарю й блокування таблиць, з яких диспетчер колектора саме зараз +// читає свій план. +func (s *Store) PurgeDevices(ctx context.Context, tenantID string, sc Scope, + ids []string) (*PurgeResult, error) { + + if len(ids) == 0 { + return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, fmt.Errorf("%w: за раз можна видалити не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + // Набір звужується ще раз, уже проти бази: перевірка прав в + // обробнику відповідає на питання «чи можна було», а цей запит — на + // «чи ще існує». deleted_at тут НЕ фільтрується навмисно: хост, + // раніше прибраний з переліку, — саме той, який найчастіше треба + // добити, і відмовити йому означало б лишити його недосяжним + // назавжди. + type target struct{ id, name, addr string } + var targets []target + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, name, COALESCE(host(address),'') + FROM inv.devices + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) + AND ($3::boolean OR id = ANY($4::uuid[])) + ORDER BY name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var t target + if err := rows.Scan(&t.id, &t.name, &t.addr); err != nil { + return err + } + targets = append(targets, t) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + if len(targets) == 0 { + return nil, fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис", + ErrInvalid) + } + + res := &PurgeResult{} + for _, t := range targets { + if ctx.Err() != nil { + break + } + one, err := s.purgeOne(ctx, tenantID, t.id, t.name, t.addr) + if err != nil { + res.Failed = append(res.Failed, PurgeFailure{ + DeviceID: t.id, Name: t.name, Error: err.Error(), + }) + continue + } + res.Purged = append(res.Purged, *one) + res.Configs += one.Configs + res.FreedBytes += one.FreedBytes + res.Series += one.Series + res.Samples += one.Samples + res.Branches += len(one.Branches) + } + if len(res.Purged) == 0 && len(res.Failed) > 0 { + return res, fmt.Errorf("жоден хост видалити не вдалося: %s", res.Failed[0].Error) + } + return res, nil +} + +// purgeOne — повне видалення одного хоста. +// +// Порядок кроків не довільний, і кожен наступний спирається на +// попередній. +// +// 1. Хост спершу АРХІВУЄТЬСЯ (deleted_at, enabled = false, чеки +// вимкнено) — тим самим кодом, що й звичайне архівне видалення. +// Це не формальність: доки хост живий, зонд і далі складає в нього +// нові виміри, і прибирання телеметрії ганялося б за власним +// хвостом. Заразом це і є точка, у якій перерваний процес лишає +// зрозумілий стан: хост уже зник з інтерфейсу, а повтор доробить +// решту. +// 2. Телеметрія без зовнішніх ключів — партіями, кожна своєю +// транзакцією. Найдовший крок, і саме тому він поза підсумковою +// транзакцією. +// 3. Одна підсумкова транзакція: відкати, тіла конфігів у core.secrets, +// сповіщення й стан лінків, сам рядок хоста (каскад забирає решту) +// і рядок черги на видалення гілки. +// 4. Локальна гілка — після коміту. До коміту її чіпати не можна: +// транзакція, що відкотилась після зняття посилання, лишила б +// історію конфігів недосяжною при цілих рядках у базі. +func (s *Store) purgeOne(ctx context.Context, tenantID, deviceID, name, addr string) (*PurgedDevice, error) { + out := &PurgedDevice{DeviceID: deviceID, Name: name, Address: addr} + + // --- крок 1: припинити збір --- + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices + SET deleted_at = COALESCE(deleted_at, now()), enabled = false, + archived_enabled = COALESCE(archived_enabled, enabled) + WHERE tenant_id = $1 AND id = $2 + `, tenantID, deviceID); err != nil { + return err + } + // Той самий archived_off, що й в архівному видаленні. Хост + // зникне назавжди за кілька кроків, тож позначка тут ні на що + // не впливає — вона стоїть, бо крок 1 має лишатись ОДНІЄЮ дією + // архівування, а не її схожою копією: перерваний посеред роботи + // процес лишає хост саме заархівованим, і з цього стану його + // мусить бути видно й можна повернути. + _, err := tx.Exec(ctx, ` + UPDATE core.checks + SET enabled = false, archived_off = true, updated_at = now() + WHERE tenant_id = $1 AND device_id = $2 AND enabled + `, tenantID, deviceID) + return err + }); err != nil { + return nil, fmt.Errorf("зупинка опитування: %w", err) + } + + // --- крок 2: телеметрія --- + // + // Ряди метрик читаються ОДИН раз і далі використовуються як перелік: + // ts.samples не має ні tenant_id, ні device_id, і єдиний шлях від + // хоста до його вимірів — через ts.series. Після кроку 1 нові ряди + // не з'являються. + var seriesIDs []int64 + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id FROM ts.series WHERE tenant_id = $1 AND device_id = $2 + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return err + } + seriesIDs = append(seriesIDs, id) + } + return rows.Err() + }); err != nil { + return nil, fmt.Errorf("ряди метрик: %w", err) + } + out.Series = len(seriesIDs) + + if len(seriesIDs) > 0 { + // Виміри прибираються ЯВНО, а не каскадом від ts.series. + // + // Не тому, що каскад не спрацював би. Спрацював би: перевірено + // на TimescaleDB 2.17.2 зі СТИСНЕНИМ шматком — DELETE рядів + // прибрав усі 2000 вимірів, які лежали стиснутими. Це варто + // було виміряти, бо припущення напрошувалось протилежне: + // зовнішній ключ оголошено на шматку, а стиснуті рядки лежать + // не в ньому. + // + // Причина в іншому: каскад робить усе однією операцією, тобто + // на хості з місячною історією тримає одну транзакцію на + // мільйони рядків. Явний прохід ділить це на партії, кожна зі + // своєю транзакцією (див. purgeTelemetry), і заразом рахує, що + // саме зникло — а це число потім читає людина у звіті й аудиті. + // Каскад не рахує нічого. + n, err := s.purgeTelemetry(ctx, tenantID, + `DELETE FROM ts.samples WHERE series_id = ANY($1::bigint[]) AND ts <= $2`, + `SELECT max(ts) FROM (SELECT ts FROM ts.samples + WHERE series_id = ANY($1::bigint[]) ORDER BY ts LIMIT $2) q`, + seriesIDs) + if err != nil { + return nil, fmt.Errorf("виміри метрик: %w", err) + } + out.Samples = n + } + + // Решта телеметрії ходить по device_id — і саме її не бачить жоден + // каскад: гіпертаблиця не може бути ціллю зовнішнього ключа. + for _, tbl := range []string{ + "ts.icmp_samples", "ts.if_counters", "ts.syslog", + "ts.snmp_traps", "ts.device_status_history", "alr.alerts_history", + } { + if _, err := s.purgeTelemetry(ctx, tenantID, + `DELETE FROM `+tbl+` WHERE tenant_id = $1 AND device_id = $3 AND ts <= $2`, + `SELECT max(ts) FROM (SELECT ts FROM `+tbl+` + WHERE tenant_id = $1 AND device_id = $3 ORDER BY ts LIMIT $2) q`, + nil, deviceID); err != nil { + return nil, fmt.Errorf("%s: %w", tbl, err) + } + } + + // --- крок 3: підсумкова транзакція --- + var branches []string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Відкати перші: ncm.rollbacks.target_config_id оголошено + // RESTRICT, тобто версія конфігу, на яку посилається відкат, не + // видаляється, поки живий сам відкат. Каскад від хоста забрав + // би обидва, але тіла конфігів ми прибираємо ДО нього — і без + // цього рядка спіткнулись би об RESTRICT. + if _, err := tx.Exec(ctx, ` + DELETE FROM ncm.rollbacks WHERE tenant_id = $1 AND device_id = $2 + `, tenantID, deviceID); err != nil { + return err + } + + // Гілки — усі, під якими хост колись комітився. Зазвичай одна, + // але перейменування могло не доїхати (RenameBranch не валить + // збір конфігу, а лише попереджає), і тоді їх дві. + rows, err := tx.Query(ctx, ` + SELECT DISTINCT branch FROM ncm.configs + WHERE tenant_id = $1 AND device_id = $2 AND branch <> '' + `, tenantID, deviceID) + if err != nil { + return err + } + seen := map[string]bool{} + for rows.Next() { + var b string + if err := rows.Scan(&b); err != nil { + rows.Close() + return err + } + if !seen[b] { + seen[b] = true + branches = append(branches, b) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + // Плюс те ім'я, яке гілка мала б ЗАРАЗ. Рядок ncm.configs може + // не встигнути про нього дізнатись: між перейменуванням хоста й + // наступним бекапом гілка вже переїхала, а в базі стоїть старе + // ім'я. + if b := DeviceBranch(deviceID, name, addr); !seen[b] { + branches = append(branches, b) + } + + // Тіла конфігів. Той самий deleteConfigRows, що й у ручному + // видаленні версій: шифротекст у core.secrets ніяким каскадом + // не прибирається (посилання SET NULL), а лежать у ньому саме + // мегабайти. + var cfgIDs []string + crows, err := tx.Query(ctx, ` + SELECT id::text FROM ncm.configs WHERE tenant_id = $1 AND device_id = $2 + `, tenantID, deviceID) + if err != nil { + return err + } + for crows.Next() { + var id string + if err := crows.Scan(&id); err != nil { + crows.Close() + return err + } + cfgIDs = append(cfgIDs, id) + } + crows.Close() + if err := crows.Err(); err != nil { + return err + } + if len(cfgIDs) > 0 { + n, freed, err := deleteConfigRows(ctx, tx, tenantID, cfgIDs) + if err != nil { + return err + } + out.Configs, out.FreedBytes = n, freed + } + + // Сповіщення й стан лінків. Обидві таблиці — гіпертаблиці, обидві + // тримають чужий id (alert_id, link_id), і обидва їхні джерела + // зникнуть каскадом за мить. Прибрати їх ПІСЛЯ видалення хоста + // було б уже нічим: переліку не лишилось би. + if _, err := tx.Exec(ctx, ` + DELETE FROM alr.notifications + WHERE tenant_id = $1 AND alert_id IN ( + SELECT id FROM alr.alerts WHERE tenant_id = $1 AND device_id = $2) + `, tenantID, deviceID); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + DELETE FROM ts.link_status + WHERE tenant_id = $1 AND link_id IN ( + SELECT id FROM topo.links + WHERE tenant_id = $1 + AND (a_device_id = $2::uuid OR b_device_id = $2::uuid)) + `, tenantID, deviceID); err != nil { + return err + } + + // Сам хост. Далі каскад забирає решту двадцяти зв'язків. + tag, err := tx.Exec(ctx, ` + DELETE FROM inv.devices WHERE tenant_id = $1 AND id = $2 + `, tenantID, deviceID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + + // Черга на видалення гілок — у ТІЙ САМІЙ транзакції, що й + // видалення хоста. Це і є відповідь на «що буде, якщо дзеркало + // недоступне»: хост зникає зараз, а гілка на тому кінці — + // коли Forgejo відповість. Рядок черги переживає і перезапуск + // процесу, і тижневу відмову дзеркала. + return enqueueRefDeletes(ctx, tx, tenantID, deviceID, name, branches) + }) + if err != nil { + return nil, err + } + out.Branches = branches + + // --- крок 4: локальна гілка --- + // + // Помилка тут не скасовує видалення: хоста вже немає, а гілка + // лишається в черзі й буде прибрана тим самим тактом, що розгрібає + // дзеркало. Тому лише журнал. + if s.git != nil { + for _, b := range branches { + if _, gerr := s.git.DeleteBranch(RepoName(tenantID), b); gerr != nil { + slog.Warn("git: локальну гілку не прибрано, лишається в черзі", + "branch", b, "device", name, "error", gerr) + } + } + } + return out, nil +} + +// purgeTelemetry прибирає рядки гіпертаблиці партіями. +// +// Партія відбирається не за кількістю, а за ЧАСОМ: спершу шукається +// позначка часу N-го найстарішого рядка, потім видаляється все до неї +// включно. Прямий `DELETE ... LIMIT` у Postgres не існує, а обхід через +// ctid на гіпертаблиці не працює — рядки лежать у шматках, і ctid +// унікальний лише всередині шматка. Час же є первинним ключем усіх цих +// таблиць, тож відбір по ньому потрапляє точно в шматок і в індекс. +// +// Кожна партія — своя транзакція. Перерваний процес лишає рівно ті +// партії, що встигли, і повтор доробляє решту: хост на цей момент уже +// архівований, тож нові рядки не додаються. +// +// args — необов'язкові додаткові параметри запиту, які починаються з $3. +func (s *Store) purgeTelemetry(ctx context.Context, tenantID, delSQL, pickSQL string, + series []int64, args ...any) (int64, error) { + + var total int64 + for { + var deleted int64 + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // $1 — tenant або перелік рядів, $2 — розмір партії. + pickArgs := make([]any, 0, 2+len(args)) + if series != nil { + pickArgs = append(pickArgs, series, telemetryBatch) + } else { + pickArgs = append(pickArgs, tenantID, telemetryBatch) + } + pickArgs = append(pickArgs, args...) + + var hi *time.Time + if err := tx.QueryRow(ctx, pickSQL, pickArgs...).Scan(&hi); err != nil { + return err + } + if hi == nil { + return nil + } + + delArgs := make([]any, 0, 2+len(args)) + if series != nil { + delArgs = append(delArgs, series, *hi) + } else { + delArgs = append(delArgs, tenantID, *hi) + } + delArgs = append(delArgs, args...) + + tag, err := tx.Exec(ctx, delSQL, delArgs...) + if err != nil { + return err + } + deleted = tag.RowsAffected() + return nil + }) + if err != nil { + return total, err + } + total += deleted + if deleted == 0 { + return total, nil + } + } +} diff --git a/server/internal/store/devices_purge_test.go b/server/internal/store/devices_purge_test.go new file mode 100644 index 0000000..d3952f8 --- /dev/null +++ b/server/internal/store/devices_purge_test.go @@ -0,0 +1,254 @@ +package store + +import ( + "context" + "os" + "testing" + "time" + + "github.com/netpulse/netpulse/server/internal/gitstore" +) + +// Перевірка повного видалення проти СПРАВЖНЬОЇ бази. +// +// Чому не мок і не перевірка SQL очима. Уся суть цієї дії в тому, що +// частину роботи робить не наш код, а каскади бази — і рівно там, де +// вони не спрацьовують, лишається сміття, якого ніхто не побачить. +// Тричі в цьому проєкті вже виявлялось, що дані не збігаються з +// оголошенням; довіряти тут опису таблиць не можна. +// +// Тест мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не +// має вимагати бази. Запускати його треба на ОДНОРАЗОВІЙ базі — +// він видаляє те, що створив, і робить це назавжди. +// +// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \ +// timescale/timescaledb:2.17.2-pg16 +// NETPULSE_TEST_DSN=postgres://postgres:x@np-test/np go test ./internal/store/ -run Purge +func TestPurgeDeviceLeavesNothing(t *testing.T) { + dsn := os.Getenv("NETPULSE_TEST_DSN") + if dsn == "" { + t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається") + } + ctx := context.Background() + + s, err := New(ctx, dsn) + if err != nil { + t.Fatalf("підключення: %v", err) + } + defer s.Close() + s.UseGit(gitstore.New(t.TempDir())) + + var tenantID string + slug := "purge-test-" + time.Now().Format("150405") + if err := s.pool.QueryRow(ctx, ` + INSERT INTO core.tenants (slug, name) VALUES ($1, 'Тест видалення') RETURNING id::text + `, slug).Scan(&tenantID); err != nil { + t.Fatalf("кабінет: %v", err) + } + defer func() { + _, _ = s.pool.Exec(context.Background(), + `DELETE FROM core.tenants WHERE id = $1`, tenantID) + }() + + var repoID, deviceID, ifaceID, secretID, alertID string + var seriesID int64 + + must := func(what string, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", what, err) + } + } + + must("репозиторій", s.pool.QueryRow(ctx, ` + INSERT INTO ncm.repos (tenant_id, name, storage_path, mirror_enabled, remote_url, remote_auth) + VALUES ($1, 'default', '/tmp/x.git', true, 'https://git.example/np.git', 'https') + RETURNING id::text`, tenantID).Scan(&repoID)) + + must("хост", s.pool.QueryRow(ctx, ` + INSERT INTO inv.devices (tenant_id, name, address, kind) + VALUES ($1, 'Леніна.21', '10.9.9.9', 'switch') RETURNING id::text`, + tenantID).Scan(&deviceID)) + + must("порт", s.pool.QueryRow(ctx, ` + INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name) + VALUES ($1, $2, 1, 'ge-0/0/1') RETURNING id::text`, + tenantID, deviceID).Scan(&ifaceID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO core.checks (tenant_id, device_id, check_type) VALUES ($1, $2, 'icmp.ping')`, + tenantID, deviceID) + must("перевірка", err) + + must("ряд метрик", s.pool.QueryRow(ctx, ` + INSERT INTO ts.series (tenant_id, device_id, metric_key) VALUES ($1, $2, 'icmp.rtt') + RETURNING id`, tenantID, deviceID).Scan(&seriesID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.samples (ts, series_id, value) + SELECT now() - (g || ' minutes')::interval, $1, g + FROM generate_series(1, 500) g`, seriesID) + must("виміри", err) + + // Ще 400 вимірів — десятиденної давності й СТИСНУТІ. + // + // Це не зайва ретельність. Стиснення в TimescaleDB тримає рядки не + // в самому шматку, а в окремій таблиці, і зовнішній ключ оголошено + // на шматку — тобто напрошується висновок, що видалення стиснутого + // не дістане. Висновок хибний (2.17.2 розтискає під DML сама), але + // перевіряти його треба тут, а не в голові: на бойових інсталяціях + // стиснуте — це майже вся телеметрія. + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.samples (ts, series_id, value) + SELECT now() - interval '10 days' + (g || ' seconds')::interval, $1, g + FROM generate_series(1, 400) g`, seriesID) + must("старі виміри", err) + + _, err = s.pool.Exec(ctx, ` + SELECT compress_chunk(format('%I.%I', chunk_schema, chunk_name)::regclass) + FROM timescaledb_information.chunks + WHERE hypertable_schema = 'ts' AND hypertable_name = 'samples' + AND NOT is_compressed AND range_end < now() - interval '2 days'`) + must("стиснення шматка", err) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.icmp_samples (ts, device_id, tenant_id, reachable) + SELECT now() - (g || ' minutes')::interval, $1, $2, true + FROM generate_series(1, 300) g`, deviceID, tenantID) + must("icmp", err) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.if_counters (ts, interface_id, device_id, tenant_id, in_octets) + SELECT now() - (g || ' minutes')::interval, $1, $2, $3, g + FROM generate_series(1, 300) g`, ifaceID, deviceID, tenantID) + must("лічильники портів", err) + + must("алерт", s.pool.QueryRow(ctx, ` + INSERT INTO alr.alerts (tenant_id, device_id, severity, title, dedup_key) + VALUES ($1, $2, 'high', 'тест', 'k1') RETURNING id::text`, + tenantID, deviceID).Scan(&alertID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO alr.notifications (tenant_id, alert_id) VALUES ($1, $2)`, + tenantID, alertID) + must("сповіщення", err) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO alr.alerts_history (ts, id, tenant_id, device_id, severity, title, started_at) + VALUES (now(), core.new_id(), $1, $2, 'high', 'тест', now())`, tenantID, deviceID) + must("історія алертів", err) + + // Тіло конфігу в core.secrets — найважливіше з того, чого каскад не + // прибирає: посилання оголошено ON DELETE SET NULL. + must("тіло конфігу", s.pool.QueryRow(ctx, ` + INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag) + VALUES ($1, 'generic', 'k1', '\x00', '\x01', '\x02') RETURNING id::text`, + tenantID).Scan(&secretID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ncm.configs (tenant_id, device_id, repo_id, commit_sha, blob_sha, + branch, path, size_bytes, content_hash, body_secret_id) + VALUES ($1, $2, $3, 'abc', 'def', 'device/Леніна.21-10.9.9.9', + 'Леніна.21/running.cfg', 4096, '\x00', $4)`, + tenantID, deviceID, repoID, secretID) + must("версія конфігу", err) + + // --- сама дія --- + + res, err := s.PurgeDevices(ctx, tenantID, Scope{Unrestricted: true}, []string{deviceID}) + if err != nil { + t.Fatalf("видалення: %v", err) + } + if len(res.Purged) != 1 { + t.Fatalf("очікували один видалений хост, отримали %d (%+v)", len(res.Purged), res.Failed) + } + got := res.Purged[0] + if got.Name != "Леніна.21" || got.Address != "10.9.9.9" { + t.Errorf("ім'я й адреса мають доїхати до звіту (це те, що піде в аудит): %+v", got) + } + if got.Samples != 900 { + t.Errorf("вимірів прибрано %d, очікували 900 (з них 400 — зі стиснутого шматка)", + got.Samples) + } + if got.Configs != 1 || got.FreedBytes != 4096 { + t.Errorf("версій %d, звільнено %d — очікували 1 і 4096", got.Configs, got.FreedBytes) + } + + // --- що лишилось --- + + check := func(what, sql string, args ...any) { + t.Helper() + var n int + if err := s.pool.QueryRow(ctx, sql, args...).Scan(&n); err != nil { + t.Fatalf("%s: %v", what, err) + } + if n != 0 { + t.Errorf("%s: лишилось %d рядків", what, n) + } + } + + check("хост", `SELECT count(*) FROM inv.devices WHERE id = $1`, deviceID) + check("перевірки", `SELECT count(*) FROM core.checks WHERE device_id = $1`, deviceID) + check("порти", `SELECT count(*) FROM inv.interfaces WHERE device_id = $1`, deviceID) + check("ряди метрик", `SELECT count(*) FROM ts.series WHERE device_id = $1`, deviceID) + check("виміри", `SELECT count(*) FROM ts.samples WHERE series_id = $1`, seriesID) + check("icmp", `SELECT count(*) FROM ts.icmp_samples WHERE device_id = $1`, deviceID) + check("лічильники портів", `SELECT count(*) FROM ts.if_counters WHERE device_id = $1`, deviceID) + check("алерти", `SELECT count(*) FROM alr.alerts WHERE device_id = $1`, deviceID) + check("сповіщення", `SELECT count(*) FROM alr.notifications WHERE alert_id = $1`, alertID) + check("історія алертів", `SELECT count(*) FROM alr.alerts_history WHERE device_id = $1`, deviceID) + check("версії конфігу", `SELECT count(*) FROM ncm.configs WHERE device_id = $1`, deviceID) + // Найважливіший рядок тесту: шифротекст, на який більше ніщо не + // посилається, лишився б у базі назавжди. + check("зашифровані тіла", `SELECT count(*) FROM core.secrets WHERE id = $1`, secretID) + + // --- гілка стала в чергу --- + + var branch string + if err := s.pool.QueryRow(ctx, ` + SELECT branch FROM ncm.ref_deletions WHERE tenant_id = $1 ORDER BY branch LIMIT 1 + `, tenantID).Scan(&branch); err != nil { + t.Fatalf("черга видалень гілок порожня: %v", err) + } + if branch != "device/Леніна.21-10.9.9.9" { + t.Errorf("у черзі гілка %q", branch) + } + // Рядок черги мусить ПЕРЕЖИТИ видалення хоста — заради цього на + // device_id і не поставлено зовнішнього ключа. + var deviceIDInQueue *string + if err := s.pool.QueryRow(ctx, ` + SELECT device_id::text FROM ncm.ref_deletions WHERE tenant_id = $1 LIMIT 1 + `, tenantID).Scan(&deviceIDInQueue); err != nil { + t.Fatalf("рядок черги: %v", err) + } + if deviceIDInQueue == nil || *deviceIDInQueue != deviceID { + t.Errorf("у черзі має лишитись id видаленого хоста, маємо %v", deviceIDInQueue) + } + + // Такт колектора бачить цей рядок і знає, що дзеркало налаштоване. + // Без цієї перевірки помилка в JOIN'ах вибірки виявилась би тоді, + // коли гілки мовчки не прибирались би тижнями. + tasks, err := s.PendingRefDeletes(ctx, nil, 100) + if err != nil { + t.Fatalf("вибірка черги: %v", err) + } + var mine *RefDeleteTask + for i := range tasks { + if tasks[i].TenantID == tenantID { + mine = &tasks[i] + break + } + } + if mine == nil { + t.Fatal("такт не побачив рядка черги") + } + if !mine.MirrorReady { + t.Error("дзеркало налаштоване — такт мав це побачити") + } + if mine.LocalDone { + t.Error("локальну гілку ще не прибирали цим шляхом") + } + if mine.Remote.URL != "https://git.example/np.git" { + t.Errorf("адреса дзеркала не доїхала: %q", mine.Remote.URL) + } +} diff --git a/server/internal/store/devices_restore.go b/server/internal/store/devices_restore.go new file mode 100644 index 0000000..65d0830 --- /dev/null +++ b/server/internal/store/devices_restore.go @@ -0,0 +1,310 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Повернення хоста з архіву. +// +// Третя дія над хостом поруч із двома наявними: архівне видалення +// (BulkDeleteDevices) прибирає з переліків, повне (PurgeDevices) стирає +// назавжди, це — повертає. Досі її не було взагалі, і архівне видалення +// через це працювало як пастка: хост не видалений, місце займає, ряди +// метрик лишає, а дістатись до нього не можна нізвідки, крім SQL. +// +// ЧОМУ ЦЕ НЕ `deleted_at = NULL` +// +// Архівування вимикає хост і всі його увімкнені перевірки. Знявши саме +// лише deleted_at, ми повернули б у перелік хост, який мовчить: він на +// місці, графіки порожні, причину не видно ніде. Тому повернення знімає +// рівно те, що поставило архівування, — і спирається на дві колонки з +// 0062, у які архівування записує, що саме воно вимкнуло. +// +// ТРИ РЕЧІ, ЯКІ МОГЛИ ЗМІНИТИСЬ ЗА ЧАС В АРХІВІ +// +// Архів — не сейф: поки хост лежав прибраним, у кабінеті працювали далі. +// Дві унікальні умови бази стосуються саме живих рядків і мовчки +// зіткнулися б із поверненням: +// +// - devices_tenant_name_uniq (tenant_id, lower(name)) WHERE deleted_at +// IS NULL. За місяць в архіві хост із такою ж назвою могли завести +// заново. Ім'я — це те, чим хост називають уголос, і мовчки додати +// до нього « (2)» означало б повернути не той хост, який просили. +// Тому такий випадок — відмова саме по цьому хосту, зі словами, що +// робити далі; решта набору повертається. +// - devices_self_agent_uniq (self_agent_id) WHERE deleted_at IS NULL. +// Прив'язку «цей хост і є машина зонда» за цей час міг забрати інший +// живий хост. Тут навпаки: хост повертається, а прив'язка з нього +// знімається — і про це прямо сказано у відповіді. Різниця з іменем +// не в примсі: ім'я незамінне, а прив'язку людина ставить назад +// двома кліками, і відмовити через неї означало б лишити хост в +// архіві через налаштування, яке й так треба переглянути. +// +// Третє — сам зонд-опитувач (agent_id). Його чіпати не треба: якщо зонд +// видалили, ключ уже поставив NULL (ON DELETE SET NULL з 0003), і хост +// повернеться без опитувача. Вигадувати йому зонд ми не маємо права, а +// порожній зонд видно в переліку хостів окремою колонкою. + +// RestoredDevice — хост, який повернувся, і в якому саме стані. +// +// Ці три поля — не звіт заради звіту. «Повернули» без них означає лише +// «рядок знову видно»: чи опитується хост, чи ввімкнені його перевірки +// й чи лишилась прив'язка — рівно ті питання, які людина поставить +// наступними, і відповіді на них є тільки тут. +type RestoredDevice struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + // Enabled — чи повернувся хост увімкненим. false означає одне з + // двох: або його вимкнули ще до архівування, або заархівували до + // появи 0062, і тоді відповіді просто немає. + Enabled bool `json:"enabled"` + // Checks — скільки перевірок увімкнено назад. + Checks int `json:"checks"` + // SelfAgentDropped — прив'язку «хост = машина зонда» знято, бо ту + // саму машину вже оголосив своєю інший живий хост. + SelfAgentDropped bool `json:"self_agent_dropped,omitempty"` +} + +// RestoreFailure — хост, якого повернути не вдалось, і чому. +type RestoreFailure struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Error string `json:"error"` +} + +// RestoreResult — підсумок повернення. +type RestoreResult struct { + Restored []RestoredDevice `json:"restored"` + Failed []RestoreFailure `json:"failed,omitempty"` + // Checks — усього ввімкнено перевірок по всьому набору. + Checks int `json:"checks"` +} + +// RestoreDevices повертає заархівовані хости в роботу. +// +// Стеля та сама, що й у решти масових дій: повернення саме по собі +// оборотне (хост завжди можна заархівувати знову), але воно вмикає +// опитування, а сорок хостів, які разом пішли в план колектора, — це +// вже навантаження, про яке людина мусить думати свідомо. +// +// Ліміт тарифу тут не перевіряється, і це не пропуск: тригер +// bill.assert_device_limit стоїть на INSERT, тобто рахує ПОЯВУ нових +// хостів. Повернутий хост не новий — він уже був у цьому кабінеті й уже +// був порахований, поки його не прибрали. +func (s *Store) RestoreDevices(ctx context.Context, tenantID string, sc Scope, + ids []string) (*RestoreResult, error) { + + if len(ids) == 0 { + return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, fmt.Errorf("%w: за раз можна повернути не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + res := &RestoreResult{Restored: []RestoredDevice{}} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Набір звужується проти бази ще раз, як і в решті масових дій: + // перевірка прав в обробнику відповідає на «чи можна було», а + // цей запит — на «чи хост ще в архіві». Заразом він відповідає + // на обидва питання про зіткнення — імені й прив'язки, — щоб не + // ловити їх помилкою унікального індексу вже після UPDATE. + rows, err := tx.Query(ctx, ` + SELECT d.id::text, d.name, COALESCE(d.archived_enabled, false), + COALESCE(d.self_agent_id::text, ''), + EXISTS (SELECT 1 FROM inv.devices o + WHERE o.tenant_id = d.tenant_id + AND o.deleted_at IS NULL + AND o.self_agent_id = d.self_agent_id), + EXISTS (SELECT 1 FROM inv.devices n + WHERE n.tenant_id = d.tenant_id + AND n.deleted_at IS NULL + AND lower(n.name) = lower(d.name)) + FROM inv.devices d + WHERE d.tenant_id = $1 AND d.id = ANY($2::uuid[]) + AND d.deleted_at IS NOT NULL + AND ($3::boolean OR d.id = ANY($4::uuid[])) + ORDER BY d.name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + var cands []restoreCandidate + for rows.Next() { + var c restoreCandidate + if err := rows.Scan(&c.id, &c.name, &c.enabled, &c.selfAgent, + &c.selfTaken, &c.nameTaken); err != nil { + rows.Close() + return err + } + cands = append(cands, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if len(cands) == 0 { + return fmt.Errorf("%w: жоден з обраних хостів не в архіві "+ + "або не доступний вам на запис", ErrInvalid) + } + + plan := planRestore(cands) + res.Restored = plan.restored + res.Failed = plan.failed + back, unbind := plan.back, plan.unbind + if len(back) == 0 { + return nil + } + + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices + SET deleted_at = NULL, + enabled = COALESCE(archived_enabled, false), + archived_enabled = NULL, + -- Стан обнуляється навмисно. Хост лежав в архіві й не + -- опитувався: те, що записано в status, — це те, як він + -- виглядав перед видаленням, і показати тритижневої + -- давнини «недоступний» як поточний стан означало б + -- збрехати ще до першого опитування. + status = 'unknown'::inv.device_status, + status_changed_at = now(), + self_agent_id = CASE WHEN id = ANY($3::uuid[]) + THEN NULL ELSE self_agent_id END, + self_machine_id = CASE WHEN id = ANY($3::uuid[]) + THEN NULL ELSE self_machine_id END + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NOT NULL + `, tenantID, nonNilIDs(back), nonNilIDs(unbind)); err != nil { + return err + } + + // Вмикаються ЛИШЕ ті перевірки, які вимкнуло архівування. + // + // next_run_at зсувається на зараз, а не лишається тим, що було + // до архівування: інакше кожна повернута перевірка одразу + // виглядає як така, що запізнилась на три тижні, — і сторінка + // «Черги» чесно показує затор, якого немає. + crows, err := tx.Query(ctx, ` + UPDATE core.checks + SET enabled = true, archived_off = false, + next_run_at = now(), updated_at = now() + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) AND archived_off + RETURNING device_id::text + `, tenantID, nonNilIDs(back)) + if err != nil { + return err + } + counts := map[string]int{} + for crows.Next() { + var id string + if err := crows.Scan(&id); err != nil { + crows.Close() + return err + } + counts[id]++ + } + crows.Close() + if err := crows.Err(); err != nil { + return err + } + + for i := range res.Restored { + n := counts[res.Restored[i].DeviceID] + res.Restored[i].Checks = n + res.Checks += n + } + return nil + }) + if err != nil { + return nil, err + } + if len(res.Restored) == 0 { + // Сюди доходить лише набір, у якому кожен хост уперся в зайняту + // назву. Помилкою, а не порожнім успіхом: людина натиснула + // «повернути» й мусить дізнатись, чому нічого не повернулось. + return res, fmt.Errorf("%w: %s", ErrInvalid, res.Failed[0].Error) + } + return res, nil +} + +// --------------------------------------------------------------------- +// Рішення про набір +// --------------------------------------------------------------------- + +// restoreCandidate — прибраний хост таким, яким його бачить база перед +// поверненням: разом із відповіддю на обидва питання про зіткнення. +type restoreCandidate struct { + id, name string + enabled bool + selfAgent string + // selfTaken — ту саму машину зонда вже оголосив своєю живий хост. + selfTaken bool + // nameTaken — ім'я вже носить живий хост. + nameTaken bool +} + +// restorePlan — що саме робити з набором. +type restorePlan struct { + back []string + unbind []string + restored []RestoredDevice + failed []RestoreFailure +} + +// planRestore розкладає набір на «повернути», «повернути без прив'язки» +// і «відмовити». +// +// Винесено з транзакції окремою чистою функцією не заради краси. Уся +// нетривіальність повернення саме тут, і перевірити її інакше нічим: +// зіткнення імен і прив'язок у базі відтворюється лише повним стендом +// із двома хостами, майданчиком і зондом, а помилка тут коштує +// унікального індексу посеред UPDATE — тобто відмови всьому набору +// замість одного хоста. +// +// Сусід по набору важить не менше за живий хост. Два прибрані хости +// могли мати однакове ім'я або вказувати на ту саму машину зонда: поки +// обидва лежали в архіві, часткові унікальні індекси їх не бачили +// (обидва — `WHERE deleted_at IS NULL`), а після повернення побачать +// одразу. Тому зайняте накопичується під час обходу, а порядок обходу — +// той, у якому набір прийшов із бази (за іменем): перший претендент +// лишає собі, решта отримує відмову або зняту прив'язку. +func planRestore(cands []restoreCandidate) restorePlan { + var p restorePlan + p.restored = []RestoredDevice{} + + agents := map[string]bool{} + names := map[string]bool{} + + for _, c := range cands { + // Порівняння без регістру — те саме, що в devices_tenant_name_uniq + // (lower(name)). Інше порівняння тут означало б, що код вважає + // набір справним, а база — ні. + key := strings.ToLower(c.name) + if c.nameTaken || names[key] { + p.failed = append(p.failed, RestoreFailure{ + DeviceID: c.id, Name: c.name, + Error: "у переліку вже є хост із такою назвою — " + + "перейменуйте його або цей і поверніть ще раз", + }) + continue + } + names[key] = true + + r := RestoredDevice{DeviceID: c.id, Name: c.name, Enabled: c.enabled} + if c.selfAgent != "" { + if c.selfTaken || agents[c.selfAgent] { + r.SelfAgentDropped = true + p.unbind = append(p.unbind, c.id) + } else { + agents[c.selfAgent] = true + } + } + p.back = append(p.back, c.id) + p.restored = append(p.restored, r) + } + return p +} diff --git a/server/internal/store/devices_restore_test.go b/server/internal/store/devices_restore_test.go new file mode 100644 index 0000000..311d870 --- /dev/null +++ b/server/internal/store/devices_restore_test.go @@ -0,0 +1,126 @@ +package store + +import "testing" + +// Повернення хоста з архіву впирається у дві часткові унікальні умови, +// які стосуються ЛИШЕ живих рядків: ім'я (devices_tenant_name_uniq) і +// прив'язка «хост = машина зонда» (devices_self_agent_uniq). Поки хост +// лежав прибраним, обидві його не бачили, і зіткнення виникає рівно в +// мить повернення. +// +// Перевіряти це на живій базі означало б стенд із двома хостами, зондом +// і майданчиком на кожен випадок; ціна помилки — помилка унікального +// індексу посеред UPDATE, тобто відмова ВСЬОМУ набору замість одного +// хоста. Тому рішення про набір винесене чистою функцією, а тут — усі +// випадки, які вона мусить розрізняти. + +func TestPlanRestoreKeepsNamesUnique(t *testing.T) { + // Живий хост уже носить це ім'я — повертати нікуди. + p := planRestore([]restoreCandidate{ + {id: "1", name: "core-sw", nameTaken: true}, + {id: "2", name: "edge-1"}, + }) + if len(p.back) != 1 || p.back[0] != "2" { + t.Fatalf("повертати треба лише edge-1, а повертається %v", p.back) + } + if len(p.failed) != 1 || p.failed[0].DeviceID != "1" { + t.Fatalf("відмова мала дістатись core-sw, а дісталась %+v", p.failed) + } + if p.failed[0].Error == "" { + t.Fatal("відмова без причини: людина не дізнається, що робити далі") + } +} + +func TestPlanRestoreCatchesDuplicateInsideBatch(t *testing.T) { + // Два ПРИБРАНІ хости з однаковим іменем: у базі вони не зіткнулись, + // бо часткова унікальна умова не бачить прибраних. Зіткнуться, щойно + // повернуться обидва — і зловити це має набір, а не індекс. + // + // Регістр різний навмисно: індекс іде по lower(name). + p := planRestore([]restoreCandidate{ + {id: "1", name: "Core-SW"}, + {id: "2", name: "core-sw"}, + }) + if len(p.back) != 1 || p.back[0] != "1" { + t.Fatalf("перший претендент мав лишити ім'я собі, повертається %v", p.back) + } + if len(p.failed) != 1 || p.failed[0].DeviceID != "2" { + t.Fatalf("другому мала бути відмова, а маємо %+v", p.failed) + } +} + +func TestPlanRestoreDropsTakenSelfAgent(t *testing.T) { + // Прив'язку до машини зонда за час в архіві забрав живий хост. + // Хост усе одно повертається — інакше він лишився б в архіві через + // налаштування, яке й так треба переглянути, — але вже без неї. + p := planRestore([]restoreCandidate{ + {id: "1", name: "srv-1", selfAgent: "agent-a", selfTaken: true}, + }) + if len(p.back) != 1 { + t.Fatalf("хост мав повернутись, повертається %v", p.back) + } + if len(p.unbind) != 1 || p.unbind[0] != "1" { + t.Fatalf("прив'язку мали зняти, знімається з %v", p.unbind) + } + if !p.restored[0].SelfAgentDropped { + t.Fatal("зняту прив'язку не позначено — людина про неї не дізнається") + } + if len(p.failed) != 0 { + t.Fatalf("відмовляти тут нема за що: %+v", p.failed) + } +} + +func TestPlanRestoreSplitsSelfAgentInsideBatch(t *testing.T) { + // Двоє прибраних претендують на ту саму машину зонда. Один може + // лишити прив'язку собі, другий — ні; вирішує порядок набору. + p := planRestore([]restoreCandidate{ + {id: "1", name: "srv-1", selfAgent: "agent-a"}, + {id: "2", name: "srv-2", selfAgent: "agent-a"}, + }) + if len(p.back) != 2 { + t.Fatalf("обидва мали повернутись, повертається %v", p.back) + } + if len(p.unbind) != 1 || p.unbind[0] != "2" { + t.Fatalf("прив'язку мали зняти з другого, знімається з %v", p.unbind) + } + if p.restored[0].SelfAgentDropped || !p.restored[1].SelfAgentDropped { + t.Fatalf("позначки зняття стоять не там: %+v", p.restored) + } +} + +func TestPlanRestoreKeepsFreeSelfAgent(t *testing.T) { + // Прив'язка вільна — знімати її нема причини. Окремий випадок, бо + // саме він найчастіший, і зайве зняття тут означало б, що збір + // конфігів сервера мовчки не починається після повернення. + p := planRestore([]restoreCandidate{ + {id: "1", name: "srv-1", selfAgent: "agent-a", enabled: true}, + // Хост без прив'язки взагалі: порожній ключ не має вважатись + // зайнятим першим же таким хостом. + {id: "2", name: "srv-2"}, + {id: "3", name: "srv-3"}, + }) + if len(p.unbind) != 0 { + t.Fatalf("нічого знімати не треба, а знімається %v", p.unbind) + } + if len(p.back) != 3 || len(p.failed) != 0 { + t.Fatalf("мали повернутись усі три: %v, %+v", p.back, p.failed) + } + // enabled їде з archived_enabled і має дійти до відповіді як є: + // «повернувся вимкненим» — це те, чого людина не побачить ніде, + // крім цього поля. + if !p.restored[0].Enabled || p.restored[1].Enabled { + t.Fatalf("стан enabled перекручено: %+v", p.restored) + } +} + +func TestPlanRestoreEmptyBatch(t *testing.T) { + p := planRestore(nil) + if len(p.back) != 0 || len(p.failed) != 0 { + t.Fatalf("з порожнього набору не береться нічого: %+v", p) + } + // Порожній зріз, а не nil: він їде в JSON відповіді, і `null` там + // означав би для клієнта «поля немає», а не «нікого не повернуто». + if p.restored == nil { + t.Fatal("restored має бути порожнім зрізом, а не nil") + } +} diff --git a/server/internal/store/downloads.go b/server/internal/store/downloads.go new file mode 100644 index 0000000..63f773b --- /dev/null +++ b/server/internal/store/downloads.go @@ -0,0 +1,107 @@ +package store + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" +) + +// Квитки на завантаження файлу звичайним посиланням. +// +// Чому вони існують — у міграції 0038. Коротко: заголовок Authorization +// уміє додати лише fetch, а файл має тягнути завантажувач браузера, тож +// право доступу мусить бути в URL. +// +// Квиток відкриває рівно один об'єкт в одному форматі й живе хвилини. +// Ширшого доступу він не дає й дати не може: kind і object_id +// зафіксовані в момент видачі, а обробник нічого, крім них, не читає. + +// DownloadTicketTTL — скільки живе виданий квиток. +// +// Дві хвилини — це «натиснув і браузер пішов за файлом» із запасом на +// повільний канал. Довший термін перетворив би посилання на те, що можна +// переслати в чат і чим можна скористатись завтра. +const DownloadTicketTTL = 2 * time.Minute + +// Види квитків. Обробник розбирає саме це поле: object_id без kind +// нічого не означає. +const DownloadKindCommandReport = "ncm.command_run_report" + +// DownloadTicket — розібраний квиток. +type DownloadTicket struct { + Token string + TenantID string + UserID string + Kind string + ObjectID string + Format string +} + +// CreateDownloadTicket видає квиток. +// +// Токен приходить ззовні, а не народжується тут: його робить той самий +// auth.NewToken, що й токен телевізора, і тримати генерацію секретів у +// шарі доступу до даних означало б завести їй друге місце. +func (s *Store) CreateDownloadTicket(ctx context.Context, + token, tenantID, userID, kind, objectID, format string) error { + + // Прибирання протухлих — тут же, а не окремим воркером. Рядків тут + // стільки, скільки натискань на «Завантажити», і заводити під це + // фонову задачу було б дорожче за сам механізм. Година запасу лишає + // слід для розбору «хто щойно качав звіт». + if _, err := s.bg.Exec(ctx, ` + DELETE FROM core.download_tickets WHERE expires_at < now() - interval '1 hour' + `); err != nil { + return err + } + + // Видача квитка — дія в межах кабінету, і контекст тут виставляється + // навмисно: WITH CHECK на core.download_tickets вимагає, щоб + // tenant_id рядка збігався з поточним. Без цього під netpulse_app + // INSERT відхиляється — тобто кнопка «Завантажити» падала б з + // помилкою одразу, а не мовчки. + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO core.download_tickets + (token, tenant_id, user_id, kind, object_id, format, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, now() + $7::interval) + `, token, tenantID, nullUUID(userID), kind, objectID, format, + DownloadTicketTTL.String()) + return err + }) +} + +// ResolveDownloadTicket знаходить квиток за токеном. +// +// Поза тенантним контекстом: на момент запиту особи ще немає, тенант +// з'ясовується з самого рядка. Той самий шлях, яким ходить публічний +// дашборд. +// +// Протухлий і неіснуючий токен нерозрізнимі назовні — різниця між ними +// це підказка тому, хто перебирає. +func (s *Store) ResolveDownloadTicket(ctx context.Context, token string) (DownloadTicket, error) { + var t DownloadTicket + if token == "" { + return t, ErrNotFound + } + + // UPDATE, а не SELECT: перше використання фіксується тим самим + // запитом, яким квиток читається. COALESCE лишає час ПЕРШОГО + // звернення — повторний запит браузера при обриві не має стирати + // відповідь на питання «коли файл поїхав». + err := s.bg.QueryRow(ctx, ` + UPDATE core.download_tickets + SET used_at = COALESCE(used_at, now()) + WHERE token = $1 AND expires_at > now() + RETURNING token, tenant_id::text, COALESCE(user_id::text,''), + kind, object_id::text, format + `, token).Scan(&t.Token, &t.TenantID, &t.UserID, &t.Kind, &t.ObjectID, &t.Format) + if err != nil { + if isNoRows(err) { + return DownloadTicket{}, ErrNotFound + } + return DownloadTicket{}, err + } + return t, nil +} diff --git a/server/internal/store/enrollment.go b/server/internal/store/enrollment.go index 85cc1fc..36ed8ee 100644 --- a/server/internal/store/enrollment.go +++ b/server/internal/store/enrollment.go @@ -158,7 +158,7 @@ func (s *Store) RedeemEnrollment(ctx context.Context, token, hostname, requested // Без InTenantTx: тенант тут ще невідомий — саме його ми й // з'ясовуємо з токена. RLS натомість не потрібна: вибірка йде за // унікальним хешем, який знає лише той, кому його видали. - tx, err := s.pool.Begin(ctx) + tx, err := s.bg.Begin(ctx) if err != nil { return nil, err } diff --git a/server/internal/store/events.go b/server/internal/store/events.go index b9a81c5..25f8b92 100644 --- a/server/internal/store/events.go +++ b/server/internal/store/events.go @@ -31,7 +31,7 @@ func (s *Store) FetchEvents(ctx context.Context, afterID int64, limit int) ([]Ev limit = 500 } - rows, err := s.pool.Query(ctx, ` + rows, err := s.bg.Query(ctx, ` SELECT id, tenant_id::text, topic, payload::text, created_at FROM core.event_outbox WHERE id > $1 @@ -62,7 +62,7 @@ func (s *Store) FetchEvents(ctx context.Context, afterID int64, limit int) ([]Ev // завантажив повний стан мапи, і все старіше в ньому вже враховано. func (s *Store) LatestEventID(ctx context.Context) (int64, error) { var id *int64 - if err := s.pool.QueryRow(ctx, + if err := s.bg.QueryRow(ctx, `SELECT max(id) FROM core.event_outbox`).Scan(&id); err != nil { return 0, err } @@ -78,7 +78,7 @@ func (s *Store) LatestEventID(ctx context.Context) (int64, error) { // прибиральнику: невідправлені події видаляти не можна, а відправлені — // можна, і без цього поля таблиця росла б вічно. func (s *Store) MarkEventsPublished(ctx context.Context, throughID int64) error { - _, err := s.pool.Exec(ctx, ` + _, err := s.bg.Exec(ctx, ` UPDATE core.event_outbox SET published_at = now() WHERE id <= $1 AND published_at IS NULL @@ -88,7 +88,7 @@ func (s *Store) MarkEventsPublished(ctx context.Context, throughID int64) error // PruneEvents видаляє доставлені події, старші за вказаний вік. func (s *Store) PruneEvents(ctx context.Context, olderThan time.Duration) (int64, error) { - tag, err := s.pool.Exec(ctx, ` + tag, err := s.bg.Exec(ctx, ` DELETE FROM core.event_outbox WHERE published_at IS NOT NULL AND created_at < now() - $1::interval `, olderThan.String()) diff --git a/server/internal/store/groups.go b/server/internal/store/groups.go index c19a162..d3a1bb9 100644 --- a/server/internal/store/groups.go +++ b/server/internal/store/groups.go @@ -441,14 +441,27 @@ func (s *Store) UpdateDevice(ctx context.Context, tenantID, deviceID string, in }) } -// DeleteDevice — м'яке видалення. +// DeleteDevice — АРХІВНЕ видалення: хост зникає з інтерфейсу, зібране +// лишається в базі. // -// Жорстке зняло б за собою всю телеметрію й історію алертів. Пристрій -// зникає з інтерфейсу, але його минуле лишається доступним для розбору. +// Це одна з двох дій, а не «видалення взагалі». Друга — PurgeDevices +// (devices_purge.go): вона стирає хост разом із телеметрією, архівом +// конфігів і гілкою в Git. Обидві законні, різниця незворотна, тому +// обирає її людина у вікні підтвердження, а не код тут. +// +// Що лишається після цієї: телеметрія, версії конфігів, історія +// алертів, вузли на мапах. Що припиняється: опитування, збір конфігів, +// нові алерти. +// +// Ця ручка лишається архівною й тоді, коли її смикають з API напряму. +// Незворотну дію не роблять параметром запиту, який легко не помітити: +// повне видалення йде окремим шляхом (POST /devices/bulk-delete з +// mode=purge), де перед ним обов'язково стоїть перелік із числами. func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { tag, err := tx.Exec(ctx, ` - UPDATE inv.devices SET deleted_at = now(), enabled = false + UPDATE inv.devices + SET deleted_at = now(), enabled = false, archived_enabled = enabled WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL `, tenantID, deviceID) if err != nil { @@ -457,6 +470,32 @@ func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) err if tag.RowsAffected() == 0 { return ErrNotFound } + + // Чеки вимикаємо разом із хостом. + // + // План їх і так не бере (BuildPlan фільтрує deleted_at), тож + // на опитування це не впливає. Впливає на все інше, що дивиться + // в core.checks: увімкнений чек, який ніколи не виконується, + // виглядає як затор — і сторінка «Черги» чесно показує його + // червоним рівно доти, доки на неї не перестануть дивитись. + // + // Не видаляємо, а вимикаємо: хост відновлюють разом із його + // налаштуваннями, а відновити те, чого немає, нема з чого. + // + // archived_off позначає, що чек вимкнула саме ця дія, а не + // людина. Без позначки повернення хоста мусило б або вмикати + // все підряд — разом із тим, що тримали вимкненим свідомо, — + // або не вмикати нічого, і тоді хост повертався б у переліки + // мовчазним. Умова `AND enabled` тут і є те, що робить + // позначку правдивою: вимкнене до архівування нею не + // позначається. + if _, err := tx.Exec(ctx, ` + UPDATE core.checks + SET enabled = false, archived_off = true, updated_at = now() + WHERE tenant_id = $1 AND device_id = $2 AND enabled + `, tenantID, deviceID); err != nil { + return err + } return nil }) } diff --git a/server/internal/store/identify.go b/server/internal/store/identify.go new file mode 100644 index 0000000..66c8d46 --- /dev/null +++ b/server/internal/store/identify.go @@ -0,0 +1,146 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +// IdentifyDecision — відповідь на «розпізнати зараз». +// +// Відмова несе готовий для людини текст, а не код: причин кілька, і +// кожна лікується по-своєму — одна вимагає завести доступ, інша +// почекати, доки зонд повернеться. Змушувати інтерфейс перекладати +// коди назад у речення означало б тримати той самий перелік у двох +// місцях і забути оновити одне з них. +type IdentifyDecision struct { + Queued bool `json:"queued"` + Reason string `json:"reason,omitempty"` +} + +// RequestIdentify просить опитати хост по SNMP просто зараз. +// +// Сам запит не надсилається: API не має сесій зондів — вони живуть у +// колекторі, іншому процесі. Тут лишається намір, який забере +// ClaimIdentifyRequests. +func (s *Store) RequestIdentify(ctx context.Context, tenantID, deviceID string) (IdentifyDecision, error) { + var d IdentifyDecision + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var ( + agentID string + agentOnline bool + hasSNMP bool + ) + err := tx.QueryRow(ctx, ` + SELECT COALESCE(d.agent_id::text, ''), + COALESCE(a.status::text = 'online', false), + EXISTS ( + SELECT 1 FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE dc.device_id = d.id + AND c.tenant_id = d.tenant_id + AND c.proto IN ('snmp_v2c','snmp_v3') + ) + FROM inv.devices d + LEFT JOIN core.agents a ON a.id = d.agent_id + WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL + `, deviceID, tenantID).Scan(&agentID, &agentOnline, &hasSNMP) + if err != nil { + return err + } + + switch { + case !hasSNMP: + d.Reason = "у хоста немає SNMP-доступу — додайте його на вкладці «Доступи», " + + "і хост розпізнається сам" + return nil + case agentID == "": + d.Reason = "хост не прив'язаний до зонда — нікому його опитати" + return nil + case !agentOnline: + d.Reason = "зонд не на зв'язку — прохання виконається, щойно він повернеться" + // Намір усе одно лишаємо: коли зонд повернеться, диспетчер + // його забере. Відмова тут описує затримку, а не поразку. + } + + // Чек має існувати до того, як просити його запуску: зонд + // зрушує задачу зі свого розкладу, а не виконує все, що + // прилетіло. Без цього рядка прохання нікуди б не влучило. + if _, err := tx.Exec(ctx, ` + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries) + SELECT $2, $1, $3, '{}'::jsonb, $4, 10000, 1 + WHERE NOT EXISTS ( + SELECT 1 FROM core.checks ch + WHERE ch.device_id = $1 AND ch.tenant_id = $2 + AND ch.check_type IN ($3, 'topology.discover') + ) + `, deviceID, tenantID, IdentifyCheckType, + int32(IdentifyProbeInterval.Seconds())); err != nil { + return err + } + + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET identify_requested_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID); err != nil { + return err + } + + d.Queued = d.Reason == "" + return nil + }) + return d, err +} + +// IdentifyRequest — прохання, готове до надсилання. +type IdentifyRequest struct { + TenantID string + AgentID string + DeviceID string +} + +// ClaimIdentifyRequests забирає прохання для зондів, які зараз на +// зв'язку. +// +// Забирає назовсім: повторне надсилання гірше за втрачене прохання — +// людина натисне ще раз і побачить результат, а зациклене опитування +// вона не побачить узагалі й дізнається про нього з трафіку. +func (s *Store) ClaimIdentifyRequests(ctx context.Context, onlineAgents []string, limit int) ([]IdentifyRequest, error) { + if len(onlineAgents) == 0 { + return nil, nil + } + if limit <= 0 { + limit = 64 + } + + rows, err := s.bg.Query(ctx, ` + UPDATE inv.devices d + SET identify_requested_at = NULL + WHERE d.id IN ( + SELECT id FROM inv.devices + WHERE identify_requested_at IS NOT NULL + AND agent_id = ANY($1::uuid[]) + AND deleted_at IS NULL + ORDER BY identify_requested_at + FOR UPDATE SKIP LOCKED + LIMIT $2 + ) + RETURNING d.tenant_id::text, d.agent_id::text, d.id::text + `, onlineAgents, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []IdentifyRequest + for rows.Next() { + var r IdentifyRequest + if err := rows.Scan(&r.TenantID, &r.AgentID, &r.DeviceID); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/server/internal/store/iffilter.go b/server/internal/store/iffilter.go new file mode 100644 index 0000000..7d15162 --- /dev/null +++ b/server/internal/store/iffilter.go @@ -0,0 +1,347 @@ +package store + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +// InterfaceFilter — фільтр складу портів, заданий шаблоном. +// +// Те саме, що в Zabbix зветься фільтром правила виявлення: пристрій +// віддає сто портів, а опитувати треба десять. Без фільтра snmp.if-чек +// бере все, що пройшло базові правила, і на Juniper QFX це сотня портів +// по десять OID кожен — за один такт. +// +// Порожній фільтр (жодної умови) означає «думки про порти немає», а не +// «нічого не пропускати». Різниця важлива там, де хосту причеплено +// кілька шаблонів: шаблон без фільтра не має права звузити те, що +// просить інший. +type InterfaceFilter struct { + // "all" (типово) — порт мусить задовольнити всі умови, + // "any" — досить однієї. + Match string `json:"match,omitempty"` + Conditions []InterfaceCondition `json:"conditions,omitempty"` +} + +// InterfaceCondition — одна умова фільтра. +type InterfaceCondition struct { + // Поле порту: name (ifName), alias (ifAlias/ifDescr), type, speed. + Field string `json:"field"` + // matches / not_matches — зразок; ge / le — порівняння швидкості. + Op string `json:"op"` + // Зразок або швидкість. Швидкість приймає суфікси: 10G, 100M, 1000K. + Value string `json:"value"` +} + +// Поля й операції винесені в константи, бо їх знають три місця: перевірка +// при збереженні, застосування й форма. Розбіжність між ними — це +// фільтр, який у формі виглядає інакше, ніж працює. +const ( + IfFieldName = "name" + IfFieldAlias = "alias" + IfFieldType = "type" + IfFieldSpeed = "speed" + + IfOpMatches = "matches" + IfOpNotMatches = "not_matches" + IfOpGE = "ge" + IfOpLE = "le" + + IfMatchAll = "all" + IfMatchAny = "any" +) + +// InterfaceCandidate — те, до чого застосовується умова. +// +// Власний тип, а не рядок таблиці: фільтр застосовується і до вмісту +// inv.interfaces, і до попереднього перегляду, і в тестах — усі троє +// мусять судити за однаковими даними. +type InterfaceCandidate struct { + Name string + Alias string + Type string + SpeedBps uint64 +} + +// IsEmpty — чи має цей фільтр хоч якусь думку. +func (f InterfaceFilter) IsEmpty() bool { return len(f.Conditions) == 0 } + +// --------------------------------------------------------------------- +// Компіляція +// --------------------------------------------------------------------- + +// Зразки застосовує Go (RE2), а не Postgres (`~`). Три причини, і +// жодна з них не про швидкість. +// +// Перша: криво написаний зразок. У Postgres він валить ЗАПИТ, а разом +// із ним — транзакцію, всередині якої перебудовується чек. Наслідок: +// snmp.if мовчки перестає оновлюватись на кожному такті автовиявлення, +// і побачити це можна хіба в журналі сервера. У Go зразок компілюється +// окремо й наперед: помилка називає себе там, де її зробили, — у формі +// шаблону. +// +// Друга: RE2 не має відкату. Зразок, який людина напише випадково +// (`(a+)+b` і подібне), у бектрекінговому рушії Postgres здатен з'їсти +// хвилини процесорного часу всередині транзакції, що тримає рядки +// inv.interfaces. RE2 такого не вміє за побудовою. +// +// Третя, найважливіша: попередній перегляд мусить казати правду. +// Перегляд іде через HTTP, тобто через Go. Якби бойове фільтрування +// жило в SQL, два рушії розійшлися б на першому ж `\d` (у POSIX ERE це +// просто літера d) — і людина побачила б у перегляді один склад портів, +// а в чеку отримала інший. +// +// У проєкті вже є місце, де зразки з довідника застосовує саме Go з +// тих самих міркувань — autoassign.go, firstGroup. +type compiledCondition struct { + field string + op string + re *regexp.Regexp + speed uint64 +} + +type compiledFilter struct { + any bool + conds []compiledCondition +} + +// filterCandidate — те, до чого умова прикладається. +// +// Інтерфейс, а не структура порту, з'явився разом із прототипами +// елементів шаблону (0059): рядок динамічної таблиці — диск, сенсор, +// член стека — фільтрують за тією самою потребою й тими самими +// зразками, що й порти, лише поля в нього інші. Другий примірник цієї +// самої механіки розійшовся б із першим на першому ж виправленні, і +// «not_matches» почав би означати різне в двох сусідніх вкладках однієї +// форми. +// +// Числових полів у рядка таблиці немає взагалі — тому numeric віддає +// ok=false, і компіляція просто не пропустить ge/le для такого набору +// полів. +type filterCandidate interface { + text(field string) string + numeric(field string) (uint64, bool) +} + +// fieldSet — які поля має цей вид кандидата. +// +// Перевіряється при КОМПІЛЯЦІЇ, а не при застосуванні: умова на поле, +// якого в кандидата немає, — це друкарська помилка людини, і сказати +// про неї треба у формі, поки вона ще на екрані. Мовчки не збігтись +// означало б фільтр, який ніколи нічого не пропускає, без жодної +// підказки чому. +type fieldSet map[string]bool + +var ifFields = fieldSet{ + IfFieldName: true, IfFieldAlias: true, IfFieldType: true, IfFieldSpeed: true, +} + +// compileInterfaceFilter перетворює опис на щось придатне до застосування. +func compileInterfaceFilter(f InterfaceFilter) (*compiledFilter, error) { + return compileFilter(f, ifFields) +} + +// compileFilter — спільне ядро для портів і для рядків таблиць. +// +// Зразок компілюється з (?i): імена портів у різних вендорів пишуться +// по-різному в тому самому сенсі — Huawei дає `Vlanif10`, інші `vlan10`. +// Фільтр, який пропустив порт через велику літеру, помиляється мовчки, а +// це рівно та помилка, від якої вся ця затія. Кому потрібен регістр — +// пише `(?-i)` на початку зразка. +func compileFilter(f InterfaceFilter, allowed fieldSet) (*compiledFilter, error) { + out := &compiledFilter{any: strings.EqualFold(f.Match, IfMatchAny)} + for i, c := range f.Conditions { + cc := compiledCondition{field: strings.TrimSpace(c.Field), op: strings.TrimSpace(c.Op)} + if !allowed[cc.field] { + return nil, fmt.Errorf("умова %d: невідоме поле «%s»", i+1, c.Field) + } + + switch cc.op { + case IfOpMatches, IfOpNotMatches: + if cc.field == IfFieldSpeed { + return nil, fmt.Errorf("умова %d: швидкість порівнюють числом, а не зразком", i+1) + } + if strings.TrimSpace(c.Value) == "" { + return nil, fmt.Errorf("умова %d: порожній зразок", i+1) + } + re, err := regexp.Compile("(?i)" + c.Value) + if err != nil { + // Текст помилки RE2 віддаємо як є: він називає + // позицію в зразку, а переказ своїми словами лише + // забрав би цю підказку. + return nil, fmt.Errorf("умова %d: зразок не читається: %s", i+1, err) + } + cc.re = re + + case IfOpGE, IfOpLE: + if cc.field != IfFieldSpeed || !allowed[IfFieldSpeed] { + return nil, fmt.Errorf("умова %d: порівняння «більше/менше» є лише для швидкості", i+1) + } + v, err := ParseSpeed(c.Value) + if err != nil { + return nil, fmt.Errorf("умова %d: %w", i+1, err) + } + cc.speed = v + + default: + return nil, fmt.Errorf("умова %d: невідома операція «%s»", i+1, c.Op) + } + + out.conds = append(out.conds, cc) + } + return out, nil +} + +// ValidateInterfaceFilter перевіряє фільтр, нічого не застосовуючи. +// +// Потрібна саме окремо: помилку в зразку треба показати тоді, коли її +// ще видно на екрані, а не через добу серед порожніх графіків. +func ValidateInterfaceFilter(f InterfaceFilter) error { + if f.Match != "" && !strings.EqualFold(f.Match, IfMatchAll) && !strings.EqualFold(f.Match, IfMatchAny) { + return fmt.Errorf("невідоме поєднання умов «%s»: буває «all» або «any»", f.Match) + } + _, err := compileInterfaceFilter(f) + return err +} + +// ParseSpeed читає швидкість із суфіксом. +// +// 10000000000 очима не читається й друкується з помилкою на один нуль +// приблизно завжди; «10G» читається. Множник десятковий, бо саме так +// його рахують у мережах: гігабітний порт — це 1 000 000 000 біт/с. +func ParseSpeed(s string) (uint64, error) { + t := strings.TrimSpace(s) + if t == "" { + return 0, fmt.Errorf("порожня швидкість") + } + mult := uint64(1) + switch last := t[len(t)-1]; last { + case 'k', 'K': + mult, t = 1_000, t[:len(t)-1] + case 'm', 'M': + mult, t = 1_000_000, t[:len(t)-1] + case 'g', 'G': + mult, t = 1_000_000_000, t[:len(t)-1] + case 't', 'T': + mult, t = 1_000_000_000_000, t[:len(t)-1] + } + v, err := strconv.ParseFloat(strings.TrimSpace(t), 64) + if err != nil || v < 0 { + return 0, fmt.Errorf("швидкість «%s» не читається: пишіть 10G, 100M або число біт/с", s) + } + return uint64(v * float64(mult)), nil +} + +// --------------------------------------------------------------------- +// Застосування +// --------------------------------------------------------------------- + +// text і numeric роблять порт придатним до спільного ядра фільтра. +// +// Невідоме поле сюди не доходить: компіляція його вже відсіяла. Тому +// тут немає гілки «інакше помилка» — вона мала б вигляд мовчазного +// порожнього рядка, тобто зразка, який не збігається ні з чим. +func (in InterfaceCandidate) text(field string) string { + switch field { + case IfFieldName: + return in.Name + case IfFieldAlias: + return in.Alias + case IfFieldType: + return in.Type + } + return "" +} + +func (in InterfaceCandidate) numeric(field string) (uint64, bool) { + if field == IfFieldSpeed { + return in.SpeedBps, true + } + return 0, false +} + +func (c compiledCondition) match(in filterCandidate) bool { + if v, ok := in.numeric(c.field); ok { + if c.op == IfOpGE { + return v >= c.speed + } + return v <= c.speed + } + + hit := c.re.MatchString(in.text(c.field)) + if c.op == IfOpNotMatches { + return !hit + } + return hit +} + +func (f *compiledFilter) match(in filterCandidate) bool { + if len(f.conds) == 0 { + return true + } + for _, c := range f.conds { + if f.any { + if c.match(in) { + return true + } + continue + } + if !c.match(in) { + return false + } + } + return !f.any +} + +// interfaceFilterSet — фільтри всіх шаблонів одного хоста. +// +// Порт проходить, якщо його пропустив ХОЧА Б ОДИН фільтр — тобто +// фільтри об'єднуються, а не перетинаються. +// +// Так, бо шаблони в цьому продукті лише ДОдають: автовиявлення чіпляє +// їх само, і другий шаблон не повинен мовчки відібрати порти, які +// просить перший. «OLT: gpon-порти» плюс «Магістраль: 10G» на одному +// ZTE має дати і те, і те; перетин дав би порожньо — і зламав би обидва +// шаблони одночасно, не назвавши винного. +// +// Шаблони без фільтра в наборі не беруть участі: відсутність думки не +// є думкою «усе». +type interfaceFilterSet struct { + // Імена шаблонів, які мають фільтр — для повідомлень про помилку. + names []string + filters []*compiledFilter +} + +func (s *interfaceFilterSet) empty() bool { return s == nil || len(s.filters) == 0 } + +func (s *interfaceFilterSet) match(in filterCandidate) bool { + if s.empty() { + return true + } + for _, f := range s.filters { + if f.match(in) { + return true + } + } + return false +} + +// parseInterfaceFilter розбирає те, що лежить у tpl.templates.if_filter. +// +// Порожньо, "{}" і зіпсований JSON дають фільтр без думки, а не помилку: +// колонка може бути заповнена чим завгодно старішими редакціями продукту +// або руками, і жодна з цих ситуацій не є підставою зупинити опитування +// портів на всьому парку. +func parseInterfaceFilter(raw string) InterfaceFilter { + var f InterfaceFilter + if strings.TrimSpace(raw) == "" { + return f + } + if err := json.Unmarshal([]byte(raw), &f); err != nil { + return InterfaceFilter{} + } + return f +} diff --git a/server/internal/store/iffilter_preview.go b/server/internal/store/iffilter_preview.go new file mode 100644 index 0000000..634ad14 --- /dev/null +++ b/server/internal/store/iffilter_preview.go @@ -0,0 +1,363 @@ +package store + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------- +// Попередній перегляд +// --------------------------------------------------------------------- + +// InterfaceFilterPort — один порт очима фільтра. +// +// У переліку є й ті порти, які фільтр навіть не розглядав: без них +// відповідь «пройшло 12 зі 100» не сходиться, і людина шукає в зразку +// помилку, якої немає, — а насправді порт вимкнений руками або це +// loopback. +type InterfaceFilterPort struct { + ID string `json:"id"` + IfIndex int64 `json:"if_index"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Type string `json:"type,omitempty"` + SpeedBps uint64 `json:"speed_bps"` + // Пройшов базові правила (monitored, не loopback, не notPresent). + Eligible bool `json:"eligible"` + // Чому не пройшов базові правила — людською мовою. + Skip string `json:"skip,omitempty"` + // Пройшов сам фільтр. + Matched bool `json:"matched"` + // Коли цей порт уже виключили фільтром. Саме це поле відповідає на + // питання «чому графік обірвався третього числа». + FilteredOutAt *time.Time `json:"filtered_out_at,omitempty"` +} + +// InterfaceFilterPreview — що дасть фільтр на конкретному хості. +type InterfaceFilterPreview struct { + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + // Усього портів в інвентарі хоста. + Total int `json:"total"` + // З них пройшли базові правила. + Eligible int `json:"eligible"` + // З них пройшли фільтр. + Matched int `json:"matched"` + // Стеля розміру чека й чи вперлись у неї. + Limit int `json:"limit"` + Truncated bool `json:"truncated"` + // Інші шаблони цього хоста, які теж мають фільтр. Склад чека буде + // ОБ'ЄДНАННЯМ, тож без цієї підказки число нижче виглядало б + // остаточним, а воно не остаточне. + OtherTemplates []string `json:"other_templates,omitempty"` + Ports []InterfaceFilterPort `json:"ports"` +} + +// PreviewInterfaceFilter проганяє фільтр по справжньому хосту, нічого не +// зберігаючи. +// +// Навіщо: зразок, застосований до сотні пристроїв наосліп, або збирає +// не те, або не збирає нічого, і дізнаються про це через добу по +// порожніх графіках. Тут те саме рішення коштує один клік. +// +// templateID — шаблон, який зараз редагують; потрібен лише щоб не +// порахувати його самого серед «інших фільтрів цього хоста». +func (s *Store) PreviewInterfaceFilter(ctx context.Context, tenantID, deviceID, templateID string, f InterfaceFilter) (*InterfaceFilterPreview, error) { + cf, err := compileInterfaceFilter(f) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalid, err) + } + + out := &InterfaceFilterPreview{ + DeviceID: deviceID, + Limit: MaxInterfacesPerCheck, + Ports: []InterfaceFilterPort{}, + } + + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + SELECT name FROM inv.devices + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + `, deviceID, tenantID).Scan(&out.DeviceName); err != nil { + if isNoRows(err) { + return ErrNotFound + } + return err + } + + rows, err := tx.Query(ctx, ` + SELECT id::text, COALESCE(if_index, 0), name, COALESCE(alias,''), + COALESCE(type,''), COALESCE(speed_bps, 0), + monitored, if_index IS NULL, + COALESCE(type,'') = 'softwareLoopback', + oper_status = 'notPresent', + filtered_out_at + FROM inv.interfaces + WHERE device_id = $1 AND tenant_id = $2 + ORDER BY if_index NULLS LAST, name + LIMIT $3 + `, deviceID, tenantID, maxInterfaceScan) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var p InterfaceFilterPort + var monitored, noIndex, loop, gone bool + if err := rows.Scan(&p.ID, &p.IfIndex, &p.Name, &p.Alias, &p.Type, + &p.SpeedBps, &monitored, &noIndex, &loop, &gone, + &p.FilteredOutAt); err != nil { + return err + } + out.Total++ + + // Причина називається одна, найважливіша: перелік із трьох + // причин на одному рядку читають гірше, ніж не читають зовсім. + switch { + case !monitored: + p.Skip = "вимкнено на порту" + case noIndex: + p.Skip = "немає ifIndex" + case loop: + p.Skip = "loopback" + case gone: + p.Skip = "порту немає в шасі" + default: + p.Eligible = true + out.Eligible++ + p.Matched = cf.match(InterfaceCandidate{ + Name: p.Name, Alias: p.Alias, Type: p.Type, SpeedBps: p.SpeedBps, + }) + if p.Matched { + out.Matched++ + } + } + out.Ports = append(out.Ports, p) + } + if err := rows.Err(); err != nil { + return err + } + out.Truncated = out.Matched > MaxInterfacesPerCheck + + // Інші фільтри цього хоста. + orows, err := tx.Query(ctx, ` + SELECT t.name, t.if_filter::text + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.device_id = $1 AND ($2 = '' OR t.id::text <> $2) + ORDER BY t.name + `, deviceID, templateID) + if err != nil { + return err + } + defer orows.Close() + for orows.Next() { + var name, raw string + if err := orows.Scan(&name, &raw); err != nil { + return err + } + if !parseInterfaceFilter(raw).IsEmpty() { + out.OtherTemplates = append(out.OtherTemplates, name) + } + } + return orows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// --------------------------------------------------------------------- +// На чому перевіряти +// --------------------------------------------------------------------- + +// InterfaceFilterDevice — хост, придатний для перевірки зразка. +type InterfaceFilterDevice struct { + ID string `json:"id"` + Name string `json:"name"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + Ports int `json:"ports"` + Eligible int `json:"eligible"` + // Чи вже причеплений шаблон, який зараз редагують. + Bound bool `json:"bound"` +} + +// ListInterfaceFilterDevices — хости з портами, найбагатші зверху. +// +// Порядок не алфавітний навмисно: зразок перевіряють на найгіршому +// випадку, і саме стопортовий Juniper має бути першим у списку, а не +// губитись між тими, де портів чотири. +// +// Хости без портів не показуються взагалі: на них перевірити нічого, а +// в переліку вони лише ховають ті, на яких можна. +func (s *Store) ListInterfaceFilterDevices(ctx context.Context, tenantID, templateID string) ([]InterfaceFilterDevice, error) { + out := []InterfaceFilterDevice{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT d.id::text, d.name, COALESCE(d.vendor,''), COALESCE(d.model,''), + count(i.id)::int, + count(i.id) FILTER (WHERE `+ifEligibleSQL+`)::int, + EXISTS (SELECT 1 FROM tpl.device_templates dt + WHERE dt.device_id = d.id AND $1 <> '' + AND dt.template_id::text = $1) + FROM inv.devices d + JOIN inv.interfaces i ON i.device_id = d.id + WHERE d.tenant_id = $2 AND d.deleted_at IS NULL + GROUP BY d.id, d.name, d.vendor, d.model + ORDER BY count(i.id) DESC, d.name + LIMIT 200 + `, templateID, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var d InterfaceFilterDevice + if err := rows.Scan(&d.ID, &d.Name, &d.Vendor, &d.Model, + &d.Ports, &d.Eligible, &d.Bound); err != nil { + return err + } + out = append(out, d) + } + return rows.Err() + }) + return out, err +} + +// --------------------------------------------------------------------- +// Захист від фільтра, який не пропускає нічого +// --------------------------------------------------------------------- + +// checkFilterLeavesPorts не дає зберегти шаблон, який зануляє склад +// портів на вже прив'язаному хості. +// +// Питання ставиться не «чи знайшов щось цей фільтр», а «чи лишиться +// хоч один порт у хоста» — тобто рівно те саме об'єднання фільтрів +// усіх його шаблонів, яке потім побудує EnsureInterfaceChecks. +// Інакше два коректні шаблони («тільки gpon» на OLT і «тільки 10G» на +// магістралі) заважали б зберегти один одного. +// +// Хости без жодного придатного порту доказом не є: там і без фільтра +// збирати нічого. +func checkFilterLeavesPorts(ctx context.Context, tx pgx.Tx, templateID string) error { + var raw string + if err := tx.QueryRow(ctx, + `SELECT if_filter::text FROM tpl.templates WHERE id = $1`, templateID).Scan(&raw); err != nil { + return err + } + if parseInterfaceFilter(raw).IsEmpty() { + return nil + } + + // Фільтри всіх шаблонів усіх хостів, яким причеплений цей шаблон, — + // одним запитом. По запиту на хост означало б сотні запитів на одне + // натискання «Зберегти». + type devState struct { + name string + set *interfaceFilterSet + eligible int + matched int + } + devs := map[string]*devState{} + + frows, err := tx.Query(ctx, ` + SELECT dt.device_id::text, d.name, t.name, t.if_filter::text + FROM tpl.device_templates dt + JOIN inv.devices d ON d.id = dt.device_id AND d.deleted_at IS NULL + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.device_id IN ( + SELECT device_id FROM tpl.device_templates WHERE template_id = $1 + ) + `, templateID) + if err != nil { + return err + } + for frows.Next() { + var devID, devName, tplName, tplFilter string + if err := frows.Scan(&devID, &devName, &tplName, &tplFilter); err != nil { + frows.Close() + return err + } + d := devs[devID] + if d == nil { + d = &devState{name: devName, set: &interfaceFilterSet{}} + devs[devID] = d + } + f := parseInterfaceFilter(tplFilter) + if f.IsEmpty() { + continue + } + cf, err := compileInterfaceFilter(f) + if err != nil { + frows.Close() + return fmt.Errorf("%w: фільтр шаблону «%s»: %s", ErrInvalid, tplName, err) + } + d.set.names = append(d.set.names, tplName) + d.set.filters = append(d.set.filters, cf) + } + frows.Close() + if err := frows.Err(); err != nil { + return err + } + if len(devs) == 0 { + return nil + } + + irows, err := tx.Query(ctx, ` + SELECT device_id::text, name, COALESCE(alias,''), COALESCE(type,''), + COALESCE(speed_bps, 0) + FROM inv.interfaces + WHERE device_id IN ( + SELECT device_id FROM tpl.device_templates WHERE template_id = $1 + ) + AND `+ifEligibleSQL, templateID) + if err != nil { + return err + } + for irows.Next() { + var devID string + var in InterfaceCandidate + if err := irows.Scan(&devID, &in.Name, &in.Alias, &in.Type, &in.SpeedBps); err != nil { + irows.Close() + return err + } + d := devs[devID] + if d == nil { + continue + } + d.eligible++ + if d.set.match(in) { + d.matched++ + } + } + irows.Close() + if err := irows.Err(); err != nil { + return err + } + + var dead []string + for _, d := range devs { + if !d.set.empty() && d.eligible > 0 && d.matched == 0 { + dead = append(dead, fmt.Sprintf("%s (0 з %d)", d.name, d.eligible)) + } + } + if len(dead) == 0 { + return nil + } + // Назви хостів у самому тексті: «фільтр нічого не пропустив» без + // відповіді «де саме» відправляє людину перебирати парк руками. + sort.Strings(dead) + if len(dead) > 5 { + dead = append(dead[:5], fmt.Sprintf("…та ще %d", len(dead)-5)) + } + return fmt.Errorf("%w на цих хостах: %s. Порожній snmp.if-чек ззовні не відрізняється від справного — виправте зразок або відчепіть шаблон від цих хостів", + ErrInterfaceFilterEmpty, strings.Join(dead, "; ")) +} diff --git a/server/internal/store/iffilter_test.go b/server/internal/store/iffilter_test.go new file mode 100644 index 0000000..ea1b72c --- /dev/null +++ b/server/internal/store/iffilter_test.go @@ -0,0 +1,304 @@ +package store + +import "testing" + +// Порти взято з живого стенду, а не вигадано. +// +// Це принципово: зразок, перевірений на «GigabitEthernet0/1», нічого не +// каже про `xe-0/0/16.0`, `802.1Q Encapsulation Tag 0101` і +// `gpon_1/2/2` — а саме вони становлять більшість того, що приносить +// автовиявлення з цього парку. +var ( + // JUN.QFX-Миронівка: 100 портів, з них по ділу — десяток. + junQFX = []InterfaceCandidate{ + {Name: "lsi", Type: "type150"}, + {Name: "dsc", Type: "type1"}, + {Name: "tap", Type: "type1"}, + {Name: "gre", Type: "tunnel"}, + {Name: "mtun", Type: "tunnel"}, + {Name: "em0", Type: "ethernetCsmacd"}, + {Name: "em0.0", Type: "propVirtual"}, + {Name: "bme0", Type: "ethernetCsmacd"}, + {Name: "irb", Type: "type1"}, + {Name: "vtep", Type: "type1"}, + {Name: "pfe-0/0/0", Type: "type1"}, + {Name: "pfh-0/0/0.16383", Type: "propVirtual"}, + {Name: "gr-0/0/0", Type: "tunnel", SpeedBps: 800_000_000}, + {Name: "xe-0/0/16", Alias: "NAT-NAS-1-1", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/16.0", Type: "propVirtual", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/10", Alias: "ae0.UPLINK.OMEGA", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/10.0", Type: "propVirtual", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/7", Alias: "ZTE.VLADISLAVKA", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + {Name: "ae0", Alias: "OMEGA-1250-1254-4051-4057-4061", Type: "ieee8023adLag", SpeedBps: 20_000_000_000}, + {Name: "ae0.0", Type: "propVirtual", SpeedBps: 20_000_000_000}, + {Name: "ae11", Type: "ieee8023adLag"}, + } + + // ПОЛЬОВЕ.7 (D-Link): 28 справжніх портів і 29 «портів», які + // насправді VLAN-и. + polove7 = []InterfaceCandidate{ + {Name: "1/1", Type: "ethernetCsmacd", SpeedBps: 100_000_000}, + {Name: "1/7", Type: "ethernetCsmacd"}, + {Name: "1/28", Type: "ethernetCsmacd", SpeedBps: 1_000_000_000}, + {Name: "802.1Q Encapsulation Tag 0001", Type: "l2vlan"}, + {Name: "802.1Q Encapsulation Tag 0126", Type: "l2vlan"}, + {Name: "System", Type: "type142"}, + } + + // ZTE Миронівка (OLT): дерево gpon плюс три ethernet-аплінки. + zteMyronivka = []InterfaceCandidate{ + {Name: "gpon_1/1/1", Type: "type250", SpeedBps: 2_500_000_000}, + {Name: "gpon_1/2/2", Alias: "Novoukrainka|segment-3|box-5|ZTEGC0DFFE60", Type: "type250", SpeedBps: 2_500_000_000}, + {Name: "gei_1/4/1", Type: "ethernetCsmacd"}, + {Name: "xgei_1/4/2", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + } + + // Леніна.21 (Huawei): опис заповнений у ВСІХ портів і не несе нічого. + lenina21 = []InterfaceCandidate{ + {Name: "NULL0", Alias: "HUAWEI, Quidway Series, NULL0 Interface", Type: "type1"}, + {Name: "Vlanif10", Alias: "HUAWEI, Quidway Series, Vlanif10 Interface", Type: "propVirtual", SpeedBps: 1_000_000_000}, + {Name: "Ethernet0/0/1", Alias: "HUAWEI, Quidway Series, Ethernet0/0/1 Interface", Type: "ethernetCsmacd", SpeedBps: 10_000_000}, + {Name: "GigabitEthernet0/0/1", Alias: "HUAWEI, Quidway Series, GigabitEthernet0/0/1 Interface", Type: "ethernetCsmacd", SpeedBps: 1_000_000_000}, + } +) + +func passed(t *testing.T, f InterfaceFilter, in []InterfaceCandidate) []string { + t.Helper() + cf, err := compileInterfaceFilter(f) + if err != nil { + t.Fatalf("фільтр не компілюється: %v", err) + } + var out []string + for _, c := range in { + if cf.match(c) { + out = append(out, c.Name) + } + } + return out +} + +func eq(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("пройшло %v, очікували %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("пройшло %v, очікували %v", got, want) + } + } +} + +// Найпоширеніший випадок на Juniper: фізичні порти є, а їхніх +// `.0`-двійників у чеку бути не має. Лічильники на підінтерфейсі +// повторюють лічильники порту, тобто це рівно вдвічі більше OID заради +// того самого графіка. +func TestFilterJuniperPhysicalOnly(t *testing.T) { + f := InterfaceFilter{ + Match: IfMatchAll, + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpMatches, Value: `^(xe|ge|et|ae)-?\d`}, + {Field: IfFieldName, Op: IfOpNotMatches, Value: `\.\d+$`}, + }, + } + // ae11 у списку не помилка, а знахідка: це заведений, але порожній + // LAG зі швидкістю 0. Зразок за іменем його не відрізняє, і саме + // тому попередній перегляд показує перелік портів, а не лише число: + // таке видно очима за секунду й не видно в жодному зразку. + eq(t, passed(t, f, junQFX), []string{ + "xe-0/0/16", "xe-0/0/10", "xe-0/0/7", "ae0", "ae11", + }) +} + +// «Усе, крім службового» — друге формулювання того самого наміру, і +// воно має давати той самий склад іншим шляхом. На D-Link це єдиний +// спосіб: імена портів там просто «1/1», і зачепитись у них нема за що — +// служебне видно лише за типом. +func TestFilterDLinkDropsVlanPseudoPorts(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldType, Op: IfOpNotMatches, Value: `^(l2vlan|propVirtual|softwareLoopback)$`}, + {Field: IfFieldName, Op: IfOpNotMatches, Value: `^System$`}, + }, + } + eq(t, passed(t, f, polove7), []string{"1/1", "1/7", "1/28"}) +} + +// Найнадійніша ознака — тип, а не ім'я: його задає RFC 2863, а не +// маркетинг вендора. На повних даних стенду ця одна умова дала +// Juniper 98 → 49, D-Link 57 → 28, Huawei 29 → 26 і ніде не дала нуля. +// +// Ціна теж видна: на OLT вона відсіює gpon-дерева разом зі службовим, +// тому в заготовках сказано, що там потрібна друга умова з «або». +func TestFilterPhysicalAndLagByType(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldType, Op: IfOpMatches, Value: `^(ethernetCsmacd|ieee8023adLag)$`}, + }, + } + eq(t, passed(t, f, polove7), []string{"1/1", "1/7", "1/28"}) + eq(t, passed(t, f, zteMyronivka), []string{"gei_1/4/1", "xgei_1/4/2"}) + eq(t, passed(t, f, junQFX), []string{ + "em0", "bme0", "xe-0/0/16", "xe-0/0/10", "xe-0/0/7", "ae0", "ae11", + }) +} + +// OLT: цікаві або дерева gpon, або аплінки — «або», а не «і». Із «і» +// той самий набір умов дав би нуль, і саме на цьому місці зразок +// зазвичай і псують. +func TestFilterOltAnyMatchesGponOrUplink(t *testing.T) { + f := InterfaceFilter{ + Match: IfMatchAny, + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpMatches, Value: `^gpon_`}, + {Field: IfFieldSpeed, Op: IfOpGE, Value: "10G"}, + }, + } + eq(t, passed(t, f, zteMyronivka), []string{"gpon_1/1/1", "gpon_1/2/2", "xgei_1/4/2"}) + + all := InterfaceFilter{ + Match: IfMatchAll, + Conditions: f.Conditions, + } + if got := passed(t, all, zteMyronivka); len(got) != 0 { + t.Fatalf("з «і» очікували порожньо, отримали %v", got) + } +} + +// Регістр не має вирішувати. Huawei пише `Vlanif10`, інші вендори — +// `vlan10`; фільтр, який промахнувся через велику літеру, помиляється +// мовчки. +func TestFilterIgnoresCase(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpNotMatches, Value: `^(vlanif|null0)`}, + }, + } + eq(t, passed(t, f, lenina21), []string{"Ethernet0/0/1", "GigabitEthernet0/0/1"}) + + // Кому потрібен регістр — має спосіб його повернути. + strict := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpNotMatches, Value: `(?-i)^(vlanif|null0)`}, + }, + } + if got := passed(t, strict, lenina21); len(got) != 4 { + t.Fatalf("з (?-i) мали пройти всі чотири, пройшло %v", got) + } +} + +// Опис як ознака працює не скрізь: на Huawei його заповнює сама +// прошивка однаковим текстом для всіх портів, тож «тільки з описом» +// там не відсіює нічого. Тест фіксує саме це — щоб ніхто не зробив +// «має опис» типовим фільтром. +func TestFilterAliasUselessOnHuawei(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldAlias, Op: IfOpMatches, Value: `\S`}, + }, + } + if got := passed(t, f, lenina21); len(got) != len(lenina21) { + t.Fatalf("на Huawei опис має всюди, пройшло %v", got) + } + // А на Juniper це осмислена ознака: опис ставить інженер руками. + if got := passed(t, f, junQFX); len(got) != 4 { + t.Fatalf("на Juniper опис мають чотири порти, пройшло %v", got) + } +} + +// Порожній фільтр — «думки немає», а не «нічого не пропускати». +func TestEmptyFilterPassesEverything(t *testing.T) { + var f InterfaceFilter + if !f.IsEmpty() { + t.Fatal("фільтр без умов має вважатись порожнім") + } + if got := passed(t, f, junQFX); len(got) != len(junQFX) { + t.Fatalf("порожній фільтр відсіяв: пройшло %d із %d", len(got), len(junQFX)) + } +} + +// Фільтри різних шаблонів ОБ'ЄДНУЮТЬСЯ: другий шаблон не має права +// мовчки відібрати порти, які просить перший. +func TestFilterSetUnion(t *testing.T) { + gpon, err := compileInterfaceFilter(InterfaceFilter{ + Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: `^gpon_`}}, + }) + if err != nil { + t.Fatal(err) + } + uplink, err := compileInterfaceFilter(InterfaceFilter{ + Conditions: []InterfaceCondition{{Field: IfFieldSpeed, Op: IfOpGE, Value: "10G"}}, + }) + if err != nil { + t.Fatal(err) + } + set := &interfaceFilterSet{ + names: []string{"OLT", "Магістраль"}, + filters: []*compiledFilter{gpon, uplink}, + } + + var got []string + for _, c := range zteMyronivka { + if set.match(c) { + got = append(got, c.Name) + } + } + eq(t, got, []string{"gpon_1/1/1", "gpon_1/2/2", "xgei_1/4/2"}) + + // Набір без жодного фільтра пропускає все. + if !(&interfaceFilterSet{}).match(InterfaceCandidate{Name: "будь-що"}) { + t.Fatal("порожній набір фільтрів мусить пропускати все") + } +} + +// Криво написаний зразок мусить назвати себе тут, а не через добу серед +// порожніх графіків. +func TestBrokenPatternRejected(t *testing.T) { + err := ValidateInterfaceFilter(InterfaceFilter{ + Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: `xe-0/0/[1-`}}, + }) + if err == nil { + t.Fatal("незакрита дужка мала дати помилку") + } + + // І решта помилок форми — теж, кожна своїм текстом. + for _, bad := range []InterfaceFilter{ + {Conditions: []InterfaceCondition{{Field: "colour", Op: IfOpMatches, Value: "x"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldName, Op: "contains", Value: "x"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldSpeed, Op: IfOpMatches, Value: "10G"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpGE, Value: "10G"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: " "}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldSpeed, Op: IfOpGE, Value: "швидко"}}}, + {Match: "обидва", Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: "x"}}}, + } { + if err := ValidateInterfaceFilter(bad); err == nil { + t.Fatalf("прийнято неприйнятне: %+v", bad) + } + } +} + +// Швидкість пишуть суфіксом, бо 10000000000 очима не читається. +func TestParseSpeed(t *testing.T) { + cases := map[string]uint64{ + "10G": 10_000_000_000, + "1g": 1_000_000_000, + "100M": 100_000_000, + "2.5G": 2_500_000_000, + "1000K": 1_000_000, + " 1000000000 ": 1_000_000_000, + } + for in, want := range cases { + got, err := ParseSpeed(in) + if err != nil { + t.Fatalf("%q: %v", in, err) + } + if got != want { + t.Fatalf("%q дало %d, очікували %d", in, got, want) + } + } + for _, bad := range []string{"", "G", "-1G", "десять"} { + if _, err := ParseSpeed(bad); err == nil { + t.Fatalf("%q мало відхилитись", bad) + } + } +} diff --git a/server/internal/store/inventory.go b/server/internal/store/inventory.go index f3f754b..b410714 100644 --- a/server/internal/store/inventory.go +++ b/server/internal/store/inventory.go @@ -2,51 +2,99 @@ package store import ( "context" + "errors" "time" "github.com/jackc/pgx/v5" ) type DeviceSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Address string `json:"address,omitempty"` - Kind string `json:"kind"` - Vendor string `json:"vendor,omitempty"` - Model string `json:"model,omitempty"` - SiteName string `json:"site_name,omitempty"` - Status string `json:"status"` - Enabled bool `json:"enabled"` - LastSeenAt *time.Time `json:"last_seen_at,omitempty"` - AgentID string `json:"agent_id,omitempty"` - IfaceCount int `json:"interface_count"` - GroupIDs []string `json:"group_ids"` + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + Kind string `json:"kind"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + OSVersion string `json:"os_version,omitempty"` + Serial string `json:"serial_number,omitempty"` + // Те, що пристрій сказав про себе сам. На цьому тримається підбір + // шаблонів і профілю збору конфігів, тож людина має бачити, чи + // взагалі є за чим підбирати. + SysObjectID string `json:"sys_object_id,omitempty"` + SysDescr string `json:"sys_descr,omitempty"` + SiteName string `json:"site_name,omitempty"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + AgentID string `json:"agent_id,omitempty"` + IfaceCount int `json:"interface_count"` + GroupIDs []string `json:"group_ids"` // Writable каже інтерфейсу, чи показувати дії. Рахується з груп // користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі, // групи — саме цей. Writable bool `json:"writable"` + // DeletedAt — коли хост прибрали з переліку. Заповнене лише в + // переліку архіву: у звичайному переліку прибраних хостів немає, і + // поле там завжди порожнє. + DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// DeviceView — які саме хости віддавати переліком. +// +// Окремий тип, а не булевий прапорець «показати видалених», і не +// значення за замовчуванням у фільтрі. Причина одна: `deleted_at IS +// NULL` стоїть у півсотні запитів по всьому продукту — у плані +// колектора, у рушії алертів, у мапах, у доборі цілей команд, — і +// жоден із них не має права почати бачити прибрані хости через те, що +// хтось додав параметр із «зручним» типовим значенням. Тому параметр +// звужений до одного місця (перелік хостів), а його нульове значення — +// це рівно колишня поведінка. +type DeviceView string + +const ( + // DeviceViewLive — те, що працює. Порожнє значення означає саме це. + DeviceViewLive DeviceView = "" + // DeviceViewArchived — ЛИШЕ прибрані хости, і нічого крім них. + // + // Не «живі плюс прибрані»: змішаний перелік — це спосіб зробити + // щось не з тим хостом. Архів — окремий екран з окремими діями + // (повернути, добити), і жодна дія над живим хостом там не + // пропонується. + DeviceViewArchived DeviceView = "archived" +) + // ListDevices — інвентар для таблиці в UI. // // Фільтр видимості накладається в самому запиті, а не після вибірки: // відсіювати вже прочитане означало б тягнути з БД чужі рядки й // покладатися на те, що жоден із них не проскочить у відповідь. -func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]DeviceSummary, error) { +func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope, + view DeviceView) ([]DeviceSummary, error) { + var out []DeviceSummary + archived := view == DeviceViewArchived err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT d.id::text, d.name, COALESCE(host(d.address), ''), d.kind::text, - COALESCE(d.vendor,''), COALESCE(d.model,''), COALESCE(st.name,''), + COALESCE(d.vendor,''), COALESCE(d.model,''), + COALESCE(d.os_version,''), COALESCE(d.serial_number,''), + COALESCE(d.sys_object_id,''), COALESCE(d.sys_descr,''), + COALESCE(st.name,''), d.status::text, d.enabled, d.last_seen_at, COALESCE(d.agent_id::text,''), - (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id) + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id), + d.deleted_at FROM inv.devices d LEFT JOIN inv.sites st ON st.id = d.site_id - WHERE d.tenant_id = $1 AND d.deleted_at IS NULL + -- Умова написана так, що при $4 = false вона тотожна + -- колишній "d.deleted_at IS NULL": параметр не розширює + -- вибірку, а перемикає її на другу половину. + WHERE d.tenant_id = $1 + AND (CASE WHEN $4::boolean THEN d.deleted_at IS NOT NULL + ELSE d.deleted_at IS NULL END) AND ($2::boolean OR d.id = ANY($3::uuid[])) ORDER BY d.name - `, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable)) + `, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable), archived) if err != nil { return err } @@ -55,8 +103,9 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]D for rows.Next() { var d DeviceSummary if err := rows.Scan(&d.ID, &d.Name, &d.Address, &d.Kind, &d.Vendor, &d.Model, + &d.OSVersion, &d.Serial, &d.SysObjectID, &d.SysDescr, &d.SiteName, &d.Status, &d.Enabled, &d.LastSeenAt, &d.AgentID, - &d.IfaceCount); err != nil { + &d.IfaceCount, &d.DeletedAt); err != nil { return err } d.Writable = sc.CanWrite(d.ID) @@ -83,6 +132,48 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]D return out, nil } +// GetDevice — один хост. +// +// Окремо від ListDevices, бо переліком користуються там, де потрібен +// один рядок: після дії, яка щось у ньому змінила. Тягти весь інвентар +// заради одного поля означало б класти на базу тим більше, чим більший +// у клієнта парк — рівно навпаки до того, як воно має поводитись. +// +// Групи не добираємо: єдиний споживач — оновлення картки після дії, і +// склад груп нею не змінюється. +func (s *Store) GetDevice(ctx context.Context, tenantID, deviceID string, sc Scope) (DeviceSummary, error) { + var d DeviceSummary + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT d.id::text, d.name, COALESCE(host(d.address), ''), d.kind::text, + COALESCE(d.vendor,''), COALESCE(d.model,''), + COALESCE(d.os_version,''), COALESCE(d.serial_number,''), + COALESCE(d.sys_object_id,''), COALESCE(d.sys_descr,''), + COALESCE(st.name,''), + d.status::text, d.enabled, d.last_seen_at, COALESCE(d.agent_id::text,''), + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id) + FROM inv.devices d + LEFT JOIN inv.sites st ON st.id = d.site_id + WHERE d.tenant_id = $1 AND d.id = $2 AND d.deleted_at IS NULL + AND ($3::boolean OR d.id = ANY($4::uuid[])) + `, tenantID, deviceID, sc.Unrestricted, nonNilIDs(sc.Readable)).Scan( + &d.ID, &d.Name, &d.Address, &d.Kind, &d.Vendor, &d.Model, + &d.OSVersion, &d.Serial, &d.SysObjectID, &d.SysDescr, + &d.SiteName, &d.Status, &d.Enabled, + &d.LastSeenAt, &d.AgentID, &d.IfaceCount) + }) + if errors.Is(err, pgx.ErrNoRows) { + return d, ErrNotFound + } + if err != nil { + return d, err + } + d.Writable = sc.CanWrite(d.ID) + d.GroupIDs = []string{} + return d, nil +} + type AgentSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -154,11 +245,18 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary // підписатись на чужу мапу за вгаданим id. func (s *Store) MapExists(ctx context.Context, tenantID, mapID string) (bool, error) { var exists bool - err := s.pool.QueryRow(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM topo.maps - WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL - ) - `, mapID, tenantID).Scan(&exists) + // Через InTenantTx, хоча предикат tenant_id у запиті вже є: під + // netpulse_app topo.maps закрита політикою, і без app.tenant_id + // EXISTS повертав би false на власну мапу кабінету. Тобто підписка + // на мапу зникла б для всіх однаково — це той випадок, коли забутий + // контекст ламає доступ, а не відкриває його. + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM topo.maps + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + ) + `, mapID, tenantID).Scan(&exists) + }) return exists, err } diff --git a/server/internal/store/maps.go b/server/internal/store/maps.go index 6540ca0..0e6f343 100644 --- a/server/internal/store/maps.go +++ b/server/internal/store/maps.go @@ -81,11 +81,24 @@ type MapNode struct { Collapsed bool `json:"collapsed"` Locked bool `json:"locked"` + // Адреса хоста. Не для запитів — для підпису на схемі: вузол з + // іменем «ЦЕХ-2» нічого не каже тому, хто прийшов лагодити, а + // 10.0.3.7 під іменем економить перехід у перелік хостів. + Address string `json:"address,omitempty"` + // Живий стан пристрою. Саме це фарбує вузол. Status string `json:"status,omitempty"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` RttMs *float32 `json:"rtt_ms,omitempty"` LossPct *float32 `json:"loss_pct,omitempty"` + + // Хост видалено (м'яко), а вузол лишився стояти. + // + // Вузол не ховаємо: людина розводила схему руками, і мовчазне + // зникнення вузла разом із лініями, які до нього вели, виглядає як + // поламана мапа. Чесніше показати стан і дати прибрати його одним + // рухом — рішення, що робити зі схемою, за людиною. + DeviceDeleted bool `json:"device_deleted,omitempty"` } type MapEdge struct { @@ -94,6 +107,10 @@ type MapEdge struct { TargetNodeID string `json:"target_node_id"` SourcePort string `json:"source_port,omitempty"` TargetPort string `json:"target_port,omitempty"` + // Порти, задані руками або автопобудовою. Ідентифікатори потрібні + // самій формі: без них вибір порту нічим показати як обраний. + SourceInterfaceID string `json:"source_interface_id,omitempty"` + TargetInterfaceID string `json:"target_interface_id,omitempty"` // До якого боку вузла кріпиться лінія. Без цього після // перезавантаження всі зв'язки перескакують на типові порти, і // розкладка, яку людина розводила руками, розсипається. @@ -109,11 +126,38 @@ type MapEdge struct { Animation json.RawMessage `json:"animation"` Thresholds json.RawMessage `json:"thresholds"` ShowMetrics bool `json:"show_metrics"` + // Подання: що писати на лінії, де саме й чи ставити стрілку. + // Порожньо — рішення за клієнтом (див. 0049_map_edge_view.sql). + View json.RawMessage `json:"view,omitempty"` // Живе завантаження — джерело швидкості анімації. LinkStatus string `json:"link_status,omitempty"` UtilPct *float64 `json:"util_pct,omitempty"` CapacityBps *int64 `json:"capacity_bps,omitempty"` + + // Звірка намальованого з тим, що видно в мережі. + // + // Ребро без link_id — не обов'язково помилка: його могли намалювати + // раніше, ніж зонд обійшов мережу. Але «не привʼязане» і + // «суперечить виявленому» — різні стани, і плутати їх не можна: + // перший лікується прив'язкою, другий — виправленням схеми. + Discovered *DiscoveredLink `json:"discovered,omitempty"` + // Обидва кінці вже мають виявлених сусідів. Разом із порожнім + // Discovered це означає: мережа каже, що ці двоє не з'єднані. + PeersDiscovered bool `json:"peers_discovered,omitempty"` +} + +// DiscoveredLink — виявлений зв'язок, який відповідає намальованій лінії. +// +// Порти вже повернуті в бік ребра (A — це його source), щоб клієнт не +// вгадував орієнтацію лінка: у topo.links сторони нормалізовані за +// ідентифікаторами пристроїв, а не за тим, як людина малювала. +type DiscoveredLink struct { + LinkID string `json:"link_id"` + SourceInterfaceID string `json:"source_interface_id,omitempty"` + TargetInterfaceID string `json:"target_interface_id,omitempty"` + SourcePort string `json:"source_port,omitempty"` + TargetPort string `json:"target_port,omitempty"` } var ErrNotFound = errors.New("не знайдено") @@ -256,8 +300,16 @@ func mapNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapNode COALESCE(n.device_id::text,''), COALESCE(n.parent_node_id::text,''), n.x, n.y, n.width, n.height, n.z_index, n.style::text, n.data::text, n.collapsed, n.locked, - COALESCE(d.status::text,''), d.last_seen_at, - i.rtt_avg_ms, i.loss_pct + COALESCE(host(d.address), ''), + -- Стан видаленого хоста — «невідомо», а не останній + -- відомий: опитувати його вже нікому, і зелена крапка + -- на схемі означала б живий пристрій, якого немає. + CASE WHEN d.deleted_at IS NOT NULL THEN '' + ELSE COALESCE(d.status::text,'') END, + CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE d.last_seen_at END, + CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE i.rtt_avg_ms END, + CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE i.loss_pct END, + d.deleted_at IS NOT NULL FROM topo.map_nodes n LEFT JOIN inv.devices d ON d.id = n.device_id AND d.tenant_id = $1 LEFT JOIN ts.device_last_icmp i ON i.device_id = n.device_id @@ -274,8 +326,9 @@ func mapNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapNode var n MapNode if err := rows.Scan(&n.ID, &n.Kind, &n.Label, &n.DeviceID, &n.ParentID, &n.X, &n.Y, &n.Width, &n.Height, &n.ZIndex, - &n.Style, &n.Data, &n.Collapsed, &n.Locked, - &n.Status, &n.LastSeenAt, &n.RttMs, &n.LossPct); err != nil { + &n.Style, &n.Data, &n.Collapsed, &n.Locked, &n.Address, + &n.Status, &n.LastSeenAt, &n.RttMs, &n.LossPct, + &n.DeviceDeleted); err != nil { return nil, err } out = append(out, n) @@ -304,17 +357,66 @@ const linkStatusExpr = ` ELSE 'up' END` +// discoveredMatchExpr — виявлений зв'язок для пари вузлів ребра. +// +// Пара шукається тим самим виразом LEAST/GREATEST, що й унікальний +// індекс links_pair_uniq: у topo.links сторони нормалізовані за +// ідентифікаторами пристроїв, і A→B та B→A — це один рядок. Порти +// одразу розвертаються в бік ребра, щоб клієнту не доводилось вгадувати +// орієнтацію. +// +// Перевірка на NULL обов'язкова: LEAST(NULL, x) у Postgres дорівнює x, +// тож без неї ребро між двома текстовими анотаціями «знайшло» б +// випадковий лінк. +const discoveredMatchExpr = ` + LEFT JOIN LATERAL ( + SELECT l2.id::text AS id, + CASE WHEN l2.a_device_id = ns.device_id + THEN l2.a_interface_id ELSE l2.b_interface_id END AS src_if, + CASE WHEN l2.a_device_id = ns.device_id + THEN l2.b_interface_id ELSE l2.a_interface_id END AS dst_if + FROM topo.links l2 + WHERE l2.tenant_id = $1 + AND ns.device_id IS NOT NULL AND nt.device_id IS NOT NULL + AND LEAST(l2.a_device_id, l2.b_device_id) = LEAST(ns.device_id, nt.device_id) + AND GREATEST(l2.a_device_id, l2.b_device_id) = GREATEST(ns.device_id, nt.device_id) + ORDER BY l2.confidence DESC, l2.last_seen_at DESC + LIMIT 1 + ) dl ON e.link_id IS NULL` + func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge, error) { rows, err := tx.Query(ctx, ` SELECT e.id::text, e.source_node_id::text, e.target_node_id::text, COALESCE(si.name,''), COALESCE(ti.name,''), + COALESCE(e.source_interface_id::text,''), COALESCE(e.target_interface_id::text,''), COALESCE(e.source_handle,''), COALESCE(e.target_handle,''), COALESCE(e.link_id::text,''), COALESCE(e.label,''), e.style::text, e.dash::text, COALESCE(e.color,''), e.width_px, e.waypoints::text, e.animation::text, e.thresholds::text, e.show_metrics, + COALESCE(e.view::text,''), CASE WHEN l.id IS NULL THEN '' ELSE `+linkStatusExpr+` END, - lv.util_pct, l.capacity_bps + lv.util_pct, + -- Без лінка швидкість каналу все одно відома: її дає + -- повільніший з двох обраних портів. Інакше лінія з + -- руками зіставленими портами лишалась би без підпису, + -- хоча все потрібне для нього вже є в інвентарі. + COALESCE(l.capacity_bps, LEAST(si.speed_bps, ti.speed_bps)), + COALESCE(dl.id,''), COALESCE(dl.src_if::text,''), COALESCE(dl.dst_if::text,''), + COALESCE(dsi.name,''), COALESCE(dti.name,''), + EXISTS ( + SELECT 1 FROM topo.links lp + WHERE lp.tenant_id = $1 AND lp.discovered_by <> 'manual' + AND ns.device_id IS NOT NULL + AND (lp.a_device_id = ns.device_id OR lp.b_device_id = ns.device_id) + ) AND EXISTS ( + SELECT 1 FROM topo.links lp + WHERE lp.tenant_id = $1 AND lp.discovered_by <> 'manual' + AND nt.device_id IS NOT NULL + AND (lp.a_device_id = nt.device_id OR lp.b_device_id = nt.device_id) + ) FROM topo.map_edges e + JOIN topo.map_nodes ns ON ns.id = e.source_node_id + JOIN topo.map_nodes nt ON nt.id = e.target_node_id LEFT JOIN inv.interfaces si ON si.id = e.source_interface_id LEFT JOIN inv.interfaces ti ON ti.id = e.target_interface_id LEFT JOIN topo.links l ON l.id = e.link_id @@ -322,7 +424,10 @@ func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge LEFT JOIN inv.devices db ON db.id = l.b_device_id LEFT JOIN inv.interfaces ia ON ia.id = l.a_interface_id LEFT JOIN inv.interfaces ib ON ib.id = l.b_interface_id - LEFT JOIN topo.link_live lv ON lv.link_id = e.link_id + LEFT JOIN topo.link_live lv ON lv.link_id = e.link_id`+ + discoveredMatchExpr+` + LEFT JOIN inv.interfaces dsi ON dsi.id = dl.src_if + LEFT JOIN inv.interfaces dti ON dti.id = dl.dst_if WHERE e.map_id = $2 AND e.tenant_id = $1 AND NOT e.hidden ORDER BY e.z_index, e.id `, tenantID, mapID) @@ -333,15 +438,35 @@ func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge out := []MapEdge{} for rows.Next() { - var e MapEdge + var ( + e MapEdge + view string + dLink string + dSrcIf, dDstIf string + dSrcPort, dDstPort string + ) if err := rows.Scan(&e.ID, &e.SourceNodeID, &e.TargetNodeID, - &e.SourcePort, &e.TargetPort, &e.SourceHandle, &e.TargetHandle, + &e.SourcePort, &e.TargetPort, + &e.SourceInterfaceID, &e.TargetInterfaceID, + &e.SourceHandle, &e.TargetHandle, &e.LinkID, &e.Label, &e.Style, &e.Dash, &e.Color, &e.WidthPx, - &e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics, - &e.LinkStatus, &e.UtilPct, &e.CapacityBps); err != nil { + &e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics, &view, + &e.LinkStatus, &e.UtilPct, &e.CapacityBps, + &dLink, &dSrcIf, &dDstIf, &dSrcPort, &dDstPort, + &e.PeersDiscovered); err != nil { return nil, err } + e.View = rawOrNil(view) + if dLink != "" { + e.Discovered = &DiscoveredLink{ + LinkID: dLink, + SourceInterfaceID: dSrcIf, + TargetInterfaceID: dDstIf, + SourcePort: dSrcPort, + TargetPort: dDstPort, + } + } out = append(out, e) } return out, rows.Err() diff --git a/server/internal/store/maps_topology.go b/server/internal/store/maps_topology.go new file mode 100644 index 0000000..29e6160 --- /dev/null +++ b/server/internal/store/maps_topology.go @@ -0,0 +1,272 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// Тут живе те, чого мапі бракувало, щоб перестати бути малюнком: +// перелік портів для ручного зіставлення й прохання зібрати топологію +// з хостів, які на цій мапі стоять. + +// DiscoverCheckType — повне автовиявлення: сусіди плюс інвентар портів. +const DiscoverCheckType = "topology.discover" + +// Параметри повного обходу. +// +// Лише LLDP і CDP, хоч модуль уміє ще ARP і FDB. Причина не в трафіку: +// ARP-таблиця маршрутизатора містить усі хости підмережі, і кожен +// резолвлений сусід став би «лінком» — на мапі це десятки ліній, яких +// фізично немає. ARP і FDB корисні як доповнення там, де LLDP вимкнено, +// і вмикати їх має людина свідомо, у формі перевірок хоста. +const discoverCheckParams = `{"protos":["lldp","cdp"],"collect_interfaces":true}` + +// Раз на годину. Топологія змінюється рідко, а обхід ifTable на +// комутаторі з сотнями портів — тисячі PDU: частіше означало б +// вантажити обладнання заради даних, які й так не змінились. +const discoverCheckInterval = time.Hour + +// Обхід усієї таблиці інтерфейсів не вкладається в типові три секунди. +const discoverCheckTimeoutMs = 30000 + +// MapInterface — порт хоста, який стоїть на мапі. +type MapInterface struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + SpeedBps *int64 `json:"speed_bps,omitempty"` + OperStatus string `json:"oper_status,omitempty"` +} + +// MapInterfaces віддає порти всіх пристроїв мапи одним запитом. +// +// Одним, а не по вузлу: інспектор ребра відкривають кліком, і +// дозапитувати два хости на кожен клік означало б показувати порожні +// списки рівно ту секунду, поки людина в них дивиться. +func (s *Store) MapInterfaces(ctx context.Context, tenantID, mapID string) ([]MapInterface, error) { + var out []MapInterface + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT i.id::text, i.device_id::text, i.name, COALESCE(i.alias,''), + i.speed_bps, i.oper_status::text + FROM inv.interfaces i + WHERE i.tenant_id = $1 + AND i.device_id IN ( + SELECT n.device_id FROM topo.map_nodes n + WHERE n.map_id = $2 AND n.tenant_id = $1 AND n.device_id IS NOT NULL + ) + ORDER BY i.device_id, i.if_index NULLS LAST, i.name + `, tenantID, mapID) + if err != nil { + return err + } + defer rows.Close() + + out = []MapInterface{} + for rows.Next() { + var i MapInterface + if err := rows.Scan(&i.ID, &i.DeviceID, &i.Name, &i.Alias, + &i.SpeedBps, &i.OperStatus); err != nil { + return err + } + out = append(out, i) + } + return rows.Err() + }) + + return out, err +} + +// MapDiscoveryResult — підсумок «зібрати топологію». +type MapDiscoveryResult struct { + // Скільки хостів на мапі взагалі. + Devices int `json:"devices"` + // Скільком поставлено прохання опитати зараз. + Queued int `json:"queued"` + // Скільком автовиявлення завелося щойно (раніше його не було). + Enabled int `json:"enabled"` + // Зонд не на зв'язку: прохання лишається й спрацює, коли він + // повернеться. + Waiting int `json:"waiting"` + // Кого пропустили й чому — готовим для показу текстом. + Skipped []MapDiscoverySkip `json:"skipped,omitempty"` +} + +// MapDiscoverySkip — хост, якому автовиявлення не завести. +type MapDiscoverySkip struct { + Device string `json:"device"` + Reason string `json:"reason"` +} + +// RequestMapDiscovery заводить і зрушує повне автовиявлення на хостах мапи. +// +// Навіщо окрема дія. Мапа приречена лишатись малюнком, доки +// `topology.discover` не заведений на кожному хості руками: без нього в +// системі немає ні сусідів, ні інвентарю портів, а отже ні лінків, ні +// підписів на лініях. Місце для цієї дії — сторінка мапи: саме там +// людина бачить, що зв'язків немає, і саме там питає «чому». +// +// Механізм — той самий, яким ходить «Розпізнати зараз» (див. +// RequestIdentify): REST лишає намір у базі, диспетчер колектора його +// забирає й надсилає живій сесії DiscoveryRequest. Зонд зрушує задачі з +// власного розкладу за префіксом `topology.`, тому чек мусить існувати +// ДО поштовху — інакше прохання не влучає нікуди. +// +// Розпізнавання підвищується до повного обходу, а не додається поруч: +// обидва чеки знімають ту саму системну групу, і два чеки одного модуля +// лише подвоїли б трафік. Зворотної дії тут немає навмисно — прибрати +// автовиявлення можна у формі перевірок хоста, де видно всі його чеки. +func (s *Store) RequestMapDiscovery(ctx context.Context, tenantID, mapID string, sc Scope) (*MapDiscoveryResult, error) { + res := &MapDiscoveryResult{} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var exists bool + if err := tx.QueryRow(ctx, ` + SELECT true FROM topo.maps + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + `, mapID, tenantID).Scan(&exists); errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + + type candidate struct { + id string + name string + hasAgent bool + agentOnline bool + hasSNMP bool + } + + rows, err := tx.Query(ctx, ` + SELECT DISTINCT d.id::text, d.name, + d.agent_id IS NOT NULL, + COALESCE(a.status::text = 'online', false), + EXISTS ( + SELECT 1 FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE dc.device_id = d.id + AND c.tenant_id = d.tenant_id + AND c.proto IN ('snmp_v2c','snmp_v3') + ) + FROM topo.map_nodes n + JOIN inv.devices d ON d.id = n.device_id + LEFT JOIN core.agents a ON a.id = d.agent_id + WHERE n.map_id = $1 AND n.tenant_id = $2 + AND d.tenant_id = $2 AND d.deleted_at IS NULL AND d.enabled + ORDER BY d.name + `, mapID, tenantID) + if err != nil { + return err + } + + var cands []candidate + for rows.Next() { + var c candidate + if err := rows.Scan(&c.id, &c.name, &c.hasAgent, &c.agentOnline, &c.hasSNMP); err != nil { + rows.Close() + return err + } + cands = append(cands, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + res.Devices = len(cands) + + for _, c := range cands { + switch { + case !sc.CanWrite(c.id): + res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name, + "немає доступу на запис до цього хоста"}) + continue + case !c.hasSNMP: + res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name, + "немає SNMP-доступу — сусідів нема чим питати"}) + continue + case !c.hasAgent: + res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name, + "не прив'язаний до зонда — нікому його опитати"}) + continue + } + + // Спершу підвищуємо розпізнавання: воно вже є майже в + // кожного хоста зі SNMP, і другий чек того самого модуля + // був би подвоєним трафіком. + tag, err := tx.Exec(ctx, ` + UPDATE core.checks ch + SET check_type = $3, + params = $4::jsonb, + interval_sec = $5, + timeout_ms = GREATEST(ch.timeout_ms, $6), + enabled = true, + updated_at = now() + WHERE ch.tenant_id = $2 AND ch.device_id = $1 + AND ch.check_type = $7 + AND NOT EXISTS ( + SELECT 1 FROM core.checks c2 + WHERE c2.tenant_id = $2 AND c2.device_id = $1 + AND c2.check_type = $3 + ) + `, c.id, tenantID, DiscoverCheckType, discoverCheckParams, + int32(discoverCheckInterval/time.Second), int32(discoverCheckTimeoutMs), + IdentifyCheckType) + if err != nil { + return err + } + made := tag.RowsAffected() + + // Хоста без розпізнавання (наприклад, заведеного до появи + // автоматики) заводимо з нуля. + tag, err = tx.Exec(ctx, ` + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries) + SELECT $2, $1, $3, $4::jsonb, $5, $6, 1 + WHERE NOT EXISTS ( + SELECT 1 FROM core.checks ch + WHERE ch.tenant_id = $2 AND ch.device_id = $1 + AND ch.check_type = $3 + ) + `, c.id, tenantID, DiscoverCheckType, discoverCheckParams, + int32(discoverCheckInterval/time.Second), int32(discoverCheckTimeoutMs)) + if err != nil { + return err + } + made += tag.RowsAffected() + if made > 0 { + res.Enabled++ + } + + // Поштовх. Колонка називається identify_requested_at, але + // несе саме «опитай цей хост зараз»: диспетчер шле + // DiscoveryRequest, а зонд зрушує за ним усі задачі модуля + // topology — і розпізнавання, і повний обхід. + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices + SET identify_requested_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, c.id, tenantID); err != nil { + return err + } + + res.Queued++ + if !c.agentOnline { + res.Waiting++ + } + } + + return nil + }) + + if err != nil { + return nil, err + } + return res, nil +} diff --git a/server/internal/store/maps_write.go b/server/internal/store/maps_write.go index c1623cf..ecb8509 100644 --- a/server/internal/store/maps_write.go +++ b/server/internal/store/maps_write.go @@ -105,9 +105,13 @@ type EdgeInput struct { Animation json.RawMessage `json:"animation,omitempty"` Thresholds json.RawMessage `json:"thresholds,omitempty"` ShowMetrics *bool `json:"show_metrics,omitempty"` - ZIndex *int `json:"z_index,omitempty"` - Locked *bool `json:"locked,omitempty"` - Hidden *bool `json:"hidden,omitempty"` + // Подання лінії. Приходить цілим об'єктом, як і style у вузла: + // зливати частковий jsonb на боці БД означало б завести другий, + // невидимий у типах спосіб задати те саме поле. + View json.RawMessage `json:"view,omitempty"` + ZIndex *int `json:"z_index,omitempty"` + Locked *bool `json:"locked,omitempty"` + Hidden *bool `json:"hidden,omitempty"` } type BackgroundPatch struct { @@ -366,7 +370,7 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP source_interface_id, target_interface_id, link_id, label, style, dash, color, width_px, waypoints, animation, thresholds, show_metrics, z_index, locked, hidden, - source_handle, target_handle) + source_handle, target_handle, view) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, COALESCE($9::topo.edge_style,'smoothstep'), COALESCE($10::topo.edge_dash,'solid'), @@ -376,14 +380,14 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP COALESCE($15::jsonb,'{}'::jsonb), COALESCE($16,true), COALESCE($17,0), COALESCE($18,false), COALESCE($19,false), - NULLIF($20,''), NULLIF($21,'')) + NULLIF($20,''), NULLIF($21,''), $22::jsonb) RETURNING id::text `, tenantID, mapID, src, dst, - nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), - nullUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx, + clearableUUID(e.SourceInterfaceID), clearableUUID(e.TargetInterfaceID), + clearableUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx, rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden, - e.SourceHandle, e.TargetHandle).Scan(&id); err != nil { + e.SourceHandle, e.TargetHandle, rawArg(e.View)).Scan(&id); err != nil { return err } if e.ClientID != "" { @@ -406,9 +410,23 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP z_index = COALESCE($13, z_index), locked = COALESCE($14, locked), hidden = COALESCE($15, hidden), - source_interface_id = COALESCE($16, source_interface_id), - target_interface_id = COALESCE($17, target_interface_id), - link_id = COALESCE($18, link_id), + -- Порти й прив'язка до лінка міняються трьома станами, + -- як і бік підключення: не передали — не чіпати, + -- 'none' — зняти, uuid — поставити. Без зняття + -- помилково обраний порт лишався б на ребрі назавжди: + -- COALESCE не відрізняє «прибери» від «не передавали». + -- + -- Ознака «зняти» їде окремим булевим параметром, а не + -- рядком 'none' у тому ж полі. Причина в планувальнику: + -- на CASE $16 WHEN '' ... ELSE $16::uuid він згортає + -- константи ще до виконання й падає на порожньому рядку + -- в гілці, до якої черга не дійшла б. + source_interface_id = CASE WHEN $21 THEN NULL + ELSE COALESCE($16, source_interface_id) END, + target_interface_id = CASE WHEN $22 THEN NULL + ELSE COALESCE($17, target_interface_id) END, + link_id = CASE WHEN $23 THEN NULL + ELSE COALESCE($18, link_id) END, -- Бік підключення міняється трьома станами, а не двома: -- порожньо — не чіпати, 'auto' — зняти привʼязку й -- віддати вибік боку полотну, решта — конкретний бік. @@ -418,13 +436,17 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP WHEN 'auto' THEN NULL ELSE $19 END, target_handle = CASE $20 WHEN '' THEN target_handle WHEN 'auto' THEN NULL ELSE $20 END, + view = COALESCE($24::jsonb, view), updated_at = now() WHERE id = $1 AND map_id = $2 AND tenant_id = $3 `, e.ID, mapID, tenantID, e.Label, e.Style, e.Dash, e.Color, e.WidthPx, rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden, - nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), nullUUID(e.LinkID), - e.SourceHandle, e.TargetHandle) + clearableUUID(e.SourceInterfaceID), clearableUUID(e.TargetInterfaceID), + clearableUUID(e.LinkID), + e.SourceHandle, e.TargetHandle, + e.SourceInterfaceID == clearRef, e.TargetInterfaceID == clearRef, + e.LinkID == clearRef, rawArg(e.View)) if err != nil { return err } @@ -535,9 +557,13 @@ func snapshotMap(ctx context.Context, tx pgx.Tx, tenantID, mapID string, revisio // --------------------------------------------------------------------- type BuildStats struct { - NodesAdded int `json:"nodes_added"` - EdgesAdded int `json:"edges_added"` - Revision int64 `json:"revision"` + NodesAdded int `json:"nodes_added"` + EdgesAdded int `json:"edges_added"` + // Скільки намальованих руками ліній привʼязалось до виявлених + // лінків. Окремо від доданих: для людини це різні події — «зʼявилось + // нове» і «те, що я малював, нарешті звірено з мережею». + EdgesLinked int `json:"edges_linked"` + Revision int64 `json:"revision"` } // BuildFromTopology доповнює мапу тим, що виявив агент. @@ -597,6 +623,55 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID } st.NodesAdded = int(tag.RowsAffected()) + // Спершу привʼязуємо те, що вже намальовано руками. + // + // Без цього кроку автопобудова клала поверх ручної лінії другу, + // свою: між тими самими вузлами зʼявлялись дві паралельні + // лінії, з яких «жива» лише одна. Людина малювала звʼязок ще до + // того, як зонд його побачив, — і за це отримувала дубль. + // + // DISTINCT ON: між парою пристроїв може бути кілька лінків + // (LAG, друге волокно). Ручній лінії дістається найвпевненіший, + // решта поїде окремими ребрами нижче. + tag, err = tx.Exec(ctx, ` + WITH matched AS ( + SELECT DISTINCT ON (e.id) + e.id AS edge_id, l.id AS link_id, + CASE WHEN l.a_device_id = ns.device_id + THEN l.a_interface_id ELSE l.b_interface_id END AS src_if, + CASE WHEN l.a_device_id = ns.device_id + THEN l.b_interface_id ELSE l.a_interface_id END AS dst_if + FROM topo.map_edges e + JOIN topo.map_nodes ns ON ns.id = e.source_node_id + JOIN topo.map_nodes nt ON nt.id = e.target_node_id + JOIN topo.links l ON l.tenant_id = $2 + AND ns.device_id IS NOT NULL AND nt.device_id IS NOT NULL + AND LEAST(l.a_device_id, l.b_device_id) + = LEAST(ns.device_id, nt.device_id) + AND GREATEST(l.a_device_id, l.b_device_id) + = GREATEST(ns.device_id, nt.device_id) + WHERE e.map_id = $1 AND e.tenant_id = $2 AND e.link_id IS NULL + AND NOT EXISTS ( + SELECT 1 FROM topo.map_edges e2 + WHERE e2.map_id = $1 AND e2.link_id = l.id + ) + ORDER BY e.id, l.confidence DESC, l.last_seen_at DESC + ) + UPDATE topo.map_edges e + SET link_id = m.link_id, + -- Обраний людиною порт не чіпаємо: вона могла знати + -- більше за LLDP. + source_interface_id = COALESCE(e.source_interface_id, m.src_if), + target_interface_id = COALESCE(e.target_interface_id, m.dst_if), + updated_at = now() + FROM matched m + WHERE e.id = m.edge_id + `, mapID, tenantID) + if err != nil { + return err + } + st.EdgesLinked = int(tag.RowsAffected()) + // Ребра для лінків, обидва кінці яких уже на мапі. tag, err = tx.Exec(ctx, ` INSERT INTO topo.map_edges @@ -620,7 +695,7 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID } st.EdgesAdded = int(tag.RowsAffected()) - if st.NodesAdded == 0 && st.EdgesAdded == 0 { + if st.NodesAdded == 0 && st.EdgesAdded == 0 && st.EdgesLinked == 0 { st.Revision = revision return nil } @@ -634,7 +709,8 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID } return snapshotMap(ctx, tx, tenantID, mapID, st.Revision, authorID, - fmt.Sprintf("автопобудова: +%d вузлів, +%d ребер", st.NodesAdded, st.EdgesAdded)) + fmt.Sprintf("автопобудова: +%d вузлів, +%d ребер, звірено %d", + st.NodesAdded, st.EdgesAdded, st.EdgesLinked)) }) if err != nil { @@ -645,6 +721,19 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID // --------------------------------------------------------------------- +// clearRef — «зняти привʼязку» в полях, де порожнє значення вже +// означає «не чіпати». +const clearRef = "none" + +// clearableUUID прибирає службове 'none' зі значення, яке їде в колонку +// uuid: сам намір «зняти» передається окремим прапорцем. +func clearableUUID(v string) any { + if v == clearRef { + return nil + } + return nullUUID(v) +} + // rawArg перетворює порожній json.RawMessage на NULL, щоб COALESCE у // запиті лишив старе значення. func rawArg(m json.RawMessage) any { diff --git a/server/internal/store/metrics_devices.go b/server/internal/store/metrics_devices.go new file mode 100644 index 0000000..54a2ac1 --- /dev/null +++ b/server/internal/store/metrics_devices.go @@ -0,0 +1,172 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Добір хостів для сторінки «Метрики». +// +// Навіщо окремий добір. Сторінка досі вивантажувала весь інвентар і +// клала його в один ` setKeepVersions(e.target.value)} + /> + + + setKeepDays(e.target.value)} + /> + + + + {ret && ( +
    +

    + Зараз у сховищі {ret.total_versions} {plural(ret.total_versions, ['версія', 'версії', 'версій'])} на{' '} + {fmtBytes(ret.total_bytes)}.{' '} + {ret.protected_latest > 0 && ( + <> + З них {ret.protected_latest} {plural(ret.protected_latest, ['версія', 'версії', 'версій'])} захищено як + останні — вони не видаляються ніколи. + + )} +

    + {/* Обіцянка «звільниться стільки-то» цінна саме ДО + натискання. Рахує її сервер тими самими умовами, що + й нічний прохід, — щоб форма й прибиральник не + розповідали дві різні історії. */} +

    0 ? 'text-amber-300' : ''}> + За чинними числами під видалення підпадає{' '} + {ret.candidates} {plural(ret.candidates, ['версія', 'версії', 'версій'])} ({fmtBytes(ret.candidate_bytes)}). + {ret.candidates > 0 && ' Перерахується після збереження.'} +

    + {ret.last_run_at && ( +

    + Останній прохід: {new Date(ret.last_run_at).toLocaleString('uk-UA')}, прибрано{' '} + {ret.last_deleted}. +

    + )} +
    + )} + + {/* Межа, про яку треба сказати вголос: тіла лежать і в Git, + і прибирання їх звідти не чіпає. Людина, яка вмикає + очистку заради місця на диску бази, має розуміти, що + репозиторій продовжить рости. */} +

    + Очистка звільняє місце в базі. Історія в Git-репозиторії лишається недоторканою — саме + для того її й заводили. +

    + + {err}
    @@ -152,6 +252,17 @@ export function BackupDefaultsForm({ credential_id: credID, apply_to_all: applyToAll, }) + // Політика зберігається другим запитом і ПІСЛЯ + // розкладу: якщо не пройде вона, розклад уже + // збережено, і людині лишається виправити лише числа. + // Зворотний порядок лишав би ввімкнену очистку при + // незбереженому розкладі — гірший із двох половинчастих + // станів. + await api.setRetention({ + enabled: retOn, + versions: Number(keepVersions) || 20, + days: Number(keepDays) || 180, + }) onSaved() } catch (e) { setErr(e instanceof Error ? e.message : String(e)) @@ -168,3 +279,16 @@ export function BackupDefaultsForm({ ) } + +/** + * Розмір людськими одиницями. + * + * Двійкові кратні (1024), бо йдеться про місце в базі, а не про + * маркетингову цифру на коробці диска. + */ +export function fmtBytes(n: number): string { + if (n < 1024) return `${n} Б` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} КБ` + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} МБ` + return `${(n / 1024 / 1024 / 1024).toFixed(2)} ГБ` +} diff --git a/web/src/components/BackupPolicyForm.tsx b/web/src/components/BackupPolicyForm.tsx index 63f8753..e0d294d 100644 --- a/web/src/components/BackupPolicyForm.tsx +++ b/web/src/components/BackupPolicyForm.tsx @@ -1,14 +1,40 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { MutableRefObject } from 'react' import { api } from '../api/client' import { Button, ErrorNote, Field, Modal, Toggle, inputClass } from './ui' import { CronPicker } from './CronPicker' import type { BackupPolicy, Credential, DeviceSummary, NcmProfile } from '../types' +const DEFAULT_CRON = '0 3 * * *' + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +/** + * Пояснення до «— автоматично —». + * + * Підказка мусить казати не «як воно працює взагалі», а що буде саме + * з цим хостом. Різниця між «підберу за SNMP» і «підбирати нема за + * чим» — це різниця між налаштованим хостом і мовчазною помилкою за + * добу, коли спрацює розклад. + */ +function autoHint(d: DeviceSummary): string { + if (d.sys_object_id) { + const first = (d.sys_descr ?? '').split(/\r?\n/)[0].trim() + const what = first !== '' ? first.slice(0, 60) : d.sys_object_id + return `Розпізнано по SNMP: ${what} — профіль підбереться сам` + } + if (d.vendor) { + return `Хост ще не опитано по SNMP — профіль підбереться за виробником «${d.vendor}»` + } + return 'Хост не розпізнано і виробник не заданий — дайте хосту SNMP-доступ або оберіть профіль тут' +} + /** * Розклад резервного копіювання хоста. * - * Профіль можна не задавати: сервер підбере його за виробником. Явно - * заданий завжди виграє — це шлях для випадків, коли прошивка + * Профіль можна не задавати: сервер підбере його за тим, що пристрій + * сказав про себе по SNMP, а якщо SNMP немає — за виробником. Явно + * заданий завжди виграє: це шлях для випадків, коли прошивка * поводиться не так, як решта родини. */ export function BackupPolicyForm({ @@ -16,17 +42,29 @@ export function BackupPolicyForm({ credentials, onClose, inline = false, + saveRef, }: { device: DeviceSummary credentials: Credential[] onClose?: () => void /** Всередині чужої форми: без власного вікна й без кнопки «Скасувати». */ inline?: boolean + /** + * Куди покласти збереження, коли форма живе всередині чужої. + * + * Дві кнопки «Зберегти» в одному вікні — це питання «а що саме + * збережеться?» на кожен клік. Тому в inline-режимі власної кнопки + * немає: сюди кладеться функція, яку картка хоста викликає зі своєї + * єдиної кнопки. Виклик дешевий — якщо розклад не чіпали, функція + * нічого не шле; помилку вона показує тут-таки й кидає далі, щоб + * батько знав, що збереження не пройшло. + */ + saveRef?: MutableRefObject<(() => Promise) | null> }) { const [profiles, setProfiles] = useState([]) const [policy, setPolicy] = useState(null) const [enabled, setEnabled] = useState(true) - const [cron, setCron] = useState('0 3 * * *') + const [cron, setCron] = useState(DEFAULT_CRON) // Чи слідує хост глобальному розкладу. Знімається щойно людина // задає розклад тут: інакше найближча зміна глобального затерла б те, // що вона щойно налаштувала руками. @@ -37,32 +75,149 @@ export function BackupPolicyForm({ const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) + // Знімок полів на момент завантаження. Потрібен, щоб єдина кнопка + // картки хоста не слала розклад, якого ніхто не чіпав: таке + // «збереження» мовчки зняло б хост зі спільного розкладу. + const [baseline, setBaseline] = useState('') + + // Свіжий стан хоста після розпізнавання. DeviceSummary приходить + // пропом і сам не оновлюється, а підказка про профіль має показати + // результат опитування, а не те, що було до нього. + const [fresh, setFresh] = useState(null) + const [identBusy, setIdentBusy] = useState(false) + const [identNote, setIdentNote] = useState(null) + + // Розпізнавання чекає на відповідь пристрою секундами; за цей час + // вікно встигають закрити, і писати в стан уже нікуди. + const alive = useRef(true) useEffect(() => { - let alive = true + alive.current = true + return () => { + alive.current = false + } + }, []) + + useEffect(() => { + let ok = true + setFresh(null) + setIdentNote(null) Promise.all([ api.listNcmProfiles().catch(() => [] as NcmProfile[]), api.getBackupPolicy(device.id).catch(() => null), ]) .then(([pr, pol]) => { - if (!alive) return + if (!ok) return setProfiles(pr) setPolicy(pol) - if (pol) { - setEnabled(pol.enabled) - setCron(pol.cron) - setFollowsDefault(pol.follows_default) - setProfileID(pol.profile_id ?? '') - setCredID(pol.credential_id ?? '') + const init = { + enabled: pol ? pol.enabled : true, + cron: pol ? pol.cron : DEFAULT_CRON, + profileID: pol?.profile_id ?? '', + credID: pol?.credential_id ?? '', } + setEnabled(init.enabled) + setCron(init.cron) + setFollowsDefault(pol?.follows_default ?? false) + setProfileID(init.profileID) + setCredID(init.credID) + setBaseline(JSON.stringify([init.enabled, init.cron.trim(), init.profileID, init.credID])) }) - .finally(() => alive && setLoading(false)) + .finally(() => ok && setLoading(false)) return () => { - alive = false + ok = false } }, [device.id]) const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet') + // Хост, за яким малюємо підказку: після розпізнавання — свіжий. + const shown = fresh ?? device + + const snapshot = JSON.stringify([enabled, cron.trim(), profileID, credID]) + const dirty = !loading && snapshot !== baseline + + async function save() { + // Вкладку відкрили й нічого не чіпали — запиту бути не повинно: + // збереження зняло б хост зі спільного розкладу за спиною людини, + // яка натискала «Зберегти» заради полів самого хоста. В окремому + // вікні навпаки: там натискання кнопки — і є те саме «хочу свій + // розклад», навіть якщо жодне поле не змінилось. + if (inline && !dirty) return + if (cron.trim() === '') { + setErr('Розклад збору конфігів не заданий') + throw new Error('Розклад збору конфігів не заданий') + } + setBusy(true) + setErr(null) + try { + await api.setBackupPolicy(device.id, { + profile_id: profileID, + credential_id: credID, + enabled, + cron: cron.trim(), + // Збереження цієї форми означає «в мене свій розклад». + follows_default: false, + }) + setBaseline(snapshot) + setFollowsDefault(false) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + throw e + } finally { + setBusy(false) + } + } + + // Посилання оновлюємо на кожному рендері: усередині save() замкнені + // поточні значення полів, і застаріле замикання зберегло б те, що + // людина бачила два натискання тому. + useEffect(() => { + if (!saveRef) return + saveRef.current = save + return () => { + saveRef.current = null + } + }) + + /** + * Опитати хост по SNMP просто зараз. + * + * Чек розпізнавання хост отримує й сам, але наступним тактом — до + * двох хвилин. Людина, яка щойно завела хост, стільки не чекає. + * Відповідь від пристрою приходить не у відповіді на цей запит, а + * окремим шляхом через зонд, тому після постановки в чергу + * перечитуємо стан хоста кілька разів: інакше підказка лишиться + * старою й кнопку натиснуть удруге. + */ + async function identify() { + setIdentBusy(true) + setIdentNote(null) + try { + const r = await api.identifyDevice(device.id) + if (!alive.current) return + if (!r.queued) { + // reason — готовий текст від сервера, показуємо як є. + setIdentNote(r.reason ?? 'Не вдалося зрушити опитування') + return + } + for (let i = 0; i < 6; i++) { + await sleep(2500) + if (!alive.current) return + const found = await api.getDevice(device.id).catch(() => null) + if (!alive.current) return + if (found?.sys_object_id) { + setFresh(found) + return + } + } + setIdentNote('Хост поки не відповів по SNMP — спробуйте ще раз за хвилину') + } catch (e) { + if (alive.current) setIdentNote(e instanceof Error ? e.message : String(e)) + } finally { + if (alive.current) setIdentBusy(false) + } + } + const body = ( loading ? (

    Завантаження…

    @@ -77,28 +232,55 @@ export function BackupPolicyForm({ {followsDefault && (

    - Зараз хост слідує спільному розкладу. Щойно ви збережете цю форму, він отримає - власний — і зміни спільного його більше не чіпатимуть. + Зараз хост слідує спільному розкладу. Щойно ви збережете{' '} + {inline ? 'хост' : 'цю форму'}, він отримає власний — і зміни спільного його більше + не чіпатимуть.

    )} - - - + {/* Профіль і стан розпізнавання — один блок: підказка пояснює + саме те, що станеться при «— автоматично —», а кнопка + дозволяє це «автоматично» отримати негайно. Кнопка навмисно + поза Field: той загорнутий у label, і клік по ній + перекидався б у select. */} +
    + + + + +
    + + {autoHint(shown)} + + {/* Розпізнаному хосту опитування вже нічого не додасть. */} + {!shown.sys_object_id && ( + + )} +
    + + {identNote && ( +

    + {identNote} +

    + )} +
    {err} -
    - {!inline && } - -
    + {inline ? ( + // Кнопки тут немає навмисно — зберігає картка хоста. Але + // мовчати теж не можна: людина, яка змінила розклад, має + // бачити, що зміна ще не поїхала на сервер. + dirty && ( +

    + Розклад змінено — збережеться разом з хостом по кнопці «Зберегти». +

    + ) + ) : ( +
    + + +
    + )}
    ) ) diff --git a/web/src/components/BulkDeviceForm.tsx b/web/src/components/BulkDeviceForm.tsx new file mode 100644 index 0000000..41bac22 --- /dev/null +++ b/web/src/components/BulkDeviceForm.tsx @@ -0,0 +1,1639 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import type { BulkChecksResult, BulkDeviceChanges, BulkListOp } from '../api/client' +import { + Button, + ErrorNote, + Field, + IntervalInput, + Modal, + Spinner, + Toggle, + humanInterval, + inputClass, + plural, +} from './ui' +import { fmtBytes } from './BackupDefaultsForm' +import { Picker } from './Picker' +import { TemplatePicker } from './TemplatePicker' +import { kindLabel } from './DeviceForm' +import { DEFAULT_INTERVAL, ParamFields, defaultsFromSchema } from './ChecksEditor' +import type { + AgentSummary, + BulkDeviceTarget, + BulkTargetMode, + DeleteMode, + PurgeResult, + RestoreResult, + CheckType, + Credential, + DeviceFilter, + DeviceGroup, + DeviceSummary, + NcmProfile, + Template, +} from '../types' + +/** + * Масова правка й масове видалення хостів. + * + * Одне вікно на дві дії, бо в них однаковий і найважливіший крок: + * показати людині ТОЧНИЙ перелік того, що вона зачепить. Перелік + * приходить із сервера, а не збирається тут із того, що лежить у + * пам'яті сторінки: між тим, як людина відмітила рядки, і тим, як + * натиснула, хост могли видалити, забрати з її груп доступу або + * вимкнути. Те, що відпало, показується окремо — мовчазна різниця між + * «обрано 50» і «змінено 43» неприпустима. + * + * Головне правило форми правки: НЕ ЧІПАТИ — ЦЕ НЕ СТЕРТИ. + * + * Порожнє поле в масовій формі не означає «зітри це в усіх». У + * п'ятдесяти хостів немає спільного поточного значення, яке можна було + * б показати в полі, тож порожнє поле означає рівно одне: «я про це не + * говорив». Через це кожне поле має явний перемикач на три стани — + * «не чіпати», «задати», «очистити», — і жоден із них не вгадується з + * вмісту поля. + */ + +type Tri = 'keep' | 'set' | 'clear' +type ListMode = 'keep' | 'add' | 'remove' | 'replace' +/** + * Дії над ручними перевірками. + * + * «Замінити» тут немає, на відміну від груп, і це не забутий випадок. + * Групи видно в переліку хостів — людина, яка натискає «замінити», + * бачить, що стирає. Набори перевірок не видно ніде, тож те саме + * натискання зітерло б невідомо що й помітилось би тоді, коли перестане + * приходити алерт. + */ +type ChecksMode = 'keep' | 'add' | 'remove' | 'tune' + +export function BulkDeviceForm({ + mode, + devices, + filter, + archived = false, + groups, + agents, + credentials, + checkTypes, + onClose, + onDone, +}: { + mode: 'edit' | 'delete' | 'restore' + /** Те, що людина відмітила в переліку. Джерело імен для відсіяних. */ + devices: DeviceSummary[] + /** Фільтр, яким вона це відбирала. Сервер бере перетин із переліком. */ + filter: DeviceFilter + /** + * Набір узято з архіву. + * + * Змінює не оформлення, а перелік доступних дій: над прибраним + * хостом архівне видалення не має сенсу (він уже прибраний), тож + * вибору між двома видаленнями там немає — лишається одне, повне. + */ + archived?: boolean + groups: DeviceGroup[] + agents: AgentSummary[] + credentials: Credential[] + /** Довідник типів перевірок — той самий, що й у картці хоста. */ + checkTypes: CheckType[] + onClose: () => void + onDone: () => Promise | void +}) { + const ids = useMemo(() => devices.map((d) => d.id), [devices]) + + const [targets, setTargets] = useState(null) + const [dropped, setDropped] = useState([]) + const [templates, setTemplates] = useState([]) + const [profiles, setProfiles] = useState([]) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + // --- поля правки: стан перемикача + значення --- + const [agentMode, setAgentMode] = useState('keep') + const [agentID, setAgentID] = useState('') + const [kindMode, setKindMode] = useState('keep') + const [kind, setKind] = useState('switch') + const [vendorMode, setVendorMode] = useState('keep') + const [vendor, setVendor] = useState('') + const [enabledMode, setEnabledMode] = useState('keep') + const [enabled, setEnabled] = useState(true) + + const [groupMode, setGroupMode] = useState('keep') + const [groupIDs, setGroupIDs] = useState([]) + const [tplMode, setTplMode] = useState('keep') + const [tplIDs, setTplIDs] = useState([]) + const [credMode, setCredMode] = useState('keep') + const [credIDs, setCredIDs] = useState([]) + + const [backupMode, setBackupMode] = useState('keep') + const [bpEnabled, setBpEnabled] = useState(true) + const [bpCron, setBpCron] = useState('0 3 * * *') + const [bpProfile, setBpProfile] = useState('') + const [bpCred, setBpCred] = useState('') + const [bpSyslog, setBpSyslog] = useState(false) + + // --- ручні перевірки --- + const [chkMode, setChkMode] = useState('keep') + const [chkType, setChkType] = useState('') + const [chkParams, setChkParams] = useState>({}) + const [chkInterval, setChkInterval] = useState(60) + const [chkEnabled, setChkEnabled] = useState(true) + // Для «змінити наявні» кожне поле саме по собі тристанове: галочка + // знята — не чіпати. Той самий принцип, що й у решті форми, лише + // всередині однієї дії. + const [tuneIntervalOn, setTuneIntervalOn] = useState(false) + const [tuneInterval, setTuneInterval] = useState(60) + const [tuneTimeoutOn, setTuneTimeoutOn] = useState(false) + const [tuneTimeout, setTuneTimeout] = useState(3000) + const [tuneRetriesOn, setTuneRetriesOn] = useState(false) + const [tuneRetries, setTuneRetries] = useState(2) + const [tuneEnabled, setTuneEnabled] = useState<'keep' | 'on' | 'off'>('keep') + + // Наслідок дії над перевірками. Вікно після неї не закривається: + // «додано 12, уже було 25, не вдалося 3» — це і є відповідь, заради + // якої дію робили, і показати її ніде більше. + const [chkResult, setChkResult] = useState(null) + + // --- видалення --- + // + // Типово — ПОВНЕ, і це рішення, а не недогляд. «Видалити» в + // інтерфейсі означає видалити; архів, який лишається після м'якого + // видалення, ніде в продукті не показується й нізвідки не + // відновлюється, тож людина, яка не помітила вибору, отримує саме те, + // чого просила. Архівне лишається окремим свідомим вибором для тих, + // хто тримає історію конфігів і алертів навмисно. + // + // Для набору з архіву вибору немає взагалі: архівувати вже + // заархівоване — дія, яка нічого не робить, і пропонувати її означало + // б поставити поруч кнопку без наслідку. + const [delMode, setDelMode] = useState('purge') + // Галочка «розумію» для повного видалення — лише коли є що втрачати. + const [understood, setUnderstood] = useState(false) + // Підсумок повного видалення. Вікно після нього не закривається: це + // єдине місце, де видно, що саме зникло, і після закриття цих чисел + // не знайти вже ніде, крім журналу аудиту. + const [purged, setPurged] = useState(null) + // Підсумок повернення. Вікно так само лишається відкритим: «хост на + // місці, але вимкнений» і «прив'язку до зонда знято» — це те, про що + // спитають одразу після дії, і показати це більше ніде. + const [restored, setRestored] = useState(null) + + // Набір звіряється з сервером щоразу при відкритті вікна, а не + // береться зі списку на сторінці: список міг постаріти, а тут із + // нього робиться підтвердження. + useEffect(() => { + let alive = true + // Режим потрібен доборові, а не оформленню: цілі для повного + // видалення й для повернення шукаються разом із прибраними + // хостами, для решти дій — лише серед живих. + const targetMode: BulkTargetMode | undefined = + mode === 'restore' ? 'restore' : mode === 'delete' ? 'purge' : undefined + api + .bulkDeviceTargets(filter, ids, targetMode) + .then((r) => { + if (!alive) return + setTargets(r.devices ?? []) + setDropped(r.dropped ?? []) + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)) + }) + return () => { + alive = false + } + // Набір фіксується на момент відкриття вікна: перезапитувати його + // при кожній зміні фільтра означало б міняти те, що людина вже + // читає як підтвердження. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (mode !== 'edit') return + let alive = true + Promise.all([ + api.listTemplates().catch(() => [] as Template[]), + api.listNcmProfiles().catch(() => [] as NcmProfile[]), + ]).then(([t, p]) => { + if (!alive) return + setTemplates(t) + setProfiles(p) + }) + return () => { + alive = false + } + }, [mode]) + + const nameByID = useMemo(() => { + const m = new Map() + for (const d of devices) m.set(d.id, d.name) + return m + }, [devices]) + + const changes = useMemo(() => { + const c: BulkDeviceChanges = {} + if (agentMode === 'set') c.agent_id = { op: 'set', value: agentID } + else if (agentMode === 'clear') c.agent_id = { op: 'clear' } + + if (kindMode === 'set') c.kind = { op: 'set', value: kind } + + if (vendorMode === 'set') c.vendor = { op: 'set', value: vendor.trim() } + else if (vendorMode === 'clear') c.vendor = { op: 'clear' } + + if (enabledMode === 'set') c.enabled = { op: 'set', value: enabled } + + const list = (m: ListMode, v: string[]): BulkListOp | undefined => + m === 'keep' ? undefined : { op: m, ids: v } + c.groups = list(groupMode, groupIDs) + c.templates = list(tplMode, tplIDs) + c.credentials = list(credMode, credIDs) + + if (backupMode === 'set') { + c.backup = { + op: 'set', + enabled: bpEnabled, + cron: bpCron.trim(), + profile_id: bpProfile, + credential_id: bpCred, + on_syslog: bpSyslog, + // Масово заданий розклад — це власний розклад хостів, а не + // повернення їх під глобальний: інакше найближча зміна + // глобального мовчки затерла б те, що людина щойно поставила. + follows_default: false, + } + } else if (backupMode === 'clear') { + c.backup = { op: 'clear' } + } + + if (chkMode === 'add' && chkType) { + // Таймаут і повтори тут не питаємо навмисно: у картці хоста їх + // теж не питають при додаванні, і два різні набори «типового» для + // однієї й тієї ж перевірки означали б, що чек, заведений масово, + // відрізняється від заведеного руками невидимо для ока. + c.checks = { + op: 'add', + check_type: chkType, + params: chkParams, + interval_sec: chkInterval, + enabled: chkEnabled, + } + } else if (chkMode === 'remove' && chkType) { + c.checks = { op: 'remove', check_type: chkType } + } else if (chkMode === 'tune' && chkType) { + c.checks = { + op: 'tune', + check_type: chkType, + interval_sec: tuneIntervalOn ? tuneInterval : undefined, + timeout_ms: tuneTimeoutOn ? tuneTimeout : undefined, + retries: tuneRetriesOn ? tuneRetries : undefined, + enabled: tuneEnabled === 'keep' ? undefined : tuneEnabled === 'on', + } + } + + // Ключі зі значенням undefined у JSON не потраплять — рівно те, що + // сервер читає як «поля немає, не чіпати». + return c + }, [ + agentMode, agentID, kindMode, kind, vendorMode, vendor, enabledMode, enabled, + groupMode, groupIDs, tplMode, tplIDs, credMode, credIDs, + backupMode, bpEnabled, bpCron, bpProfile, bpCred, bpSyslog, + chkMode, chkType, chkParams, chkInterval, chkEnabled, + tuneIntervalOn, tuneInterval, tuneTimeoutOn, tuneTimeout, + tuneRetriesOn, tuneRetries, tuneEnabled, + ]) + + // Людською мовою те саме, що поїде на сервер. Показується поруч із + // кнопкою: масову дію підтверджують за описом наслідку, а не за + // станом десятка перемикачів, розкиданих по формі. + const summary = useMemo(() => { + const out: string[] = [] + const agentName = (id: string) => agents.find((a) => a.id === id)?.name ?? '—' + const groupNames = (v: string[]) => + v.map((id) => groups.find((g) => g.id === id)?.name ?? '—').join(', ') + const tplNames = (v: string[]) => + v.map((id) => templates.find((t) => t.id === id)?.name ?? '—').join(', ') + const credNames = (v: string[]) => + v.map((id) => credentials.find((c) => c.id === id)?.name ?? '—').join(', ') + const listWord: Record = { + add: 'додати', + remove: 'прибрати', + replace: 'замінити на', + } + + if (agentMode === 'set') out.push(`Зонд → ${agentName(agentID)}`) + if (agentMode === 'clear') out.push('Зонд → відв’язати (хост перестане опитуватись)') + if (kindMode === 'set') out.push(`Тип → ${kindLabel[kind] ?? kind}`) + if (vendorMode === 'set') out.push(`Виробник → ${vendor.trim()}`) + if (vendorMode === 'clear') out.push('Виробник → очистити') + if (enabledMode === 'set') out.push(enabled ? 'Опитування → увімкнути' : 'Опитування → вимкнути') + if (groupMode !== 'keep') out.push(`Групи: ${listWord[groupMode]} ${groupNames(groupIDs) || '—'}`) + if (tplMode !== 'keep') out.push(`Шаблони: ${listWord[tplMode]} ${tplNames(tplIDs) || '—'}`) + if (credMode !== 'keep') out.push(`Доступи: ${listWord[credMode]} ${credNames(credIDs) || '—'}`) + if (backupMode === 'set') { + out.push( + `Збір конфігів → ${bpEnabled ? 'увімкнено' : 'вимкнено'}, розклад «${bpCron.trim()}»`, + ) + } + if (backupMode === 'clear') out.push('Збір конфігів → прибрати розклад') + + if (chkMode !== 'keep' && chkType) { + const t = checkTypes.find((x) => x.key === chkType) + const label = `«${t?.name ?? chkType}» (${chkType})` + if (chkMode === 'add') { + out.push( + `Перевірки: додати ${label}, кожні ${humanInterval(chkInterval)}` + + (chkEnabled ? '' : ', вимкненою') + + ' — тим, у кого її ще немає', + ) + } else if (chkMode === 'remove') { + out.push(`Перевірки: прибрати ${label} у тих, у кого вона заведена руками`) + } else { + const bits: string[] = [] + if (tuneIntervalOn) bits.push(`кожні ${humanInterval(tuneInterval)}`) + if (tuneTimeoutOn) bits.push(`таймаут ${tuneTimeout} мс`) + if (tuneRetriesOn) bits.push(`повторів ${tuneRetries}`) + if (tuneEnabled !== 'keep') bits.push(tuneEnabled === 'on' ? 'увімкнути' : 'вимкнути') + out.push(`Перевірки: ${label} — ${bits.join(', ') || '—'}`) + } + } + return out + }, [ + agents, groups, templates, credentials, checkTypes, + agentMode, agentID, kindMode, kind, vendorMode, vendor, enabledMode, enabled, + groupMode, groupIDs, tplMode, tplIDs, credMode, credIDs, backupMode, bpEnabled, bpCron, + chkMode, chkType, chkInterval, chkEnabled, + tuneIntervalOn, tuneInterval, tuneTimeoutOn, tuneTimeout, + tuneRetriesOn, tuneRetries, tuneEnabled, + ]) + + const chkTypeObj = useMemo( + () => checkTypes.find((t) => t.key === chkType) ?? null, + [checkTypes, chkType], + ) + + /** + * Обов'язкові параметри, яких людина ще не заповнила. + * + * Це і є та межа, де масове додавання впирається в природу + * параметрів. `url` у http.status обов'язковий, а спільного значення + * для сорока хостів у нього не існує — тому дозволено підстановку + * `{{address}}` / `{{name}}` / `{{fqdn}}`, і лише її. Порожнім поле + * лишити не можна: чек без url не запуститься взагалі, і сорок + * непрацездатних перевірок гірші за одну незручність тут. + */ + const missingParams = useMemo(() => { + if (chkMode !== 'add' || !chkTypeObj) return [] + return (chkTypeObj.params_schema?.required ?? []).filter((k) => { + const v = chkParams[k] + if (v === undefined || v === null) return true + if (typeof v === 'string') return v.trim() === '' + if (Array.isArray(v)) return v.length === 0 + return false + }) + }, [chkMode, chkTypeObj, chkParams]) + + // Чи є що втрачати. Хост без жодного зібраного байта видаляють + // назавжди без зайвих церемоній: галочка «розумію» на порожньому + // місці навчає натискати її не читаючи — і тоді вона не спрацює там, + // де справді потрібна. + const hasHistory = useMemo( + () => + (targets ?? []).some( + (t) => t.configs > 0 || t.samples > 0 || t.alerts > 0 || t.series > 0, + ), + [targets], + ) + + // Причина, чому кнопку не можна натиснути. Текстом, а не мовчазним + // disabled: людина має знати, чого від неї хочуть. + const blocker = useMemo(() => { + if (targets === null) return 'Звіряємо перелік…' + if (targets.length === 0) return 'Немає жодного хоста, доступного на запис' + if (mode === 'restore') return null + if (mode === 'delete') { + if (delMode === 'purge' && hasHistory && !understood) + return 'Підтвердьте, що зібране можна стерти назавжди' + return null + } + // Незавершений вибір типу перевірки перевіряється ДО загального + // «оберіть, що саме змінити»: без типу дія в summary не потрапляє + // взагалі, і людина читала б, ніби вона нічого не обрала, хоча + // обрала — і не доклала. + if (chkMode !== 'keep' && !chkType) return 'Оберіть тип перевірки' + if (summary.length === 0) return 'Оберіть, що саме змінити' + if (agentMode === 'set' && !agentID) return 'Оберіть зонд' + if (vendorMode === 'set' && !vendor.trim()) return 'Впишіть виробника' + if (groupMode !== 'keep' && groupMode !== 'replace' && groupIDs.length === 0) + return 'Оберіть групи' + if (tplMode !== 'keep' && tplMode !== 'replace' && tplIDs.length === 0) return 'Оберіть шаблони' + if (credMode !== 'keep' && credMode !== 'replace' && credIDs.length === 0) + return 'Оберіть доступи' + if (backupMode === 'set' && !bpCron.trim()) return 'Вкажіть розклад' + if (chkMode === 'add' && missingParams.length > 0) + return `Заповніть параметр${missingParams.length > 1 ? 'и' : ''}: ${missingParams.join(', ')}` + if ( + chkMode === 'tune' && + !tuneIntervalOn && + !tuneTimeoutOn && + !tuneRetriesOn && + tuneEnabled === 'keep' + ) + return 'Оберіть, що змінити в перевірці' + return null + }, [ + targets, mode, delMode, understood, hasHistory, + summary, agentMode, agentID, vendorMode, vendor, + groupMode, groupIDs, tplMode, tplIDs, credMode, credIDs, backupMode, bpCron, + chkMode, chkType, missingParams, + tuneIntervalOn, tuneTimeoutOn, tuneRetriesOn, tuneEnabled, + ]) + + const total = targets?.length ?? 0 + + const run = async () => { + setBusy(true) + setErr(null) + try { + const picked = (targets ?? []).map((t) => t.device_id) + if (mode === 'restore') { + const res = await api.bulkRestoreDevices(filter, picked) + await onDone() + // Вікно не закривається навіть тоді, коли все пройшло рівно: + // «повернуто 3, увімкнено 11 перевірок, один лишився вимкненим» + // — це і є відповідь на питання, чи хост справді працює. + setRestored(res.result ?? null) + return + } + if (mode === 'delete') { + const res = await api.bulkDeleteDevices(filter, picked, delMode) + await onDone() + // Архівне видалення нічого не повідомляє понад те, що людина + // вже прочитала в підтвердженні, — вікно закривається. Повне + // повідомляє: скільки версій, рядів і вимірів справді зникло і + // чи все вдалося. + if (delMode === 'purge') { + setPurged(res.purged ?? null) + return + } + onClose() + return + } + const res = await api.bulkUpdateDevices(filter, picked, changes) + await onDone() + // Дія над перевірками — єдина, у якої наслідок не збігається з + // наміром: частина хостів уже мала таку перевірку, частина не + // мала чим підставити адресу. Закрити вікно, показавши лише + // «готово», означало б сховати рівно те, про що спитають далі. + if (res.checks) { + setChkResult(res.checks) + return + } + onClose() + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + return ( + +
    + {restored ? ( + <> + +
    + +
    + + ) : purged ? ( + <> + +
    + +
    + + ) : chkResult ? ( + <> + +
    + +
    + + ) : targets === null ? ( + // Помилка звірки (найчастіше — перевищена стеля) читається + // нижче, у ErrorNote; крутити при цьому спінер означало б + // обіцяти, що воно от-от завантажиться. + err ? null : ( + + ) + ) : ( + <> + + + {dropped.length > 0 && ( +

    + Не потрапили в дію: {dropped.map((id) => nameByID.get(id) ?? id).join(', ')}. + Хост зник, більше не підпадає під фільтр або доступний лише на читання. +

    + )} + + {mode === 'restore' ? ( + + ) : mode === 'delete' ? ( + { + setDelMode(m) + setUnderstood(false) + }} + understood={understood} + onUnderstood={setUnderstood} + needsConfirm={hasHistory} + archived={archived} + /> + ) : ( +
    +

    + Поля, залишені на «не чіпати», не змінюються — у кожного хоста лишається своє + значення. «Очистити» — окремий вибір, і він саме стирає. +

    + + + + + + + + + + + setVendor(e.target.value)} + /> + + + + + + + + ({ id: g.id, label: g.name }))} + value={groupIDs} + onChange={setGroupIDs} + empty="Груп ще немає" + /> + + + + + + + + ({ id: c.id, label: c.name, hint: c.proto }))} + value={credIDs} + onChange={setCredIDs} + empty="Доступів ще немає" + /> + + + +
    + +
    + + setBpCron(e.target.value)} + placeholder="0 3 * * *" + /> + + + + + + + + +
    +
    +
    + + { + setChkMode(m) + // Тип не скидаємо: людина найчастіше перемикається + // між «прибрати» і «змінити наявні» на тому самому + // типі, і змушувати обирати його заново — марна + // робота. Параметри при цьому не переносяться: вони + // є лише в «додати». + setErr(null) + }} + type={chkType} + onType={(k) => { + setChkType(k) + const t = checkTypes.find((x) => x.key === k) + setChkParams(t ? defaultsFromSchema(t) : {}) + setChkInterval(DEFAULT_INTERVAL[k] ?? 60) + }} + typeObj={chkTypeObj} + params={chkParams} + onParams={setChkParams} + interval={chkInterval} + onInterval={setChkInterval} + enabled={chkEnabled} + onEnabled={setChkEnabled} + tuneIntervalOn={tuneIntervalOn} + onTuneIntervalOn={setTuneIntervalOn} + tuneInterval={tuneInterval} + onTuneInterval={setTuneInterval} + tuneTimeoutOn={tuneTimeoutOn} + onTuneTimeoutOn={setTuneTimeoutOn} + tuneTimeout={tuneTimeout} + onTuneTimeout={setTuneTimeout} + tuneRetriesOn={tuneRetriesOn} + onTuneRetriesOn={setTuneRetriesOn} + tuneRetries={tuneRetries} + onTuneRetries={setTuneRetries} + tuneEnabled={tuneEnabled} + onTuneEnabled={setTuneEnabled} + /> + + {summary.length > 0 && ( +
    +
    + Буде застосовано до {total} {plural(total, ['хоста', 'хостів', 'хостів'])} +
    +
      + {summary.map((line) => ( +
    • · {line}
    • + ))} +
    +
    + )} +
    + )} + + )} + + {err} + + {!chkResult && !purged && !restored && ( +
    + {blocker && {blocker}} + + +
    + )} +
    +
    + ) +} + +/** + * Точний перелік того, що зачепить дія. + * + * Не «50 хостів», а імена й адреси: кількість підтверджує намір, + * перелік підтверджує вибір, і замінити друге першим не можна. + */ +function TargetList({ targets }: { targets: BulkDeviceTarget[] }) { + if (targets.length === 0) { + return ( +

    + Жоден з обраних хостів не доступний вам на запис — дія нічого не зачепить. +

    + ) + } + return ( +
    +
    + {targets.length} {plural(targets.length, ['хост', 'хости', 'хостів'])} +
    +
      + {targets.map((t) => ( +
    • + {t.name} + + {t.address || '—'} + +
    • + ))} +
    +
    + ) +} + +/** + * Вибір між двома видаленнями — і числа, якими вони різняться. + * + * ЧОМУ ТУТ ВИБІР, А НЕ ОДНА ДІЯ + * + * «Видалити» має означати видалити — це очікування, і воно правильне. + * Але зібраний архів конфігів і історія алертів у частини інсталяцій + * тримаються навмисно: перше — бо ним доводять, як виглядало залізо в + * конкретний день, друге — бо ним розбирають аварії. Одна дія на два + * законні наміри означала б, що комусь із двох система мовчки псує + * роботу. + * + * Тому обидва названі своїми словами, а не «видалити» й «видалити + * назавжди (розширено)». Типово обране повне: воно відповідає слову на + * кнопці, а архівне лишається свідомим вибором. + * + * ЧОМУ ЧИСЛА, А НЕ «ВИ ВПЕВНЕНІ?» + * + * Питання «ви впевнені» перевіряє, чи людина натиснула кнопку, а не чи + * розуміє наслідок. Різниця між двома цими діями вимірюється в + * конкретних величинах — версіях конфігу, рядах метрик, вимірах, + * алертах, — і саме вони мають стояти поруч із вибором. Числа + * рахуються сервером на тому ж кроці, що й перелік хостів + * (BulkDeviceTarget): другий їх лічильник розійшовся б із першим на + * найпершій правці. + */ +function DeleteChoice({ + targets, + value, + onChange, + understood, + onUnderstood, + needsConfirm, + archived, +}: { + targets: BulkDeviceTarget[] + value: DeleteMode + onChange: (m: DeleteMode) => void + understood: boolean + onUnderstood: (v: boolean) => void + needsConfirm: boolean + /** Набір узято з архіву — архівувати його вдруге нема куди. */ + archived?: boolean +}) { + const sum = (f: (t: BulkDeviceTarget) => number) => targets.reduce((a, t) => a + f(t), 0) + const checks = sum((t) => t.checks) + const activeAlerts = sum((t) => t.active_alerts) + const nodes = sum((t) => t.map_nodes) + const configs = sum((t) => t.configs) + const configBytes = sum((t) => t.config_bytes) + const series = sum((t) => t.series) + const samples = sum((t) => t.samples) + const capped = targets.some((t) => t.samples_capped) + const alerts = sum((t) => t.alerts) + const ifaces = sum((t) => t.interfaces) + const policies = targets.filter((t) => t.has_backup_policy).length + const branches = targets.filter((t) => !!t.branch).length + + const num = (n: number) => n.toLocaleString('uk-UA') + + return ( +
    + onChange('purge')} + title="Видалити назавжди" + hint="Хоста й усього, що з ним пов'язане, більше не буде. Повернути неможливо." + > +
      +
    • + · {num(configs)}{' '} + {plural(configs, ['версія', 'версії', 'версій'])} конфігу + {configBytes > 0 && <> ({fmtBytes(configBytes)})} — разом із зашифрованими тілами +
    • +
    • + · {num(series)}{' '} + {plural(series, ['ряд', 'ряди', 'рядів'])} метрик і{' '} + {capped ? `понад ${num(samples)}` : num(samples)}{' '} + {plural(samples, ['вимірювання', 'вимірювання', 'вимірювань'])} +
    • +
    • + · {num(alerts)}{' '} + {plural(alerts, ['алерт', 'алерти', 'алертів'])} разом з історією +
    • +
    • + · {num(checks)}{' '} + {plural(checks, ['перевірка', 'перевірки', 'перевірок'])},{' '} + {num(ifaces)}{' '} + {plural(ifaces, ['порт', 'порти', 'портів'])},{' '} + {num(nodes)}{' '} + {plural(nodes, ['вузол', 'вузли', 'вузлів'])} на мапах,{' '} + {num(policies)}{' '} + {plural(policies, ['розклад', 'розклади', 'розкладів'])} збору +
    • + {branches > 0 && ( +
    • + · {num(branches)}{' '} + {plural(branches, ['гілка', 'гілки', 'гілок'])} у Git-архіві — тут і на дзеркалі +
    • + )} +
    + {branches > 0 && ( + // Про дзеркало кажемо прямо: воно на чужому сервері, і його + // недоступність не скасовує видалення хоста, а лише відкладає + // прибирання гілки. Мовчання тут читалось би як «зникло + // всюди», і виявилась би неправда через тиждень. +

    + Гілки прибираються локально одразу, а на зовнішньому Git — найближчим тактом + дзеркалення. Якщо він зараз недоступний, видалення хоста однаково відбудеться, а + гілка почекає в черзі. +

    + )} +

    + Лишиться запис у журналі аудиту — з іменем і адресою хоста, щоб через рік було видно, + що саме зникло. +

    +
    + + {!archived && ( + onChange('archive')} + title="Прибрати з переліку" + hint="Хост зникає з інтерфейсу, зібране лишається в базі." + > +
      +
    • + · {num(checks)} перевірок лишаться в базі, але + опитування спиниться +
    • +
    • + · {num(activeAlerts)} активних алертів закриються на + найближчому такті правил +
    • +
    • + · {num(policies)} розкладів збору лишаться, збір + спиниться +
    • +
    • + · {num(nodes)} вузлів на мапах ЛИШАТЬСЯ з останнім + відомим станом +
    • +
    • + · {num(configs)} версій конфігу,{' '} + {num(series)} рядів метрик і гілки в Git зберігаються +
    • +
    +

    + Прибрані хости лишаються видимими: перелік хостів → «Архів». Звідти хост повертають у + роботу або видаляють назавжди. +

    +
    + )} + + {archived && ( +

    + {targets.length === 1 ? 'Цей хост уже прибраний' : 'Ці хости вже прибрані'} з переліку, + тож вибору тут немає — лишається тільки видалити назавжди. Щоб натомість повернути + {targets.length === 1 ? ' його ' : ' їх '}у роботу, закрийте це вікно й натисніть + «Повернути». +

    + )} + + {value === 'purge' && needsConfirm && ( + + )} +
    + ) +} + +/** Один із двох варіантів видалення — картка-перемикач. */ +function ModeCard({ + active, + danger, + onClick, + title, + hint, + children, +}: { + active: boolean + danger?: boolean + onClick: () => void + title: string + hint: string + children: React.ReactNode +}) { + const border = active + ? danger + ? 'border-red-700 bg-red-950/30' + : 'border-slate-600 bg-slate-900/60' + : 'border-slate-800 bg-slate-900/20' + return ( +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + }} + className={`cursor-pointer rounded border p-2.5 ${border}`} + > +
    + + + {title} + + {hint} +
    + {/* Наслідки показуються лише для обраного: два переліки чисел + поруч читаються як один довгий, і саме та різниця, заради + якої вони тут, у ньому губиться. */} + {active &&
    {children}
    } +
    + ) +} + +/** + * Що справді зникло. + * + * Наслідок незворотний, і показати його треба один раз — після дії. + * Числа тут не ті самі, що в підтвердженні: між переглядом і + * натисканням могли доїхати нові виміри, а якийсь хост міг і не + * видалитись. + */ +function PurgeOutcome({ result }: { result: PurgeResult }) { + const num = (n: number) => n.toLocaleString('uk-UA') + const done = result.purged ?? [] + const failed = result.failed ?? [] + return ( +
    +
    +

    + Видалено назавжди: {done.length} {plural(done.length, ['хост', 'хости', 'хостів'])}. +

    +
      +
    • + · {num(result.configs)} {plural(result.configs, ['версія', 'версії', 'версій'])} конфігу, + звільнено {fmtBytes(result.freed_bytes)} +
    • +
    • + · {num(result.series)} {plural(result.series, ['ряд', 'ряди', 'рядів'])} метрик,{' '} + {num(result.samples)}{' '} + {plural(result.samples, ['вимірювання', 'вимірювання', 'вимірювань'])} +
    • +
    • + · {num(result.branches)} {plural(result.branches, ['гілка', 'гілки', 'гілок'])}{' '} + поставлено в чергу на видалення в Git +
    • +
    +
    + + {done.length > 0 && ( +
      + {done.map((d) => ( +
    • + {d.name} + + {d.address || '—'} + +
    • + ))} +
    + )} + + {failed.length > 0 && ( + // Видалення йде по одному хосту в своїй транзакції, тож частина + // могла не пройти. Мовчати про це не можна: у переліку хостів + // вони просто лишаться, і причина буде невідома. +
    +

    Не вдалося видалити:

    +
      + {failed.map((f) => ( +
    • + · {f.name} — {f.error} +
    • + ))} +
    +
    + )} +
    + ) +} + +/** + * Що станеться при поверненні — до натискання. + * + * Той самий принцип, що й у вікні видалення: не «ви впевнені», а + * числа. Різниця лише в тому, що тут вони не про втрату, а про те, чи + * справді хост запрацює: повернутий хост, у якого нічого не + * опитується, виглядає як зроблена дія й нею не є. + * + * Чого тут навмисно немає — обіцянки, що хост увімкнеться. Архівування + * запам'ятовує, увімкненим він був чи ні, і повертає рівно те; для + * хостів, прибраних до появи цієї пам'яті, відповіді немає взагалі, і + * вигадувати її не можна. Тому текст говорить про перевірки (їх + * система вимикала сама й сама ж вмикає назад) і чесно попереджає про + * решту. + */ +function RestoreNote({ targets }: { targets: BulkDeviceTarget[] }) { + const checks = targets.reduce((a, t) => a + t.checks, 0) + const nodes = targets.reduce((a, t) => a + t.map_nodes, 0) + return ( +
    +
    +

    Повернути в роботу

    +
      +
    • · хости знову з'являться в переліках, на мапах і в добірках
    • +
    • + · перевірки, вимкнені саме архівуванням, увімкнуться назад (усього в цих хостів{' '} + {checks}) +
    • +
    • · розклад збору конфігів і гілка в Git-архіві продовжаться своєю історією
    • + {nodes > 0 && ( +
    • + · {nodes} вузлів на мапах знову покажуть живий стан +
    • + )} +
    +
    +

    + Хост повертається в тому стані, у якому його прибрали: вимкнений до архівування — + лишиться вимкненим. Перевірки, вимкнені руками ще до видалення, теж лишаться + вимкненими. Стан («на зв'язку», «недоступний») скидається до «невідомо» — доки не + пройде перше опитування, справжнього ми не знаємо. +

    +
    + ) +} + +/** + * Що вийшло з повернення. + * + * Три речі, яких немає більше ніде: чи ввімкнувся хост, скільки + * перевірок ожило й чи довелось зняти прив'язку до машини зонда. + * Остання — єдина зміна, яку повернення робить самé, і промовчати про + * неї не можна: збір конфігів такого сервера просто не почнеться, а + * причина буде невидима. + */ +function RestoreOutcome({ result }: { result: RestoreResult }) { + const done = result.restored ?? [] + const failed = result.failed ?? [] + const offline = done.filter((d) => !d.enabled) + const unbound = done.filter((d) => d.self_agent_dropped) + return ( +
    +
    +

    + Повернуто: {done.length} {plural(done.length, ['хост', 'хости', 'хостів'])}, увімкнено{' '} + {result.checks} {plural(result.checks, ['перевірку', 'перевірки', 'перевірок'])}. +

    +
    + + {done.length > 0 && ( +
      + {done.map((d) => ( +
    • + {d.name} + + {d.enabled ? 'опитування увімкнено' : 'лишився вимкненим'} ·{' '} + {d.checks} {plural(d.checks, ['перевірка', 'перевірки', 'перевірок'])} + +
    • + ))} +
    + )} + + {offline.length > 0 && ( +

    + Лишились вимкненими: {offline.map((d) => d.name).join(', ')}. Опитування таких хостів не + почнеться, доки їх не увімкнути в картці — це або їхній стан до архівування, або хост + прибрали ще до того, як система почала це запам'ятовувати. +

    + )} + + {unbound.length > 0 && ( +

    + Знято прив'язку «хост = машина зонда»: {unbound.map((d) => d.name).join(', ')}. Поки + хост був в архіві, ту саму машину оголосив своєю інший хост, а вона може бути лише в + одного. Якщо потрібен саме цей — прив'яжіть заново в його картці. +

    + )} + + {failed.length > 0 && ( +
    +

    Не вдалося повернути:

    +
      + {failed.map((f) => ( +
    • + · {f.name} — {f.error} +
    • + ))} +
    +
    + )} +
    + ) +} + +/** Рядок форми з перемикачем «не чіпати / задати / очистити». */ +function TriRow({ + label, + hint, + mode, + onMode, + clearLabel, + children, +}: { + label: string + hint?: string + mode: Tri + onMode: (m: Tri) => void + /** Якщо не задано — поле очистити не можна (значення обов'язкове). */ + clearLabel?: string + children: React.ReactNode +}) { + return ( +
    +
    + {label} + +
    + {hint &&

    {hint}

    } + {mode === 'set' &&
    {children}
    } +
    + ) +} + +/** + * Рядок форми для набору зв'язків. + * + * Чотири стани замість трьох — і це не ускладнення, а суть множини. + * «Додати до груп» і «прибрати з груп» — різні дії: хости в наборі + * мають різні поточні групи, і спільного «задати» для них не існує, + * поки людина не скаже, що старе можна викинути. «Замінити» лишено, + * бо без нього неможливо навести лад там, де вже наставлено абияк, — + * але окремим вибором і зі своїм попередженням. + */ +function ListRow({ + label, + hint, + mode, + onMode, + children, +}: { + label: string + hint?: string + mode: ListMode + onMode: (m: ListMode) => void + children: React.ReactNode +}) { + return ( +
    +
    + {label} + +
    + {hint &&

    {hint}

    } + {mode === 'replace' && ( +

    + Замінити — це стерти в цих хостах усе наявне й поставити рівно те, що обрано нижче. + Порожній вибір прибере все. +

    + )} + {mode !== 'keep' &&
    {children}
    } +
    + ) +} + +/** + * Ручні перевірки набору хостів. + * + * Чому це взагалі тут. Ручні перевірки колись лишили поза масовою + * формою з міркуванням, що вони поодинокі — те, чого немає в шаблонах. + * Поодинока насправді причина, а не кількість: «додати http.status усім + * вебсерверам дільниці» і «прибрати icmp.ping із сорока хостів, яким + * його колись наставили руками» — це одне рішення на сорок рядків, + * тобто рівно те, заради чого масова форма існує. + * + * Дії три, і кожна називає ТИП, якого стосується. «Замінити весь набір» + * тут немає навмисно: набори перевірок не видно в переліку хостів, тож + * заміна стерла б те, чого людина не бачила й побачити не могла. + * + * Автокеровані типи не пропонуються — так само, як у картці хоста: + * система завела свій такий чек сама, додати другий неможливо, а + * прибрати безглуздо, бо вона заведе його знову. + */ +function ChecksRow({ + types, + mode, + onMode, + type, + onType, + typeObj, + params, + onParams, + interval, + onInterval, + enabled, + onEnabled, + tuneIntervalOn, + onTuneIntervalOn, + tuneInterval, + onTuneInterval, + tuneTimeoutOn, + onTuneTimeoutOn, + tuneTimeout, + onTuneTimeout, + tuneRetriesOn, + onTuneRetriesOn, + tuneRetries, + onTuneRetries, + tuneEnabled, + onTuneEnabled, +}: { + types: CheckType[] + mode: ChecksMode + onMode: (m: ChecksMode) => void + type: string + onType: (k: string) => void + typeObj: CheckType | null + params: Record + onParams: (v: Record) => void + interval: number + onInterval: (v: number) => void + enabled: boolean + onEnabled: (v: boolean) => void + tuneIntervalOn: boolean + onTuneIntervalOn: (v: boolean) => void + tuneInterval: number + onTuneInterval: (v: number) => void + tuneTimeoutOn: boolean + onTuneTimeoutOn: (v: boolean) => void + tuneTimeout: number + onTuneTimeout: (v: number) => void + tuneRetriesOn: boolean + onTuneRetriesOn: (v: boolean) => void + tuneRetries: number + onTuneRetries: (v: number) => void + tuneEnabled: 'keep' | 'on' | 'off' + onTuneEnabled: (v: 'keep' | 'on' | 'off') => void +}) { + // Для «додати» потрібен увімкнений плагін — інакше зонд не має чим + // виконувати чек. Для «прибрати» й «змінити» — ні: рядки, що + // лишились від вимкненого плагіна, інакше не прибрати ніяк, а вони + // далі стоять у планах і на сторінці «Черги». + const pickable = types.filter((t) => !t.auto_managed && (mode !== 'add' || t.available)) + + return ( +
    +
    + Ручні перевірки + + {mode !== 'keep' && ( + + )} +
    +

    + Тільки перевірки, заведені руками. Ті, що прийшли з шаблону або стоять на інтерфейсі, не + чіпаються: перші міняють у шаблоні, другі веде автовиявлення. +

    + + {mode === 'add' && typeObj && ( +
    +
    + + +
    + + + +

    + У текстових параметрах працює підстановка: {'{{address}}'} — адреса + хоста, {'{{name}}'} — імʼя, {'{{fqdn}}'} — доменне імʼя. + Наприклад http://{'{{address}}'}/health. Це єдине, чим хости в наборі + законно різняться; хост, у якого підставити нічого, потрапить у «не вдалося», а не + отримає зіпсовану перевірку. +

    +

    + Хостам, у яких перевірка цього типу вже є, нічого не станеться — вони підуть у «уже + було». Параметри наявних перевірок масово не міняються: у них різні значення, і + задати всім одне означало б зламати сорок перевірок одним рухом. +

    +
    + )} + + {mode === 'remove' && type && ( +

    + Перевірка зникне разом зі своїм розкладом; зібрані метрики лишаються. Якщо цей тип + заводить сама система (як опитування інтерфейсів), вона заведе його знову на + найближчому такті — прибирати такі руками нема сенсу. +

    + )} + + {mode === 'tune' && type && ( +
    + + + + + onTuneTimeout(Number(e.target.value))} + /> + + + onTuneRetries(Number(e.target.value))} + /> + + + + +

    + Знята галочка означає «не чіпати»: у кожного хоста лишається своє значення. Хости + без такої перевірки просто не потраплять у дію — це не помилка. +

    +
    + )} +
    + ) +} + +/** Поле «змінити наявні»: галочка вмикає саме це поле, а не всю дію. */ +function TuneField({ + label, + on, + onOn, + children, +}: { + label: string + on: boolean + onOn: (v: boolean) => void + children: React.ReactNode +}) { + return ( +
    + + {on && children} +
    + ) +} + +/** + * Наслідок дії над перевірками. + * + * Три списки, а не число. Масове додавання ГАРАНТОВАНО натрапляє на + * хости, у яких така перевірка вже є, — і це не помилка, а звичайний + * стан: половину дільниці колись налаштували руками. Показати + * «додано 12» і промовчати про решту означає збрехати; показати + * помилку — злякати на рівному місці. Тому те саме розрізнення, що й у + * відсіяних: зроблено / не було чого робити / не вдалося. + */ +function ChecksOutcome({ result, typeName }: { result: BulkChecksResult; typeName: string }) { + const verb: Record = { + add: 'Додано', + remove: 'Прибрано', + tune: 'Змінено', + } + const nothing: Record = { + add: 'Уже було', + remove: 'Не було чого прибирати', + tune: 'Немає такої перевірки', + } + + const rows: { title: string; tone: string; names: string[] }[] = [ + { + title: `${verb[result.op] ?? 'Зроблено'}: ${result.done.length}`, + tone: 'border-emerald-900/60 bg-emerald-950/20 text-emerald-100', + names: result.done, + }, + { + title: `${nothing[result.op] ?? 'Пропущено'}: ${result.skipped.length}`, + tone: 'border-slate-800 bg-slate-900/40 text-slate-300', + names: result.skipped.map((o) => o.device), + }, + { + title: `Не вдалося: ${result.failed.length}`, + tone: 'border-red-900/60 bg-red-950/20 text-red-100', + names: result.failed.map((o) => `${o.device} — ${o.reason}`), + }, + ] + + return ( +
    +
    + Перевірка «{typeName}» ({result.check_type}) +
    + {rows.map((r) => + r.names.length === 0 ? null : ( +
    +
    {r.title}
    +
      + {r.names.map((n) => ( +
    • · {n}
    • + ))} +
    +
    + ), + )} +

    + Решта змін у цій формі застосована до всіх хостів набору. +

    +
    + ) +} diff --git a/web/src/components/ChecksEditor.tsx b/web/src/components/ChecksEditor.tsx index 377d3e6..2827433 100644 --- a/web/src/components/ChecksEditor.tsx +++ b/web/src/components/ChecksEditor.tsx @@ -7,12 +7,16 @@ import type { CheckType } from '../types' // прохід по таблиці інтерфейсів коштує десятки OID-ів, і робити його // щоп'ять секунд означає навантажити обладнання заради даних, які // однаково усереднюються. -const DEFAULT_INTERVAL: Record = { +export const DEFAULT_INTERVAL: Record = { 'icmp.ping': 30, 'snmp.get': 60, 'snmp.if': 300, 'snmp.walk': 300, 'topology.discover': 3600, + // Розпізнавання ставить собі інтервал саме: часто, поки хост не + // впізнано, і рідко після. Значення тут — про всяк випадок: цей тип + // позначений auto_managed і в переліку для додавання не з'являється. + 'topology.identify': 21600, 'http.status': 60, 'http.ssl_expiry': 86400, 'ncm.backup': 86400, @@ -39,7 +43,10 @@ export function ChecksEditor({ const [adding, setAdding] = useState('') const used = new Set(checks.map((c) => c.check_type)) - const addable = types.filter((t) => t.available && !used.has(t.key)) + // Автокеровані типи не пропонуємо: систему вже завела свій такий чек + // і сама тримає йому розклад, а ручний дубль упирався б в унікальний + // індекс і повертався незрозумілою помилкою. + const addable = types.filter((t) => t.available && !t.auto_managed && !used.has(t.key)) function add(key: string) { const t = types.find((x) => x.key === key) @@ -142,7 +149,7 @@ export function ChecksEditor({ * типам. Складніші структури (перелік регістрів Modbus) поки редагуються * як текст: показати неповну форму гірше, ніж показати чесний JSON. */ -function ParamFields({ +export function ParamFields({ type, value, onChange, @@ -220,7 +227,7 @@ function ParamFields({ ) } -function defaultsFromSchema(t: CheckType): Record { +export function defaultsFromSchema(t: CheckType): Record { const out: Record = {} for (const [k, spec] of Object.entries(t.params_schema?.properties ?? {})) { if (spec?.default !== undefined) out[k] = spec.default diff --git a/web/src/components/ConfigDiff.tsx b/web/src/components/ConfigDiff.tsx new file mode 100644 index 0000000..8e3ce1d --- /dev/null +++ b/web/src/components/ConfigDiff.tsx @@ -0,0 +1,635 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { CSSProperties, ReactNode, UIEvent } from 'react' +import { Button, plural } from './ui' +import { buildDiff, splitLines, wordDiff } from './linediff' +import type { DiffRow, Part } from './linediff' +import { + CODE_FONT, + ROW_H, + gutterWidth, + maxLen, + rowAt, + useCharWidth, + useVirtualWindow, +} from './ConfigViewer' +import { Minimap, buildShape } from './Minimap' +import type { MinimapMark } from './Minimap' + +/** + * Порівняння двох конфігів у дві колонки. + * + * Зліва стара версія, справа нова, рядок навпроти рядка. Там, де з + * одного боку рядка немає, стоїть заштрихований заповнювач — без нього + * колонки поїхали б після першої ж вставки, і «два вікна» перестали б + * означати «одне й те саме місце конфігу». + * + * Прокрутка вертикально спільна, горизонтально — окрема для кожного + * боку: рядки конфігу різної довжини, і змушувати дивитись на кінець + * короткого рядка тільки тому, що навпроти довгий, немає сенсу. + * + * На вузькому екрані дві колонки перетворюються на нечитабельні дві + * стрічки по двадцять символів, тому там вигляд сам стає одностовпцевим. + */ + +/** Скільки незмінених рядків лишається видимими навколо зміни. */ +const CONTEXT = 6 +/** Менші проміжки не згортаємо: кнопка «розгорнути» замість шести рядків — гірше за самі рядки. */ +const MIN_GAP = 10 +/** Крок часткового розгортання. */ +const STEP = 40 +/** Висота смуги згорнутої ділянки. */ +const GAP_H = 26 + +interface Gap { + from: number + to: number +} + +interface Item { + /** Індекс у rows або -1 для згорнутої ділянки. */ + row: number + gap: Gap | null +} + +const rowTone: Record = { + add: 'bg-emerald-950/60', + del: 'bg-red-950/60', + change: 'bg-amber-950/40', + equal: '', +} + +const gutterTone: Record = { + add: 'bg-emerald-950 text-emerald-500', + del: 'bg-red-950 text-red-500', + change: 'bg-amber-950 text-amber-500', + equal: 'bg-slate-900 text-slate-600', +} + +/** + * Заповнювач замість відсутнього рядка. + * + * Штриховка, а не просто темніший фон: порожній прямокутник читається + * як «тут порожній рядок конфігу», а це зовсім інше твердження. + */ +const FILLER: CSSProperties = { + backgroundImage: + 'repeating-linear-gradient(135deg, rgba(148,163,184,.06) 0 6px, transparent 6px 12px)', +} + +const sign: Record = { add: '+', del: '−', change: '~', equal: ' ' } + +/** Ділянки без змін, які можна сховати. */ +function collapse(rows: DiffRow[]): Gap[] { + const gaps: Gap[] = [] + let i = 0 + while (i < rows.length) { + if (rows[i].kind !== 'equal') { + i++ + continue + } + let j = i + while (j < rows.length && rows[j].kind === 'equal') j++ + // Початок і кінець файлу контексту не потребують — там нема чого + // відділяти від попередньої зміни. + const from = i === 0 ? i : i + CONTEXT + const to = j === rows.length ? j : j - CONTEXT + if (to - from >= MIN_GAP) gaps.push({ from, to }) + i = j + } + return gaps +} + +export function ConfigDiff({ + oldText, + newText, + oldLabel, + newLabel, +}: { + oldText: string + newText: string + oldLabel: string + newLabel: string +}) { + const oldLines = useMemo(() => splitLines(oldText), [oldText]) + const newLines = useMemo(() => splitLines(newText), [newText]) + const model = useMemo(() => buildDiff(oldLines, newLines), [oldLines, newLines]) + const rows = model.rows + + const [mode, setMode] = useState<'split' | 'unified'>('split') + const [narrow, setNarrow] = useState(false) + const rootRef = useRef(null) + + // Поріг живий, а не медіа-запит: панель ділить ширину з переліком + // версій і бічним меню, тож «широкий екран» ще не означає «широка + // панель». + useEffect(() => { + const el = rootRef.current + if (!el) return + const ro = new ResizeObserver(() => setNarrow(el.clientWidth < 860)) + ro.observe(el) + setNarrow(el.clientWidth < 860) + return () => ro.disconnect() + }, []) + + const split = mode === 'split' && !narrow + + const initial = useMemo(() => collapse(rows), [rows]) + const [gaps, setGaps] = useState(initial) + useEffect(() => setGaps(initial), [initial]) + + const { items, rowItem } = useMemo(() => { + const list: Item[] = [] + const map = new Int32Array(rows.length).fill(-1) + let i = 0 + let g = 0 + while (i < rows.length) { + if (g < gaps.length && gaps[g].from === i) { + list.push({ row: -1, gap: gaps[g] }) + i = gaps[g].to + g++ + continue + } + map[i] = list.length + list.push({ row: i, gap: null }) + i++ + } + return { items: list, rowItem: map } + }, [rows, gaps]) + + const offsets = useMemo(() => { + const o = new Int32Array(items.length + 1) + for (let i = 0; i < items.length; i++) { + const it = items[i] + const h = it.gap + ? GAP_H + : !split && rows[it.row].kind === 'change' + ? ROW_H * 2 + : ROW_H + o[i + 1] = o[i] + h + } + return o + }, [items, rows, split]) + + const vw = useVirtualWindow(offsets) + const { charW, probe } = useCharWidth() + const rightRef = useRef(null) + + // Верхній видимий рядок — щоб перемикання вигляду не викидало на + // початок файлу: у конфізі на 30 тисяч рядків повертатись до потрібного + // місця довелося б руками. + const offsetsRef = useRef(offsets) + offsetsRef.current = offsets + const topItem = useRef(0) + + // Синхронна вертикаль без прапорців-замків: колонка, якій уже + // виставили потрібне значення, просто нічого не робить у відповідь на + // власну подію прокрутки, і луна затухає на першому кроці. + const onMainScroll = useCallback( + (e: UIEvent) => { + vw.onScroll(e) + const t = e.currentTarget.scrollTop + topItem.current = rowAt(offsetsRef.current, t) + const r = rightRef.current + if (r && r.scrollTop !== t) r.scrollTop = t + }, + [vw], + ) + const onRightScroll = useCallback((e: UIEvent) => { + const t = e.currentTarget.scrollTop + const l = vw.ref.current + if (l && l.scrollTop !== t) l.scrollTop = t + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const firstRender = useRef(true) + useLayoutEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + const el = vw.ref.current + if (el) el.scrollTop = offsetsRef.current[topItem.current] + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [split]) + + // Слова всередині змінених рядків рахуються ліниво, лише для видимих + // рядків, і запам'ятовуються: на прокрутці той самий рядок інакше + // перераховувався б щокадру. + const wcache = useRef(new Map()) + useEffect(() => { + wcache.current = new Map() + }, [rows]) + const parts = (idx: number, r: DiffRow) => { + let p = wcache.current.get(idx) + if (p === undefined) { + p = wordDiff(r.oldText, r.newText) + wcache.current.set(idx, p) + } + return p + } + + const blockItems = useMemo( + () => model.blocks.map((r) => rowItem[r]).filter((i) => i >= 0), + [model.blocks, rowItem], + ) + + /** + * Форма файлу для мінікарти. + * + * Береться нова версія, а зі старої — лише те, чого в новій немає + * (видалені рядки). Інакше мініатюра показувала б порожні смуги там, + * де щось прибрали, і місце видалення виглядало б як кінець файлу. + * Згорнута ділянка справді лишається порожньою: там і показувати + * нічого — рядки сховані. + */ + const shape = useMemo( + () => + buildShape( + items.map((it) => { + if (it.gap) return '' + const r = rows[it.row] + return r.newText || r.oldText + }), + ), + [items, rows], + ) + + /** + * Місця змін на мінікарті. + * + * Суцільними відрізками, а не рядок за рядком: блок на двісті + * видалених рядків — це одна позначка зі span, а не двісті. На файлі, + * що розійшовся цілком, різниця між цими двома підходами — тисячі + * записів у масиві проти десятків. + */ + const changeMarks = useMemo(() => { + const out: MinimapMark[] = [] + let i = 0 + while (i < items.length) { + const it = items[i] + if (it.gap || rows[it.row].kind === 'equal') { + i++ + continue + } + const kind = rows[it.row].kind + let j = i + 1 + while (j < items.length && !items[j].gap && rows[items[j].row].kind === kind) j++ + out.push({ + row: i, + span: j - i, + color: + kind === 'add' + ? 'rgba(52,211,153,0.9)' + : kind === 'del' + ? 'rgba(248,113,113,0.9)' + : 'rgba(251,191,36,0.9)', + }) + i = j + } + return out + }, [items, rows]) + + // Якір — те місце, куди «ставиться» зміна після переходу; від нього ж + // рахується, на якій зміні ми зараз. Інакше «наступна» одразу після + // переходу знаходила б ту саму. + const anchor = vw.scrollTop + ROW_H * 3 + 4 + let curBlock = -1 + for (let i = 0; i < blockItems.length; i++) { + if (offsets[blockItems[i]] <= anchor) curBlock = i + else break + } + + const goBlock = (delta: number) => { + if (blockItems.length === 0) return + let target: number + if (delta > 0) { + target = Math.min(curBlock + 1, blockItems.length - 1) + } else if (curBlock < 0) { + target = 0 + } else { + // Якщо поточна зміна вже втекла вгору за межі екрана, «назад» + // означає повернутись до неї, а не перестрибнути через голову. + const above = offsets[blockItems[curBlock]] < anchor - ROW_H + target = above ? curBlock : Math.max(0, curBlock - 1) + } + vw.scrollToRow(blockItems[target], 'top') + } + + const expand = (gap: Gap, how: 'up' | 'down' | 'all') => { + setGaps((cur) => + cur.flatMap((g) => { + if (g.from !== gap.from || g.to !== gap.to) return [g] + if (how === 'all') return [] + const next = how === 'up' ? { from: g.from + STEP, to: g.to } : { from: g.from, to: g.to - STEP } + return next.to - next.from >= MIN_GAP ? [next] : [] + }), + ) + } + + const gwOld = gutterWidth(oldLines.length, charW) + Math.ceil(charW) + 6 + const gwNew = gutterWidth(newLines.length, charW) + Math.ceil(charW) + 6 + const wOld = gwOld + Math.ceil(maxLen(oldLines) * charW) + 32 + const wNew = gwNew + Math.ceil(maxLen(newLines) * charW) + 32 + const gwUni = gutterWidth(oldLines.length, charW) + gutterWidth(newLines.length, charW) + Math.ceil(charW) + 8 + const wUni = gwUni + Math.ceil(Math.max(maxLen(oldLines), maxLen(newLines)) * charW) + 32 + + function painted(ps: Part[] | undefined, text: string, cls: string): ReactNode { + if (!ps) return text + return ps.map((p, i) => + p.changed ? ( + + {p.text} + + ) : ( + {p.text} + ), + ) + } + + function gapRow(it: Item, top: number, key: string) { + const g = it.gap as Gap + return ( +
    + + + + + +
    + ) + } + + /** Один бік двоколонкового вигляду. */ + function side(which: 'old' | 'new'): ReactNode[] { + const out: ReactNode[] = [] + const gw = which === 'old' ? gwOld : gwNew + const hl = which === 'old' ? 'rounded-sm bg-red-500/30' : 'rounded-sm bg-emerald-500/30' + for (let i = vw.first; i < vw.last; i++) { + const it = items[i] + const top = offsets[i] + if (it.gap) { + out.push(gapRow(it, top, `${which}-${i}`)) + continue + } + const r = rows[it.row] + const num = which === 'old' ? r.oldNum : r.newNum + const text = which === 'old' ? r.oldText : r.newText + if (num === 0) { + out.push( +
    , + ) + continue + } + const p = r.kind === 'change' ? parts(it.row, r) : null + out.push( +
    + + {num} + {sign[r.kind]} + + + {painted(which === 'old' ? p?.a : p?.b, text, hl)} + +
    , + ) + } + return out + } + + /** Один стовпець: класичний unified, змінений рядок займає два рядки. */ + function unified(): ReactNode[] { + const out: ReactNode[] = [] + const gwA = gutterWidth(oldLines.length, charW) + const gwB = gutterWidth(newLines.length, charW) + const line = ( + key: string, + top: number, + kind: string, + a: number, + b: number, + text: ReactNode, + ) => ( +
    + + + {a || ''} + + + {b || ''} + + {sign[kind]} + + {text} +
    + ) + + for (let i = vw.first; i < vw.last; i++) { + const it = items[i] + const top = offsets[i] + if (it.gap) { + out.push(gapRow(it, top, `u-${i}`)) + continue + } + const r = rows[it.row] + if (r.kind === 'change') { + const p = parts(it.row, r) + out.push( + line(`u-${i}-a`, top, 'del', r.oldNum, 0, painted(p?.a, r.oldText, 'rounded-sm bg-red-500/30')), + ) + out.push( + line( + `u-${i}-b`, + top + ROW_H, + 'add', + 0, + r.newNum, + painted(p?.b, r.newText, 'rounded-sm bg-emerald-500/30'), + ), + ) + continue + } + const text = r.kind === 'add' ? r.newText : r.oldText + out.push(line(`u-${i}`, top, r.kind, r.oldNum, r.newNum, text)) + } + return out + } + + const changes = blockItems.length + + // Аргументи мінікарти однакові для обох виглядів — вертикаль у них + // одна й та сама. Тримаємо одним об'єктом, щоб два місця не почали + // розходитись правками. + const mapProps = { + count: items.length, + offsets, + total: vw.total, + viewTop: vw.scrollTop, + viewH: vw.viewH, + shape, + marks: changeMarks, + // Права колонка — те, що є зараз; там, де рядка немає, беремо + // ліву. Мінікарта одна на обидві, і показувати вона має той бік, + // який людина читає як «поточний стан». + lineAt: (i: number) => { + const it = items[i] + if (!it || it.gap) return '' + const r = rows[it.row] + return r.newText || r.oldText || '' + }, + onSeek: vw.scrollToY, + } + + return ( +
    + {probe} + +
    + + +{model.added}{' '} + −{model.removed} + + + додано + видалено + змінено + + + {changes > 0 && ( + + + + {curBlock >= 0 ? curBlock + 1 : '–'} / {changes} + + + + )} + + + {narrow ? ( + вузько для двох колонок + ) : ( + <> + + + + )} + +
    + + {model.truncated && ( +

    + Версії розійшлися надто сильно — показано грубу заміну блоку, а не порядкові зміни +

    + )} + + {changes === 0 ? ( +

    + Ці дві версії однакові +

    + ) : split ? ( + <> +
    +
    + було · {oldLabel} +
    +
    + стало · {newLabel} +
    +
    +
    +
    +
    + {side('old')} +
    +
    +
    +
    + {side('new')} +
    +
    + {/* Одна мінікарта на обидві колонки: вертикаль у них + спільна, і друга смужка показувала б те саме вдруге. */} + +
    + + ) : ( +
    +
    +
    + {unified()} +
    +
    + +
    + )} +
    + ) +} + +function Chip({ cls, children }: { cls: string; children: ReactNode }) { + return ( + + + {children} + + ) +} diff --git a/web/src/components/ConfigViewer.tsx b/web/src/components/ConfigViewer.tsx new file mode 100644 index 0000000..31df0fe --- /dev/null +++ b/web/src/components/ConfigViewer.tsx @@ -0,0 +1,488 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode, UIEvent } from 'react' +import { api } from '../api/client' +import { Button, plural } from './ui' +import { splitLines } from './linediff' +import { Minimap, buildShape } from './Minimap' + +/** + * Перегляд тіла конфігу. + * + * Розрахунок на реальний масштаб, а не на демонстраційний: ZTE C320 + * віддає 32 854 рядки, і кожне рішення тут — наслідок саме цієї цифри. + * Рядки малюються не всі, а лише видимі; ширина блоку рахується з + * найдовшого рядка, щоб горизонтальна прокрутка не сіпалася під час + * вертикальної; пошук іде по заздалегідь опущеному регістру, бо + * приводити 1.5 МБ тексту на кожне натискання клавіші — це помітна оку + * затримка. + * + * Прокручується блок коду, а не сторінка: конфіг завжди ширший за + * екран, і горизонтальний скрол усієї сторінки забирав би з очей і + * панель версій, і шапку. + */ + +/** Висота рядка коду. Мусить збігатися з leading-5 у розмітці. */ +export const ROW_H = 20 + +/** Шрифт коду. Один рядок на всі три місця, де він потрібен, — щоб + * вимірювання ширини символа не розійшлося з тим, що намальовано. */ +export const CODE_FONT = 'font-mono text-[12px] leading-5' + +/** Останній рядок, що починається не нижче за y. */ +export function rowAt(offsets: Int32Array, y: number): number { + let lo = 0 + let hi = offsets.length - 2 + if (hi < 0) return 0 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (offsets[mid] <= y) lo = mid + else hi = mid - 1 + } + return lo +} + +/** Накопичені координати верху рядків однакової висоти. */ +export function uniformOffsets(count: number, h: number): Int32Array { + const o = new Int32Array(count + 1) + for (let i = 0; i < count; i++) o[i + 1] = o[i] + h + return o +} + +/** + * Вікно віртуального списку. + * + * Тридцять тисяч рядків у DOM — це секунди на першу відмальовку й + * підвисання на кожній прокрутці. Тримаємо лише видиме плюс запас + * зверху й знизу, щоб швидкий скрол не показував порожнечу. + * + * offsets мають довжину count+1: останній елемент — повна висота. + */ +export function useVirtualWindow(offsets: Int32Array, overscan = 16) { + const ref = useRef(null) + const [node, setNode] = useState(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewH, setViewH] = useState(480) + + // Прокручуваний елемент підставляється через callback-ref, а не через + // сам ref: перемикання «дві колонки / один стовпець» замінює вузол, і + // спостерігач, повішений один раз на монтуванні, стежив би за + // елементом, якого вже немає. + const attach = useCallback((n: HTMLDivElement | null) => { + ref.current = n + setNode(n) + }, []) + + useEffect(() => { + if (!node) return + setViewH(node.clientHeight) + // Новий вузол прокручений на початок, а стан пам'ятає позицію + // попереднього — без цього рядки малювалися б там, куди вже ніхто + // не дивиться, і людина бачила б порожнечу. + setScrollTop(node.scrollTop) + const ro = new ResizeObserver(() => setViewH(node.clientHeight)) + ro.observe(node) + return () => ro.disconnect() + }, [node]) + + const count = Math.max(0, offsets.length - 1) + const total = count > 0 ? offsets[count] : 0 + const first = Math.max(0, rowAt(offsets, scrollTop) - overscan) + const last = Math.min(count, rowAt(offsets, scrollTop + viewH) + 1 + overscan) + + const onScroll = useCallback((e: UIEvent) => { + setScrollTop(e.currentTarget.scrollTop) + }, []) + + /** Показати рядок: зверху з невеликим відступом або посередині. */ + const scrollToRow = useCallback( + (i: number, place: 'top' | 'center' = 'top') => { + const el = ref.current + if (!el || i < 0 || i >= offsets.length - 1) return + const y = + place === 'center' + ? offsets[i] - el.clientHeight / 2 + ROW_H + : offsets[i] - ROW_H * 3 + el.scrollTop = Math.max(0, y) + }, + [offsets], + ) + + /** Показати з початку — при заміні вмісту стара позиція безглузда. */ + const resetScroll = useCallback(() => { + if (ref.current) ref.current.scrollTop = 0 + setScrollTop(0) + }, []) + + /** + * Перейти на задану координату документа. + * + * Потрібно мінікарті: вона мислить пікселями файлу, а не номерами + * рядків, — саме тому, що один її піксель накриває півсотні рядків, і + * округлення до рядка зробило б тягу смикливою. + */ + const scrollToY = useCallback((y: number) => { + const el = ref.current + if (!el) return + el.scrollTop = Math.max(0, Math.min(el.scrollHeight - el.clientHeight, y)) + }, []) + + return { + ref, + attach, + scrollTop, + viewH, + first, + last, + total, + count, + onScroll, + scrollToRow, + scrollToY, + resetScroll, + } +} + +/** + * Ширина символа моноширинного шрифту. + * + * Потрібна, щоб порахувати ширину блоку коду наперед. Без цього ширина + * визначалася б найдовшим із НАМАЛЬОВАНИХ рядків, тобто змінювалася б + * під час прокрутки — і горизонтальний повзунок стрибав би сам собою. + * Вимірюємо живим елементом, а не константою: шрифт залежить від + * системи. + */ +export function useCharWidth(): { charW: number; probe: ReactNode } { + const ref = useRef(null) + const [charW, setCharW] = useState(7.2) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const w = el.getBoundingClientRect().width / 20 + if (w > 0) setCharW(w) + }, []) + + const probe = ( + + 00000000000000000000 + + ) + return { charW, probe } +} + +/** Найдовший рядок — від нього рахується ширина блоку коду. */ +export function maxLen(lines: string[]): number { + let m = 0 + for (let i = 0; i < lines.length; i++) if (lines[i].length > m) m = lines[i].length + return m +} + +/** Ширина колонки з номерами під найбільший номер. */ +export function gutterWidth(count: number, charW: number): number { + return Math.ceil(String(Math.max(1, count)).length * charW) + 16 +} + +const bodyCache = new Map() + +/** + * Тіло конфігу з невеликим кешем. + * + * Перемикання «повний текст / порівняння» і вибір бази для порівняння + * повертають до тих самих версій по колу, а кожна з них — мегабайт + * тексту через мережу. Тримаємо кілька останніх; тримати всі означало б + * з'їсти пам'ять вкладки за десяток кліків. + */ +export async function fetchConfigBody(id: string): Promise { + const hit = bodyCache.get(id) + if (hit !== undefined) { + bodyCache.delete(id) + bodyCache.set(id, hit) + return hit + } + const r = await api.getConfig(id) + bodyCache.set(id, r.body) + while (bodyCache.size > 4) { + const oldest = bodyCache.keys().next() + if (oldest.done) break + bodyCache.delete(oldest.value) + } + return r.body +} + +interface Hits { + list: { line: number; at: number }[] + byLine: Map + len: number + capped: boolean +} + +const NO_HITS: Hits = { list: [], byLine: new Map(), len: 0, capped: false } + +function findHits(lower: string[], query: string): Hits { + const needle = query.toLowerCase() + if (needle === '') return NO_HITS + const list: { line: number; at: number }[] = [] + const byLine = new Map() + let capped = false + for (let i = 0; i < lower.length && !capped; i++) { + let at = lower[i].indexOf(needle) + if (at < 0) continue + const starts: number[] = [] + while (at >= 0) { + starts.push(at) + list.push({ line: i, at }) + // Двадцять тисяч збігів — це вже не пошук, а підсвічений файл; + // рахувати далі означає лише витрачати час на непотрібне. + if (list.length >= 20000) { + capped = true + break + } + at = lower[i].indexOf(needle, at + needle.length) + } + byLine.set(i, starts) + } + return { list, byLine, len: needle.length, capped } +} + +/** Рядок із підсвіченими збігами пошуку. */ +function marked(text: string, starts: number[] | undefined, len: number, cur: number): ReactNode { + if (!starts || starts.length === 0 || len === 0) return text + const out: ReactNode[] = [] + let pos = 0 + for (const s of starts) { + if (s > pos) out.push(text.slice(pos, s)) + out.push( + + {text.slice(s, s + len)} + , + ) + pos = s + len + } + if (pos < text.length) out.push(text.slice(pos)) + return out +} + +const searchInput = + 'w-40 rounded border border-slate-700 bg-slate-800 py-1 pl-2 pr-14 text-xs text-slate-100 ' + + 'outline-none placeholder:text-slate-600 focus:border-sky-600 md:w-56' + +export function ConfigViewer({ + text, + filename, + actions, +}: { + text: string + filename: string + /** Кнопки сторінки, які логічно стоять у цій же смузі. */ + actions?: ReactNode +}) { + const lines = useMemo(() => splitLines(text), [text]) + const lower = useMemo(() => lines.map((l) => l.toLowerCase()), [lines]) + const offsets = useMemo(() => uniformOffsets(lines.length, ROW_H), [lines.length]) + const vw = useVirtualWindow(offsets) + const { charW, probe } = useCharWidth() + + const [query, setQuery] = useState('') + const hits = useMemo(() => findHits(lower, query), [lower, query]) + const [cur, setCur] = useState(0) + const [copied, setCopied] = useState(false) + + // Новий запит — знову з першого збігу; інакше «3 з 5» лишалося б від + // попереднього слова й показувало не те, що підсвічено. + useEffect(() => setCur(0), [hits]) + + // Інша версія — інший конфіг: лишатись на тому самому номері рядка + // означало б показати випадкове місце чужого файлу. + const reset = vw.resetScroll + useLayoutEffect(() => reset(), [text, reset]) + + const go = useCallback( + (delta: number) => { + const n = hits.list.length + if (n === 0) return + const next = (cur + delta + n) % n + setCur(next) + vw.scrollToRow(hits.list[next].line, 'center') + }, + [cur, hits, vw], + ) + + // Перший збіг має бути видно одразу, без зайвого натискання «далі». + useEffect(() => { + if (hits.list.length > 0) vw.scrollToRow(hits.list[0].line, 'center') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hits]) + + const width = gutterWidth(lines.length, charW) + Math.ceil(maxLen(lines) * charW) + 32 + const gw = gutterWidth(lines.length, charW) + const active = hits.list[cur] + + // Форма файлу для мінікарти. Рахується один раз на текст: перебір + // 33 000 рядків дешевий, але не на кожну прокрутку. + const shape = useMemo(() => buildShape(lines), [lines]) + + /** + * Збіги пошуку на мінікарті. + * + * По рядках, а не по кожному входженню: рядок із двадцятьма + * входженнями дає на смужці рівно ту саму одну лінію, і платити за + * нього двадцять разів немає за що. Стеля в тисячу — не про + * малювання (мінікарта однаково згортає до пікселя), а про розмір + * самого масиву: список збігів доходить до 20 000, і будувати з + * нього такий самий масив об'єктів щоразу, коли міняється запит, — + * єдина тут по-справжньому помітна витрата. + */ + /** + * Рядки зі збігами — для мінікарти. + * + * Без стелі. Попередній варіант обрізав перелік на тисячі позначок і + * тим брехав: у файлі з п'ятьма тисячами збігів мінікарта показувала + * лише перші дві тисячі, тобто «збіги є на початку, далі чисто». + * Людина робила з цього хибний висновок — а це рівно те, для чого + * вона на мінікарту й дивиться. + * + * Ключі Map і так є, множина з них — один прохід; малює мінікарта + * однаково не більше, ніж має пікселів. + */ + const hitRows = useMemo(() => new Set(hits.byLine.keys()), [hits]) + + const rows: ReactNode[] = [] + for (let i = vw.first; i < vw.last; i++) { + const starts = hits.byLine.get(i) + rows.push( +
    + + {i + 1} + + + {marked(lines[i], starts, hits.len, active && active.line === i ? active.at : -1)} + +
    , + ) + } + + return ( +
    + {probe} +
    +
    + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + go(e.shiftKey ? -1 : 1) + } + if (e.key === 'Escape') setQuery('') + }} + /> + {query !== '' && ( + + {hits.list.length === 0 ? 'немає' : `${cur + 1}/${hits.list.length}${hits.capped ? '+' : ''}`} + + )} +
    + + + + {lines.length} {plural(lines.length, ['рядок', 'рядки', 'рядків'])} + +
    + {actions} + + +
    +
    + + {lines.length === 0 ? ( +

    Конфіг порожній

    + ) : ( + // Мінікарта збоку від тексту, у спільній рядковій смузі: вона + // має бути заввишки з саме прокручуваний блок, а не зі всю + // панель, інакше рамка вікна показувала б не те місце. +
    +
    +
    + {rows} +
    +
    + lines[i] ?? ''} + onSeek={vw.scrollToY} + /> +
    + )} +
    + ) +} + +/** Зберегти текст файлом. */ +export function download(text: string, filename: string) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +/** + * Ім'я файла для збереження: без пробілів і всього, що ламає шляхи. + * + * Літери БУДЬ-ЯКОЇ абетки, а не лише латиниця. `\w` у JavaScript — це + * рівно ASCII, тому «Київ-ядро» і «Львів-ядро» давали однакове `_-_`: + * людина вивантажувала два конфіги й отримувала один файл — другий тихо + * затирав перший. На сервері цією ж хворобою хворів sanitizePath і + * вилікуваний тим самим: unicode.IsLetter замість латиниці, `\p{L}` із + * прапорцем `u` — його прямий відповідник. + * + * «..» згортається до однієї крапки з тієї ж причини, що й там: ім'я + * потрапляє в атрибут download як є, і ланцюжок крапок у ньому — це + * спроба вийти за теку завантажень. Крапки всередині імені законні: + * «10.0.0.1» має лишитись собою. + */ +export function safeName(parts: string[]): string { + let out = parts.join('-').replace(/[^\p{L}\p{N}._-]+/gu, '_') + while (out.includes('..')) out = out.replace(/\.\.+/g, '.') + return out +} diff --git a/web/src/components/DeviceFilterPanel.tsx b/web/src/components/DeviceFilterPanel.tsx new file mode 100644 index 0000000..ff3a472 --- /dev/null +++ b/web/src/components/DeviceFilterPanel.tsx @@ -0,0 +1,257 @@ +import { useMemo } from 'react' +import type { ReactNode } from 'react' +import { inputClass } from './ui' +import { kindLabel } from './DeviceForm' +import type { DeviceFilter, DeviceGroup, DeviceSummary, VersionOp } from '../types' + +/** + * Спільний відбір хостів. + * + * Три сторінки ставлять до інвентарю одне й те саме питання — «які саме + * хости» — і донедавна кожна питала його своїм набором елементів. Поки + * сторінок було дві, розбіжність коштувала одного зайвого перечитування + * коду; на третій вона почала коштувати оператору: людина, яка навчилась + * відбирати хости в «Командах», не має вчити другий набір понять у + * «Конфігах». + * + * Тому поля живуть тут, а сторінка додає до них лише те, що має сенс + * тільки на ній (див. `children`). Правило поділу просте: якщо питання + * ставлять до ХОСТА — воно тут; якщо до того, що з ним робить конкретна + * сторінка, — воно на сторінці. + * + * На сервері цим полям відповідає одна структура (store.DeviceFilter) і + * одна SQL-умова, тож нове поле досить додати в трьох місцях — тип, ця + * панель, умова, — і воно з'явиться скрізь одразу. + */ +export function DeviceFilterPanel({ + filter, + onChange, + devices, + groups, + children, +}: { + filter: DeviceFilter + onChange: (f: DeviceFilter) => void + /** Інвентар — джерело значень для перемикачів, а не перелік для показу. */ + devices: DeviceSummary[] + groups: DeviceGroup[] + /** Поля, що мають сенс лише на цій сторінці. Стають після спільних. */ + children?: ReactNode +}) { + // Довідники беремо з уже завантаженого інвентарю, а не окремими + // запитами: перелік хостів у пам'яті вже є, а виробники й майданчики — + // це буквально його стовпці. Так у фільтрі не з'явиться значення, + // якого в жодного хоста немає. + const vendors = useMemo(() => uniq(devices.map((d) => d.vendor ?? '')), [devices]) + const kinds = useMemo(() => uniq(devices.map((d) => d.kind)), [devices]) + const sites = useMemo(() => uniq(devices.map((d) => d.site_name ?? '')), [devices]) + const models = useMemo(() => uniq(devices.map((d) => d.model ?? '')), [devices]) + // Версії — не перемикачами, а підказкою до поля. + // + // Їх на дільниці бувають десятки, і ряд із десятків кнопок читається + // гірше за поле, у яке видно, що набирати. Крім того, питання до + // версії ставлять умовою («усе, крім…»), а не вибором зі списку. + const versions = useMemo(() => uniq(devices.map((d) => d.os_version ?? '')), [devices]) + + return ( +
    + onChange({ ...filter, query: e.target.value })} + /> + + ({ value: g.id, label: g.name }))} + selected={filter.group_ids ?? []} + onChange={(v) => onChange({ ...filter, group_ids: v })} + /> + ({ value: v, label: v }))} + selected={filter.vendors ?? []} + onChange={(v) => onChange({ ...filter, vendors: v })} + /> + ({ value: k, label: kindLabel[k] ?? k }))} + selected={filter.kinds ?? []} + onChange={(v) => onChange({ ...filter, kinds: v })} + /> + ({ value: s, label: s }))} + selected={filter.sites ?? []} + onChange={(v) => onChange({ ...filter, sites: v })} + /> + ({ value: m, label: m }))} + selected={filter.models ?? []} + onChange={(v) => onChange({ ...filter, models: v })} + /> + onChange({ ...filter, version_op: op, version_value: value })} + /> + onChange({ ...filter, statuses: v })} + /> + + {children} + + +
    + ) +} + +/** + * Ряд перемикачів-значень для одного поля фільтра. + * + * Усередині ряду значення складаються через АБО, між рядами — через І. + * Експортується разом із панеллю навмисно: поле, специфічне для однієї + * сторінки, має виглядати й поводитись так само, як спільні, інакше + * людина шукатиме в ньому іншу логіку. + */ +export function ChipRow({ + title, + options, + selected, + onChange, +}: { + title: string + options: { value: string; label: string }[] + selected: string[] + onChange: (v: string[]) => void +}) { + if (options.length === 0) return null + return ( +
    + {title} + {options.map((o) => { + const on = selected.includes(o.value) + return ( + + ) + })} +
    + ) +} + +/** + * Умова на версію ПЗ. + * + * Не ряд перемикачів, як у решти полів, і це не примха. Версій на + * дільниці бувають десятки, а головне питання до них звучить інакше: + * не «покажи ці», а «покажи все, що НЕ ця» — саме з цього починається + * планування оновлення прошивки. Переліком значень воно не виражається + * взагалі, тому тут операція плюс значення. + * + * П'ять операцій, і кожна відповідає на своє питання: + * «точно» — перевірка після оновлення; «усе, крім» — що лишилось + * оновити; «містить»/«не містить» — те саме на рівні сімейства релізу + * (у Juniper 14.1X53-D27.3 і 14.1X53-D46 — одне сімейство, і точний + * збіг тут не працює); «не визначена» — D-Link версії в sysDescr не + * повідомляє, і таких хостів може виявитись пів дільниці. + * + * «Усе, крім» навмисно захоплює й хости з невідомою версією: вони + * справді не на цільовій прошивці. Саме тому потрібна окрема операція, + * щоб подивитись на них. + */ +export function VersionRow({ + op, + value, + known, + onChange, +}: { + op?: VersionOp + value: string + known: string[] + onChange: (op: VersionOp | undefined, value: string) => void +}) { + const opts: { value: VersionOp | ''; label: string }[] = [ + { value: '', label: 'не важливо' }, + { value: 'eq', label: 'точно' }, + { value: 'ne', label: 'усе, крім' }, + { value: 'contains', label: 'містить' }, + { value: 'ncontains', label: 'не містить' }, + { value: 'empty', label: 'не визначена' }, + ] + + return ( +
    + Версія ПЗ + + + {op && op !== 'empty' && ( + <> + onChange(op, e.target.value)} + /> + {/* Підказка з тих версій, що справді є в інвентарі: набирати + «V100R006C01SPC100» з пам'яті ніхто не буде, а помилка в + одному символі дала б порожній перелік без пояснення. */} + + {known.map((v) => ( + + + )} +
    + ) +} + +export function uniq(values: string[]): string[] { + return [...new Set(values.filter(Boolean))].sort() +} diff --git a/web/src/components/DeviceForm.tsx b/web/src/components/DeviceForm.tsx index 4c9604e..fb38c9f 100644 --- a/web/src/components/DeviceForm.tsx +++ b/web/src/components/DeviceForm.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { api } from '../api/client' -import { Button, Confirm, ErrorNote, Field, Modal, Tabs, Toggle, inputClass } from './ui' -import type { ConfirmRequest } from './ui' +import { Button, ErrorNote, Field, Modal, Tabs, Toggle, inputClass } from './ui' import { Picker } from './Picker' import { TemplatePicker } from './TemplatePicker' import { ChecksEditor } from './ChecksEditor' @@ -89,10 +88,17 @@ export function DeviceForm({ const [checks, setChecks] = useState([]) const [checksLoaded, setChecksLoaded] = useState(!device) - const [confirm, setConfirm] = useState(null) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) + // Збереження вкладки «Збір конфігів». Розклад лежить в іншій таблиці + // й іншій ручці API, але для людини це одне вікно — тож і кнопка + // збереження тут одна, а вкладка лише віддає їй свою частину роботи. + const ncmSave = useRef<(() => Promise) | null>(null) + // Чи заходили на вкладку збору конфігів. Решта вкладок вільно + // розмонтовується, а ця — ні: див. коментар біля неї. + const [ncmSeen, setNcmSeen] = useState(false) + useEffect(() => { if (!device) return let alive = true @@ -166,7 +172,14 @@ export function DeviceForm({ return (
    - + { + if (t === 'ncm') setNcmSeen(true) + setTab(t) + }} + /> {tab === 'main' && ( <> @@ -203,8 +216,17 @@ export function DeviceForm({
    + {device?.sys_object_id && } +
    - + )} - {tab === 'ncm' && - (device ? ( - - ) : ( -
    -

    - Розклад збору конфігів кріпиться до хоста, тож налаштовується після його - створення. -

    -

    - Натисніть «Зберегти» — картка відкриється знову, і ця вкладка запрацює. Щоб профіль - підібрався сам, заповніть «Виробник» на вкладці «Хост». -

    -
    - ))} + {/* Відвідану вкладку збору конфігів ховаємо, а не розмонтовуємо. + Кнопка збереження в картці одна й лежить у футері — форма, + яку зняли при перемиканні вкладки, забрала б із собою і + незбережений розклад, і саме посилання на збереження, тож + зміни зникали б мовчки. */} + {device && ncmSeen && ( +
    + +
    + )} + + {tab === 'ncm' && !device && ( +
    +

    + Розклад збору конфігів кріпиться до хоста, тож налаштовується після його створення. +

    +

    + Натисніть «Зберегти» — далі можна буде одразу відкрити картку хоста, і ця вкладка + запрацює. Щоб профіль підібрався сам, заповніть «Виробник» на вкладці «Хост». +

    +
    + )} {tab === 'checks' && ( <> @@ -311,28 +340,21 @@ export function DeviceForm({ )} {err} - setConfirm(null)} />
    {onDelete && ( - )} @@ -344,6 +366,16 @@ export function DeviceForm({ setBusy(true) setErr(null) try { + // Спершу розклад збору конфігів, потім сам хост: + // успішне збереження хоста закриває вікно, і показати + // помилку розкладу вже не буде де. Свою помилку вкладка + // показує сама — нам лишається привести до неї людину. + try { + await ncmSave.current?.() + } catch { + setTab('ncm') + return + } await onSave({ name: name.trim(), address: address.trim(), @@ -373,3 +405,46 @@ export function DeviceForm({ ) } + +/** + * Що пристрій сказав про себе сам. + * + * Окремим блоком, а не полями форми: це не налаштування, а факт із + * мережі. Редагована модель у сусідстві з тією, що приїхала по SNMP, + * читалась би як пропозиція її поправити — а поправлене система все + * одно не перезапише, і людина лишилась би з двома різними правдами. + * + * sysDescr показуємо повністю: саме з нього дістаються модель і версія, + * і коли щось дісталось не так, дивитись треба сюди. + */ +function IdentityCard({ device }: { device: DeviceSummary }) { + const rows: [string, string][] = [ + ['Модель', device.model ?? ''], + ['Версія ПЗ', device.os_version ?? ''], + ['Серійний номер', device.serial_number ?? ''], + ['sysObjectID', device.sys_object_id ?? ''], + ].filter((r): r is [string, string] => r[1] !== '') + + return ( +
    +
    + Розпізнано по SNMP +
    +
    + {rows.map(([k, v]) => ( +
    +
    {k}:
    +
    + {v} +
    +
    + ))} +
    + {device.sys_descr && ( +

    + {device.sys_descr.trim()} +

    + )} +
    + ) +} diff --git a/web/src/components/DeviceMetricsView.tsx b/web/src/components/DeviceMetricsView.tsx new file mode 100644 index 0000000..82a9967 --- /dev/null +++ b/web/src/components/DeviceMetricsView.tsx @@ -0,0 +1,605 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import { Button, Card, ErrorNote, ago, fmtValue, inputClass, plural } from './ui' +import { ChartLegend, MetricChart } from './MetricChart' +import { + OrphanNote, + OriginBadge, + matchesMetricFilter, + matchesMetricQuery, + metricFilters, + type MetricFilter, +} from './device/DeviceCollected' +import type { DeviceGraph, LatestMetric, MetricResult } from '../types' + +// Вікна перегляду. Ширші за добу вже читаються з роллапів, тому +// коштують стільки ж, скільки вузькі. +const RANGES: { key: string; label: string }[] = [ + { key: '30m', label: '30 хв' }, + { key: '6h', label: '6 год' }, + { key: '24h', label: 'доба' }, + { key: '7d', label: 'тиждень' }, + { key: '30d', label: 'місяць' }, +] + +// Скільки ліній малювати одразу. Більше однаково не читається, а сервер +// відмовляє після двадцяти. +const MAX_LINES = 6 + +/** + * Графіки одного хоста. + * + * Винесено зі сторінки «Метрики», щоб те саме показувати у вкладці + * картки хоста. Другої реалізації графіків у продукті бути не повинно: + * дві розійшлися б у дрібницях — межах осей, виборі роллапу, — і + * людина, яка порівнює два екрани, не знала б, якому вірити. + * + * Діапазон і добір рядів компонент тримає сам: вони належать графіку, а + * не сторінці, і на картці хоста для них немає своєї шапки. + * + * Перелік рядів береться з `/latest`, а не з `/series`, і це головна + * зміна в цьому файлі. `/series` віддає лише «що є», а питання, з яким + * до двохсот рядів приходять, — «що з них ще живе»: на стенді 471 ряд + * із 496 не має за собою жодної перевірки й не оновиться вже ніколи. + * Відповідь на це `/latest` уже рахує (походження, `stale`), і другого + * джерела тих самих фактів заводити не можна — вони розійшлися б, і + * ряд вважався б мертвим у картці хоста й живим у графіках. Заразом + * той запит ще й дешевший: 1.29 мс проти 2.42 мс на хості з 200 рядами + * (`/series` робить два корельовані підзапити на кожен ряд, `/latest` — + * один LATERAL). + */ +export function DeviceMetricsView({ + deviceID, + compact = false, + initialSeries, +}: { + deviceID: string + /** Вужча колонка — картка хоста віддає графікам менше місця, ніж сторінка. */ + compact?: boolean + /** + * Ряди, з яких почати. + * + * Приходить із «Останніх даних»: людина побачила число й хоче + * побачити, як воно змінювалось. Без цього перехід між вкладками + * означав би знайти той самий ряд удруге серед двохсот. + */ + initialSeries?: number[] +}) { + const [metrics, setMetrics] = useState([]) + const [orphans, setOrphans] = useState(0) + const [selected, setSelected] = useState([]) + const [range, setRange] = useState('6h') + const [data, setData] = useState(null) + const [graphs, setGraphs] = useState([]) + // Готові графіки з шаблонів чи власний добір метрик. + // + // Шаблонні першими: у дев'яти випадках із десяти людина приходить + // подивитись «як там пам'ять», а не збирати графік з нуля. + const [mode, setMode] = useState<'templates' | 'custom'>('templates') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + // Добір рядів усередині хоста. Ті самі два питання, що в «Останніх + // даних» картки хоста, і навмисно тими самими словами. + const [query, setQuery] = useState('') + const [state, setState] = useState('all') + const [open, setOpen] = useState>({}) + + // Перелік рядів хоста. Вибір скидається разом із хостом: id рядів + // наскрізні, і лишити їх означало б попросити чужі дані. + useEffect(() => { + if (!deviceID) return + let alive = true + setMetrics([]) + setOrphans(0) + setSelected([]) + setData(null) + setQuery('') + setState('all') + setOpen({}) + api + .deviceGraphs(deviceID) + .then((g) => { + if (!alive) return + setGraphs(g) + // Без шаблонних графіків показувати порожню вкладку немає сенсу. + // Названий ряд теж переважає шаблони: людина прийшла по нього. + setMode(g.length > 0 && !initialSeries?.length ? 'templates' : 'custom') + }) + .catch(() => alive && setGraphs([])) + api + .deviceLatest(deviceID) + .then((d) => { + if (!alive) return + setMetrics(d.metrics) + setOrphans(d.orphans) + // Одразу показуємо перші кілька: порожній графік із проханням + // щось відмітити — це зайвий крок перед відповіддю на питання + // «що тут відбувається». + // + // Але саме ЖИВІ. Це найдешевша частина всієї правки: на + // JUN.QFX перші ряди за абеткою — це порти, які стоять із + // 25 серпня, і графік «за замовчуванням» відкривався порожнім. + // Людина при цьому не робила нічого неправильного, а бачила + // зламану сторінку. + setSelected( + initialSeries?.length + ? initialSeries.slice(0, MAX_LINES) + : pickInitial(d.metrics, MAX_LINES), + ) + }) + .catch((e) => alive && setErr(e instanceof Error ? e.message : String(e))) + return () => { + alive = false + } + }, [deviceID, initialSeries]) + + const load = useCallback(() => { + if (!deviceID || selected.length === 0) { + setData(null) + return + } + setBusy(true) + api + .queryMetrics(deviceID, selected, range) + .then(setData) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + .finally(() => setBusy(false)) + }, [deviceID, selected, range]) + + useEffect(load, [load]) + + // Групуємо за одиницею виміру: відсотки й біти на секунду на спільній + // осі перетворюють графік на пряму лінію біля нуля. + const groups = useMemo(() => { + const by = new Map() + for (const s of data?.series ?? []) { + const k = s.unit || '—' + const list = by.get(k) ?? [] + list.push(s) + by.set(k, list) + } + return [...by.entries()] + }, [data]) + + // --- добір рядів ------------------------------------------------------ + + const q = query.trim().toLowerCase() + const rows = useMemo( + () => metrics.filter((m) => matchesMetricFilter(m, state) && matchesMetricQuery(m, q)), + [metrics, state, q], + ) + + /** + * Ряди складаються в купки за КЛЮЧЕМ метрики, а не за портом. + * + * Двісті рядів JUN.QFX — це сто портів по дві метрики, і скласти їх + * можна по будь-якій із двох осей. Але складання по портах дало б сто + * купок замість двохсот рядків, тобто те саме гортання; складання по + * ключу дає шість — рівно стільки, скільки хост насправді вимірює. + * + * Другу вісь бере на себе пошук: набране «xe-0/0/12» лишає по одному + * ряду в кожній купці, тобто саме ту пару «вхід + вихід», заради якої + * до порту й приходять. Складання по портах, навпаки, не рятує від + * питання «покажи вхідний трафік на п'яти аплінках» узагалі. + * + * Та сама вісь, що в «Останніх даних» картки хоста, — і це не збіг: + * двоє однакових переліків, згорнутих по-різному, людина сприймає як + * різні дані. + */ + const seriesGroups = useMemo(() => { + const by = new Map() + for (const m of rows) { + const list = by.get(m.metric_key) ?? [] + list.push(m) + by.set(m.metric_key, list) + } + return [...by.entries()] + }, [rows]) + + const toggle = (id: number) => + setSelected((prev) => + prev.includes(id) + ? prev.filter((x) => x !== id) + : prev.length >= MAX_LINES + ? prev + : [...prev, id], + ) + + // Ряд за його id — щоб про намальовану лінію можна було спитати те, + // що знає лише перелік: чи вона ще жива й коли востаннє оновлювалась. + const byID = useMemo(() => { + const m = new Map() + for (const x of metrics) m.set(x.series_id, x) + return m + }, [metrics]) + + /** + * Чому цей графік порожній. + * + * Порожнє полотно має дві причини, і плутати їх найдорожче саме тут: + * «за цей період нічого не приходило» — це або вузьке вікно, або + * збій збору; «ці ряди більше не наповнюють» — це історія, і ширше + * вікно тут єдине, що допоможе. На стенді є хост (Леніна.21), у + * якого мертві ВСІ 62 ряди: без цього рядка його графіки виглядають + * як зламана сторінка. + */ + const emptyNoteFor = (list: MetricResult['series']): string | undefined => { + const known = list.map((s) => byID.get(s.id)).filter((m): m is LatestMetric => !!m) + if (known.length === 0 || !known.every((m) => m.stale)) return undefined + const last = known + .map((m) => m.last_at) + .filter(Boolean) + .sort() + .slice(-1)[0] + return `Ці ряди більше не оновлюються — останнє значення ${ago(last)}. Візьміть ширше вікно або інший ряд.` + } + + return ( +
    + {err} + +
    + {graphs.length > 0 && ( +
    + + +
    + )} +
    + {RANGES.map((r) => ( + + ))} +
    +
    + + {metrics.length === 0 ? ( +

    + Для цього хоста ще нічого не зібрано. Метрики з’являться після першого успішного + опитування — перевірте, що хост увімкнено й до нього прив’язано зонд. +

    + ) : mode === 'templates' ? ( +
    + {graphs.map((g) => ( + + ))} +
    + ) : ( +
    +
    + {/* Та сама плашка, що в картці хоста, — не другий її варіант. + Стоїть над добором, бо пояснює саме те, що людина зараз у + ньому побачить. */} + + + +
    + setQuery(e.target.value)} + /> + {/* Ті самі чотири стани й ті самі слова, що в «Останніх + даних»: другий набір означав би, що «застрягли» тут і + там — можливо, різні речі. */} +
    + {metricFilters.map((f) => ( + + ))} +
    +
    + + Вибрано {selected.length} з {MAX_LINES} + + + {rows.length} з {metrics.length} {plural(metrics.length, ['ряду', 'рядів', 'рядів'])} + +
    +
    + +
    + {seriesGroups.length === 0 ? ( +

    Нічого не знайдено

    + ) : ( + seriesGroups.map(([key, list]) => { + // Купку з двох-трьох рядів згортати немає сенсу; + // купку на сто портів — навпаки. Пошук, що звузив + // купку до трьох, розгортає її тим самим правилом. + const expanded = open[key] ?? list.length <= 3 + const head = list[0] + const chosen = list.filter((m) => selected.includes(m.series_id)).length + return ( +
    + + + {expanded && ( +
      + {list.map((m) => ( +
    • + +
    • + ))} +
    + )} +
    + ) + }) + )} +
    +
    +
    + +
    + {data && ( +

    + Крок {data.step_sec} с, джерело{' '} + {data.source === 'raw' ? 'сирі дані' : `роллап ${data.source}`} + {busy && ' · оновлюємо…'} +

    + )} + + {groups.length === 0 ? ( + +

    + {selected.length === 0 ? 'Відмітьте метрику зліва' : 'За цей період даних немає'} +

    +
    + ) : ( + groups.map(([unit, list]) => ( + +
    + + {unit === '—' ? 'без одиниці' : unit} + + +
    + +
    + )) + )} +
    +
    + )} +
    + ) +} + +/** + * З чого почати, коли людина нічого не називала. + * + * Спершу живі ряди, і лише якщо живих немає — будь-які. Порядок + * усередині лишається серверний (ключ, потім порт), тож добір + * передбачуваний: це перші кілька рядів переліку, а не «якісь». + * + * Три, а не MAX_LINES: шість ліній із першого ж кадру читаються гірше + * за три, а додати решту — один клац. + */ +function pickInitial(metrics: LatestMetric[], max: number): number[] { + const live = metrics.filter((m) => !m.stale && m.origin.kind !== 'none') + const from = live.length > 0 ? live : metrics + return from.slice(0, Math.min(3, max)).map((m) => m.series_id) +} + +/** + * Один графік, описаний у шаблоні. + * + * Вантажить себе сам: графіків на хості буває десяток, і вимикати + * половину з них заради одного спільного запиту означало б чекати на + * найповільніший. + */ +function TemplateGraphCard({ + graph, + deviceID, + range, + emptyNoteFor, +}: { + graph: DeviceGraph + deviceID: string + range: string + /** + * Чому цей графік порожній — рахує батько, бо лише в нього є перелік + * рядів зі станом. Шаблонний графік — типовий вигляд сторінки, і + * саме на ньому обрив лінії помічають першим. + */ + emptyNoteFor: (list: MetricResult['series']) => string | undefined +}) { + const [data, setData] = useState(null) + const [err, setErr] = useState(null) + + useEffect(() => { + let alive = true + api + .queryMetrics(deviceID, graph.series_ids, range, 250) + .then((r) => alive && setData(r)) + .catch((e) => alive && setErr(e instanceof Error ? e.message : String(e))) + return () => { + alive = false + } + }, [deviceID, range, graph.series_ids]) + + const single = graph.kind === 'stat' || graph.kind === 'gauge' + const last = data?.series[0]?.points.filter((p) => p.v != null).slice(-1)[0]?.v ?? null + + return ( + +
    + {graph.name} + {graph.template_name} +
    + + {err ? ( +

    {err}

    + ) : !data ? ( +

    Завантаження…

    + ) : single ? ( + + ) : ( + <> + +
    + +
    + + )} +
    + ) +} + +function SingleValue({ + graph, + value, + note, +}: { + graph: DeviceGraph + value: number | null + /** Пояснення порожньої плитки, коли воно є. Див. MetricChart.emptyNote. */ + note?: string +}) { + if (value == null) { + return ( +

    + {note || 'Даних немає'} +

    + ) + } + if (graph.kind === 'stat') { + return ( +
    + + {fmtValue(value)} + + {graph.options?.unit} +
    + ) + } + + const min = graph.options?.min ?? 0 + const max = graph.options?.max ?? 100 + const pct = Math.max(0, Math.min(1, (value - min) / Math.max(1e-9, max - min))) + // Ті самі пороги, що на шкалах дашборда: до 70 % спокійно, до 90 % + // варто глянути, далі погано. + const color = pct >= 0.9 ? '#ef4444' : pct >= 0.7 ? '#f59e0b' : '#34d399' + + return ( +
    +
    + + {fmtValue(value)} + + {graph.options?.unit} +
    +
    +
    +
    +
    + ) +} diff --git a/web/src/components/DeviceNode.tsx b/web/src/components/DeviceNode.tsx index 71bfb9b..2f50707 100644 --- a/web/src/components/DeviceNode.tsx +++ b/web/src/components/DeviceNode.tsx @@ -1,6 +1,7 @@ import { Fragment } from 'react' import type { ReactNode } from 'react' import { Handle, Position, type Node, type NodeProps } from '@xyflow/react' +import { NODE_DEFAULTS } from './mapStyle' import type { DeviceStatus } from '../types' export type DeviceNodeData = { @@ -30,8 +31,14 @@ export type DeviceNodeData = { iconId?: string /** Розмір картинки або крапки в пікселях. */ iconSize?: number - /** Показувати цифри пінга. */ + /** Не показувати цифри пінга. */ hideMetrics?: boolean + /** Адреса хоста. */ + address?: string + /** Виводити адресу під іменем. */ + showAddress?: boolean + /** Хост прибрали з обліку, а вузол лишився стояти на схемі. */ + deleted?: boolean } export type DeviceNodeType = Node @@ -108,23 +115,31 @@ const HANDLE_CLASS = 'transition-opacity group-hover:opacity-100 hover:!bg-sky-400' export function DeviceNode({ data, selected }: NodeProps) { - const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown + // Видалений хост має вигляд «невідомо», а не останнього стану: + // зелена крапка означала б живий пристрій, якого вже немає в обліку. + const gone = data.deleted === true + const s = gone ? STATUS.unknown : (STATUS[data.status || 'unknown'] ?? STATUS.unknown) // 'none' — свідома відсутність значка, а не «не задано»: на схемі з // власними картинками типовий квадратик поруч із ними лише заважає. // Значка типово немає: поруч із крапкою він лише додає шуму, а сенс // несе колір стану. const showIcon = data.icon !== undefined && data.icon !== '' && data.icon !== 'none' const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other - const size = data.size ?? 'sm' + const size = data.size ?? NODE_DEFAULTS.size const textSize = TEXT_SIZE[size] ?? TEXT_SIZE.md // Типовий вигляд — крапка з підписом знизу, без значка й цифр. // // Картка з рамкою добре читається на схемі з десяти вузлів і // перетворює схему на сітку прямокутників уже на п'ятдесяти. Крапка // лишає читабельним головне: де вузол і якого він стану. - const shape = data.shape ?? 'dot' - const labelPos = data.labelPos ?? 'bottom' - const accent = data.color ?? s.raw + // + // Значення беруться з NODE_DEFAULTS, а не пишуться тут числом: ті + // самі типові має знати й інспектор, інакше він показує одне, а + // полотно малює інше (саме так і було: форма казала «картка» про + // вузол, який насправді крапка). + const shape = data.shape ?? NODE_DEFAULTS.shape + const labelPos = data.labelPos ?? NODE_DEFAULTS.labelPos + const accent = gone ? s.raw : (data.color ?? s.raw) // Виділення — кільце того ж кольору, що й акцент інтерфейсу, поверх // тіні стану. Два різні кільця одне на одному читаються як брак, тому @@ -165,13 +180,46 @@ export function DeviceNode({ data, selected }: NodeProps) { ) + // Що стоїть під іменем. + // + // Умова була `hideMetrics === false`, тобто цифри показувались лише + // тоді, коли хтось явно записав у стиль «false». А інспектор такого + // не пише ніколи — знявши галочку, він просто прибирає ключ. Тому + // RTT не з'являвся на жодному вузлі, і галочка «не показувати цифри + // пінга» виглядала як завжди ввімкнена. + const showMetrics = !data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) + const sub = + (data.showAddress && data.address) || showMetrics ? ( + + {data.showAddress && data.address && ( + {data.address} + )} + {showMetrics && data.rtt !== undefined && ( + {data.rtt.toFixed(2)} мс + )} + {showMetrics && data.loss !== undefined && data.loss > 0 && ( + втрати {data.loss.toFixed(0)}% + )} + + ) : null + + // Підпис видаленого хоста закреслений, а не прихований: людина має + // впізнати вузол, який збирається прибрати зі схеми. const label = data.hideLabel || labelPos === 'none' ? null : ( - {data.label} + + {data.label} + + {sub} ) @@ -272,6 +320,9 @@ export function DeviceNode({ data, selected }: NodeProps) { {data.label} + {/* Пігулка — форма в один рядок, тому підпис під іменем стає + праворуч від нього, а не зникає. */} + {sub}
    ) } @@ -304,15 +355,10 @@ export function DeviceNode({ data, selected }: NodeProps) {
    {/* Цифри показуємо лише коли вони є: порожній рядок «— мс» - створює враження зламаного моніторингу. */} - {data.hideMetrics === false && (data.rtt !== undefined || data.loss !== undefined) && ( -
    - {data.rtt !== undefined && {data.rtt.toFixed(2)} мс} - {data.loss !== undefined && data.loss > 0 && ( - втрати {data.loss.toFixed(0)}% - )} -
    - )} + створює враження зламаного моніторингу. Той самий блок, що + й під крапкою, — щоб зміна форми вузла не міняла того, ЩО + про нього написано. */} + {sub &&
    {sub}
    }
    ) diff --git a/web/src/components/EdgeInspector.tsx b/web/src/components/EdgeInspector.tsx index 355c12c..18ed5ee 100644 --- a/web/src/components/EdgeInspector.tsx +++ b/web/src/components/EdgeInspector.tsx @@ -1,6 +1,16 @@ -import { useEffect, useState } from 'react' -import { Button, Field, Toggle, inputClass } from './ui' -import type { EdgeInput, MapEdge, MapNode } from '../types' +import { useEffect, useMemo, useState } from 'react' +import { Button, Field, Toggle, formatBps, inputClass } from './ui' +import { Check, InspectorPanel, Row, Section } from './InspectorShell' +import { EDGE_TONE_CLASS, edgeState } from './edgeState' +import { + EDGE_DEFAULTS, + EDGE_LABEL_DEFAULT, + EDGE_LABEL_FIELDS, + EDGE_LABEL_FIELD_TITLE, + autoSides, + type EdgeLabelField, +} from './mapStyle' +import type { EdgeInput, MapEdge, MapInterface, MapNode } from '../types' // Форма лінії. smoothstep — типова: прямі кути читаються як схема, а не // як павутина, і саме так малюють мережі на папері. @@ -28,9 +38,23 @@ const DASHES: { key: string; label: string }[] = [ { key: 'dotted', label: 'крапками' }, ] +// Стрілка — не оформлення, а відповідь на питання «куди дивиться +// напрямок»: на лінії, підписаній портами A→Б, без неї не видно, який +// кінець який. +const ARROWS: { key: string; label: string }[] = [ + { key: 'none', label: 'без стрілки' }, + { key: 'forward', label: 'A → Б' }, + { key: 'both', label: 'в обидва боки' }, +] + /** * Інспектор зв'язку. * + * Налаштування розкладені по групах, а не одним стовпчиком. Причина не + * в місці: «до якого порту ввімкнено» — це факт про мережу, «якою + * товщиною малювати» — оформлення, і читати їх поспіль означає щоразу + * перебирати весь список очима. + * * Колір лінії рахується зі стану: зелений — працює, жовтий і червоний — * за порогами завантаження, червоний пунктир — обрив. Власний колір * перекриває цей розрахунок, і саме тому він тут окремим полем із @@ -40,29 +64,48 @@ const DASHES: { key: string; label: string }[] = [ export function EdgeInspector({ edge, nodes, + interfaces, readOnly, + edgeCount = 1, onPatch, + onPatchAll, onDelete, onClose, + onDiscover, + discovering, }: { edge: MapEdge nodes: MapNode[] + /** Порти хостів мапи — для ручного зіставлення. Порожньо, доки не зібрано інвентар. */ + interfaces: MapInterface[] readOnly: boolean + /** Скільки всього ліній на мапі — для «застосувати до всіх». */ + edgeCount?: number onPatch: (patch: EdgeInput, comment: string) => Promise | void + /** Той самий вигляд на всі лінії мапи. */ + onPatchAll?: (patch: Omit, comment: string) => Promise | void onDelete: () => void onClose: () => void + /** «Зібрати топологію» зі сторінки мапи — щоб стан вів до дії, а не був ярликом. */ + onDiscover?: () => void + discovering?: boolean }) { const [label, setLabel] = useState(edge.label ?? '') const [style, setStyle] = useState(edge.style) const [dash, setDash] = useState(edge.dash) const [color, setColor] = useState(edge.color ?? '') - const [width, setWidth] = useState(String(edge.width_px ?? 2)) - const [warn, setWarn] = useState(String(edge.thresholds?.warn_pct ?? 70)) - const [crit, setCrit] = useState(String(edge.thresholds?.crit_pct ?? 90)) + const [width, setWidth] = useState(String(edge.width_px ?? EDGE_DEFAULTS.widthPx)) + const [warn, setWarn] = useState(String(edge.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct)) + const [crit, setCrit] = useState(String(edge.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct)) const [animated, setAnimated] = useState(edge.animation?.enabled !== false) const [showMetrics, setShowMetrics] = useState(edge.show_metrics) const [srcSide, setSrcSide] = useState(edge.source_handle || 'auto') const [dstSide, setDstSide] = useState(edge.target_handle || 'auto') + const [srcIf, setSrcIf] = useState(edge.source_interface_id ?? '') + const [dstIf, setDstIf] = useState(edge.target_interface_id ?? '') + const [fields, setFields] = useState(edge.view?.show ?? EDGE_LABEL_DEFAULT) + const [labelAt, setLabelAt] = useState(String(Math.round((edge.view?.label_at ?? 0) * 100))) + const [arrow, setArrow] = useState(edge.view?.arrow ?? EDGE_DEFAULTS.arrow) const [busy, setBusy] = useState(false) // Вибір іншої лінії має перезаряджати форму, інакше вона показувала б @@ -72,48 +115,122 @@ export function EdgeInspector({ setStyle(edge.style) setDash(edge.dash) setColor(edge.color ?? '') - setWidth(String(edge.width_px ?? 2)) - setWarn(String(edge.thresholds?.warn_pct ?? 70)) - setCrit(String(edge.thresholds?.crit_pct ?? 90)) + setWidth(String(edge.width_px ?? EDGE_DEFAULTS.widthPx)) + setWarn(String(edge.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct)) + setCrit(String(edge.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct)) setAnimated(edge.animation?.enabled !== false) setShowMetrics(edge.show_metrics) setSrcSide(edge.source_handle || 'auto') setDstSide(edge.target_handle || 'auto') + setSrcIf(edge.source_interface_id ?? '') + setDstIf(edge.target_interface_id ?? '') + setFields(edge.view?.show ?? EDGE_LABEL_DEFAULT) + setLabelAt(String(Math.round((edge.view?.label_at ?? 0) * 100))) + setArrow(edge.view?.arrow ?? EDGE_DEFAULTS.arrow) }, [edge]) const nameOf = (id: string) => nodes.find((n) => n.id === id)?.label ?? '—' + // Порти показуємо лише свого хоста: перелік усіх портів мапи в + // випадному списку — це сотні рядків, у яких потрібний не знайти. + const srcDevice = nodes.find((n) => n.id === edge.source_node_id)?.device_id ?? '' + const dstDevice = nodes.find((n) => n.id === edge.target_node_id)?.device_id ?? '' + const srcPorts = useMemo( + () => interfaces.filter((i) => i.device_id === srcDevice), + [interfaces, srcDevice], + ) + const dstPorts = useMemo( + () => interfaces.filter((i) => i.device_id === dstDevice), + [interfaces, dstDevice], + ) + + // Видалений хост на кінці перекриває решту станів: доки його не + // повернули, звіряти лінію нема з чим. + const endpointGone = [edge.source_node_id, edge.target_node_id].some( + (id) => nodes.find((n) => n.id === id)?.device_deleted, + ) + const st = edgeState(edge, endpointGone) + + // Бік, який суперечить розташуванню вузлів. + // + // Лінія, що виходить з ВЕРХУ вузла до сусіда, який стоїть ЗНИЗУ, + // мусить обійти обидва вузли — React Flow чесно малює гак через + // півсхеми. Найчастіша причина — один невдалий рух мишею при + // малюванні, і виправити його інакше як видаленням лінії досі було + // ніяк: у формі просто стояло «зверху», і чому лінія крива, звідти + // не читалось. + const sideAdvice = useMemo(() => { + const a = nodes.find((n) => n.id === edge.source_node_id) + const b = nodes.find((n) => n.id === edge.target_node_id) + if (!a || !b) return null + const [wantSrc, wantDst] = autoSides(a, b) + const bad = + (srcSide !== 'auto' && srcSide !== wantSrc) || (dstSide !== 'auto' && dstSide !== wantDst) + return bad ? { wantSrc, wantDst } : null + }, [nodes, edge.source_node_id, edge.target_node_id, srcSide, dstSide]) + + const viewFields = edge.view?.show ?? EDGE_LABEL_DEFAULT const dirty = label !== (edge.label ?? '') || style !== edge.style || dash !== edge.dash || color !== (edge.color ?? '') || - width !== String(edge.width_px ?? 2) || - warn !== String(edge.thresholds?.warn_pct ?? 70) || - crit !== String(edge.thresholds?.crit_pct ?? 90) || + width !== String(edge.width_px ?? EDGE_DEFAULTS.widthPx) || + warn !== String(edge.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct) || + crit !== String(edge.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct) || animated !== (edge.animation?.enabled !== false) || showMetrics !== edge.show_metrics || srcSide !== (edge.source_handle || 'auto') || - dstSide !== (edge.target_handle || 'auto') + dstSide !== (edge.target_handle || 'auto') || + srcIf !== (edge.source_interface_id ?? '') || + dstIf !== (edge.target_interface_id ?? '') || + fields.join(',') !== viewFields.join(',') || + labelAt !== String(Math.round((edge.view?.label_at ?? 0) * 100)) || + arrow !== (edge.view?.arrow ?? EDGE_DEFAULTS.arrow) + + // Вигляд без семантики — те, що має сенс застосувати до всіх ліній + // одразу. Порти, боки й текст підпису сюди не входять: вони в кожної + // лінії свої, і однаковими бути не можуть за визначенням. + // + // Положення підпису теж не входить навмисно: однакова частка на всіх + // лініях, що виходять з одного вузла, знову зведе їхні підписи в одну + // точку — тобто зробить рівно те, від чого це поле й рятує. + function appearance(): Omit { + return { + style, + dash, + // Порожній колір означає «за станом»: сервер зберігає порожнє + // значення, і розрахунок за завантаженням повертається. + color, + width_px: Number(width) || EDGE_DEFAULTS.widthPx, + thresholds: { + warn_pct: Number(warn) || EDGE_DEFAULTS.warnPct, + crit_pct: Number(crit) || EDGE_DEFAULTS.critPct, + }, + animation: { ...edge.animation, enabled: animated }, + show_metrics: showMetrics, + view: { show: fields, arrow }, + } + } async function apply() { setBusy(true) try { await onPatch( { + ...appearance(), id: edge.id, label, - style, - dash, - // Порожній колір означає «за станом»: сервер зберігає NULL, - // і розрахунок повертається. - color, - width_px: Number(width) || 2, - thresholds: { warn_pct: Number(warn) || 70, crit_pct: Number(crit) || 90 }, - animation: { ...edge.animation, enabled: animated }, - show_metrics: showMetrics, source_handle: srcSide, target_handle: dstSide, + // 'none' — саме «зняти порт», а не «не передавали»: без + // цього помилково обраний порт лишався б на ребрі назавжди. + source_interface_id: srcIf || 'none', + target_interface_id: dstIf || 'none', + // Положення підпису — на відміну від решти вигляду, воно + // особисте: 0 означає «рахуй сам», і саме так лишається у + // всіх ліній, яких людина не чіпала. + view: { show: fields, arrow, label_at: (Number(labelAt) || 0) / 100 }, }, 'зміна вигляду звʼязку', ) @@ -122,11 +239,54 @@ export function EdgeInspector({ } } + async function applyToAll() { + if (!onPatchAll) return + setBusy(true) + try { + await onPatchAll(appearance(), `вигляд усіх ліній (${edgeCount})`) + } finally { + setBusy(false) + } + } + + // Привʼязка до виявленого лінка. + // + // Порти беремо ті, що назвав зонд, а не ті, що стоять у формі: + // сенс дії саме в тому, щоб лінія почала показувати мережу, а не + // здогад того, хто малював. + async function bindDiscovered() { + if (!edge.discovered) return + setBusy(true) + try { + await onPatch( + { + id: edge.id, + link_id: edge.discovered.link_id, + source_interface_id: edge.discovered.source_interface_id || 'none', + target_interface_id: edge.discovered.target_interface_id || 'none', + }, + 'привʼязка звʼязку до виявленого лінка', + ) + } finally { + setBusy(false) + } + } + + // Зняття привʼязки: лінк міг залишитись від зниклого обладнання, і + // тоді лінія показує стан того, чого вже немає. + async function unbind() { + setBusy(true) + try { + await onPatch({ id: edge.id, link_id: 'none' }, 'зняття привʼязки звʼязку') + } finally { + setBusy(false) + } + } + + const atPct = Number(labelAt) || 0 + return ( -
    +
    {nameOf(edge.source_node_id)} → {nameOf(edge.target_node_id)} @@ -136,9 +296,43 @@ export function EdgeInspector({
    + {/* Стан звірки — перше, що людина читає про лінію. + Раніше тут стояло одне слово «без привʼязки»: правдиве й + непридатне, бо не казало ні що не так, ні що робити. */} +
    +
    {st.label}
    +

    {st.hint}

    + {st.action &&

    {st.action}

    } + {/* Збір топології доступний і в режимі перегляду: він не міняє + схему, а лише просить зонд опитати обладнання. Привʼязка — + навпаки, правка полотна, і без права на неї не показується. */} + {(!readOnly || onDiscover) && ( +
    + {!readOnly && st.key === 'unbound' && ( + + )} + {!readOnly && st.key === 'linked' && ( + + )} + {st.key !== 'linked' && onDiscover && ( + + )} +
    + )} +
    +
    - + {edge.link_id && } {edge.util_pct !== undefined && } + {edge.capacity_bps !== undefined && ( + + )} {edge.source_port && } {edge.target_port && }
    @@ -146,147 +340,316 @@ export function EdgeInspector({ {readOnly ? (

    Перегляд — редагування потребує maps:write

    ) : ( -
    - - setLabel(e.target.value)} - /> - +
    + {/* Порти — саме те, заради чого на схему й дивляться: яким + портом у який увімкнено. Доки автовиявлення не пройшло, + зіставити їх можна руками — тоді на лінії зʼявляться і + назви портів, і швидкість каналу. */} +
    +
    + + + + + + +
    + {srcPorts.length === 0 && dstPorts.length === 0 && ( +

    + Інвентар портів порожній — його заповнює автовиявлення. +

    + )} -
    - - - - - - -
    +
    + + + + + + +
    -
    - - - - - - -
    + {sideAdvice && ( +
    +

    + Бік не збігається з розташуванням вузлів — лінія йде в обхід. +

    + +
    + )} +
    -
    - +
    + + + setWidth(e.target.value)} + value={label} + disabled={!showMetrics} + onChange={(e) => setLabel(e.target.value)} /> - -
    + + {/* Кожне поле окремо, бо саме довжина підпису вирішує, чи + зіткнуться сусідні: два рядки по 30 символів на чотирьох + лініях з одного вузла не розведе жодне розміщення. */} +
    + {EDGE_LABEL_FIELDS.map((f) => ( + + setFields((prev) => + v + ? [...EDGE_LABEL_FIELDS].filter((x) => x === f || prev.includes(x)) + : prev.filter((x) => x !== f), + ) + } + > + {EDGE_LABEL_FIELD_TITLE[f as EdgeLabelField]} + + ))} +
    + + {/* Положення вздовж лінії. Типово рахує полотно — і це не + «нічого не задано», а окрема відповідь: воно розводить + підписи ліній, що сходяться в одному вузлі. */} + +
    setColor(e.target.value)} + type="range" + className="w-full accent-sky-500" + min="0" + max="95" + step="5" + disabled={!showMetrics} + value={labelAt} + onChange={(e) => setLabelAt(e.target.value)} /> - {color && ( + {atPct !== 0 && ( )}
    -
    +
    -
    - - s.key === style)?.label ?? style}`}> +
    + + + + + + +
    + +
    + + setWidth(e.target.value)} + /> + + +
    + setColor(e.target.value)} + /> + {color && ( + + )} +
    +
    +
    + + + - - setCrit(e.target.value)} - /> - -
    + - - +
    +
    + + setWarn(e.target.value)} + /> + + + setCrit(e.target.value)} + /> + +
    + +
    -
    +
    + {onPatchAll && edgeCount > 1 && ( + + )}
    )} -
    + + ) +} + +/** + * Вибір порту. + * + * Швидкість поруч з іменем не для краси: на комутаторі два десятки + * однакових Gi0/x, і саме швидкість підказує, який із них аплінк. + */ +function PortSelect({ + value, + ports, + onChange, +}: { + value: string + ports: MapInterface[] + onChange: (v: string) => void +}) { + return ( + ) } @@ -298,18 +661,10 @@ function statusLabel(s?: string): string { return 'обрив' case 'warning': return 'проблеми' + case 'maintenance': + return 'обслуговування' default: - // Порожній статус — не помилка: так виглядає лінія, намальована - // рукою й не привʼязана до фізичного лінка. - return 'без привʼязки' + // Лінк є, але жоден його кінець ще не опитували. + return 'ще не опитано' } } - -function Row({ k, v }: { k: string; v: string }) { - return ( -
    - {k} - {v} -
    - ) -} diff --git a/web/src/components/InspectorShell.tsx b/web/src/components/InspectorShell.tsx new file mode 100644 index 0000000..71bcf13 --- /dev/null +++ b/web/src/components/InspectorShell.tsx @@ -0,0 +1,113 @@ +import { useState, type ReactNode } from 'react' + +/** + * Обгортка інспектора: панель у куті полотна. + * + * Чому з власною прокруткою: панель приклеєна до НИЖНЬОГО краю й росте + * вгору. Щойно налаштувань стало більше, ніж вміщає екран ноутбука, + * верхні поля виїжджали за межу вікна — прокрутити туди неможливо, бо + * сторінка сама по собі не прокручується. Тому висота обмежена, а + * зайве прокручується всередині. + * + * Ширина трохи більша за колишні 288 px: у два стовпчики вміщується + * пара «підпис + значення», а не обрізаний селект. + */ +export function InspectorPanel({ children }: { children: ReactNode }) { + return ( +
    + {children} +
    + ) +} + +/** + * Група налаштувань, що згортається. + * + * Причина не в економії місця, а в тому, що налаштування різного роду: + * «до якого порту ввімкнено» — це факт про мережу, «якою товщиною + * малювати» — оформлення, і читати їх одним стовпчиком означає щоразу + * перебирати весь список очима. Групи з заголовками дають те саме, що + * дає Zabbix своїми вкладками: людина знає, де шукати, ще до того, як + * подивилась. + * + * Стан групи локальний і не зберігається: він живе рівно доти, доки + * відкрита панель, а мапу відкривають, щоб дивитись на схему, а не щоб + * повертатись до вчорашнього стану гармошки. + */ +export function Section({ + title, + hint, + defaultOpen = false, + right, + children, +}: { + title: string + hint?: string + defaultOpen?: boolean + /** Короткий підсумок праворуч від заголовка — видно й згорнутим. */ + right?: ReactNode + children: ReactNode +}) { + const [open, setOpen] = useState(defaultOpen) + + return ( +
    + + {open && ( +
    + {hint &&

    {hint}

    } + {children} +
    + )} +
    + ) +} + +/** Рядок «ключ — значення» у зведенні над формою. */ +export function Row({ k, v }: { k: string; v: string }) { + return ( +
    + {k} + {v} +
    + ) +} + +/** Галочка з підписом — форма зустрічається в обох інспекторах. */ +export function Check({ + checked, + onChange, + children, + disabled, +}: { + checked: boolean + onChange: (v: boolean) => void + children: ReactNode + disabled?: boolean +}) { + return ( + + ) +} diff --git a/web/src/components/InterfaceFilterEditor.tsx b/web/src/components/InterfaceFilterEditor.tsx new file mode 100644 index 0000000..fd62329 --- /dev/null +++ b/web/src/components/InterfaceFilterEditor.tsx @@ -0,0 +1,492 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import { Button, ErrorNote, Field, formatBps, inputClass, plural } from './ui' +import type { + IfFilterField, + IfFilterOp, + InterfaceFilter, + InterfaceFilterCondition, + InterfaceFilterDevice, + InterfaceFilterPreview, +} from '../types' + +/** + * Фільтр складу портів шаблону. + * + * Навіщо: без нього snmp.if-чек бере всі порти, які знайшло + * автовиявлення. На стенді це сто портів у Juniper QFX, з яких сорок — + * `.0`-двійники фізичних, а решта службові; у D-Link із 57 «портів» 29 + * насправді VLAN-и. Опитувати їх — це сотні OID за такт заради + * графіків, на які ніхто не подивиться. + * + * Головне тут — не поля вводу, а кнопка «Перевірити»: зразок, + * застосований до сотні пристроїв наосліп, або збирає не те, або не + * збирає нічого, і дізнаються про це через добу по порожніх графіках. + */ + +const FIELDS: { key: IfFilterField; label: string; hint: string }[] = [ + { key: 'name', label: "ім'я порту", hint: 'ifName: xe-0/0/1, Ethernet0/0/5, gpon_1/2/2' }, + { key: 'alias', label: 'опис', hint: 'ifAlias: те, що інженер написав про порт' }, + { key: 'type', label: 'тип', hint: 'ethernetCsmacd, ieee8023adLag, l2vlan, propVirtual' }, + { key: 'speed', label: 'швидкість', hint: 'номінальна: 10G, 1G, 100M' }, +] + +const OPS: { key: IfFilterOp; label: string; forSpeed: boolean }[] = [ + { key: 'matches', label: 'збігається зі зразком', forSpeed: false }, + { key: 'not_matches', label: 'не збігається зі зразком', forSpeed: false }, + { key: 'ge', label: 'не менше ніж', forSpeed: true }, + { key: 'le', label: 'не більше ніж', forSpeed: true }, +] + +/** + * Заготовки. + * + * Не вбудовані шаблони, а саме кнопки: зразок, який ніхто не перевіряв + * на вашому залізі, гірший за його відсутність — він створює враження, + * що склад портів під наглядом. Кнопка ж лише заповнює поля, і далі + * людина натискає «Перевірити» на своєму хості. + */ +const PRESETS: { label: string; title: string; filter: InterfaceFilter }[] = [ + { + label: 'Фізичні порти й LAG', + title: + 'Найнадійніше з усього: тип порту не залежить від того, як вендор його назвав. ' + + 'На стенді Juniper QFX 98 → 49, D-Link 57 → 28 (усі 29 псевдопортів «802.1Q Encapsulation Tag» відпали). ' + + 'На OLT відсіє й gpon-дерева — там додайте другу умову й «хоча б одну»', + filter: { + match: 'all', + conditions: [{ field: 'type', op: 'matches', value: '^(ethernetCsmacd|ieee8023adLag)$' }], + }, + }, + { + label: 'Juniper: xe-/ge-/ae-', + title: + 'За іменем, без .0-підінтерфейсів. На JUN.QFX: 98 → 44. На інших вендорах не дасть нічого — перевіряйте', + filter: { + match: 'all', + conditions: [ + { field: 'name', op: 'matches', value: '^(xe|ge|et|ae)-?\\d' }, + { field: 'name', op: 'not_matches', value: '\\.\\d+$' }, + ], + }, + }, + { + label: 'Тільки магістральні', + title: + 'Порти від 10 Гбіт/с. На стенді це порожньо на трьох хостах із пʼяти — самі по собі магістральні порти є не всюди', + filter: { + match: 'all', + conditions: [{ field: 'speed', op: 'ge', value: '10G' }], + }, + }, + { + label: 'Тільки з описом', + title: + 'Порт, який комусь був потрібен настільки, щоб його підписати. ' + + 'Ознака ненадійна: Huawei заповнює опис сам («HUAWEI, Quidway Series, …») і не відсіює нічого, ' + + 'а на D-Link описів немає взагалі — там пройде нуль портів', + filter: { + match: 'all', + conditions: [{ field: 'alias', op: 'matches', value: '\\S' }], + }, + }, +] + +const emptyCondition = (): InterfaceFilterCondition => ({ + field: 'name', + op: 'matches', + value: '', +}) + +export function InterfaceFilterEditor({ + value, + templateID, + readOnly, + onChange, +}: { + value?: InterfaceFilter + /** Потрібен лише щоб не рахувати цей самий шаблон серед «інших фільтрів хоста». */ + templateID?: string + readOnly?: boolean + onChange: (next: InterfaceFilter | undefined) => void +}) { + const conditions = value?.conditions ?? [] + const matchAny = value?.match === 'any' + + const [devices, setDevices] = useState([]) + const [deviceID, setDeviceID] = useState('') + const [preview, setPreview] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + const [onlyEligible, setOnlyEligible] = useState(true) + + useEffect(() => { + api + .listInterfaceFilterDevices(templateID) + .then((list) => { + setDevices(list) + // Типово — хост, якому цей шаблон уже причеплений: саме на + // ньому зміна складу портів станеться насправді. Якщо таких + // немає, беремо найбагатший на порти: зразок перевіряють на + // найгіршому випадку. + const bound = list.find((d) => d.bound) + setDeviceID((cur) => cur || bound?.id || list[0]?.id || '') + }) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + }, [templateID]) + + function patch(next: Partial, conds?: InterfaceFilterCondition[]) { + const c = conds ?? conditions + if (c.length === 0) { + // Фільтр без умов — це відсутність фільтра, а не «нічого не + // брати». Порожній обʼєкт у базі означав би те саме, але змушував + // би всіх читачів здогадуватись. + onChange(undefined) + return + } + onChange({ match: value?.match ?? 'all', conditions: c, ...next }) + } + + async function run() { + if (!deviceID) return + setBusy(true) + setErr(null) + try { + setPreview( + await api.previewInterfaceFilter( + deviceID, + { match: matchAny ? 'any' : 'all', conditions }, + templateID, + ), + ) + } catch (e) { + setPreview(null) + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + // Порти, які зараз опитуються, а після цього фільтра перестануть. + // Саме це число, а не «пройшло 12», лякає — і саме його треба + // побачити до збереження, а не по обірваному графіку через тиждень. + const willStop = useMemo( + () => (preview?.ports ?? []).filter((p) => p.eligible && !p.matched && !p.filtered_out_at), + [preview], + ) + + const rows = useMemo( + () => (preview?.ports ?? []).filter((p) => !onlyEligible || p.eligible), + [preview, onlyEligible], + ) + + return ( +
    +

    + Без фільтра в чек потрапляють усі порти, які знайшло автовиявлення. На стопортовому + комутаторі це сотні OID за такт заради графіків, на які ніхто не дивиться. Умови + застосовуються до того, що віддав пристрій: {"ім'я"} (ifName), опис (ifAlias), тип і + номінальна швидкість. +

    + + {!readOnly && ( +
    + {PRESETS.map((p) => ( + + ))} + {conditions.length > 0 && ( + + )} +
    + )} + + {conditions.length > 1 && ( +
    + Порт проходить, якщо: + {[ + { on: false, label: 'виконано всі умови' }, + { on: true, label: 'виконано хоча б одну' }, + ].map((o) => ( + + ))} +
    + )} + + {conditions.map((c, i) => { + const isSpeed = c.field === 'speed' + const field = FIELDS.find((f) => f.key === c.field) + return ( +
    + + + + + + patch( + {}, + conditions.map((x, n) => (n === i ? { ...x, value: e.target.value } : x)), + ) + } + /> + + {!readOnly && ( + + )} + +

    + {isSpeed + ? 'Швидкість пишуть суфіксом: 10G, 1G, 100M. Порти, які пристрій віддає з нульовою швидкістю, під «не менше» не підпадають.' + : `${field?.hint ?? ''} · Зразок шукається в будь-якому місці рядка; на початок прив'язує ^, на кінець $. Регістр не враховується.`} +

    +
    + ) + })} + + {!readOnly && ( + + )} + + {conditions.length === 0 && ( +

    + Фільтра немає — цей шаблон не звужує склад портів. Якщо хосту причеплений інший + шаблон із фільтром, діятиме він: фільтри шаблонів обʼєднуються, і шаблон без думки + про порти нічого в інших не відбирає. +

    + )} + + {/* ---- Перевірка на справжньому хості ---- */} + +
    +
    +
    + + + +
    + +
    + + {err} + + {preview && ( +
    +
    + + {preview.matched} з {preview.eligible} + + + придатних портів ({preview.total} усього в інвентарі) + +
    + + {preview.matched === 0 && preview.eligible > 0 && ( +

    + Жоден порт не проходить. Такий шаблон зберегти не вийде: порожній snmp.if-чек + ззовні не відрізнити від справного, і помітять його тоді, коли метрик портів + уже тиждень немає. +

    + )} + + {willStop.length > 0 && ( +

    + {willStop.length} {plural(willStop.length, ['порт', 'порти', 'портів'])} зараз опитується й після + застосування фільтра перестане: {willStop.slice(0, 8).map((p) => p.name).join(', ')} + {willStop.length > 8 ? ` та ще ${willStop.length - 8}` : ''}. Зібрані метрики + лишаться в історії, але оновлюватись перестануть — графік обірветься на дні + застосування. +

    + )} + + {preview.truncated && ( +

    + Пройшло більше за стелю чека ({preview.limit}) — зайве буде обрізано. Звузьте + фільтр, інакше який саме порт випаде, вирішить порядок ifIndex. +

    + )} + + {preview.other_templates && preview.other_templates.length > 0 && ( +

    + У цього хоста є ще фільтри: {preview.other_templates.join(', ')}. У чек піде + обʼєднання — шаблон не має права відбирати порти, які просить інший. +

    + )} + + + +
    + + + + + + + + + + + + {rows.map((p) => ( + + + + + + {/* Коротка форма: колонка вузька, і назва одиниці + в неї не влазить — зате число те саме, що на + осі графіка. */} + + + ))} + +
    портопистипшвидк.
    + {!p.eligible ? '·' : p.matched ? '✓' : '✕'} + + {p.name} + {p.filtered_out_at && ( + + уже виключено + + )} + {p.skip && — {p.skip}} + {p.alias ?? ''}{p.type ?? ''} + {p.speed_bps ? formatBps(p.speed_bps, 'short') : '—'} +
    +
    +
    + )} +
    +
    + ) +} diff --git a/web/src/components/JsonView.tsx b/web/src/components/JsonView.tsx new file mode 100644 index 0000000..97d80cf --- /dev/null +++ b/web/src/components/JsonView.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' + +/** + * Показ довільного JSON. + * + * Потрібен там, де вміст не має схеми на клієнті й мати її не може: + * `meta` в журналі аудиту складає та ручка, яка записала подію, і в ній + * лежить те, що вона вважала за потрібне зберегти. Малювати таке + * табличкою «поле — значення» означало б вигадати схему й приховати + * все, що в неї не влізло, — тобто зіпсувати рівно ту властивість, + * заради якої журнал і читають. + * + * Тому дерево, а не таблиця, і сирі ключі, а не перекладені. Переказ + * українською вже є в переліку (його робить сервер); тут — те, що + * справді лежить у базі, у тому вигляді, у якому воно там лежить. + * + * Чому не `JSON.stringify(v, null, 2)` у
    . Стенограма сесії або
    + * перелік із чотирьохсот імен перетворюють такий блок на кілометр
    + * тексту, у якому структура зникає. Згортання дає відповідь на «що тут
    + * узагалі є» до того, як людина почне читати.
    + */
    +
    +/** Скільки рівнів розгорнуто одразу. */
    +const AUTO_OPEN_DEPTH = 2
    +/** Скільки елементів довгого масиву показувати до «ще». */
    +const ARRAY_HEAD = 50
    +/** Довжина рядка, після якої він ховається під «показати повністю». */
    +const STRING_CLAMP = 300
    +
    +export function JsonView({ value, label }: { value: unknown; label?: string }) {
    +  if (value === undefined || value === null) {
    +    return 

    порожньо

    + } + return ( +
    + +
    + ) +} + +function Node({ k, v, depth }: { k?: string; v: unknown; depth: number }) { + const [open, setOpen] = useState(depth < AUTO_OPEN_DEPTH) + const [showAll, setShowAll] = useState(false) + + const isArray = Array.isArray(v) + const isObject = !isArray && typeof v === 'object' && v !== null + + if (!isArray && !isObject) { + return ( +
    + {k !== undefined && {k}:} + +
    + ) + } + + const entries: [string, unknown][] = isArray + ? (v as unknown[]).map((e, i) => [String(i), e]) + : Object.entries(v as Record) + + // Порожні контейнери не варті кнопки згортання: вона обіцяє вміст, + // якого немає. + if (entries.length === 0) { + return ( +
    + {k !== undefined && {k}:} + {isArray ? '[]' : '{}'} +
    + ) + } + + const shown = showAll ? entries : entries.slice(0, ARRAY_HEAD) + const hidden = entries.length - shown.length + + return ( +
    + + + {open && ( +
    + {shown.map(([key, val]) => ( + + ))} + {hidden > 0 && ( + + )} +
    + )} +
    + ) +} + +function Scalar({ v }: { v: unknown }) { + const [full, setFull] = useState(false) + + if (typeof v === 'string') { + // Багаторядкове (вивід команди, стенограма) лишається багаторядковим: + // склеїти його в один рядок означає зробити нечитабельним саме те, + // заради чого на нього дивляться. + const long = v.length > STRING_CLAMP + const text = long && !full ? v.slice(0, STRING_CLAMP) + '…' : v + return ( + + {text} + {long && ( + + )} + + ) + } + if (typeof v === 'number') return {v} + if (typeof v === 'boolean') return {String(v)} + return null +} diff --git a/web/src/components/MapAddHosts.tsx b/web/src/components/MapAddHosts.tsx index cc5bdbe..c989fe4 100644 --- a/web/src/components/MapAddHosts.tsx +++ b/web/src/components/MapAddHosts.tsx @@ -88,7 +88,7 @@ export function MapAddHosts({
    - Знайдено {plural(list.length, 'хост', 'хости', 'хостів')}, доступно до додавання{' '} + Знайдено {list.length} {plural(list.length, ['хост', 'хости', 'хостів'])}, доступно до додавання{' '} {selectable.length} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index 60cd90e..59cffb9 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -15,11 +15,22 @@ import { ConnectionMode, ReactFlowProvider, useReactFlow, + useStore, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { DeviceNode, type DeviceNodeType } from './DeviceNode' import { TrafficEdge, type TrafficEdgeType } from './TrafficEdge' +import { edgeState } from './edgeState' +import { + EDGE_DEFAULTS, + EDGE_LABEL_DEFAULT, + autoLabelPositions, + autoSides, + type EdgeLabelField, +} from './mapStyle' +import { setLabelZoom } from './labelLayout' +import { plural } from './ui' import type { MapPatch, MapState } from '../types' const nodeTypes = { device: DeviceNode } @@ -84,6 +95,12 @@ function MapCanvasInner({ iconId: n.style?.iconId as string | undefined, iconSize: n.style?.iconSize as number | undefined, hideMetrics: n.style?.hideMetrics as boolean | undefined, + // Адреса під іменем. Хост із іменем «ЦЕХ-2» нічого не каже + // тому, хто прийшов лагодити; 10.0.3.7 економить перехід у + // перелік хостів. + address: n.address, + showAddress: n.style?.showAddress as boolean | undefined, + deleted: n.device_deleted, }, })), [state.nodes, readOnly], @@ -97,6 +114,20 @@ function MapCanvasInner({ return m }, [state.nodes]) + // Вузли видалених хостів: лінія до такого веде в нікуди, і це окремий + // стан ребра, а не «просто не звірено». + const goneNodes = useMemo( + () => new Set(state.nodes.filter((n) => n.device_deleted).map((n) => n.id)), + [state.nodes], + ) + + // Де стоятимуть підписи ліній. + // + // Рахується для всіх ребер разом, а не в кожному окремо: ребро не + // знає, скільки ще ліній сходиться в його вузлах, а саме це й + // визначає, чи вистачить місця підпису в середині лінії. + const labelAt = useMemo(() => autoLabelPositions(state.edges, nodePos), [state.edges, nodePos]) + const rfEdges = useMemo( () => state.edges.map((e) => { @@ -120,6 +151,10 @@ function MapCanvasInner({ selected: e.id === selectedEdge, data: { label: e.label, + // Форма лінії з бази. Досі сюди не доїжджала, і полотно + // завжди малювало криву — вибір в інспекторі зберігався й + // не робив нічого. + shape: e.style, dash: e.dash, widthPx: e.width_px, color: e.color, @@ -128,14 +163,23 @@ function MapCanvasInner({ linkStatus: e.link_status, utilPct: e.util_pct, capacityBps: e.capacity_bps, - warnPct: e.thresholds?.warn_pct ?? 70, - critPct: e.thresholds?.crit_pct ?? 90, + warnPct: e.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct, + critPct: e.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct, animated: e.animation?.enabled !== false, showMetrics: e.show_metrics, + fields: (e.view?.show as EdgeLabelField[] | undefined) ?? EDGE_LABEL_DEFAULT, + labelAt: e.view?.label_at, + autoLabelAt: labelAt.get(e.id) ?? 0.5, + arrow: e.view?.arrow, + // Стан звірки рахується тут, а не в самому ребрі: та сама + // функція живить інспектор, і два незалежні розрахунки + // рано чи пізно розійшлися б у трактуванні. + state: edgeState(e, goneNodes.has(e.source_node_id) || goneNodes.has(e.target_node_id)) + .key, }, } }), - [state.edges, selectedEdge, nodePos], + [state.edges, selectedEdge, nodePos, goneNodes, labelAt], ) const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes) @@ -175,12 +219,22 @@ function MapCanvasInner({ [onSelectionChange], ) + // Перехід з вузла на вузол приходить ОДНИМ пакетом змін: зняти + // виділення зі старого й поставити на новий. Раніше цикл кликав + // onSelect на кожну зміну, тож підсумок залежав від порядку в + // масиві — прийшов «зняли» останнім, і панель показувала не той + // вузол або закривалась на щойно обраному. Беремо підсумок пакета: + // якщо в ньому когось обрали — це він, інакше вибору немає. const handleNodesChange = useCallback( (changes: NodeChange[]) => { onNodesChange(changes) + let picked: string | null | undefined for (const c of changes) { - if (c.type === 'select') onSelect(c.selected ? c.id : null) + if (c.type !== 'select') continue + if (c.selected) picked = c.id + else if (picked === undefined) picked = null } + if (picked !== undefined) onSelect(picked) }, [onNodesChange, onSelect], ) @@ -197,7 +251,7 @@ function MapCanvasInner({ return !before || Math.round(before.x) !== n.x || Math.round(before.y) !== n.y }) if (upsert.length === 0) return - onPatch({ nodes: { upsert } }, `перетягування ${upsert.length} вузлів`) + onPatch({ nodes: { upsert } }, `перетягування ${upsert.length} ${plural(upsert.length, ['вузла', 'вузлів', 'вузлів'])}`) }, [onPatch, state.nodes], ) @@ -327,6 +381,8 @@ function MapCanvasInner({ proOptions={{ hideAttribution: false }} className="netpulse-canvas" > + + {/* Два шари сітки: дрібна для відчуття масштабу й велика для орієнтації. Одна сітка або рябить, або не читається — залежно від кроку, а крок задає людина. */} @@ -368,6 +424,19 @@ function MapCanvasInner({ ) } +/** + * Масштаб полотна — для розкладки підписів. + * + * Окремий компонент, який нічого не малює, свідомо: підписка на зум у + * самому полотні перемальовувала б усі вузли й лінії на кожен щиглик + * колеса, а тут перемальовується один порожній вузол. + */ +function LabelZoomProbe() { + const zoom = useStore((s) => s.transform[2]) + useEffect(() => setLabelZoom(zoom), [zoom]) + return null +} + /** * Ідентифікатор порту з бази у вигляд, який розуміє полотно. * @@ -379,25 +448,6 @@ function normalizeHandle(h?: string): string | null { return h.endsWith('-s') ? h.slice(0, -2) : h } -/** - * Боки підключення для лінії без явної привʼязки. - * - * React Flow сам найкоротший бік не шукає — без імені порту він бере - * перший-ліпший, і лінія чіпляється до лівого краю навіть коли сусід - * стоїть праворуч. Тому рахуємо самі: що більше — різниця по осі X чи - * по Y — та вісь і диктує пару боків. - */ -function autoSides( - a?: { x: number; y: number }, - b?: { x: number; y: number }, -): [string, string] { - if (!a || !b) return ['r', 'l'] - const dx = b.x - a.x - const dy = b.y - a.y - if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? ['r', 'l'] : ['l', 'r'] - return dy >= 0 ? ['b', 't'] : ['t', 'b'] -} - /** * React Flow тримає стан у контексті, і useReactFlow працює лише * всередині нього. Провайдер тут, а не в сторінці, щоб виклик мапи diff --git a/web/src/components/MapSettings.tsx b/web/src/components/MapSettings.tsx index 4660ecf..90cc71b 100644 --- a/web/src/components/MapSettings.tsx +++ b/web/src/components/MapSettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../api/client' -import { Button, ErrorNote, Modal, inputClass, plural } from './ui' +import { Button, ErrorNote, Field, Modal, inputClass, plural } from './ui' import type { Icon, MapPermission, MapSummary, UserGroup } from '../types' const LEVELS: { key: string; label: string; hint: string }[] = [ @@ -10,11 +10,10 @@ const LEVELS: { key: string; label: string; hint: string }[] = [ ] /** - * Налаштування мапи: доступи груп і бібліотека іконок. + * Налаштування мапи: полотно, доступи груп і бібліотека іконок. * - * Доступи й іконки в одному вікні свідомо: і те, і те налаштовують раз - * на мапу й рідко, і розкидати їх по різних місцях означало б змусити - * шукати. + * Усе в одному вікні свідомо: це налаштовують раз на мапу й рідко, і + * розкидати по різних місцях означало б змусити шукати. */ export function MapSettings({ map, @@ -25,12 +24,15 @@ export function MapSettings({ onClose: () => void onChanged: () => void }) { - const [tab, setTab] = useState<'access' | 'icons'>('access') + const [tab, setTab] = useState<'canvas' | 'access' | 'icons'>('canvas') return (
    + @@ -39,16 +41,107 @@ export function MapSettings({
    - {tab === 'access' ? ( - - ) : ( - - )} + {tab === 'canvas' && } + {tab === 'access' && } + {tab === 'icons' && }
    ) } +/** + * Полотно: назва мапи й сітка. + * + * Сітку полотно читало з мапи від самого початку (`state.grid`), а + * задати її не давало ніде — крок 16 px був вирізаний у камені. Крок + * сітки визначає, наскільки рівно стають вузли при перетягуванні, і на + * схемі з рядами хостів це найпомітніше налаштування з усіх. + * + * Ревізія навмисно не передається: це правка властивостей мапи, а не + * полотна, і 409 «вас випередили» тут лише заважав би — двоє людей, які + * одночасно змінюють крок сітки, це не конфлікт даних. + */ +function CanvasTab({ map, onChanged }: { map: MapSummary; onChanged: () => void }) { + const [name, setName] = useState(map.name) + const [grid, setGrid] = useState('') + const [snap, setSnap] = useState(true) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + // Сітка живе в стані полотна, а не в переліку мап, тож читаємо її + // окремо: інакше форма показувала б типові значення замість заданих. + useEffect(() => { + api + .getMap(map.id) + .then((st) => { + setGrid(String(st.grid?.size ?? 16)) + setSnap(st.grid?.snap !== false) + }) + .catch(() => {}) + }, [map.id]) + + return ( +
    + + setName(e.target.value)} /> + + +
    + + setGrid(e.target.value)} + /> + + +
    + + {err} + +
    + +
    +
    + ) +} + function AccessTab({ mapID, onChanged }: { mapID: string; onChanged: () => void }) { const [groups, setGroups] = useState([]) const [perms, setPerms] = useState([]) @@ -79,12 +172,16 @@ function AccessTab({ mapID, onChanged }: { mapID: string; onChanged: () => void if (loading) return

    Завантаження…

    + // Число й слово тепер друкуються поруч, тож рахунок беремо один раз: + // двічі писати той самий filter у рядку — напрошуватись на розбіжність. + const allowed = perms.filter((p) => p.level !== 'deny').length + return (

    {perms.length === 0 ? 'Зараз мапа доступна всім, хто має право дивитись мапи. Щойно ви дасте доступ хоч одній групі, решта її не побачить.' - : `Мапу бачать ${plural(perms.filter((p) => p.level !== 'deny').length, 'група', 'групи', 'груп')}. Заборона перекриває дозвіл.`} + : `Мапу бачать ${allowed} ${plural(allowed, ['група', 'групи', 'груп'])}. Заборона перекриває дозвіл.`}

    {groups.length === 0 ? ( diff --git a/web/src/components/MetricChart.tsx b/web/src/components/MetricChart.tsx index c4cb3c0..401eba1 100644 --- a/web/src/components/MetricChart.tsx +++ b/web/src/components/MetricChart.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { fmtValue } from './ui' import type { MetricPoint, MetricSeries } from '../types' // Палітра ліній. Кольори підібрані так, щоб розрізнятися й у відтінках @@ -25,6 +26,7 @@ export function MetricChart({ kind = 'line', min, max, + emptyNote, }: { series: MetricSeries[] /** line | area | stacked | bar */ @@ -32,13 +34,23 @@ export function MetricChart({ /** Жорсткі межі осі. Порожньо — рахуються з даних. */ min?: number max?: number + /** + * Чим замінити «За цей період даних немає». + * + * Порожній графік має дві різні причини, і плутати їх дорого саме + * тут: «за цей період нічого не приходило» лікується ширшим вікном + * або пошуком збою збору, а «цей ряд узагалі більше не наповнюють» — + * ні тим, ні тим. Знає про різницю той, хто добирав ряди, тому текст + * приходить звідти, а не вигадується полотном. + */ + emptyNote?: string }) { const model = useMemo(() => build(series, kind, min, max), [series, kind, min, max]) if (!model) { return ( -
    - За цей період даних немає +
    + {emptyNote || 'За цей період даних немає'}
    ) } @@ -239,21 +251,4 @@ function makeTicks(min: number, max: number) { return out } -/** - * Коротке число для осі. - * - * Біти на секунду й байти пам'яті на одній осі з відсотками не - * поміщаються без скорочення, а «14437155.5» на графіку не читає ніхто. - */ -export function fmtValue(v: number): string { - const a = Math.abs(v) - if (a >= 1e9) return `${(v / 1e9).toFixed(1)}G` - if (a >= 1e6) return `${(v / 1e6).toFixed(1)}M` - if (a >= 1e3) return `${(v / 1e3).toFixed(1)}k` - if (a >= 10) return v.toFixed(0) - if (a >= 1) return v.toFixed(1) - if (a === 0) return '0' - return v.toFixed(3) -} - export type { MetricPoint } diff --git a/web/src/components/Minimap.tsx b/web/src/components/Minimap.tsx new file mode 100644 index 0000000..fa09116 --- /dev/null +++ b/web/src/components/Minimap.tsx @@ -0,0 +1,592 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +/** + * Мінікарта великого тексту — вузька смужка збоку з усім файлом у + * мініатюрі й рамкою поточного вікна. + * + * Розрахунок на реальний масштаб: ZTE C320 віддає 32 854 рядки. Звідси + * всі рішення нижче. + * + * ЧОМУ CANVAS, А НЕ DOM. Тридцять три тисячі рядків — це тридцять три + * тисячі елементів, тобто рівно те, від чого вже тікає віртуалізація в + * ConfigViewer. Малювати мініатюру елементами означало б повернути в + * сторінку весь той DOM, який вона обходить. + * + * ЧОМУ СМУЖКА ПРОКРУЧУЄТЬСЯ, А НЕ ВМІЩАЄ ВЕСЬ ФАЙЛ. Перша версія + * стискала весь документ у висоту смужки. На 32 000 рядків це п'ятдесят + * рядків на піксель — і мініатюра перетворювалась на суцільну заливку, + * бо в кожному пікселі знаходився хоч один довгий рядок і хоч один без + * відступу. Ні кольори, ні контраст цього не лікують: інформації там + * уже немає, лишається силует. + * + * Тому як у редакторах коду: рядок отримує щонайменше два пікселі, а + * коли файл у смужку не влазить — смужка сама їде за прокруткою. + * Видно рядки, а не хвилю. Короткий файл, як і раніше, показується + * цілком: там прокручувати нічого. + * + * Позначки (збіги пошуку, місця змін) при цьому лишаються по ВСЬОМУ + * документу — вузькою колонкою ліворуч. Інакше пошук втратив би головне: + * скільки збігів і де вони, а не лише ті, що поруч. + * + * ЧОМУ КІЛЬКА ШАРІВ. Позначки (збіги пошуку, місця змін) живуть на + * власному полотні й залежать лише від самих позначок. Рамка видимого + * вікна — звичайний div, який рухається зміною style. + * + * У режимі «влазить цілком» прокрутка не коштує жодного піксельного + * малювання взагалі. У режимі прокрутки вміст перемальовується разом із + * вікном — але це рівно стільки прямокутників, скільки рядків у вікні + * (близько трьохсот), а не тридцять тисяч. Ціна свідома: без неї + * мініатюра лишалась би суцільною плямою, тобто не працювала б зовсім. + */ + +/** Позначка на смужці: рядок (або діапазон рядків) певним кольором. */ +export interface MinimapMark { + /** Індекс рядка у віртуальному документі. */ + row: number + /** Скільки рядків займає позначка; 1 — один рядок. */ + span?: number + color: string +} + +/** + * Ширина смужки. + * + * Було 68 — і цього замало: на такій ширині слова зливаються в суцільну + * рису незалежно від того, як їх малювати. Разом із більшою шириною + * росте й MAX_COLS: на смужку лягає більше символів рядка, кожен — + * вужчий. Тобто «текст дрібніший, а видно його більше». + * + * Число ОДНЕ на полотно й на контейнер. Було два: полотно малювало по + * 180, а клас лишався `w-[68px]` від попередньої ширини — і браузер + * стискав намальоване в два з половиною рази. На екрані це виглядало не + * як помилка, а як «дрібний шрифт»: смужка чесно показувала весь текст, + * тільки сплюснутий по горизонталі. Тому ширина йде в style, а не + * класом: клас із довільним числом Tailwind збирає статично, і наступна + * зміна MAP_W знову розійшлася б із ним мовчки. + */ +export const MAP_W = 180 + +/** + * Скільки пікселів заввишки віддається рядку — і скільки з них займає + * сам рядок. + * + * Три й два: рядок малюється двома пікселями, третій лишається + * порожнім. Проміжок обов'язковий — без нього рядки зливаються в + * пляму; але й одного пікселя на рядок замало, він виходить блідим і + * рваним. Два пікселі дають щільний штрих, який видно чітко. + * + * Ціна — менше рядків у вікні (близько двохсот замість трьохсот). + * Розмін свідомий: мініатюра, на якій нічого не розібрати, не стає + * кориснішою від того, що вміщає більше. + */ +const ROW_PX = 3 +const BAR_PX = 2 + +/** Ширина колонки позначок по всьому документу (режим прокрутки). */ +const GUTTER_W = 6 + +/** Скільки символів рядка вміщається в ширину смужки. Далі — обрізаємо. */ +const MAX_COLS = 280 + +/** + * Скільки слів у рядку малюємо окремо. + * + * Саме проміжки між словами роблять смужку схожою на текст: суцільна + * риса від відступу до кінця рядка читається як штрих, а не як рядок. + * + * Стеля потрібна, бо в конфігах трапляються рядки на сотні коротких + * лексем (списки VLAN, префікс-листи): малювати їх усі — це тисячі + * прямокутників заради ділянки в кілька пікселів завширшки. + */ +const MAX_SEGS = 40 + +const SP = ' ' +const TAB = ' ' + +/** + * Форма тексту: довжина й відступ кожного рядка. + * + * Рахується один раз на текст. Int32Array, а не масив об'єктів: на + * 33 000 рядків різниця між двома типізованими масивами й тридцятьма + * трьома тисячами об'єктів — це мегабайти купи й робота для збирача + * сміття під час прокрутки. + */ +export interface Shape { + len: Int32Array + indent: Int32Array + /** Вид рядка: KIND_*. Визначає колір смужки. */ + kind: Uint8Array +} + +export function buildShape(lines: string[]): Shape { + const n = lines.length + const len = new Int32Array(n) + const indent = new Int32Array(n) + const kind = new Uint8Array(n) + for (let i = 0; i < n; i++) { + const s = lines[i] + len[i] = s.length + let k = 0 + while (k < s.length && (s.charCodeAt(k) === 32 || s.charCodeAt(k) === 9)) k++ + // Табуляція шириною чотири: у конфігах відступ несе структуру, і + // рахувати табуляцію за один символ означало б показати блоки + // Juniper пласкими. + let w = 0 + for (let j = 0; j < k; j++) w += s.charCodeAt(j) === 9 ? 4 : 1 + indent[i] = s.length === k ? 0 : w + + // Вид рядка визначає колір на мініатюрі. Без нього карта — рівна + // сіра штриховка, на якій не видно, де закінчився один розділ і + // почався інший; а саме це людина й шукає, коли тягне повзунок. + if (s.length === k) { + kind[i] = KIND_BLANK + } else { + const c = s.charCodeAt(k) + // #, !, ; — коментар у всіх поширених CLI; «//» — у JunOS-подібних. + const comment = + c === 35 || c === 33 || c === 59 || (c === 47 && s.charCodeAt(k + 1) === 47) + if (comment) kind[i] = KIND_COMMENT + else if (w === 0) kind[i] = KIND_TOP + else kind[i] = KIND_BODY + } + } + return { len, indent, kind } +} + +// Види рядків. Числами, а не рядками: масив на тридцять тисяч +// елементів має бути Uint8Array, інакше сама мініатюра коштує більше +// за текст, який вона показує. +export const KIND_BLANK = 0 +export const KIND_TOP = 1 +export const KIND_BODY = 2 +export const KIND_COMMENT = 3 + +// Кольори підібрані так, щоб карта читалась як текст, а не як +// штрихування: рядок верхнього рівня — найяскравіший, бо саме він +// розділяє розділи; коментар — приглушений, бо його читають востаннє. +// +// Насиченість тут навмисно висока. Мініатюра шириною в кількасот +// пікселів і так втрачає все, крім силуету; бліда заливка перетворює +// цей силует на рівний сірий шум, у якому не видно жодної межі. +/** Колір знайденого рядка. Той самий бурштин, що й у підсвітці тексту. */ +const HIT_COLOR = '#fbbf24' + +export const KIND_COLOR: Record = { + [KIND_TOP]: '#e2e8f0', + [KIND_BODY]: '#94a3b8', + [KIND_COMMENT]: '#64748b', +} + +/** + * Колір штриха рядка. null — рядок не малюється зовсім. + * + * Окремою функцією, бо це єдине місце мінікарти, де є що перевірити без + * полотна, — і саме тут ховалась помилка: рядок із самих пробілів + * отримує KIND_BLANK, але довжину має ненульову, тож доходив до + * малювання. Кольору для KIND_BLANK у таблиці немає, спрацьовував + * запасний колір тіла — і порожній рядок виглядав звичайним. А саме + * порожні рядки розділяють розділи конфігу: намальовані як текст, вони + * стирають межі, по яких людина на мінікарту й дивиться. + * + * Знайдений рядок перефарбовується цілком, а не заклеюється смугою + * поверх: смуга на всю ширину ховала форму рядка — те саме, що людина + * шукає очима. + */ +export function rowColor(shape: Shape, i: number, hit: boolean): string | null { + if (shape.len[i] <= 0 || shape.kind[i] === KIND_BLANK) return null + if (hit) return HIT_COLOR + return KIND_COLOR[shape.kind[i]] ?? KIND_COLOR[KIND_BODY] +} + +export function Minimap({ + count, + offsets, + total, + viewTop, + viewH, + shape, + marks, + hits, + lineAt, + onSeek, +}: { + /** Скільки рядків у віртуальному документі. */ + count: number + /** Верх кожного рядка в пікселях документа; довжина count+1. */ + offsets: Int32Array + /** Повна висота документа. */ + total: number + /** Поточна позиція прокрутки. */ + viewTop: number + /** Висота видимого вікна. */ + viewH: number + shape: Shape + marks?: MinimapMark[] + /** Куди прокрутити (координата документа, верх вікна). */ + /** + * Текст рядка. Без нього мініатюра малює суцільні риси; з ним — + * окремі слова, і саме це відрізняє «схоже на текст» від + * «схоже на штрихування». + * + * Функція, а не масив: читаються лише ті кількасот рядків, що зараз + * у вікні смужки, і тримати заради них другу копію документа не треба. + */ + /** + * Рядки зі збігами пошуку. + * + * Окремо від marks, бо поводяться інакше: збіг — це один рядок, який + * треба ПЕРЕФАРБУВАТИ, а не ділянка, яку треба позначити смугою. + * Множина, а не масив позначок: сюди потрапляють усі збіги до + * єдиного, і на двадцяти тисячах різниця відчутна. + */ + hits?: Set + lineAt?: (i: number) => string + onSeek: (top: number) => void +}) { + const box = useRef(null) + const content = useRef(null) + const overlay = useRef(null) + const gutter = useRef(null) + const [h, setH] = useState(0) + + useEffect(() => { + const el = box.current + if (!el) return + const ro = new ResizeObserver(() => setH(el.clientHeight)) + ro.observe(el) + setH(el.clientHeight) + return () => ro.disconnect() + }, []) + + // Чи влазить документ цілком. + // + // Влазить — показуємо весь, як карту; не влазить — смужка їде за + // прокруткою й показує вікно рядків. Межа не в кількості рядків, а в + // тому, чи лишається рядку два пікселі: саме з них починається + // схожість на текст. + const winRows = Math.max(1, Math.floor(h / ROW_PX)) + const fits = count > 0 && count <= winRows + + // Скільки пікселів смужки припадає на піксель документа. Потрібне + // лише в режимі «влазить цілком». + const k = total > 0 ? h / total : 0 + + // Перший рядок вікна смужки. + // + // Частка прокрутки документа стає часткою прокрутки смужки: коли + // людина внизу файлу — смужка теж унизу. Так само поводяться + // мінікарти в редакторах, і будь-яка інша прив'язка збиває відчуття + // «де я». + const startRow = useMemo(() => { + if (fits || count <= winRows) return 0 + const room = Math.max(1, total - viewH) + const frac = Math.max(0, Math.min(1, viewTop / room)) + return Math.round(frac * (count - winRows)) + }, [fits, count, winRows, total, viewH, viewTop]) + + /** Рядок → y на смужці. -1, якщо рядок поза видимим вікном смужки. */ + const yOfRow = useCallback( + (i: number) => { + if (fits) return Math.min(h - 1, (offsets[i] * k) | 0) + const r = i - startRow + return r < 0 || r >= winRows ? -1 : r * ROW_PX + }, + [fits, offsets, k, h, startRow, winRows], + ) + + /** y на смужці → рядок. Зворотне до yOfRow. */ + const rowAtY = useCallback( + (y: number) => { + if (!fits) return Math.max(0, Math.min(count - 1, startRow + Math.floor(y / ROW_PX))) + // Двійковий пошук по offsets: рядки можуть мати різну висоту + // (у порівнянні є порожні заповнювачі), тож ділення не годиться. + const target = k > 0 ? y / k : 0 + let lo = 0 + let hi = count - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (offsets[mid] <= target) lo = mid + else hi = mid - 1 + } + return lo + }, + [fits, count, startRow, offsets, k], + ) + + // Найдовший рядок задає горизонтальний масштаб. Обрізаємо стелею: + // один рядок ACL на тисячу символів інакше стиснув би весь інший + // файл у ліву третину смужки й позбавив би мініатюру сенсу. + const cols = useMemo(() => { + let m = 1 + for (let i = 0; i < shape.len.length; i++) if (shape.len[i] > m) m = shape.len[i] + return Math.min(m, MAX_COLS) + }, [shape]) + + // --- шар 1: сам файл --- + useEffect(() => { + const cv = content.current + if (!cv || h <= 0 || count === 0) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + cv.width = Math.round(MAP_W * dpr) + cv.height = Math.round(h * dpr) + const ctx = cv.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, MAP_W, h) + + const left = fits ? 3 : GUTTER_W + 3 + const scale = (MAP_W - left - 3) / cols + + // У режимі «влазить цілком» рядку дістається стільки пікселів, + // скільки дає масштаб; беремо не більше, ніж у режимі прокрутки, + // щоб короткий файл не перетворювався на набір товстих брусків. + // Середня висота рядка документа в пікселях смужки. Точну взяти + // нема звідки — у порівнянні є порожні заповнювачі, — але для + // товщини штриха середньої досить. + const perRow = count > 0 ? (h * 1.0) / count : BAR_PX + const barH = fits ? Math.max(1, Math.min(BAR_PX, Math.floor(perRow) - 1 || 1)) : BAR_PX + + const bar = (a: number, b: number, y: number) => { + const x0 = left + Math.min(a, cols) * scale + const x1 = left + Math.min(b, cols) * scale + // Мінімум один піксель ширини: коротке `!` теж має бути видно. + ctx.fillRect(x0, y, Math.max(1, x1 - x0), barH) + } + + const draw = (i: number) => { + const color = rowColor(shape, i, hits?.has(i) === true) + if (color === null) return + const L = shape.len[i] + const y = yOfRow(i) + if (y < 0) return + ctx.fillStyle = color + + // Без доступу до самого тексту лишається суцільна риса від + // відступу до кінця рядка. Працює, але читається гірше. + const text = lineAt?.(i) + if (!text) { + bar(shape.indent[i], L, y) + return + } + + let j = 0 + let segs = 0 + while (j < text.length && segs < MAX_SEGS) { + while (j < text.length && (text[j] === SP || text[j] === TAB)) j++ + const a = j + while (j < text.length && text[j] !== SP && text[j] !== TAB) j++ + if (j > a) { + bar(a, j, y) + segs++ + } + } + } + + if (fits) { + for (let i = 0; i < count; i++) draw(i) + } else { + const last = Math.min(count, startRow + winRows) + for (let i = startRow; i < last; i++) draw(i) + } + }, [count, shape, cols, h, fits, startRow, winRows, yOfRow, lineAt, hits]) + + // --- шар 1б: позначки по ВСЬОМУ документу (лише в режимі прокрутки) --- + // + // Вікно смужки показує кількасот рядків із тридцяти тисяч. Без цієї + // колонки людина, яка шукає слово, бачила б лише збіги поруч і не + // знала б ні скільки їх усього, ні куди прокручувати. + useEffect(() => { + const cv = gutter.current + if (!cv || h <= 0) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + cv.width = Math.round(GUTTER_W * dpr) + cv.height = Math.round(h * dpr) + const ctx = cv.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, GUTTER_W, h) + if (fits || count === 0) return + + // Щільність, а не «є/немає». + // + // На 32 000 рядків у 620 пікселів в один піксель падає п'ятдесят + // рядків. Бінарна заливка перетворює колонку на суцільну смугу вже + // від сотні розкиданих збігів — і людина бачить «збіги всюди» + // замість «ось де їх багато». + const kk = h / count + const dens = new Float32Array(h) + let peak = 0 + const bump = (row: number) => { + if (row < 0 || row >= count) return + const y = Math.min(h - 1, (row * kk) | 0) + dens[y] += 1 + if (dens[y] > peak) peak = dens[y] + } + let color = 'rgba(251,191,36,0.9)' + if (hits) for (const row of hits) bump(row) + else if (marks) { + for (const m of marks) bump(m.row) + if (marks.length > 0) color = marks[0].color + } + if (peak === 0) return + + for (let y = 0; y < h; y++) { + const d = dens[y] + if (d === 0) continue + // Корінь, а не пряма пропорція: поодинокий збіг має лишатись + // помітним поруч зі згустком на півсотні. + ctx.globalAlpha = 0.35 + 0.65 * Math.sqrt(d / peak) + ctx.fillStyle = color + ctx.fillRect(0, y, GUTTER_W, 1) + } + ctx.globalAlpha = 1 + }, [marks, hits, count, h, fits]) + + // --- шар 2: позначки --- + useEffect(() => { + const cv = overlay.current + if (!cv || h <= 0) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + cv.width = Math.round(MAP_W * dpr) + cv.height = Math.round(h * dpr) + const ctx = cv.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, MAP_W, h) + if (!marks || marks.length === 0 || count === 0) return + + // Двадцять тисяч збігів пошуку в шістсот пікселів — це двадцять + // тисяч викликів fillRect поверх шести сотень видимих ліній. + // Позначка на піксель уже намальована — другу малювати нема сенсу. + // Збіги пошуку сюди не потрапляють: їх малює шар вмісту, кольором + // самого рядка. Тут лишаються смуги порівняння — вони позначають + // ділянку, а не окремий рядок, і закривати текст їм не страшно. + const left = fits ? 0 : GUTTER_W + const painted = new Map() + for (const m of marks) { + if (m.row < 0 || m.row >= count) continue + const y0 = yOfRow(m.row) + if (y0 < 0) continue + const y1 = fits + ? Math.min(h, (offsets[Math.min(count, m.row + (m.span ?? 1))] * k) | 0) + : y0 + Math.max(1, (m.span ?? 1) * ROW_PX) + for (let y = y0; y < Math.max(y0 + 1, Math.min(h, y1)); y++) { + if (!painted.has(y)) painted.set(y, m.color) + } + } + for (const [y, color] of painted) { + ctx.fillStyle = color + ctx.fillRect(left, y, MAP_W - left, 1) + } + }, [marks, offsets, count, h, k, fits, yOfRow]) + + // --- перетягування --- + // + // Клік і тяга — одна дія, а не дві: людина тисне на потрібне місце й + // нерідко одразу ж підправляє позицію, не відпускаючи кнопки. + // setPointerCapture потрібен, щоб курсор, який виїхав за смужку, не + // обривав тягу — інакше довелося б вести мишу рівно по ширині смужки. + const seekTo = useCallback( + (clientY: number) => { + const el = box.current + if (!el || k <= 0) return + const rect = el.getBoundingClientRect() + const y = Math.max(0, Math.min(rect.height, clientY - rect.top)) + // Клікнуте місце стає ЦЕНТРОМ вікна, а не його верхом: людина + // цілиться в те, що хоче побачити, а не в межу екрана. + onSeek(Math.max(0, offsets[rowAtY(y)] - viewH / 2)) + }, + [rowAtY, offsets, viewH, onSeek], + ) + + const [dragging, setDragging] = useState(false) + + // Рамка видимого вікна. Звичайний div: прокрутка рухає його зміною + // style, і жодного піксельного малювання при цьому не відбувається. + // Рамка видимого вікна. + // + // У режимі прокрутки вона рахується в РЯДКАХ, а не в пікселях + // документа: смужка тепер показує рядки, і рамка, побудована на + // піксельній частці, з'їжджала б із них на пів екрана. + const firstRow = fits ? 0 : viewFirstRow(offsets, count, viewTop) + const rowsInView = fits ? 0 : Math.max(1, Math.round(viewH / Math.max(1, total / count))) + const frameTop = fits + ? Math.max(0, viewTop * k) + : Math.max(0, (firstRow - startRow) * ROW_PX) + const frameH = fits + ? Math.max(4, Math.min(h - frameTop, viewH * k)) + : Math.max(4, Math.min(h - frameTop, rowsInView * ROW_PX)) + + if (count === 0) return null + + return ( +