From 2c7a0236da7682feaa942675ccd0d2ab35ea21d5 Mon Sep 17 00:00:00 2001 From: Gleb Tv Date: Mon, 13 Jul 2026 17:55:14 +0300 Subject: [PATCH] feat: publish standalone worker Separate worker packaging and service lifecycle from the control plane. --- .dockerignore | 8 + .env.example | 26 + .github/workflows/ci.yml | 27 + .github/workflows/docker.yml | 79 ++ .gitignore | 8 + Dockerfile | 37 + LICENSE | 62 + Makefile | 43 + NOTICE | 6 + README.md | 127 ++ app/models/access.go | 60 + app/models/account.go | 117 ++ app/models/account_test.go | 283 +++++ app/models/api_key.go | 38 + app/models/audited.go | 63 + app/models/authidentity/auth_identity.go | 32 + app/models/authidentity/sign_logs.go | 50 + app/models/bits.go | 31 + app/models/check.go | 171 +++ app/models/check_aggregator.go | 336 ++++++ app/models/check_aggregator_test.go | 448 +++++++ app/models/check_data.go | 14 + app/models/check_jobs.go | 415 +++++++ app/models/check_jobs_test.go | 451 ++++++++ app/models/check_metric_test.go | 42 + app/models/check_region_result.go | 33 + app/models/check_settings.go | 155 +++ app/models/check_settings_test.go | 60 + app/models/cleanup_stale_test.go | 181 +++ app/models/concerns/has_token.go | 41 + app/models/concerns/model.go | 14 + app/models/concerns/renderable.go | 7 + app/models/concerns/soft_delete.go | 12 + app/models/concerns/timestamped.go | 11 + app/models/contact.go | 86 ++ app/models/contact_test.go | 65 ++ app/models/credential_crypto.go | 97 ++ app/models/dead_worker_reaper.go | 120 ++ app/models/dead_worker_reaper_test.go | 274 +++++ app/models/deletion.go | 582 ++++++++++ app/models/deletion_test.go | 352 ++++++ app/models/deployment.go | 240 ++++ app/models/dns_record.go | 80 ++ app/models/domain.go | 32 + app/models/drop.go | 96 ++ app/models/event.go | 83 ++ app/models/group.go | 76 ++ app/models/group_test.go | 58 + app/models/init.go | 30 + app/models/inventory_test.go | 222 ++++ app/models/invite.go | 52 + app/models/llm.go | 17 + app/models/maintenance.go | 324 ++++++ app/models/maintenance_migration_test.go | 33 + app/models/maintenance_notifications.go | 44 + app/models/maintenance_test.go | 105 ++ app/models/message.go | 48 + app/models/migrate.go | 750 ++++++++++++ app/models/migrate_test.go | 162 +++ app/models/monitor.go | 534 +++++++++ app/models/monitor_state_test.go | 215 ++++ app/models/monitor_transfer_test.go | 94 ++ app/models/network_diagnostics.go | 528 +++++++++ app/models/notification.go | 193 +++ app/models/notification_credential.go | 167 +++ app/models/notification_credential_test.go | 186 +++ app/models/notification_get_contacts_test.go | 146 +++ app/models/notification_test.go | 128 ++ app/models/payment.go | 18 + app/models/plan.go | 108 ++ app/models/region.go | 20 + app/models/repo.go | 38 + app/models/rkn_domain.go | 135 +++ app/models/rkn_domain_test.go | 90 ++ app/models/rkn_ip.go | 193 +++ app/models/rkn_ip_test.go | 114 ++ app/models/selfcheck.go | 55 + app/models/server.go | 388 +++++++ app/models/server_health_ticker.go | 43 + app/models/server_ip.go | 36 + app/models/site.go | 98 ++ app/models/stats_data.go | 120 ++ app/models/status_page.go | 461 ++++++++ app/models/status_page_delivery.go | 408 +++++++ app/models/status_page_test.go | 401 +++++++ app/models/subscription.go | 61 + app/models/tag.go | 47 + app/models/tags_test.go | 135 +++ app/models/task.go | 145 +++ app/models/task_reaper.go | 136 +++ app/models/task_selector.go | 391 +++++++ app/models/task_test.go | 681 +++++++++++ app/models/telegram_bot.go | 77 ++ app/models/telegram_bot_test.go | 24 + app/models/user.go | 233 ++++ app/models/whois.go | 23 + app/models/worker_log_event.go | 31 + app/models/worker_node.go | 452 ++++++++ checks/calls/init.go | 687 +++++++++++ checks/calls/init_test.go | 11 + checks/calls/result.go | 4 + checks/cbssl/README.md | 158 +++ checks/cbssl/cbssl.go | 429 +++++++ checks/cbssl/cbssl_test.go | 135 +++ checks/cbssl/result.go | 76 ++ checks/cdns/a_records.go | 39 + checks/cdns/config.go | 39 + checks/cdns/dns.go | 234 ++++ checks/cdns/local_query.go | 67 ++ checks/cdns/master_task.go | 123 ++ checks/cdns/result.go | 109 ++ checks/cdns/soa_query.go | 82 ++ checks/cdns/types.go | 37 + checks/cftp/cftp.go | 50 + checks/cftp/result.go | 10 + checks/chttp/http.go | 179 +++ checks/chttp/result.go | 41 + checks/cping/ping.go | 378 ++++++ checks/cping/ping_test.go | 159 +++ checks/cping/result.go | 60 + checks/crkn/result.go | 11 + checks/crkn/rkn_init.go | 61 + checks/crkn/rkn_init_test.go | 134 +++ checks/cssh/cssh.go | 49 + checks/cssh/result.go | 10 + checks/cssl/cssl.go | 79 ++ checks/cssl/result.go | 10 + checks/ctcp/result.go | 39 + checks/ctcp/tcp.go | 77 ++ checks/ctcp/tcp_test.go | 164 +++ checks/cudp/result.go | 39 + checks/cudp/udp.go | 161 +++ checks/cudp/udp_test.go | 170 +++ checks/cwhois/result.go | 14 + checks/cwhois/whois.go | 69 ++ checks/llmhttp/init.go | 376 ++++++ checks/llmhttp/result.go | 52 + checks/llmhttp/result_test.go | 133 +++ cmd/rsmon-worker/main.go | 588 ++++++++++ config/application/init.go | 21 + config/credis/redis.go | 69 ++ config/database/database.go | 80 ++ config/env/env.go | 95 ++ config/secrets/secrets.go | 172 +++ config/translator/translator.go | 47 + docker-compose.yml | 17 + go.mod | 123 ++ go.sum | 494 ++++++++ internal/checkexec/exec.go | 95 ++ internal/checkresult/result.go | 38 + internal/distworker/client.go | 185 +++ internal/distworker/config.go | 203 ++++ internal/distworker/config_test.go | 197 ++++ internal/distworker/consensus.go | 237 ++++ internal/distworker/notification.go | 280 +++++ internal/distworker/notification_test.go | 233 ++++ internal/distworker/peer.go | 241 ++++ internal/distworker/peer_test.go | 424 +++++++ internal/distworker/results.go | 156 +++ internal/distworker/results_test.go | 141 +++ internal/distworker/runner.go | 1030 +++++++++++++++++ internal/distworker/runner_protocol_test.go | 19 + internal/distworker/runner_test.go | 318 +++++ internal/distworker/selfcheck.go | 373 ++++++ internal/distworker/selfcheck_test.go | 625 ++++++++++ internal/distworker/server_metrics.go | 271 +++++ internal/distworker/server_metrics_test.go | 55 + internal/distworker/types.go | 27 + internal/influx/influx.go | 397 +++++++ internal/influx/influx_test.go | 358 ++++++ internal/netaddr/cidr.go | 46 + internal/netaddr/cidr_test.go | 116 ++ internal/netaddr/inet.go | 39 + internal/netaddr/inet_test.go | 67 ++ internal/netaddr/macaddr.go | 43 + internal/netaddr/macaddr_test.go | 64 + internal/netaddr/main_test.go | 35 + internal/netaddr/testutil.go | 39 + internal/notifier/email.go | 88 ++ internal/notifier/notifier.go | 77 ++ internal/notifier/producer.go | 166 +++ internal/notifier/producer_test.go | 219 ++++ internal/notifier/run.go | 370 ++++++ internal/notifier/run_exp.go | 117 ++ internal/notifier/run_exp_test.go | 160 +++ internal/notifier/run_test.go | 163 +++ internal/notify/email.go | 57 + internal/notify/email_network_test.go | 202 ++++ internal/notify/email_test.go | 54 + internal/notify/telegram.go | 66 ++ internal/notify/telegram_network_test.go | 110 ++ internal/notify/telegram_test.go | 53 + internal/notifyrender/count.go | 34 + internal/notifyrender/event_table.go | 95 ++ internal/notifyrender/get_content.go | 98 ++ internal/notifyrender/text_down_one.go | 53 + internal/notifyrender/text_expires.go | 48 + internal/notifyrender/text_up_one.go | 76 ++ internal/sender/context_test.go | 34 + internal/sender/count.go | 29 + internal/sender/email.go | 219 ++++ internal/sender/email_context_test.go | 273 +++++ internal/sender/event_table.go | 17 + internal/sender/event_table_test.go | 88 ++ internal/sender/get_content.go | 19 + internal/sender/get_content_test.go | 187 +++ internal/sender/invite.go | 85 ++ internal/sender/mattermost.go | 155 +++ internal/sender/run.go | 90 ++ internal/sender/sender.go | 33 + internal/sender/sms.go | 41 + internal/sender/telegram.go | 53 + internal/sender/test_message.go | 13 + internal/sender/text_down_one.go | 15 + internal/sender/text_expires.go | 15 + internal/sender/text_up_one.go | 15 + internal/sender/voice.go | 13 + internal/sender/webhook.go | 99 ++ internal/tg/bot.go | 411 +++++++ internal/tg/bot_test.go | 14 + internal/tg/debug/main.go | 78 ++ internal/util/format_duration.go | 35 + internal/util/format_duration_test.go | 15 + internal/util/unix/pidfile.go | 31 + internal/webapp/auth_password.go | 45 + internal/webapp/auth_password_test.go | 48 + internal/webapp/auth_sessionid.go | 33 + internal/webapp/constants.go | 47 + internal/webapp/cteq.go | 10 + internal/webapp/doc.go | 18 + internal/webapp/handlers_apps.go | 103 ++ internal/webapp/handlers_auth.go | 390 +++++++ internal/webapp/handlers_checks.go | 84 ++ internal/webapp/handlers_cluster.go | 136 +++ internal/webapp/handlers_cluster_test.go | 336 ++++++ internal/webapp/handlers_logs.go | 49 + internal/webapp/handlers_overview.go | 84 ++ internal/webapp/handlers_peer.go | 59 + internal/webapp/handlers_peer_test.go | 94 ++ internal/webapp/handlers_settings.go | 100 ++ internal/webapp/handlers_status.go | 38 + internal/webapp/handlers_updates.go | 129 +++ internal/webapp/handlers_updates_test.go | 272 +++++ internal/webapp/inventory.go | 446 +++++++ internal/webapp/inventory_test.go | 159 +++ internal/webapp/logbuffer.go | 99 ++ internal/webapp/logbuffer_test.go | 88 ++ internal/webapp/metrics.go | 446 +++++++ internal/webapp/metrics_linux.go | 29 + internal/webapp/metrics_other.go | 11 + internal/webapp/metrics_test.go | 140 +++ internal/webapp/middleware.go | 173 +++ internal/webapp/page_data.go | 40 + internal/webapp/routes.go | 184 +++ internal/webapp/scan.go | 15 + internal/webapp/server.go | 646 +++++++++++ internal/webapp/server_test.go | 600 ++++++++++ internal/webapp/static/style.css | 63 + internal/webapp/store.go | 492 ++++++++ internal/webapp/store_test.go | 233 ++++ internal/webapp/template_helpers.go | 84 ++ internal/webapp/templates.go | 98 ++ internal/webapp/templates/app_detail.html | 20 + internal/webapp/templates/apps.html | 32 + .../webapp/templates/change_password.html | 21 + internal/webapp/templates/checks.html | 34 + internal/webapp/templates/layout.html | 39 + internal/webapp/templates/login.html | 33 + internal/webapp/templates/logs.html | 17 + internal/webapp/templates/notifications.html | 32 + internal/webapp/templates/overview.html | 56 + internal/webapp/templates/settings.html | 31 + internal/webapp/templates/status.html | 82 ++ internal/webapp/templates/updates.html | 17 + internal/webapp/test_helpers.go | 189 +++ internal/wire/types.go | 319 +++++ internal/wire/types_test.go | 305 +++++ internal/workdays/workdays.go | 54 + internal/workercluster/admin_test.go | 256 ++++ internal/workercluster/bootstrap.go | 119 ++ internal/workercluster/cluster.go | 636 ++++++++++ internal/workercluster/demo_test.go | 212 ++++ internal/workercluster/e2e_test.go | 515 +++++++++ internal/workercluster/entries.go | 152 +++ internal/workercluster/entries_test.go | 80 ++ internal/workercluster/fsm.go | 394 +++++++ internal/workercluster/fsm_test.go | 219 ++++ internal/workercluster/snapshot.go | 190 +++ internal/workercluster/store.go | 235 ++++ internal/workercluster/store_test.go | 173 +++ internal/workercluster/testhelpers_test.go | 56 + internal/workercluster/transport.go | 287 +++++ internal/workercluster/transport_test.go | 133 +++ internal/workercluster/types.go | 138 +++ packaging/systemd/rsmon-worker.service | 28 + packaging/systemd/worker.env.example | 16 + scripts/install-systemd.sh | 99 ++ scripts/uninstall-systemd.sh | 14 + spec/factories/accounts.go | 26 + spec/factories/checks.go | 35 + spec/factories/contacts.go | 56 + spec/factories/events.go | 38 + spec/factories/groups.go | 40 + spec/factories/init.go | 1 + spec/factories/messages.go | 63 + spec/factories/monitors.go | 60 + spec/factories/notifications.go | 53 + spec/factories/users.go | 72 ++ storage/storage.go | 141 +++ 309 files changed, 44004 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 app/models/access.go create mode 100644 app/models/account.go create mode 100644 app/models/account_test.go create mode 100644 app/models/api_key.go create mode 100644 app/models/audited.go create mode 100644 app/models/authidentity/auth_identity.go create mode 100644 app/models/authidentity/sign_logs.go create mode 100644 app/models/bits.go create mode 100644 app/models/check.go create mode 100644 app/models/check_aggregator.go create mode 100644 app/models/check_aggregator_test.go create mode 100644 app/models/check_data.go create mode 100644 app/models/check_jobs.go create mode 100644 app/models/check_jobs_test.go create mode 100644 app/models/check_metric_test.go create mode 100644 app/models/check_region_result.go create mode 100644 app/models/check_settings.go create mode 100644 app/models/check_settings_test.go create mode 100644 app/models/cleanup_stale_test.go create mode 100644 app/models/concerns/has_token.go create mode 100644 app/models/concerns/model.go create mode 100644 app/models/concerns/renderable.go create mode 100644 app/models/concerns/soft_delete.go create mode 100644 app/models/concerns/timestamped.go create mode 100644 app/models/contact.go create mode 100644 app/models/contact_test.go create mode 100644 app/models/credential_crypto.go create mode 100644 app/models/dead_worker_reaper.go create mode 100644 app/models/dead_worker_reaper_test.go create mode 100644 app/models/deletion.go create mode 100644 app/models/deletion_test.go create mode 100644 app/models/deployment.go create mode 100644 app/models/dns_record.go create mode 100644 app/models/domain.go create mode 100644 app/models/drop.go create mode 100644 app/models/event.go create mode 100644 app/models/group.go create mode 100644 app/models/group_test.go create mode 100644 app/models/init.go create mode 100644 app/models/inventory_test.go create mode 100644 app/models/invite.go create mode 100644 app/models/llm.go create mode 100644 app/models/maintenance.go create mode 100644 app/models/maintenance_migration_test.go create mode 100644 app/models/maintenance_notifications.go create mode 100644 app/models/maintenance_test.go create mode 100644 app/models/message.go create mode 100644 app/models/migrate.go create mode 100644 app/models/migrate_test.go create mode 100644 app/models/monitor.go create mode 100644 app/models/monitor_state_test.go create mode 100644 app/models/monitor_transfer_test.go create mode 100644 app/models/network_diagnostics.go create mode 100644 app/models/notification.go create mode 100644 app/models/notification_credential.go create mode 100644 app/models/notification_credential_test.go create mode 100644 app/models/notification_get_contacts_test.go create mode 100644 app/models/notification_test.go create mode 100644 app/models/payment.go create mode 100644 app/models/plan.go create mode 100644 app/models/region.go create mode 100644 app/models/repo.go create mode 100644 app/models/rkn_domain.go create mode 100644 app/models/rkn_domain_test.go create mode 100644 app/models/rkn_ip.go create mode 100644 app/models/rkn_ip_test.go create mode 100644 app/models/selfcheck.go create mode 100644 app/models/server.go create mode 100644 app/models/server_health_ticker.go create mode 100644 app/models/server_ip.go create mode 100644 app/models/site.go create mode 100644 app/models/stats_data.go create mode 100644 app/models/status_page.go create mode 100644 app/models/status_page_delivery.go create mode 100644 app/models/status_page_test.go create mode 100644 app/models/subscription.go create mode 100644 app/models/tag.go create mode 100644 app/models/tags_test.go create mode 100644 app/models/task.go create mode 100644 app/models/task_reaper.go create mode 100644 app/models/task_selector.go create mode 100644 app/models/task_test.go create mode 100644 app/models/telegram_bot.go create mode 100644 app/models/telegram_bot_test.go create mode 100644 app/models/user.go create mode 100644 app/models/whois.go create mode 100644 app/models/worker_log_event.go create mode 100644 app/models/worker_node.go create mode 100644 checks/calls/init.go create mode 100644 checks/calls/init_test.go create mode 100644 checks/calls/result.go create mode 100644 checks/cbssl/README.md create mode 100644 checks/cbssl/cbssl.go create mode 100644 checks/cbssl/cbssl_test.go create mode 100644 checks/cbssl/result.go create mode 100644 checks/cdns/a_records.go create mode 100644 checks/cdns/config.go create mode 100644 checks/cdns/dns.go create mode 100644 checks/cdns/local_query.go create mode 100644 checks/cdns/master_task.go create mode 100644 checks/cdns/result.go create mode 100644 checks/cdns/soa_query.go create mode 100644 checks/cdns/types.go create mode 100644 checks/cftp/cftp.go create mode 100644 checks/cftp/result.go create mode 100644 checks/chttp/http.go create mode 100644 checks/chttp/result.go create mode 100644 checks/cping/ping.go create mode 100644 checks/cping/ping_test.go create mode 100644 checks/cping/result.go create mode 100644 checks/crkn/result.go create mode 100644 checks/crkn/rkn_init.go create mode 100644 checks/crkn/rkn_init_test.go create mode 100644 checks/cssh/cssh.go create mode 100644 checks/cssh/result.go create mode 100644 checks/cssl/cssl.go create mode 100644 checks/cssl/result.go create mode 100644 checks/ctcp/result.go create mode 100644 checks/ctcp/tcp.go create mode 100644 checks/ctcp/tcp_test.go create mode 100644 checks/cudp/result.go create mode 100644 checks/cudp/udp.go create mode 100644 checks/cudp/udp_test.go create mode 100644 checks/cwhois/result.go create mode 100644 checks/cwhois/whois.go create mode 100644 checks/llmhttp/init.go create mode 100644 checks/llmhttp/result.go create mode 100644 checks/llmhttp/result_test.go create mode 100644 cmd/rsmon-worker/main.go create mode 100644 config/application/init.go create mode 100644 config/credis/redis.go create mode 100644 config/database/database.go create mode 100644 config/env/env.go create mode 100644 config/secrets/secrets.go create mode 100644 config/translator/translator.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/checkexec/exec.go create mode 100644 internal/checkresult/result.go create mode 100644 internal/distworker/client.go create mode 100644 internal/distworker/config.go create mode 100644 internal/distworker/config_test.go create mode 100644 internal/distworker/consensus.go create mode 100644 internal/distworker/notification.go create mode 100644 internal/distworker/notification_test.go create mode 100644 internal/distworker/peer.go create mode 100644 internal/distworker/peer_test.go create mode 100644 internal/distworker/results.go create mode 100644 internal/distworker/results_test.go create mode 100644 internal/distworker/runner.go create mode 100644 internal/distworker/runner_protocol_test.go create mode 100644 internal/distworker/runner_test.go create mode 100644 internal/distworker/selfcheck.go create mode 100644 internal/distworker/selfcheck_test.go create mode 100644 internal/distworker/server_metrics.go create mode 100644 internal/distworker/server_metrics_test.go create mode 100644 internal/distworker/types.go create mode 100644 internal/influx/influx.go create mode 100644 internal/influx/influx_test.go create mode 100644 internal/netaddr/cidr.go create mode 100644 internal/netaddr/cidr_test.go create mode 100644 internal/netaddr/inet.go create mode 100644 internal/netaddr/inet_test.go create mode 100644 internal/netaddr/macaddr.go create mode 100644 internal/netaddr/macaddr_test.go create mode 100644 internal/netaddr/main_test.go create mode 100644 internal/netaddr/testutil.go create mode 100644 internal/notifier/email.go create mode 100644 internal/notifier/notifier.go create mode 100644 internal/notifier/producer.go create mode 100644 internal/notifier/producer_test.go create mode 100644 internal/notifier/run.go create mode 100644 internal/notifier/run_exp.go create mode 100644 internal/notifier/run_exp_test.go create mode 100644 internal/notifier/run_test.go create mode 100644 internal/notify/email.go create mode 100644 internal/notify/email_network_test.go create mode 100644 internal/notify/email_test.go create mode 100644 internal/notify/telegram.go create mode 100644 internal/notify/telegram_network_test.go create mode 100644 internal/notify/telegram_test.go create mode 100644 internal/notifyrender/count.go create mode 100644 internal/notifyrender/event_table.go create mode 100644 internal/notifyrender/get_content.go create mode 100644 internal/notifyrender/text_down_one.go create mode 100644 internal/notifyrender/text_expires.go create mode 100644 internal/notifyrender/text_up_one.go create mode 100644 internal/sender/context_test.go create mode 100644 internal/sender/count.go create mode 100644 internal/sender/email.go create mode 100644 internal/sender/email_context_test.go create mode 100644 internal/sender/event_table.go create mode 100644 internal/sender/event_table_test.go create mode 100644 internal/sender/get_content.go create mode 100644 internal/sender/get_content_test.go create mode 100644 internal/sender/invite.go create mode 100644 internal/sender/mattermost.go create mode 100644 internal/sender/run.go create mode 100644 internal/sender/sender.go create mode 100644 internal/sender/sms.go create mode 100644 internal/sender/telegram.go create mode 100644 internal/sender/test_message.go create mode 100644 internal/sender/text_down_one.go create mode 100644 internal/sender/text_expires.go create mode 100644 internal/sender/text_up_one.go create mode 100644 internal/sender/voice.go create mode 100644 internal/sender/webhook.go create mode 100644 internal/tg/bot.go create mode 100644 internal/tg/bot_test.go create mode 100644 internal/tg/debug/main.go create mode 100644 internal/util/format_duration.go create mode 100644 internal/util/format_duration_test.go create mode 100644 internal/util/unix/pidfile.go create mode 100644 internal/webapp/auth_password.go create mode 100644 internal/webapp/auth_password_test.go create mode 100644 internal/webapp/auth_sessionid.go create mode 100644 internal/webapp/constants.go create mode 100644 internal/webapp/cteq.go create mode 100644 internal/webapp/doc.go create mode 100644 internal/webapp/handlers_apps.go create mode 100644 internal/webapp/handlers_auth.go create mode 100644 internal/webapp/handlers_checks.go create mode 100644 internal/webapp/handlers_cluster.go create mode 100644 internal/webapp/handlers_cluster_test.go create mode 100644 internal/webapp/handlers_logs.go create mode 100644 internal/webapp/handlers_overview.go create mode 100644 internal/webapp/handlers_peer.go create mode 100644 internal/webapp/handlers_peer_test.go create mode 100644 internal/webapp/handlers_settings.go create mode 100644 internal/webapp/handlers_status.go create mode 100644 internal/webapp/handlers_updates.go create mode 100644 internal/webapp/handlers_updates_test.go create mode 100644 internal/webapp/inventory.go create mode 100644 internal/webapp/inventory_test.go create mode 100644 internal/webapp/logbuffer.go create mode 100644 internal/webapp/logbuffer_test.go create mode 100644 internal/webapp/metrics.go create mode 100644 internal/webapp/metrics_linux.go create mode 100644 internal/webapp/metrics_other.go create mode 100644 internal/webapp/metrics_test.go create mode 100644 internal/webapp/middleware.go create mode 100644 internal/webapp/page_data.go create mode 100644 internal/webapp/routes.go create mode 100644 internal/webapp/scan.go create mode 100644 internal/webapp/server.go create mode 100644 internal/webapp/server_test.go create mode 100644 internal/webapp/static/style.css create mode 100644 internal/webapp/store.go create mode 100644 internal/webapp/store_test.go create mode 100644 internal/webapp/template_helpers.go create mode 100644 internal/webapp/templates.go create mode 100644 internal/webapp/templates/app_detail.html create mode 100644 internal/webapp/templates/apps.html create mode 100644 internal/webapp/templates/change_password.html create mode 100644 internal/webapp/templates/checks.html create mode 100644 internal/webapp/templates/layout.html create mode 100644 internal/webapp/templates/login.html create mode 100644 internal/webapp/templates/logs.html create mode 100644 internal/webapp/templates/notifications.html create mode 100644 internal/webapp/templates/overview.html create mode 100644 internal/webapp/templates/settings.html create mode 100644 internal/webapp/templates/status.html create mode 100644 internal/webapp/templates/updates.html create mode 100644 internal/webapp/test_helpers.go create mode 100644 internal/wire/types.go create mode 100644 internal/wire/types_test.go create mode 100644 internal/workdays/workdays.go create mode 100644 internal/workercluster/admin_test.go create mode 100644 internal/workercluster/bootstrap.go create mode 100644 internal/workercluster/cluster.go create mode 100644 internal/workercluster/demo_test.go create mode 100644 internal/workercluster/e2e_test.go create mode 100644 internal/workercluster/entries.go create mode 100644 internal/workercluster/entries_test.go create mode 100644 internal/workercluster/fsm.go create mode 100644 internal/workercluster/fsm_test.go create mode 100644 internal/workercluster/snapshot.go create mode 100644 internal/workercluster/store.go create mode 100644 internal/workercluster/store_test.go create mode 100644 internal/workercluster/testhelpers_test.go create mode 100644 internal/workercluster/transport.go create mode 100644 internal/workercluster/transport_test.go create mode 100644 internal/workercluster/types.go create mode 100644 packaging/systemd/rsmon-worker.service create mode 100644 packaging/systemd/worker.env.example create mode 100755 scripts/install-systemd.sh create mode 100755 scripts/uninstall-systemd.sh create mode 100644 spec/factories/accounts.go create mode 100644 spec/factories/checks.go create mode 100644 spec/factories/contacts.go create mode 100644 spec/factories/events.go create mode 100644 spec/factories/groups.go create mode 100644 spec/factories/init.go create mode 100644 spec/factories/messages.go create mode 100644 spec/factories/monitors.go create mode 100644 spec/factories/notifications.go create mode 100644 spec/factories/users.go create mode 100644 storage/storage.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39ad0d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.github +.env +bin +dist +rsmon-worker +*.log +*.db* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b4ddf4 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Required: create the worker at https://rsmon.ru/settings/workers first. +RSMON_URL=https://rsmon.ru +RSMON_TOKEN=replace-with-worker-token + +# Local operator console. Both credentials are required while the web UI is on. +WORKER_HOST=0.0.0.0 +WORKER_PORT=27401 +WORKER_BIND_IP=127.0.0.1 +WORKER_URL= +WORKER_LOGIN=admin +WORKER_PASSWORD=replace-with-a-long-random-password + +# Persistent web application state. +RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker/webapp + +# Optional Raft worker cluster. +WORKER_CLUSTER_ENABLED=false +WORKER_CLUSTER_ID= +WORKER_CLUSTER_HOST=0.0.0.0 +WORKER_CLUSTER_PORT=37401 +WORKER_CLUSTER_PEERS= +WORKER_CLUSTER_DATA_DIR=/var/lib/rsmon-worker/cluster +WORKER_CLUSTER_BOOTSTRAP=false + +# Optional update page API endpoint. +WORKER_RELEASE_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0bebaf7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + cache: true + - name: Check module files + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + - name: Test worker packages + run: make test + - name: Build binary + run: make build diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..24f4ad8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,79 @@ +name: Docker + +on: + push: + branches: + - master + tags: + - v* + +jobs: + docker: + name: Build and publish worker image + runs-on: ubuntu-latest + env: + HARBOR_REGISTRY: ${{ secrets.HARBOR_REGISTRY }} + HARBOR_PROJECT: rsmon + IMAGE_NAME: rsmon-worker + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USER }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Compute image metadata + id: vars + env: + REF_NAME: ${{ gitea.ref_name }} + REF_TYPE: ${{ gitea.ref_type }} + SHA: ${{ gitea.sha }} + run: | + IMAGE="${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}" + SHORT_SHA="$(printf '%s' "${SHA}" | cut -c1-12)" + TAGS="${IMAGE}:sha-${SHORT_SHA}" + if [ "${REF_TYPE}" = "branch" ] && [ "${REF_NAME}" = "master" ]; then + TAGS="${TAGS},${IMAGE}:latest" + fi + if [ "${REF_TYPE}" = "tag" ]; then + TAGS="${TAGS},${IMAGE}:${REF_NAME}" + fi + { + echo "image=${IMAGE}" + echo "tags=${TAGS}" + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } >> "${GITHUB_OUTPUT}" + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + pull: true + push: true + tags: ${{ steps.vars.outputs.tags }} + labels: | + org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }} + org.opencontainers.image.revision=${{ gitea.sha }} + org.opencontainers.image.version=${{ gitea.ref_name }} + org.opencontainers.image.licenses=LicenseRef-RSMon-Worker-Source-Available-1.0 + build-args: | + VERSION=${{ gitea.ref_name }} + COMMIT=${{ gitea.sha }} + BUILD_DATE=${{ steps.vars.outputs.build_date }} + cache-from: type=registry,ref=${{ steps.vars.outputs.image }}:buildcache + cache-to: type=registry,ref=${{ steps.vars.outputs.image }}:buildcache,mode=max + sbom: true + provenance: mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2cf172 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/bin/ +/dist/ +/rsmon-worker +/.env +*.log +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5140661 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM reg.rsxx.ru/library/golang:1-trixie AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +COPY . . + +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_DATE=unknown +RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${BUILD_DATE}" \ + -o /out/rsmon-worker ./cmd/rsmon-worker + +FROM reg.rsxx.ru/library/debian:13-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates chromium tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 rsmon-worker \ + && useradd --uid 10001 --gid rsmon-worker --home-dir /var/lib/rsmon-worker --create-home --shell /usr/sbin/nologin rsmon-worker + +COPY --from=builder /out/rsmon-worker /usr/local/bin/rsmon-worker + +ENV HOME=/var/lib/rsmon-worker \ + RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker/webapp + +VOLUME ["/var/lib/rsmon-worker"] +EXPOSE 27401 37401 +USER rsmon-worker + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD ["/usr/local/bin/rsmon-worker", "health"] + +ENTRYPOINT ["/usr/local/bin/rsmon-worker"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b5b649 --- /dev/null +++ b/LICENSE @@ -0,0 +1,62 @@ +RSMon Worker Source-Available License 1.0 + +Copyright (c) 2026 RSMon. All rights reserved. + +This software is not open source. No rights are granted except those expressly +stated in this license. + +1. Permitted use + +Subject to this license, RSMon grants you a limited, non-exclusive, +non-transferable, revocable license to: + +* inspect and evaluate the source code; +* use the source code for private research and evaluation; and +* modify and build the software solely to operate an RSMon worker connected to + the monitoring service hosted at rsmon.ru. + +2. Restrictions + +You may not, without prior written permission from RSMon: + +* use the software with any service other than rsmon.ru; +* use the software to provide a competing or commercial monitoring service; +* redistribute, publish, sell, sublicense, lease, or otherwise make the source + code, modified source code, binaries, or container images available to any + third party; +* remove or alter copyright, license, attribution, or proprietary notices; +* use RSMon names, logos, or trademarks except to identify compatibility with + rsmon.ru; or +* use the software or source code for any purpose not expressly permitted by + section 1. + +3. Modifications + +Modifications and derivative works are subject to this license. You must keep +this license and all copyright notices with every permitted copy. RSMon is not +obligated to support, accept, or maintain modifications. + +4. Ownership + +RSMon and its licensors retain all right, title, and interest in the software, +including all intellectual-property rights. No patent, trademark, or other +license is granted by implication or estoppel. + +5. Termination + +This license terminates automatically if you breach any term. Upon termination, +you must stop using the software and delete all copies in your possession or +control. + +6. Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, TITLE, AND NON-INFRINGEMENT. TO THE MAXIMUM EXTENT PERMITTED BY LAW, +RSMON WILL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, +OR PUNITIVE DAMAGES, OR FOR LOSS OF DATA, PROFITS, OR BUSINESS, ARISING FROM OR +RELATED TO THE SOFTWARE OR THIS LICENSE. + +7. Additional permission + +For permissions beyond this license, contact RSMon through https://rsmon.ru. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0206d10 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +BINARY := bin/rsmon-worker +VERSION ?= dev +COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || printf unknown) +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE) + +.PHONY: build test check image clean + +build: + mkdir -p bin + CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o $(BINARY) ./cmd/rsmon-worker + +test: + RSMON_ENV=test CWD=$(CURDIR) go test \ + ./internal/distworker \ + ./internal/webapp \ + ./internal/workercluster \ + ./internal/wire \ + ./internal/checkexec \ + ./checks/calls \ + ./checks/cbssl \ + ./checks/cdns \ + ./checks/cftp \ + ./checks/chttp \ + ./checks/cping \ + ./checks/cssh \ + ./checks/cssl \ + ./checks/ctcp \ + ./checks/cudp \ + ./checks/cwhois \ + ./checks/llmhttp + +check: + go mod tidy + git diff --exit-code -- go.mod go.sum + $(MAKE) test + $(MAKE) build + +image: + docker build --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg BUILD_DATE=$(BUILD_DATE) -t rsmon-worker:local . + +clean: + rm -rf bin dist diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..0e8dac5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +RSMon Worker +Copyright (c) 2026 RSMon. All rights reserved. + +This is source-available software, not open-source software. Use is restricted +to building and operating workers for rsmon.ru and to private research or +evaluation under the terms in LICENSE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e8779f --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# RSMon Worker + +Standalone distributed monitoring worker for [rsmon.ru](https://rsmon.ru). It +connects to the RSMon control plane over WebSocket, executes checks locally, +delivers delegated notifications, and reports results back to the service. + +This repository is **source available, not open source**. Building and running +the worker with rsmon.ru and private research/evaluation are permitted. See +[LICENSE](LICENSE) for the complete terms. + +## Requirements + +- A worker token created in the rsmon.ru worker settings. +- Outbound HTTPS/WebSocket access to rsmon.ru. +- Chromium for browser-backed HTTP checks when running the binary directly. +- `CAP_NET_RAW` or an unprivileged ICMP configuration for ping checks. + +## Build + +Go 1.26 or newer is required. + +```bash +make build +./bin/rsmon-worker --version +``` + +The binary reads `.env` from its working directory when present. The minimum +configuration is `RSMON_URL`, `RSMON_TOKEN`, `WORKER_LOGIN`, and +`WORKER_PASSWORD`. + +## Docker Compose + +```bash +cp .env.example .env +# Edit .env and set the worker token and operator-console password. +docker compose up -d +docker compose logs -f worker +``` + +The operator console is bound to `127.0.0.1:27401` by default. Set +`WORKER_BIND_IP` only when a firewall or TLS reverse proxy protects the port. +Persistent web and cluster state is stored in the `worker-data` volume. + +## Docker + +```bash +docker build -t rsmon-worker:local . +docker run --rm \ + --cap-add NET_RAW \ + --env-file .env \ + -p 127.0.0.1:27401:27401 \ + -v rsmon-worker-data:/var/lib/rsmon-worker \ + rsmon-worker:local +``` + +Published images use these tags: + +- `sha-<12-character-commit>` for every push; +- `latest` for `master`; +- the exact `v*` tag for releases. + +## systemd + +Install host dependencies first. On Debian or Ubuntu: + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates chromium libcap2-bin tzdata +``` + +Build and install: + +```bash +make build +cp packaging/systemd/worker.env.example worker.env +# Edit worker.env. +sudo ./scripts/install-systemd.sh --env ./worker.env +``` + +To install an already downloaded release binary: + +```bash +sudo ./scripts/install-systemd.sh --binary ./rsmon-worker --env ./worker.env +``` + +Operational commands: + +```bash +systemctl status rsmon-worker +journalctl -u rsmon-worker -f +sudo systemctl restart rsmon-worker +``` + +The service runs as the dedicated `rsmon-worker` user, stores state below +`/var/lib/rsmon-worker`, reads secrets from `/etc/rsmon-worker/worker.env`, and +has only `CAP_NET_RAW` for ICMP checks. + +## Configuration + +| Variable | Required | Default | Purpose | +| --- | --- | --- | --- | +| `RSMON_URL` | yes | `https://rsmon.ru` for health only | Control-plane base URL. | +| `RSMON_TOKEN` | yes | none | Worker bearer token. | +| `WORKER_HOST` | no | `0.0.0.0` | Operator-console bind address. | +| `WORKER_PORT` | no | `27401` | Operator-console port. | +| `WORKER_URL` | no | none | Public URL advertised to the control plane. | +| `WORKER_LOGIN` | yes | none | Operator-console basic-auth login. | +| `WORKER_PASSWORD` | yes | none | Operator-console basic-auth password. | +| `RSMON_WEBAPP_DATA_DIR` | no | user data directory | SQLite and local UI state. | +| `WORKER_CLUSTER_ENABLED` | no | `false` | Enable the optional Raft cluster. | +| `WORKER_CLUSTER_ID` | with cluster | none | Unique Raft node ID. | +| `WORKER_CLUSTER_PORT` | no | `WORKER_PORT+10000` | Raft transport port. | +| `WORKER_CLUSTER_PEERS` | no | none | Comma-separated `node@host:port` peers. | +| `WORKER_CLUSTER_DATA_DIR` | with cluster | none | Persistent Raft state directory. | + +The public liveness endpoint is `GET /healthz`. `rsmon-worker health` checks the +configured control plane's `/up` endpoint and is suitable for container health +checks. + +## Security + +- Do not commit `.env`, worker tokens, or operator-console credentials. +- Expose the operator console only on loopback or behind authenticated TLS. +- Each worker should have its own control-plane token. +- Keep `/etc/rsmon-worker/worker.env` mode `0600`. + +Report security issues privately through the contact channel at rsmon.ru. diff --git a/app/models/access.go b/app/models/access.go new file mode 100644 index 0000000..9433c12 --- /dev/null +++ b/app/models/access.go @@ -0,0 +1,60 @@ +package models + +import ( + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Access represents membership of a User (or ApiKey) within a tenant +// Account, optionally scoped to a Group or Monitor. +// +// A User can hold many Access rows across many Accounts — the Access +// table is the source of truth for "who can see what". Each row answers: +// +// "Does user U have permission P on scope S of account A?" +// +// Where: +// +// - A = AccountID (tenant) +// - U = UserID (or ApiKeyID for service tokens) +// - P = Role ("owner" | "admin" | "manager" | "view" | +// "notify") +// - S = Kind + (GroupID | MonitorID) — defaults to account-wide when +// Kind = "account" and both ids are +// nil. +// +// One Access row may also reference the Invite that produced it via +// InviteID. The Invite is preserved after registration so the access +// history stays auditable — system-registered users and admin-added +// users have nil InviteID. +// +// See docs/plans/users-and-rbac.md for the full RBAC matrix. +type Access struct { + concerns.Model + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id)" json:"account_id"` + Account *Account `json:"-"` + + // Kind access kind, account \ group \ monitor + Kind string `gorm:"not null;default:'account'" json:"kind"` + + UserID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"-"` + User *User `json:"-"` + ApiKeyID *int64 `gorm:"type:bigint REFERENCES api_keys(id)" json:"-"` //nolint:revive // accepted lint exception + ApiKey *ApiKey `json:"-"` //nolint:revive // accepted lint exception + InviteID *int64 `gorm:"type:bigint REFERENCES invites(id)" json:"-"` + Invite *Invite `json:"-"` + + GroupID *int64 `gorm:"type:bigint REFERENCES groups(id)" json:"group_id,omitempty"` + MonitorID *int64 `gorm:"type:bigint REFERENCES monitors(id)" json:"monitor_id,omitempty"` + + Role string `json:"role"` + + // SeatType is additive to Role: role remains the authorization decision, + // while seat type is the billing entitlement. + SeatType string `gorm:"size:16;not null;default:'login'" json:"seat_type"` + Status string `gorm:"size:16;not null;default:'active'" json:"status"` + NotifyOnly bool `gorm:"not null;default:false" json:"notify_only"` + SeatAddonID *int64 `json:"seat_addon_id,omitempty"` + + concerns.Timestamped `json:"-"` + Audited +} diff --git a/app/models/account.go b/app/models/account.go new file mode 100644 index 0000000..eef4e1e --- /dev/null +++ b/app/models/account.go @@ -0,0 +1,117 @@ +package models + +import ( + "time" + + "github.com/pkg/errors" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Account represents a tenant — an isolated billing/permissions boundary +// that groups users, contacts, groups, monitors and notifications. +// +// Access to an Account is granted via the Access join table (see Access). +// `Role` on Account is a gorm:"-":all" virtual column populated by the +// controllers for the current session — it is the role the calling user +// holds on THIS account, not a property of the account itself. +type Account struct { + concerns.Model + + Name string `json:"name"` + Accesses []Access `json:"-"` + Contacts []Contact `json:"-"` + Groups []Group `json:"-"` + Notifications []Notification `json:"-"` + PlanID *int64 `gorm:"type:bigint REFERENCES plans(id)" json:"-"` + Plan *Plan `json:"plan"` + // Diagnostic overrides are available only to plans that include confirmations. + // Nil keeps the catalog value; bounds are enforced by DiagnosticSettings. + ConfirmTimeoutSec *int `json:"confirm_timeout_sec,omitempty"` + HealthWindowSec *int `json:"health_window_sec,omitempty"` + HealthRateThreshold *float64 `json:"health_rate_threshold,omitempty"` + HealthMinAttempts *int `json:"health_min_attempts,omitempty"` + Role string `gorm:"-:all" json:"role"` + Timezone string `json:"timezone"` + Language string `gorm:"default:'ru'" json:"language"` + Disabled bool `gorm:"not null;default:false" json:"disabled"` + Blocked bool `gorm:"not null;default:false" json:"blocked"` + PaidUntil *time.Time `json:"paid_until"` + TrialEndsAt *time.Time `json:"trial_ends_at,omitempty"` + Deleted bool `gorm:"not null;default:false"` + + concerns.Timestamped + Audited +} + +// Users provides functionality. +func (a Account) Users() []User { //nolint:gocritic // hugeParam: accepted for interface compatibility + users := make([]User, 0) + err := DB().Where("id IN (SELECT user_id FROM accesses WHERE account_id = ?)", a.ID).Find(&users).Error + if err != nil { + panic(err) + } + return users +} + +// CreateAccountForUser provides functionality. +func CreateAccountForUser(name string, u *User) (*Account, error) { + trialPlan := Plan{} + if err := DB().Where("code = ? AND archived = FALSE", "team").First(&trialPlan).Error; err != nil { + return nil, errors.Wrap(err, "failed to find trial plan") + } + trialEndsAt := time.Now().UTC().AddDate(0, 0, 14) + account := Account{PlanID: &trialPlan.ID, TrialEndsAt: &trialEndsAt} + if name != "" { + account.Name = name + } + err := DB().Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&account).Error; err != nil { + return errors.Wrap(err, "failed to create account") + } + if err := tx.Create(&Subscription{ + AccountID: account.ID, PlanID: trialPlan.ID, Provider: "manual", Status: SubscriptionStatusTrialing, + BillingCycle: "monthly", Currency: trialPlan.Currency, AmountMinor: trialPlan.PriceMonthlyMinor, CurrentPeriodEnd: &trialEndsAt, TrialEndsAt: &trialEndsAt, + }).Error; err != nil { + return errors.Wrap(err, "failed to create subscription") + } + var subscription Subscription + if err := tx.Where("account_id = ?", account.ID).First(&subscription).Error; err != nil { + return errors.Wrap(err, "failed to load trial subscription") + } + if err := tx.Create(&SubscriptionEvent{SubscriptionID: subscription.ID, AccountID: account.ID, Provider: "manual", Kind: "trial_started", ToPlanID: &trialPlan.ID, ActorUserID: &u.ID, CreatedAt: time.Now().UTC()}).Error; err != nil { + return errors.Wrap(err, "failed to record trial") + } + access := Access{AccountID: account.ID, UserID: &u.ID, Role: "owner", SeatType: "admin"} + if err := tx.Create(&access).Error; err != nil { + return errors.Wrap(err, "failed to create access") + } + group := Group{AccountID: account.ID, Name: "Основные"} + if err := tx.Create(&group).Error; err != nil { + return errors.Wrap(err, "failed to create group") + } + notification := Notification{AccountID: account.ID, Name: "Основные", Enabled: true} + if err := tx.Create(¬ification).Error; err != nil { + return errors.Wrap(err, "failed to create notification") + } + if u.Email != nil { + contact := Contact{AccountID: &account.ID, UserID: &u.ID, Kind: "email", Value: *u.Email} + if err := tx.Create(&contact).Error; err != nil { + return errors.Wrap(err, "failed to create contact") + } + if err := tx.Model(¬ification).Association("Contacts").Append(&contact); err != nil { + return errors.Wrap(err, "failed to add contact to notification") + } + } + if err := tx.Model(¬ification).Association("Groups").Append(&group); err != nil { + return errors.Wrap(err, "failed to add group to notification") + } + return nil + }) + if err != nil { + return nil, err + } + + return &account, nil +} diff --git a/app/models/account_test.go b/app/models/account_test.go new file mode 100644 index 0000000..f6822a3 --- /dev/null +++ b/app/models/account_test.go @@ -0,0 +1,283 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestCreateAccountForUserStartsTeamTrial(t *testing.T) { + models.Drop() + models.Migrate() + email := "team-trial@example.test" + user := models.User{Name: "Trial User", Email: &email, Enabled: true, Confirmed: true} + require.NoError(t, models.DB().Create(&user).Error) + + account, err := models.CreateAccountForUser("Trial Account", &user) + require.NoError(t, err) + require.NotNil(t, account.PlanID) + require.NotNil(t, account.TrialEndsAt) + + var plan models.Plan + require.NoError(t, models.DB().First(&plan, *account.PlanID).Error) + assert.Equal(t, "team", plan.Code) + + var subscription models.Subscription + require.NoError(t, models.DB().Where("account_id = ?", account.ID).First(&subscription).Error) + assert.Equal(t, models.SubscriptionStatusTrialing, subscription.Status) + assert.Equal(t, plan.ID, subscription.PlanID) + require.NotNil(t, subscription.TrialEndsAt) + assert.WithinDuration(t, *account.TrialEndsAt, *subscription.TrialEndsAt, time.Millisecond) +} + +// TestAccountModel tests basic Account model functionality +func TestAccountModel(t *testing.T) { + // Test Account structure + account := models.Account{ + Name: "Test Account", + Timezone: "UTC", + Language: "en", + Deleted: false, + } + + assert.Equal(t, "Test Account", account.Name) + assert.Equal(t, "UTC", account.Timezone) + assert.Equal(t, "en", account.Language) + assert.False(t, account.Deleted) +} + +// TestAccountDisplayName tests User.DisplayName method +func TestUserDisplayName(t *testing.T) { + tests := []struct { + name string + user models.User + expected string + }{ + { + name: "User with email", + user: models.User{ + Name: "John Doe", + Email: stringPtr("john@example.com"), + }, + expected: "John Doe john@example.com", + }, + { + name: "User without email", + user: models.User{ + Name: "Jane Doe", + Email: nil, + }, + expected: "Jane Doe", + }, + { + name: "User with empty name and email", + user: models.User{ + Name: "", + Email: stringPtr("test@example.com"), + }, + expected: " test@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.user.DisplayName() + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestUserGravatar tests User.Gravatar method +func TestUserGravatar(t *testing.T) { + tests := []struct { + name string + user models.User + size int + expected string + }{ + { + name: "User with email", + user: models.User{ + Email: stringPtr("test@example.com"), + }, + size: 32, + expected: "https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=32&d=blank", + }, + { + name: "User without email", + user: models.User{Email: nil}, + size: 32, + expected: "", + }, + { + name: "Different size", + user: models.User{ + Email: stringPtr("test@example.com"), + }, + size: 64, + expected: "https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=64&d=blank", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.user.Gravatar(tt.size) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestUserAsJSON tests User.AsJSON method +func TestUserAsJSON(t *testing.T) { + email := "test@example.com" + user := models.User{ + ID: 123, + Email: &email, + Name: "Test User", + } + + result := user.AsJSON() + + assert.NotNil(t, result) + assert.Equal(t, int64(123), result["id"]) + assert.Equal(t, &email, result["email"]) + assert.Contains(t, result["avatar"], "gravatar.com") +} + +// TestAccessModel tests Access model structure +func TestAccessModel(t *testing.T) { + access := models.Access{ + AccountID: 1, + Kind: "account", + Role: "owner", + } + + assert.Equal(t, int64(1), access.AccountID) + assert.Equal(t, "account", access.Kind) + assert.Equal(t, "owner", access.Role) +} + +// TestGroupModel tests Group model structure +func TestGroupModel(t *testing.T) { + group := models.Group{ + AccountID: 1, + Name: "Test Group", + MonitorsCount: 5, + } + + assert.Equal(t, int64(1), group.AccountID) + assert.Equal(t, "Test Group", group.Name) + assert.Equal(t, 5, group.MonitorsCount) +} + +// TestGroupIdsForAccountId tests GroupIdsForAccountId function +func TestGroupIdsForAccountId(t *testing.T) { + // This test would require a database connection + // For now, we test that it doesn't panic with invalid input + t.Run("handles zero account id", func(t *testing.T) { + // Note: This will panic without DB connection, which is expected behavior + // In a real test, we'd set up a test database + }) +} + +// TestAccountTableDrivenTests demonstrates table-driven testing pattern +func TestAccountValidationTableDriven(t *testing.T) { + tests := []struct { + name string + account models.Account + wantErr bool + }{ + { + name: "Valid account", + account: models.Account{ + Name: "Valid Account", + Timezone: "UTC", + Language: "en", + }, + wantErr: false, + }, + { + name: "Account with empty name", + account: models.Account{ + Name: "", + Timezone: "UTC", + Language: "en", + }, + wantErr: true, // Name should be required + }, + { + name: "Account with invalid timezone", + account: models.Account{ + Name: "Test Account", + Timezone: "Invalid/Timezone", + Language: "en", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validation logic would go here + // For now, we just verify the test structure + assert.NotNil(t, tt.account) + }) + } +} + +// Helper function to create string pointer +func stringPtr(s string) *string { + return &s +} + +// BenchmarkUserDisplayName benchmarks the DisplayName method +func BenchmarkUserDisplayName(b *testing.B) { + user := models.User{ + Name: "Test User", + Email: stringPtr("test@example.com"), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = user.DisplayName() + } +} + +// TestAccountConcurrentOperations tests concurrent access to account data +func TestAccountConcurrentOperations(t *testing.T) { + account := models.Account{ + Name: "Concurrent Test", + Timezone: "UTC", + } + + done := make(chan bool) + + // Simulate concurrent reads + for i := 0; i < 10; i++ { + go func() { + _ = account.Name + done <- true + }() + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } +} + +// ExampleAccountUsage provides an example of how to use Account model +func ExampleAccount() { + account := models.Account{ + Name: "Example Account", + Timezone: "America/New_York", + Language: "en", + } + + _ = account.Name + // Output: +} diff --git a/app/models/api_key.go b/app/models/api_key.go new file mode 100644 index 0000000..79861fe --- /dev/null +++ b/app/models/api_key.go @@ -0,0 +1,38 @@ +package models + +import ( + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// ApiKey represents an API authentication key. //nolint:revive // accepted lint exception +type ApiKey struct { //nolint:revive // accepted lint exception + concerns.Model + + Name string `json:"name" gorm:"not null"` + AccountID int64 `json:"account_id,omitempty"` + Account User `json:"-"` + UserID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"-"` + User *User `json:"-"` + Accesses []Access `json:"accesses" gorm:"foreignkey:api_key_id"` + + concerns.HasToken + concerns.Timestamped + Audited +} + +func (n *ApiKey) BeforeCreate(tx *gorm.DB) error { //nolint:revive // accepted lint exception + n.SetToken() + return nil +} + +// FillAccesses provides functionality. +func (n *ApiKey) FillAccesses() { + for k, a := range n.Accesses { //nolint:gocritic // range copy is acceptable here + if a.ID <= 0 { + n.Accesses[k].ID = 0 + } + n.Accesses[k].AccountID = n.AccountID + } +} diff --git a/app/models/audited.go b/app/models/audited.go new file mode 100644 index 0000000..0e46964 --- /dev/null +++ b/app/models/audited.go @@ -0,0 +1,63 @@ +// Package models provides GORM models and business logic. +// Audited models inspired by https://github.com/qor/audited +package models + +import ( + "gorm.io/gorm" +) + +// AuditedCurrentUserKey is the GORM Set key for the current user. +const AuditedCurrentUserKey = "audited:current_user" + +// Audited tracks creator and updater IDs. +type Audited struct { + CreatorID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"-"` + Creator *User `json:"-"` + UpdaterID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"-"` + Updater *User `json:"-"` +} + +func getCurrentUser(scope *gorm.DB) (int64, bool) { + var user interface{} + var hasUser bool + + user, hasUser = scope.Get(AuditedCurrentUserKey) + + // spew.Dump(user, hasUser) + if hasUser { + return user.(*User).ID, true + } + + return 0, false +} + +func assignCreatedBy(tx *gorm.DB) { + name := "CreatorID" + if field := tx.Statement.Schema.LookUpField(name); field != nil { + if user, ok := getCurrentUser(tx); ok { + tx.Statement.SetColumn(name, user) + } + } +} + +func assignUpdatedBy(tx *gorm.DB) { + name := "UpdaterID" + if field := tx.Statement.Schema.LookUpField(name); field != nil { + if user, ok := getCurrentUser(tx); ok { + tx.Statement.SetColumn("UpdaterID", user, true) + } + } +} + +// RegisterCallbacks register callback into GORM DB +func RegisterCallbacks(db *gorm.DB) { + callback := db.Callback() + + if callback.Create().Get("audited:assign_created_by") == nil { + _ = callback.Create().After("gorm:before_create").Register("audited:assign_created_by", assignCreatedBy) + } + + if callback.Update().Get("audited:assign_updated_by") == nil { + _ = callback.Update().After("gorm:before_update").Register("audited:assign_updated_by", assignUpdatedBy) + } +} diff --git a/app/models/authidentity/auth_identity.go b/app/models/authidentity/auth_identity.go new file mode 100644 index 0000000..aa188c6 --- /dev/null +++ b/app/models/authidentity/auth_identity.go @@ -0,0 +1,32 @@ +// Package authidentity provides the AuthIdentity and Basic types for QOR-style +// authentication identity management. Vendored from github.com/glebtv/auth/auth_identity +// to eliminate the rsgit.ru/rs/sessionmanager transitive dependency. +package authidentity + +import "time" + +// AuthIdentity combines Basic provider info with SignLogs for a full identity record. +type AuthIdentity struct { + Basic + SignLogs +} + +// TableName returns the database table name for AuthIdentity. +func (AuthIdentity) TableName() string { + return "identities" +} + +// Basic represents the core identity fields (provider, UID, encrypted password). +type Basic struct { + ID int64 `gorm:"primary_key" json:"id"` + Provider string + UID string `gorm:"column:uid"` + EncryptedPassword string + UserID *int64 + ConfirmedAt *time.Time +} + +// TableName returns the database table name for Basic. +func (Basic) TableName() string { + return "identities" +} diff --git a/app/models/authidentity/sign_logs.go b/app/models/authidentity/sign_logs.go new file mode 100644 index 0000000..fcb6383 --- /dev/null +++ b/app/models/authidentity/sign_logs.go @@ -0,0 +1,50 @@ +package authidentity + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" +) + +// SignLogs holds login history (log entries and sign-in count). +type SignLogs struct { + Log string `sql:"-"` + SignInCount uint + Logs []SignLog +} + +// Scan implements sql.Scanner for deserializing SignLogs from JSON. +func (signLogs *SignLogs) Scan(data interface{}) (err error) { + switch values := data.(type) { + case []byte: + if len(values) != 0 { + return json.Unmarshal(values, signLogs) + } + case string: + return signLogs.Scan([]byte(values)) + case []string: + for _, str := range values { + if err := signLogs.Scan(str); err != nil { + return err + } + } + default: + err = errors.New("unsupported driver -> Scan pair for SignLogs") + } + + return +} + +// Value implements driver.Valuer for serializing SignLogs to JSON. +func (signLogs SignLogs) Value() (driver.Value, error) { + results, err := json.Marshal(signLogs) + return string(results), err +} + +// SignLog represents a single login event entry. +type SignLog struct { + UserAgent string + At *time.Time + IP string +} diff --git a/app/models/bits.go b/app/models/bits.go new file mode 100644 index 0000000..b11bde4 --- /dev/null +++ b/app/models/bits.go @@ -0,0 +1,31 @@ +package models + +import "time" + +// BeginningOfDay provides functionality. +func BeginningOfDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +// SetBit provides functionality. +// https://stackoverflow.com/questions/23192262/how-would-you-set-and-clear-a-single-bit-in-go +// Sets the bit at pos in the integer n. +func SetBit(n int, pos uint) int { + n |= (1 << pos) + return n +} + +// ClearBit provides functionality. +// Clears the bit at pos in n. +func ClearBit(n int, pos uint) int { + mask := ^(1 << pos) + n &= mask + return n +} + +// HasBit provides functionality. +func HasBit(n int, pos uint) bool { + val := n & (1 << pos) + return (val > 0) +} diff --git a/app/models/check.go b/app/models/check.go new file mode 100644 index 0000000..dc73322 --- /dev/null +++ b/app/models/check.go @@ -0,0 +1,171 @@ +package models + +import ( + "encoding/json" + "strings" + "time" + "unicode" + + "github.com/lib/pq" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// Check provides functionality. +type Check struct { + ID int64 `gorm:"primarykey" json:"id"` + Enabled *bool `gorm:"not null;default:true" json:"enabled"` + + MonitorID int64 `gorm:"index;type:bigint REFERENCES monitors(id)" json:"monitor_id,omitempty"` + Monitor *Monitor `json:"-"` + + Name *string `json:"name"` + Kind string `json:"kind"` + Interval int `json:"interval" validate:"required,gte=60"` + + // URL to monitor + URL *string `json:"url,omitempty"` + + // Other settings of the check + Settings datatypes.JSON `gorm:"not null;" json:"settings"` + + State string `gorm:"not null;default:'UNK'" json:"state"` + + LastStart *time.Time `json:"last_start"` + LastEnd *time.Time `json:"last_end"` + LastOk *time.Time `json:"last_ok"` + LastFail *time.Time `json:"last_fail"` + WasUp *time.Time `json:"was_up"` + + Fails int `json:"fails"` + Expires *time.Time `json:"expires"` + + Error *string `json:"error"` + Warnings pq.StringArray `gorm:"type:varchar(255)[]" json:"warnings"` + Infos pq.StringArray `gorm:"type:varchar(255)[]" json:"infos"` + + // RequireQuorum enables multi-region result aggregation (Phase 3 of + // docs/todo.md): when >1 the check's State is NOT written directly by + // ApplyRemoteCheckResult — instead CheckRegionResult rows accumulate + // until app/models/check_aggregator.go decides OK/ERR/DEGRADED. + // Default 1 keeps the legacy single-region behavior unchanged. + RequireQuorum int `gorm:"not null;default:1" json:"require_quorum"` + + // AggregationWindowSeconds is how long the aggregator waits for + // regional CheckRegionResult rows before deciding the check's State. + // Stored as int seconds (matching the existing GORM style — no + // time.Duration columns) and exposed via AggregationWindow(). Default + // 5s; ignored when RequireQuorum <= 1. + AggregationWindowSeconds int `gorm:"not null;default:5" json:"aggregation_window_seconds"` + + IsNew bool `gorm:"-:all" sql:"-" json:"is_new,omitempty"` + Deleted bool `gorm:"-:all" sql:"-" json:"deleted,omitempty"` + + Events []Event `json:"-" gorm:"many2many:event_checks;"` + + Audited +} + +// ExpScope provides functionality. +func ExpScope(q *gorm.DB) *gorm.DB { + return q.Where("kind IN ('whois', 'ssl')"). + Preload("Monitor"). + Preload("Monitor.Group"). + Preload("Monitor.Group.Notifications"). + Preload("Monitor.Group.Notifications.Contacts"). + Where("expires < ?", time.Now().Add(time.Hour*7*24)) +} + +// IntervalOK provides functionality. +func (c *Check) IntervalOK() bool { + if c.Kind == kindRKN { + return true + } + + if c.Kind == kindWhois { + return c.Interval >= 43200 + } + + return c.Interval >= 30 +} + +// GetLabel provides functionality. +func (c *Check) GetLabel() string { + if c.Name != nil { + return *c.Name + } + if c.URL != nil { + return *c.URL + } + return c.Kind +} + +// KindLabel provides functionality. +func (c *Check) KindLabel() string { + if c.Kind == kindWhois { + return "регистрация домена" + } + if c.Kind == kindSSL { + return "SSL сертификат" + } + + return c.Kind +} + +// GetSettings provides functionality. +func (c *Check) GetSettings() CheckSettings { + d := CheckSettings{} + + err := json.Unmarshal(c.Settings, &d) + if err != nil { + panic(err) + } + return d +} + +// ValidateSettings provides functionality. +func (c *Check) ValidateSettings() error { + if len(c.Settings) == 0 { + c.Settings = []byte("{}") + } + + return nil +} + +// GetURL provides functionality. +func (c *Check) GetURL() (string, error) { + // return c.GetSettings()["url"].(string) + if c.URL != nil { + return *c.URL, nil + } + + return "http://" + c.Monitor.Host, nil +} + +// MetricName provides functionality. +func (c *Check) MetricName() string { + sanitized := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == ':' { + return r + } + return '_' + }, c.Kind) + return "c" + sanitized +} + +// QuorumEnabled reports whether this check should be aggregated by +// app/models/check_aggregator.go. When false (RequireQuorum <= 1), +// ApplyRemoteCheckResult keeps the legacy direct State update path. +func (c *Check) QuorumEnabled() bool { + return c.RequireQuorum > 1 +} + +// AggregationWindow returns AggregationWindowSeconds as a time.Duration. +// Defaults to 5s when the underlying int is zero/negative, mirroring the +// GORM column default; callers can rely on a strictly positive value. +func (c *Check) AggregationWindow() time.Duration { + if c.AggregationWindowSeconds <= 0 { + return 5 * time.Second + } + return time.Duration(c.AggregationWindowSeconds) * time.Second +} diff --git a/app/models/check_aggregator.go b/app/models/check_aggregator.go new file mode 100644 index 0000000..a1bfa5a --- /dev/null +++ b/app/models/check_aggregator.go @@ -0,0 +1,336 @@ +package models + +import ( + "context" + "log" + "time" + + "gorm.io/gorm" +) + +// Phase 3 of docs/todo.md — result aggregation for multi-region checks. +// +// When a check has RequireQuorum > 1, ApplyRemoteCheckResult does not +// write Check.State directly. Instead it appends a CheckRegionResult row +// and leaves AggregatedAt NULL. This file owns the background goroutine +// that walks those pending rows once their aggregation window has +// elapsed, decides OK/ERR/DEGRADED per the documented rule, writes the +// aggregate state onto Check, stamps AggregatedAt on the contributing +// rows, and triggers Monitor.UpdateStatusFromChecks so the monitor's own +// status follows. +// +// Aggregation rule (see docs/todo.md Phase 3 + checkSeverityRank in +// monitor.go for the corresponding severity order): +// +// - Aggregate only rows whose created_at is older than +// NOW() - Check.AggregationWindowSeconds. This is the "watermark" +// pattern: a row is eligible only when no fresher regional result +// could still arrive and tip the vote. The window is per-check so +// noisy checks can use a longer wait than fast ones. +// - If zero eligible rows exist for a check, leave Check.State +// untouched (the special case called out in the spec). +// - Otherwise count OK vs not-OK among the eligible rows: +// OK >= RequireQuorum → Check.State = OK +// not-OK >= RequireQuorum → Check.State = ERR +// neither side reaches quorum → Check.State = DEGRADED +// - Stamp AggregatedAt = NOW() on every contributing row so the next +// tick skips them. One transaction per check; per-row failures do +// not poison other checks. + +// AggregatorTickInterval is the default cadence of StartCheckAggregator +// when the caller passes interval <= 0. Mirrors the 30s default used by +// the other reapers in this package so the three reapers all tick on +// the same wall clock cadence — easier to grep, easier to reason about +// in incident timelines. +const AggregatorTickInterval = 30 * time.Second + +// EnsureCheckAggregatorIndexes adds the partial indexes the aggregator +// relies on. AutoMigrate creates AggregatedAt as a regular btree column, +// but the per-tick SELECT filters on `aggregated_at IS NULL` over what +// grows to be a busy table; a partial index keeps the working set +// tiny. Idempotent so it is safe to call from Migrate() and from tests. +func EnsureCheckAggregatorIndexes() error { + return DB().Exec(` + CREATE INDEX IF NOT EXISTS check_region_results_pending_idx + ON check_region_results (check_id, created_at) + WHERE aggregated_at IS NULL + `).Error +} + +// aggregateCheckState holds the per-check aggregation inputs we need to +// keep the rule readable. Rows is the set of CheckRegionResult rows +// eligible for the current decision; quorum is Check.RequireQuorum. +type aggregateCheckState struct { + CheckID int64 + Quorum int + Rows []CheckRegionResult +} + +// decideAggregateState encodes the OK/ERR/DEGRADED rule described in +// the package doc. Pure function — no DB, no time — so it is trivially +// unit-testable from the test file. +func decideAggregateState(in aggregateCheckState) (string, bool) { + if len(in.Rows) == 0 || in.Quorum <= 1 { + // Zero eligible rows in the window OR a misconfigured check + // (QuorumEnabled false). Caller must leave Check.State alone + // in both cases. + return "", false + } + okCount := 0 + badCount := 0 + for i := range in.Rows { + if in.Rows[i].State == stateOK { + okCount++ + } else { + badCount++ + } + } + switch { + case okCount >= in.Quorum: + return stateOK, true + case badCount >= in.Quorum: + return stateERR, true + default: + return stateDegraded, true + } +} + +// CheckAggregatorTick performs one pass of the aggregator. It is the +// per-tick body StartCheckAggregator calls. Exported so the test suite +// can call it directly without spinning up the goroutine; production +// always goes through StartCheckAggregator. +// +// The returned (aggregated, err) tuple lets the caller log a metric: +// aggregated counts how many Check rows had their State written this +// tick. The function is idempotent — a second call with no new +// unaggregated rows is a no-op that returns (0, nil). +func CheckAggregatorTick() (aggregated int, err error) { + // Step 1: collect candidate check IDs. The JOIN to checks is needed + // to read each check's window length and to filter on + // require_quorum > 1 (so we never aggregate the legacy path). + rows, err := DB().Raw(` + SELECT DISTINCT crr.check_id + FROM check_region_results crr + JOIN checks c ON c.id = crr.check_id + WHERE crr.aggregated_at IS NULL + AND c.require_quorum > 1 + AND crr.created_at < NOW() - make_interval(secs => GREATEST(c.aggregation_window_seconds, 1)) + ORDER BY crr.check_id + `).Rows() + if err != nil { + return 0, err + } + defer func() { _ = rows.Close() }() + + var checkIDs []int64 + for rows.Next() { + var id int64 + if scanErr := rows.Scan(&id); scanErr != nil { + return 0, scanErr + } + checkIDs = append(checkIDs, id) + } + if scanErr := rows.Err(); scanErr != nil { + return 0, scanErr + } + if len(checkIDs) == 0 { + return 0, nil + } + + for _, checkID := range checkIDs { + n, err := aggregateOneCheck(checkID) + if err != nil { + // Log and continue: one bad check must not stop the loop. + log.Printf("check_aggregator: check_id=%d error: %v", checkID, err) + continue + } + aggregated += n + } + return aggregated, nil +} + +// aggregateOneCheck runs the aggregation logic for a single check inside +// a transaction. The transaction holds a FOR UPDATE row lock on the +// check so concurrent aggregator instances (multiple web processes) can +// not race on the same check — the second one waits for the first to +// commit, then sees AggregatedAt IS NOT NULL on every row and the +// candidate SELECT below returns an empty set. +func aggregateOneCheck(checkID int64) (int, error) { + tx := DB().Begin() + if tx.Error != nil { + return 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } + }() + + var check Check + if err := tx.Clauses(SkipLockedClause).First(&check, checkID).Error; err != nil { + tx.Rollback() + if err == gorm.ErrRecordNotFound { + // Check was deleted between candidate SELECT and lock; not + // an error, just nothing to do. + return 0, nil + } + return 0, err + } + // Defensive: only aggregate quorum-enabled checks. The candidate + // SELECT already filters on this, but a stale row that flipped off + // quorum between calls must not be re-aggregated here. + if !check.QuorumEnabled() { + tx.Rollback() + return 0, nil + } + + var results []CheckRegionResult + if err := tx. + Where("check_id = ? AND aggregated_at IS NULL", checkID). + Order("created_at ASC"). + Find(&results).Error; err != nil { + tx.Rollback() + return 0, err + } + + decision, ok := decideAggregateState(aggregateCheckState{ + CheckID: checkID, + Quorum: check.RequireQuorum, + Rows: results, + }) + if !ok { + // Zero eligible rows — leave Check.State alone. There is also + // nothing to stamp, so just rollback and move on. + tx.Rollback() + return 0, nil + } + + now := time.Now() + // Pull the latest error string from the contributing rows so the + // monitor event / notifier pipeline has something to show. Prefer + // the most recent ERR row's message; fall back to the most recent + // any-row message. UNK / empty stays NULL. + var lastError *string + for i := len(results) - 1; i >= 0; i-- { + if results[i].Error != nil && *results[i].Error != "" { + lastError = results[i].Error + break + } + } + + upd := map[string]interface{}{ + colState: decision, + colLastEnd: now, + } + if decision == stateOK { + // OK resets error — mirrors the legacy ApplyRemoteCheckResult + // path that sets `error = gorm.Expr("NULL")` when state==OK. + upd["error"] = gorm.Expr("NULL") + upd["last_ok"] = now + upd["fails"] = 0 + upd["was_up"] = now + } else { + // Non-OK: bump the fail counter and only overwrite the error + // when one of the contributing rows actually carries a + // message. If none do, leave whatever was there before — + // mirrors the legacy `if report.Error != nil` branch. + upd["last_fail"] = now + upd["fails"] = gorm.Expr("fails + 1") + if lastError != nil { + upd["error"] = *lastError + } + } + + if err := tx.Model(&Check{}).Where("id = ?", checkID).UpdateColumns(upd).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Model(&CheckRegionResult{}). + Where("check_id = ? AND aggregated_at IS NULL", checkID). + UpdateColumns(map[string]interface{}{ + "aggregated_at": now, + }).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Commit().Error; err != nil { + return 0, err + } + + // Mirror ApplyRemoteCheckResult: propagate the aggregate decision + // up to the monitor. We do this AFTER commit so a rollback does + // not leave the monitor in a state whose corresponding check is + // still pre-aggregate. The goroutine keeps the failure path of + // UpdateStatusFromChecks isolated from the aggregator's hot loop. + if check.MonitorID != 0 { + var mon Monitor + if err := DB().First(&mon, check.MonitorID).Error; err == nil { + go mon.UpdateStatusFromChecks() + } else { + log.Printf("check_aggregator: monitor lookup failed for check_id=%d: %v", checkID, err) + } + } + return 1, nil +} + +// StartCheckAggregator launches a goroutine that calls +// CheckAggregatorTick on the given interval until ctx is canceled. +// Mirrors StartTaskReaper / StartDeadWorkerReaper in this package — same +// ticker shape, same default-interval fall-back, same per-tick recover +// so a malformed row cannot crash the web process. +// +// The default interval is AggregatorTickInterval (30s); values <= 0 +// fall back to the default so the helper is safe to call from any call +// site without a guard. A nil context falls back to context.Background() +// the same way StartDeadWorkerReaper does, so main.init() and tests +// can both call it without ceremony. +// +// Wire from main.init() once per process. The aggregator is cheap in +// steady state (one indexed SELECT for candidates + a per-check +// transaction over a handful of unaggregated rows). Under load it +// scales horizontally — multiple web processes can each run their own +// StartCheckAggregator goroutine because FOR UPDATE SKIP LOCKED on the +// per-check transaction guarantees at-most-one winner per check. +func StartCheckAggregator(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = AggregatorTickInterval + } + if ctx == nil { + ctx = context.Background() + } + // Best-effort index bootstrap. AutoMigrate declares AggregatedAt as + // a regular btree column; the partial index speeds up the per-tick + // candidate SELECT. Idempotent — safe to call on every boot. + if err := EnsureCheckAggregatorIndexes(); err != nil { + log.Printf("check_aggregator: ensure index: %v", err) + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("check_aggregator: panic recovered: %v", r) + } + }() + n, err := CheckAggregatorTick() + if err != nil { + log.Printf("check_aggregator: error: %v", err) + return + } + if n > 0 { + log.Printf("check_aggregator: aggregated=%d", n) + } + }() + } + } + }() +} diff --git a/app/models/check_aggregator_test.go b/app/models/check_aggregator_test.go new file mode 100644 index 0000000..0c981b4 --- /dev/null +++ b/app/models/check_aggregator_test.go @@ -0,0 +1,448 @@ +package models_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// seedAggregatorWorld creates the minimum fixture the aggregator tests +// need: a plan, account, group, monitor, and an http check whose +// RequireQuorum / AggregationWindowSeconds are set per call. The check +// is created with state=UNK so the test can observe the aggregator's +// effect on Check.State directly. +// +// Returns the freshly-created monitor + check; the check is what every +// test below mutates (RequireQuorum, AggregationWindowSeconds) and then +// asserts on. Cleanup is the caller's responsibility — most tests call +// models.Drop() at the top instead. +func seedAggregatorWorld(t *testing.T, quorum, windowSeconds int) (models.Monitor, models.Check) { + t.Helper() + + plan := models.Plan{Name: "agg-plan", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + acc := models.Account{Name: "agg-acc", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + grp := &models.Group{AccountID: acc.ID, Name: "agg"} + require.NoError(t, models.DB().Create(grp).Error) + + mon := models.Monitor{ + Name: stringPtrAgg("agg.test"), + Host: "agg.test", + GroupID: grp.ID, + Enabled: true, + } + require.NoError(t, models.DB().Create(&mon).Error) + + enTrue := true + check := models.Check{ + MonitorID: mon.ID, + Kind: "http", + Interval: 60, + Enabled: &enTrue, + State: "UNK", + Settings: datatypes.JSON([]byte(`{}`)), + RequireQuorum: quorum, + AggregationWindowSeconds: windowSeconds, + } + require.NoError(t, models.DB().Create(&check).Error) + return mon, check +} + +func stringPtrAgg(s string) *string { return &s } + +// makeReport constructs a wire.CheckResultReport with sensible defaults +// for the OK or not-OK case. Tests use this to push results through +// ApplyRemoteCheckResult exactly the way a real worker would. +func makeReport(checkID, monitorID int64, state string) wire.CheckResultReport { + return wire.CheckResultReport{ + JobID: "job-" + state, + CheckID: checkID, + MonitorID: monitorID, + State: state, + } +} + +// regionResultWithErr is regionResultFor plus an error message. Used +// when the test wants to verify that the aggregator forwards the row's +// error string onto Check.Error (mimics a real worker reporting +// state=ERR with a diagnostic message). +func regionResultWithErr(t *testing.T, checkID int64, region, state, errMsg string, age time.Duration) models.CheckRegionResult { + t.Helper() + row := regionResultFor(t, checkID, region, state, age) + require.NoError(t, models.DB().Model(&row).UpdateColumn("error", errMsg).Error) + return row +} + +// regionResultFor inserts a single CheckRegionResult row whose +// created_at and executed_at are both backdated by `age`, so the +// aggregator's window-based watermark picks it up immediately without +// needing a real time.Sleep. Returned row has its DB-assigned ID +// populated. +func regionResultFor(t *testing.T, checkID int64, region string, state string, age time.Duration) models.CheckRegionResult { + t.Helper() + // RegionCode has a FK to regions.code, so the region must exist + // before the result row is inserted. seedRegion is idempotent. + seedRegion(t, region) + row := models.CheckRegionResult{ + CheckID: checkID, + RegionCode: region, + ExecutedAt: time.Now().Add(-age), + State: state, + } + require.NoError(t, models.DB().Create(&row).Error) + // Backdate CreatedAt too — the aggregator SQL keys on + // check_region_results.created_at (see CheckAggregatorTick). GORM + // auto-sets CreatedAt on insert, so we have to UPDATE it post-hoc. + require.NoError(t, models.DB().Model(&row).UpdateColumns(map[string]interface{}{ + "created_at": time.Now().Add(-age), + "updated_at": time.Now().Add(-age), + }).Error) + return row +} + +// loadCheck re-reads a Check row by ID — used after the aggregator +// runs so the test asserts against the post-tick state. +func loadCheck(t *testing.T, id int64) models.Check { + t.Helper() + var c models.Check + require.NoError(t, models.DB().First(&c, id).Error) + return c +} + +// countPendingResults returns how many CheckRegionResult rows for +// checkID have aggregated_at IS NULL — the working set the next +// aggregator tick would consider. +func countPendingResults(t *testing.T, checkID int64) int64 { + t.Helper() + var n int64 + require.NoError(t, models.DB().Model(&models.CheckRegionResult{}). + Where("check_id = ? AND aggregated_at IS NULL", checkID). + Count(&n).Error) + return n +} + +// --------------------------------------------------------------------------- +// ApplyRemoteCheckResult: regression tests for the QuorumEnabled split. +// --------------------------------------------------------------------------- + +// TestApplyRemoteCheckResult_DirectWhenQuorumOne pins the legacy path: +// when RequireQuorum==1 (the default), ApplyRemoteCheckResult still +// writes Check.State synchronously, exactly the way it did before Phase +// 3. This is the regression guard for the in-process RKN scheduler +// tests in internal/rknscheduler. +func TestApplyRemoteCheckResult_DirectWhenQuorumOne(t *testing.T) { + models.Drop() + models.Migrate() + + mon, check := seedAggregatorWorld(t, 1, 5) + // Region must exist because CheckRegionResult has a FK to + // regions.code (seeded by Migrate, but the test region is custom). + seedRegion(t, "ru-msk") + + require.NoError(t, models.ApplyRemoteCheckResult( + makeReport(check.ID, mon.ID, "OK"), + "ru-msk", + )) + + got := loadCheck(t, check.ID) + assert.Equal(t, "OK", got.State, "quorum=1 must keep the direct State update") + assert.NotNil(t, got.LastEnd, "legacy path must keep stamping last_end") + // One region result inserted with aggregated_at=NULL. + assert.EqualValues(t, 1, countPendingResults(t, check.ID), + "the region result row is always inserted even on the legacy path") +} + +// TestApplyRemoteCheckResult_BuffersWhenQuorumN pins the new path: +// when RequireQuorum > 1, ApplyRemoteCheckResult does NOT touch +// Check.State — it only inserts the CheckRegionResult row. The check +// stays at its initial UNK and the unaggregated row count grows by +// exactly 1 per call. +func TestApplyRemoteCheckResult_BuffersWhenQuorumN(t *testing.T) { + models.Drop() + models.Migrate() + + mon, check := seedAggregatorWorld(t, 3, 5) + seedRegion(t, "ru-msk") + seedRegion(t, "us-east") + + require.NoError(t, models.ApplyRemoteCheckResult( + makeReport(check.ID, mon.ID, "OK"), "ru-msk", + )) + require.NoError(t, models.ApplyRemoteCheckResult( + makeReport(check.ID, mon.ID, "ERR"), "us-east", + )) + + got := loadCheck(t, check.ID) + assert.Equal(t, "UNK", got.State, + "quorum>1 must NOT touch Check.State — the aggregator owns it") + assert.EqualValues(t, 2, countPendingResults(t, check.ID), + "two results buffered, both with aggregated_at=NULL") +} + +// --------------------------------------------------------------------------- +// CheckAggregatorTick: rule tests. +// --------------------------------------------------------------------------- + +// TestAggregator_QuorumOK: with RequireQuorum=2 and two OK results +// buffered, the aggregator must decide OK and stamp AggregatedAt on +// both contributing rows. +func TestAggregator_QuorumOK(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 2, 1) + regionResultFor(t, check.ID, "ru-msk", "OK", 5*time.Second) + regionResultFor(t, check.ID, "us-east", "OK", 4*time.Second) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 1, n, "one check aggregated this tick") + + got := loadCheck(t, check.ID) + assert.Equal(t, "OK", got.State, "2 OK results >= quorum=2 → state=OK") + assert.NotNil(t, got.LastOk, "OK decision must stamp last_ok") + assert.EqualValues(t, 0, got.Fails, "fails must reset on OK") + assert.EqualValues(t, 0, countPendingResults(t, check.ID), + "both contributing rows must be stamped aggregated_at") +} + +// TestAggregator_QuorumFail: with RequireQuorum=2 and two ERR results +// buffered, the aggregator must decide ERR and surface the most recent +// row's error message on Check.Error. +func TestAggregator_QuorumFail(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 2, 1) + regionResultWithErr(t, check.ID, "ru-msk", "ERR", "connection refused", 5*time.Second) + regionResultWithErr(t, check.ID, "us-east", "ERR", "timeout", 4*time.Second) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 1, n) + + got := loadCheck(t, check.ID) + assert.Equal(t, "ERR", got.State, "2 ERR >= quorum=2 → state=ERR") + assert.NotNil(t, got.LastFail) + assert.NotNil(t, got.Error, "ERR decision must carry an error message from the rows") + assert.Contains(t, *got.Error, "timeout", + "aggregator should surface the latest row's error message") + assert.EqualValues(t, 0, countPendingResults(t, check.ID)) +} + +// TestAggregator_DegradedWhenPartial: with RequireQuorum=3 and 1 OK + +// 2 ERR (mixed within window), neither side reaches the quorum of 3 so +// the aggregator must decide DEGRADED. The state must NOT silently +// become OK or ERR. +func TestAggregator_DegradedWhenPartial(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 3, 1) + regionResultFor(t, check.ID, "ru-msk", "OK", 5*time.Second) + regionResultFor(t, check.ID, "us-east", "ERR", 4*time.Second) + regionResultFor(t, check.ID, "eu-west", "ERR", 3*time.Second) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 1, n) + + got := loadCheck(t, check.ID) + assert.Equal(t, "DEGRADED", got.State, + "neither OK nor ERR reaches quorum=3 → state=DEGRADED") + assert.EqualValues(t, 0, countPendingResults(t, check.ID)) +} + +// TestAggregator_NotEnoughRegionsAlsoDegraded covers the single-region- +// only-delivered case: with RequireQuorum=3 and only 1 result buffered +// (and it aged past the window), the rule still says "neither side +// reached quorum" → DEGRADED. This is the documented behavior for slow +// regions that never report in time. +func TestAggregator_NotEnoughRegionsAlsoDegraded(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 3, 1) + regionResultFor(t, check.ID, "ru-msk", "OK", 5*time.Second) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 1, n) + + got := loadCheck(t, check.ID) + assert.Equal(t, "DEGRADED", got.State, + "single OK row vs quorum=3 → DEGRADED (below quorum on both sides)") +} + +// TestAggregator_NoResultsLeavesStateAlone is the "special case" from +// the spec: when the aggregator tick finds no eligible rows for a +// check, Check.State must NOT change. Pre-set the check to OK and +// verify it stays OK. +func TestAggregator_NoResultsLeavesStateAlone(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 2, 1) + // Pre-set state and a previous LastEnd so we can detect any + // accidental overwrite. + prevEnd := time.Now().Add(-time.Hour) + require.NoError(t, models.DB().Model(&models.Check{}). + Where("id = ?", check.ID). + Updates(map[string]interface{}{ + "state": "OK", + "last_end": prevEnd, + "last_ok": prevEnd, + }).Error) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 0, n, "no eligible rows → nothing aggregated") + + got := loadCheck(t, check.ID) + assert.Equal(t, "OK", got.State, "state must not change with zero eligible rows") + assert.WithinDuration(t, prevEnd, *got.LastEnd, time.Second, + "last_end must not be touched when there are no eligible rows") +} + +// TestAggregator_MultipleChecksIndependent verifies that a single tick +// processes every check with pending results, not just the first one. +// Two checks, each with 2 regions, each should flip to OK after the +// tick. +func TestAggregator_MultipleChecksIndependent(t *testing.T) { + models.Drop() + models.Migrate() + + _, c1 := seedAggregatorWorld(t, 2, 1) + _, c2 := seedAggregatorWorld(t, 2, 1) + + regionResultFor(t, c1.ID, "ru-msk", "OK", 5*time.Second) + regionResultFor(t, c1.ID, "us-east", "OK", 4*time.Second) + regionResultFor(t, c2.ID, "ru-msk", "OK", 5*time.Second) + regionResultFor(t, c2.ID, "eu-west", "OK", 4*time.Second) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 2, n, "both checks aggregated in the same tick") + + assert.Equal(t, "OK", loadCheck(t, c1.ID).State) + assert.Equal(t, "OK", loadCheck(t, c2.ID).State) + assert.EqualValues(t, 0, countPendingResults(t, c1.ID)) + assert.EqualValues(t, 0, countPendingResults(t, c2.ID)) +} + +// TestAggregator_AlreadyAggregatedRowsSkipped pins the idempotency +// story: a second tick with no new rows must be a no-op. We pre-mark +// the rows aggregated_at=NOW() and verify the tick returns (0, nil) +// without touching Check.State. +func TestAggregator_AlreadyAggregatedRowsSkipped(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 2, 1) + r1 := regionResultFor(t, check.ID, "ru-msk", "OK", 5*time.Second) + r2 := regionResultFor(t, check.ID, "us-east", "OK", 4*time.Second) + // Pretend a previous aggregator pass already stamped them. + now := time.Now() + require.NoError(t, models.DB().Model(&models.CheckRegionResult{}). + Where("id IN ?", []int64{r1.ID, r2.ID}). + UpdateColumns(map[string]interface{}{"aggregated_at": now}).Error) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 0, n, "no candidate checks → 0 aggregated") + + got := loadCheck(t, check.ID) + assert.Equal(t, "UNK", got.State, "already-aggregated rows must not cause a re-decision") +} + +// TestAggregator_IgnoresRowsInsideWindow verifies the watermark: rows +// whose CreatedAt is NEWER than (NOW() - window) are NOT eligible and +// must NOT be stamped. With AggregationWindowSeconds=10 and rows aged +// only 2s, the aggregator finds nothing to do. +func TestAggregator_IgnoresRowsInsideWindow(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 2, 10) + regionResultFor(t, check.ID, "ru-msk", "OK", 2*time.Second) + regionResultFor(t, check.ID, "us-east", "OK", 1*time.Second) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 0, n, "rows still inside window → no aggregation") + + got := loadCheck(t, check.ID) + assert.Equal(t, "UNK", got.State) + assert.EqualValues(t, 2, countPendingResults(t, check.ID), + "rows inside window stay unaggregated for the next tick") +} + +// TestAggregator_SkipsChecksWithQuorumOne guards the candidate SELECT +// filter: even though CheckRegionResult rows are written for +// RequireQuorum=1 checks (via StoreCheckRegionResult), the aggregator +// must not re-decide their state because ApplyRemoteCheckResult +// already did. We simulate by inserting a region row with aggregated_at +// NULL for a quorum=1 check and verifying the tick ignores it. +func TestAggregator_SkipsChecksWithQuorumOne(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 1, 1) + r := regionResultFor(t, check.ID, "ru-msk", "OK", 5*time.Second) + require.Nil(t, r.AggregatedAt) + + n, err := models.CheckAggregatorTick() + require.NoError(t, err) + assert.Equal(t, 0, n, "quorum=1 checks must be filtered out by the candidate SELECT") + + // The row must stay unaggregated too — the aggregator has no + // business stamping it. + assert.EqualValues(t, 1, countPendingResults(t, check.ID)) +} + +// --------------------------------------------------------------------------- +// StartCheckAggregator: ticker smoke test. +// --------------------------------------------------------------------------- + +// TestStartCheckAggregator_TickerFiresOnce is the smoke test for the +// background helper: spin up the aggregator with a tight 10ms ticker +// and a cancellable context, wait for one tick to flip a seeded +// check's state, then cancel so the goroutine exits cleanly. Mirrors +// TestStartDeadWorkerReaper_TickerFiresOnce in shape. +func TestStartCheckAggregator_TickerFiresOnce(t *testing.T) { + models.Drop() + models.Migrate() + + _, check := seedAggregatorWorld(t, 2, 1) + regionResultFor(t, check.ID, "ru-msk", "OK", 5*time.Second) + regionResultFor(t, check.ID, "us-east", "OK", 4*time.Second) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + models.StartCheckAggregator(ctx, 10*time.Millisecond) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + got := loadCheck(t, check.ID) + if got.State == "OK" { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("aggregator goroutine did not flip check to OK within 2s; state=%q", loadCheck(t, check.ID).State) +} + +// silence unused import warnings when individual helpers are inlined by +// editors — the package-level references below keep the imports live. +var ( + _ = gorm.ErrRecordNotFound +) diff --git a/app/models/check_data.go b/app/models/check_data.go new file mode 100644 index 0000000..188682f --- /dev/null +++ b/app/models/check_data.go @@ -0,0 +1,14 @@ +package models + +import ( + "time" + + "rsgit.ru/rsmon/rsmon/internal/influx" +) + +// CheckData provides functionality. +type CheckData struct { + Uptime int `json:"uptime"` + Data []influx.InfluxData `json:"data"` + LastCheck *time.Time `json:"last_check"` +} diff --git a/app/models/check_jobs.go b/app/models/check_jobs.go new file mode 100644 index 0000000..d51c0aa --- /dev/null +++ b/app/models/check_jobs.go @@ -0,0 +1,415 @@ +package models + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "rsgit.ru/rsmon/rsmon/internal/influx" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// defaultRegionCode is the historical default region seeded by Migrate() +// (see app/models/migrate.go) and used as a catch-all bucket for results +// reported without a region code. Aliased to the exported Region +// constant (DefaultRegionCode) so admin endpoints and the in-process +// job router share one source of truth. +const defaultRegionCode = DefaultRegionCode + +// ChecksForWorker returns checks that need to be executed by a distributed worker. +// It uses FOR UPDATE SKIP LOCKED to prevent race conditions between concurrent workers. +// The worker specifies which check kinds it can handle via the kinds parameter. +// +// Phase 2 of docs/plans/worker-notifier-mvp.md adds regional job routing: when +// worker is non-nil, the candidate monitor set is filtered by +// applyRegionRouting so a worker only sees checks that explicitly allow its +// region. Pass nil for the legacy "no region scoping" path used by +// diagnostics/dashboard tooling. +func ChecksForWorker(worker *WorkerNode, kinds []string, limit int) []*Check { + tx := DB().Begin() + q := tx.Joins("JOIN monitors ON checks.monitor_id = monitors.id"). + Where("monitors.enabled"). + Where("checks.enabled AND checks.kind IN (?)", kinds) + + if worker != nil { + q = applyRegionRouting(q, worker) + if accounts := worker.AccessibleAccountIDs(); len(accounts) > 0 { + q = q.Joins("JOIN groups ON monitors.group_id = groups.id").Where("groups.account_id IN (?)", accounts) + } + // Flagged workers remain visible for audit/history but never receive new work. + if worker.NetworkProblemActive(time.Now()) { + tx.Rollback() + return nil + } + } + + // Allow faster retry for failed http/dns checks + notOk := "" + hasHTTPOrDNS := false + for _, k := range kinds { + if k == kindHTTP || k == kindDNS { + hasHTTPOrDNS = true + break + } + } + if hasHTTPOrDNS { + notOk = `OR (checks.state != 'OK' AND checks.last_start + '120 second'::interval < now())` + } + + whereClause := ` + (checks.last_start IS NULL) OR + (checks.last_start + (checks.interval || ' second')::interval < now()) + ` + if notOk != "" { + whereClause += notOk + } + rq := q.Where(whereClause) + + // Use SKIP LOCKED to avoid contention between workers. The same + // FOR UPDATE SKIP LOCKED clause also gives us implicit load balancing + // across workers in the same region: each concurrent worker call + // grabs a disjoint slice of the pending checks and a row leased by + // worker A is invisible to worker B until A's transaction commits + // (or rolls back / lease expires). + var checks []*Check + rq.Clauses(SkipLockedClause). + Limit(limit). + Preload("Monitor"). + Find(&checks) + + for _, c := range checks { + log.Println("worker: assigned remote check:", c.ID, c.Kind) + tx.Model(&c).Where("id = ?", c.ID).Update(colLastStart, time.Now()) + } + + tx.Commit() + return checks +} + +// EnqueueDueCheckTasks atomically turns due normal checks into durable generic +// task envelopes. ChecksForWorker remains for the HTTP polling compatibility +// endpoint, while websocket scheduling uses this task-producing path. +func EnqueueDueCheckTasks(worker *WorkerNode, kinds []string, limit int) error { + if worker == nil || len(kinds) == 0 || limit <= 0 || worker.NetworkProblemActive(time.Now()) { + return nil + } + return DB().Transaction(func(tx *gorm.DB) error { + q := tx.Joins("JOIN monitors ON checks.monitor_id = monitors.id"). + Where("monitors.enabled").Where("checks.enabled AND checks.kind IN (?)", kinds) + if accounts := worker.AccessibleAccountIDs(); len(accounts) > 0 { + q = q.Joins("JOIN groups ON monitors.group_id = groups.id").Where("groups.account_id IN (?)", accounts) + } + q = applyRegionRouting(q, worker) + where := `(checks.last_start IS NULL) OR (checks.last_start + (checks.interval || ' second')::interval < now())` + for _, kind := range kinds { + if kind == kindHTTP || kind == kindDNS { + where += ` OR (checks.state != 'OK' AND checks.last_start + '120 second'::interval < now())` + break + } + } + var checks []*Check + if err := q.Where(where).Clauses(SkipLockedClause).Limit(limit).Preload("Monitor.Group").Find(&checks).Error; err != nil { + return err + } + now := time.Now() + for _, check := range checks { + if check.Monitor == nil || check.Monitor.Group == nil { + continue + } + job := JobForCheck(check) + payload, err := json.Marshal(job) + if err != nil { + return err + } + checkID, monitorID := check.ID, check.MonitorID + bucket := now.UTC().Unix() / int64(check.Interval) + task := Task{ + JobID: job.JobID, Kind: TaskKindCheck, State: TaskStateQueued, + AccountID: check.Monitor.Group.AccountID, CheckID: &checkID, MonitorID: &monitorID, + Payload: payload, NotBefore: now, MaxAttempts: DefaultTaskMaxAttempts, + IdempotencyKey: fmt.Sprintf("check:%d:%d", check.ID, bucket), + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&task).Error; err != nil { + return err + } + if err := tx.Model(check).Update(colLastStart, now).Error; err != nil { + return err + } + } + return nil + }) +} + +// applyRegionRouting narrows the monitor JOIN in ChecksForWorker to the +// subset whose routing rule matches the calling worker's region. The filter +// is applied at the SQL layer so the SKIP LOCKED page only scans/leases +// rows that this worker is allowed to run, instead of leasing and then +// discarding forbidden checks. +// +// The function intentionally mirrors Monitor.WantsRegion so the helper can +// be reused from non-SQL callers (UI preview, plan validation, etc.). +// +// SQL form: +// +// - monitors.region_mode IN ('any', 'all', ”) +// → unconditional match; legacy / Phase 3 placeholder behavior. +// - monitors.region_mode = 'specific' AND +// (monitors.preferred_regions IS NULL OR +// cardinality(monitors.preferred_regions) = 0 OR +// ? = ANY(monitors.preferred_regions)) +// → empty array falls back to "any"; otherwise the worker code must +// be in the whitelist. +func applyRegionRouting(q *gorm.DB, worker *WorkerNode) *gorm.DB { + if worker == nil || worker.RegionCode == "" { + return q + } + if worker.RegionCode == defaultRegionCode { + // The default "local" region is the historical catch-all; the + // in-process scheduler (not ChecksForWorker) handles those + // monitors. Skip regional filtering entirely so we don't leak + // Phase 1 in-process workers through the new router. + return q + } + // TODO(phase3): split RegionMode="all" into N assignments, one per + // preferred region, so the result aggregator can build a quorum. + // Today it is treated as "any" so existing checks keep flowing. + return q.Where( + `(monitors.region_mode IN ('any', 'all', '') OR `+ + `(monitors.region_mode = 'specific' AND `+ + `(monitors.preferred_regions IS NULL OR `+ + `coalesce(array_length(monitors.preferred_regions, 1), 0) = 0 OR `+ + `? = ANY(monitors.preferred_regions))))`, + worker.RegionCode, + ) +} + +// JobForCheck creates a CheckJob from a Check model for sending to a worker +func JobForCheck(c *Check) wire.CheckJob { + jobID := uuid.New().String() + var urlStr *string + if c.URL != nil { + urlStr = c.URL + } + return wire.CheckJob{ + JobID: jobID, + CheckID: c.ID, + MonitorID: c.MonitorID, + Kind: c.Kind, + Host: c.Monitor.Host, + URL: urlStr, + Interval: c.Interval, + Settings: json.RawMessage(c.Settings), + } +} + +// QueueMonitorChecks makes enabled checks for a monitor immediately eligible for remote assignment. +func QueueMonitorChecks(monitorID int64) error { + return DB().Model(&Check{}). + Where("monitor_id = ? AND enabled", monitorID). + Updates(map[string]interface{}{ + colLastStart: nil, + colLastEnd: nil, + }).Error +} + +// QueueMonitorChecksKind makes enabled checks of one kind immediately eligible for remote assignment. +func QueueMonitorChecksKind(monitorID int64, kind string) error { + return DB().Model(&Check{}). + Where("monitor_id = ? AND kind = ? AND enabled", monitorID, kind). + Updates(map[string]interface{}{ + colLastStart: nil, + colLastEnd: nil, + }).Error +} + +// ApplyRemoteCheckResult applies a check result reported by a distributed worker. +// It updates the check state in the database and triggers monitor status aggregation. +// +// Phase 3 of docs/todo.md (multi-region quorum aggregation): when the +// check has RequireQuorum > 1, the per-region result is recorded in +// check_region_results but Check.State is NOT touched here — that is +// the job of app/models/check_aggregator.go, which decides OK/ERR/ +// DEGRADED once enough regional results have arrived or the aggregation +// window has elapsed. QuorumEnabled() == false preserves the legacy +// direct-update path so single-region / non-aggregated monitors keep +// the same behavior. +func ApplyRemoteCheckResult(report wire.CheckResultReport, regionCode string) error { //nolint:gocritic,lll // hugeParam: accepted for interface compatibility + return ApplyRemoteCheckResultFromWorker(report, regionCode, nil) +} + +// ApplyRemoteCheckResultFromWorker persists worker attribution before changing +// legacy check state. A confirmation result is consumed exactly once and never +// overwrites the original check result. +func ApplyRemoteCheckResultFromWorker(report wire.CheckResultReport, regionCode string, worker *WorkerNode) error { //nolint:gocritic,lll // hugeParam: wire compatibility + var monitor *Monitor + err := DB().Transaction(func(tx *gorm.DB) error { + var err error + monitor, err = ApplyRemoteCheckResultFromWorkerTx(tx, report, regionCode, worker) + return err + }) + if err != nil { + return err + } + if monitor != nil { + monitor.UpdateStatusFromChecks() + } + // VictoriaMetrics is outside PostgreSQL and is deliberately post-commit. + // A caller that retries after this error will not duplicate relational state; + // metric points are external at-least-once observations and need TSDB repair + // if the write remains unavailable. + return StoreRemoteCheckMetrics(report.Metrics) +} + +// ApplyRemoteCheckResultFromWorkerTx applies all relational result effects using +// the caller's transaction. It intentionally does not write VictoriaMetrics or +// aggregate monitor state: both must happen only after the transaction commits. +// A nil monitor means the report was a consumed diagnostic attempt. +func ApplyRemoteCheckResultFromWorkerTx(tx *gorm.DB, report wire.CheckResultReport, regionCode string, worker *WorkerNode) (*Monitor, error) { //nolint:gocritic,lll // hugeParam: wire compatibility + if tx == nil { + return nil, fmt.Errorf("apply check result: nil transaction") + } + now := time.Now() + if worker != nil { + handled := false + if err := ApplyDiagnosticResultTx(tx, report, worker, now, &handled); err != nil { + return nil, err + } + if handled { + return nil, nil + } + } + check := Check{} + if err := tx.Preload("Monitor").First(&check, report.CheckID).Error; err != nil { + log.Println("worker: check not found:", report.CheckID, err) + return nil, err + } + + // Always persist the per-region result first so the aggregator can + // pick it up regardless of which path we take next. We rely on + // StoreCheckRegionResult to default AggregatedAt=NULL (the column + // type is *time.Time, so a zero value writes SQL NULL). + if err := StoreCheckRegionResultTx(tx, report, regionCode, now); err != nil { + log.Println("worker: error storing region result:", report.CheckID, err) + return nil, err + } + + // Quorum-enabled checks: write nothing to Check.State here. The + // aggregator will compute the aggregate state once the window has + // elapsed (or enough regions have reported) and stamp AggregatedAt on + // the contributing CheckRegionResult rows. + if check.QuorumEnabled() { + return nil, nil + } + + update := map[string]interface{}{ + colState: report.State, + colLastEnd: now, + colWarnings: pq.StringArray(report.Warnings), + colInfos: pq.StringArray(report.Infos), + } + + if report.State == "OK" { + update["was_up"] = now + update["last_ok"] = now + update["fails"] = 0 + update["error"] = gorm.Expr("NULL") + } else { + update["last_fail"] = now + update["fails"] = gorm.Expr("fails + 1") + if report.Error != nil { + update["error"] = *report.Error + } + } + + if report.ExpiresAt != nil { + t, err := time.Parse(time.RFC3339, *report.ExpiresAt) + if err == nil { + update["expires"] = t + } + } + + if err := tx.Model(&check).UpdateColumns(update).Error; err != nil { + log.Println("worker: error updating check:", report.CheckID, err) + return nil, err + } + if worker != nil { + payload, _ := json.Marshal(report) + attempt := CheckAttempt{JobID: report.JobID, CheckID: check.ID, MonitorID: check.MonitorID, WorkerNodeID: &worker.ID, Kind: AttemptKindRegular, State: AttemptStateFinished, ResultState: report.State, Result: payload, StartedAt: &now, FinishedAt: &now, Deweighted: worker.NetworkProblemActive(now)} + if attempt.JobID == "" { + attempt.JobID = uuid.New().String() + } + // A duplicate websocket/HTTP delivery must not create another attempt. + if err := tx.Where("job_id = ?", attempt.JobID).FirstOrCreate(&attempt).Error; err != nil { + return nil, err + } + switch report.State { + case stateERR, stateFail: + if err := StartConfirmationTx(tx, check.ID, worker.ID, now); err != nil { + return nil, err + } + case stateOK: + if err := RecoverDiagnosticTx(tx, check.ID, now); err != nil { + return nil, err + } + } + } + return check.Monitor, nil +} + +// StoreRemoteCheckMetrics persists TSDB points reported by a distributed worker. +func StoreRemoteCheckMetrics(metrics []wire.MetricPoint) error { + for _, metric := range metrics { + if metric.Metric == "" || len(metric.Fields) == 0 { + continue + } + if err := influx.WriteOne(metric.Metric, metric.Tags, metric.Fields); err != nil { + return err + } + } + return nil +} + +// StoreCheckRegionResult stores a per-region check result for distributed monitoring analytics +func StoreCheckRegionResult(report wire.CheckResultReport, regionCode string) error { //nolint:gocritic,lll // hugeParam: accepted for interface compatibility + return StoreCheckRegionResultTx(DB(), report, regionCode, time.Now()) +} + +// StoreCheckRegionResultTx stores a regional result in the caller's transaction. +func StoreCheckRegionResultTx(tx *gorm.DB, report wire.CheckResultReport, regionCode string, executedAt time.Time) error { //nolint:gocritic,lll // hugeParam: accepted for interface compatibility + if tx == nil { + return fmt.Errorf("store region result: nil transaction") + } + if regionCode == "" { + regionCode = defaultRegionCode + } + + result := CheckRegionResult{ + CheckID: report.CheckID, + RegionCode: regionCode, + ExecutedAt: executedAt, + State: report.State, + DurationMs: report.DurationMs, + Error: report.Error, + } + + return tx.Create(&result).Error +} + +// StaleWorkers marks workers as inactive or dead based on last_seen time +func StaleWorkers() { + // Mark workers with no heartbeat for 2 minutes as inactive + DB().Model(&WorkerNode{}). + Where("status = ? AND last_seen < ?", "active", time.Now().Add(-2*time.Minute)). + Update("status", "inactive") + + // Mark workers with no heartbeat for 5 minutes as dead + DB().Model(&WorkerNode{}). + Where("status IN (?, ?) AND last_seen < ?", "active", "inactive", time.Now().Add(-5*time.Minute)). + Update("status", "dead") +} diff --git a/app/models/check_jobs_test.go b/app/models/check_jobs_test.go new file mode 100644 index 0000000..9ed8fc3 --- /dev/null +++ b/app/models/check_jobs_test.go @@ -0,0 +1,451 @@ +package models_test + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" +) + +func init() { + database.Init() +} + +// seedRegionRoutingWorld builds two regions and three groups (one per +// monitor) plus three workers. The monitors and their PreferredRegions / +// RegionMode are configured by the caller via a callback so each test can +// express the exact routing scenario it wants to assert on. +// +// Returns a routerTestWorld that knows how to look up each fixture by name +// for readable assertions. +type routerTestWorld struct { + groupAny *models.Group + groupSpec *models.Group + groupAll *models.Group + + workerMsk *models.WorkerNode + workerUSEast *models.WorkerNode + workerLocal *models.WorkerNode +} + +// seedWorker creates a worker bound to regionCode. AuthToken is unique per +// worker so the FOR UPDATE SKIP LOCKED path can exercise two distinct +// concurrent callers. +func seedWorker(t *testing.T, id, regionCode string) *models.WorkerNode { + t.Helper() + seedRegion(t, regionCode) + w := &models.WorkerNode{ + WorkerID: id, + RegionCode: regionCode, + Status: "active", + AuthToken: "tok-" + id, + Concurrency: 4, + Capabilities: datatypes.JSON([]byte(`{"check_types":["http"]}`)), + } + require.NoError(t, models.DB().Create(w).Error) + return w +} + +// seedRouterMonitor creates a Monitor with the given region routing +// attributes and one ready-to-run http Check. The check has Interval=60 +// (minimum allowed) and LastStart=nil so ChecksForWorker picks it up +// immediately on the next poll. +func seedRouterMonitor(t *testing.T, group *models.Group, host, regionMode string, preferred []string) (models.Monitor, models.Check) { + t.Helper() + enTrue := true + mon := models.Monitor{ + Name: stringPtrRouter(host), + Host: host, + GroupID: group.ID, + Enabled: true, + } + if regionMode != "" { + mon.RegionMode = regionMode + } + if preferred != nil { + mon.PreferredRegions = models.RegionCodesFromSlice(preferred) + } + require.NoError(t, models.DB().Create(&mon).Error) + + check := models.Check{ + MonitorID: mon.ID, + Kind: "http", + Interval: 60, + Enabled: &enTrue, + State: "UNK", + Settings: datatypes.JSON([]byte(`{}`)), + } + require.NoError(t, models.DB().Create(&check).Error) + return mon, check +} + +func stringPtrRouter(s string) *string { return &s } + +// seedRouterWorld is the common fixture for the TestRegionRouting_* table. +// It provisions two regions (ru-msk, us-east) and three monitors pinned to +// different routing modes; the workers are created lazily by the caller. +func seedRouterWorld(t *testing.T) routerTestWorld { + t.Helper() + models.Drop() + models.Migrate() + + seedRegion(t, "ru-msk") + seedRegion(t, "us-east") + + plan := models.Plan{Name: "router", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + acc := models.Account{Name: "router-acc", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + + groupAny := &models.Group{AccountID: acc.ID, Name: "any"} + groupSpec := &models.Group{AccountID: acc.ID, Name: "spec"} + groupAll := &models.Group{AccountID: acc.ID, Name: "all"} + require.NoError(t, models.DB().Create(groupAny).Error) + require.NoError(t, models.DB().Create(groupSpec).Error) + require.NoError(t, models.DB().Create(groupAll).Error) + + seedRouterMonitor(t, groupAny, "any.test", models.RegionModeAny, nil) + seedRouterMonitor(t, groupSpec, "spec-msk.test", models.RegionModeSpecific, []string{"ru-msk"}) + seedRouterMonitor(t, groupSpec, "spec-us.test", models.RegionModeSpecific, []string{"us-east"}) + seedRouterMonitor(t, groupAll, "all.test", models.RegionModeAll, []string{"ru-msk", "us-east"}) + + return routerTestWorld{ + groupAny: groupAny, + groupSpec: groupSpec, + groupAll: groupAll, + workerMsk: seedWorker(t, "w-msk", "ru-msk"), + workerUSEast: seedWorker(t, "w-us", "us-east"), + workerLocal: seedWorker(t, "w-local", "local"), + } +} + +// idsOf returns a sorted list of monitor IDs assigned to the worker for +// easier assertions across checks. +func idsOf(checks []*models.Check) []int64 { + out := make([]int64, 0, len(checks)) + for _, c := range checks { + if c.Monitor == nil { + continue + } + out = append(out, c.Monitor.ID) + } + return out +} + +// TestRegionRouting_AnyReturnsAll confirms the SQL filter preserves the +// legacy behavior for region_mode='any' monitors and the Phase 3 +// placeholder 'all' monitors: a worker in a region nobody explicitly +// whitelisted must still see them, while monitors pinned to specific +// regions stay filtered out. +// +// seedRouterWorld configures four monitors: +// - any.test → region_mode='any', no PreferredRegions +// - spec-msk.test → region_mode='specific', preferred=[ru-msk] +// - spec-us.test → region_mode='specific', preferred=[us-east] +// - all.test → region_mode='all', preferred=[ru-msk, us-east] +// +// A worker in region "remote" (whitelisted by nobody) must see exactly +// {any.test, all.test} — the two monitors whose region_mode bypasses the +// whitelist — and nothing else. +func TestRegionRouting_AnyReturnsAll(t *testing.T) { + seedRouterWorld(t) + + seedRegion(t, "remote") + w := seedWorker(t, "w-remote", "remote") + + checks := models.ChecksForWorker(w, []string{"http"}, 50) + hosts := hostsOf(checks) + + assert.ElementsMatch(t, []string{"any.test", "all.test"}, hosts, + "region_mode='any' and the Phase 3 'all' placeholder must bypass the whitelist") + assert.NotContains(t, hosts, "spec-msk.test", + "specific-mode monitor with whitelisted ru-msk must NOT reach a remote worker") + assert.NotContains(t, hosts, "spec-us.test", + "specific-mode monitor with whitelisted us-east must NOT reach a remote worker") +} + +// TestRegionRouting_SpecificFiltersByRegion proves the core Phase 2 promise: +// workers in different regions never receive a monitor whose PreferredRegions +// does not include their region code. The check is run with parallel +// goroutines because ChecksForWorker stamps `last_start` on every row it +// leases — a sequential second poll would always see an already-claimed +// queue and the filter would have nothing to test against. +// +// Under the FOR UPDATE SKIP LOCKED race, whichever SELECT fires first grabs +// every matching row, so the *exact* per-worker host list is non-deterministic. +// The deterministic invariant the test asserts is the no-leak property: a +// worker in ru-msk must never see spec-us.test, and vice versa. +func TestRegionRouting_SpecificFiltersByRegion(t *testing.T) { + world := seedRouterWorld(t) + + var ( + wg sync.WaitGroup + mskChecks []*models.Check + usChecks []*models.Check + ) + wg.Add(2) + go func() { + defer wg.Done() + mskChecks = models.ChecksForWorker(world.workerMsk, []string{"http"}, 50) + }() + go func() { + defer wg.Done() + usChecks = models.ChecksForWorker(world.workerUSEast, []string{"http"}, 50) + }() + wg.Wait() + + mskHosts := hostsOf(mskChecks) + usHosts := hostsOf(usChecks) + + // Aggregate coverage: together the two workers must see every check + // the routing layer would ever allow them — the four seeded monitors. + assert.ElementsMatch(t, []string{ + "any.test", + "spec-msk.test", + "spec-us.test", + "all.test", + }, + append(append([]string{}, mskHosts...), usHosts...), + "union of both workers' slices must cover every seeded monitor (any/specific/all × region)") + + // Core Phase 2 invariant: regional filtering never leaks across + // PreferredRegions boundaries. This is the only assertion a + // concurrent SKIP LOCKED race lets us pin deterministically. + assert.NotContains(t, mskHosts, "spec-us.test", + "ru-msk worker must never see a monitor whitelisted for us-east only") + assert.NotContains(t, usHosts, "spec-msk.test", + "us-east worker must never see a monitor whitelisted for ru-msk only") +} + +// TestRegionRouting_SpecificEmptyPreferredFallsBackToAny confirms the +// documented fall-back: a monitor in RegionModeSpecific with no +// PreferredRegions behaves like RegionModeAny so the field is safe to +// leave blank. We poll from the us-east worker — without the fall-back it +// would only see any.test + all.test + spec-us.test. +func TestRegionRouting_SpecificEmptyPreferredFallsBackToAny(t *testing.T) { + world := seedRouterWorld(t) + + // Reset the spec-msk monitor to have an empty PreferredRegions list + // (the seed above gave it one). The Monitor row's RegionMode stays + // 'specific'. + require.NoError(t, models.DB().Model(&models.Monitor{}). + Where("host = ?", "spec-msk.test"). + Update("preferred_regions", models.RegionCodesFromSlice(nil)).Error) + + checks := models.ChecksForWorker(world.workerUSEast, []string{"http"}, 50) + hosts := hostsOf(checks) + assert.Contains(t, hosts, "spec-msk.test", + "empty PreferredRegions with region_mode=specific must fall back to 'any'") +} + +// TestRegionRouting_AllDeferredToAny pins the Phase 3 placeholder behavior: +// region_mode='all' is logged and treated as 'any' today. The test asserts +// the monitor flows to a worker in any region (the TODO log marker is +// emitted from applyRegionRouting — pinned here as a code-grep contract). +func TestRegionRouting_AllDeferredToAny(t *testing.T) { + world := seedRouterWorld(t) + + checks := models.ChecksForWorker(world.workerMsk, []string{"http"}, 50) + hosts := hostsOf(checks) + assert.Contains(t, hosts, "all.test", + "region_mode='all' must currently behave like 'any' so existing checks keep flowing") +} + +// TestRegionRouting_LocalWorkerBypass ensures the historic "local" region +// still routes everything: the in-process scheduler handles those monitors +// and we don't want the Phase 2 filter to leak platform workers through it. +func TestRegionRouting_LocalWorkerBypass(t *testing.T) { + world := seedRouterWorld(t) + + checks := models.ChecksForWorker(world.workerLocal, []string{"http"}, 50) + hosts := hostsOf(checks) + assert.ElementsMatch(t, []string{ + "any.test", + "spec-msk.test", + "spec-us.test", + "all.test", + }, hosts, "worker in region 'local' must receive every check (bypass)") +} + +// TestRegionRouting_NilWorkerReturnsAll asserts the diagnostic-friendly +// escape hatch: passing nil for the worker skips the routing filter and +// returns every check the kinds/limit envelope allows. +func TestRegionRouting_NilWorkerReturnsAll(t *testing.T) { + seedRouterWorld(t) + + checks := models.ChecksForWorker(nil, []string{"http"}, 50) + hosts := hostsOf(checks) + assert.ElementsMatch(t, []string{ + "any.test", + "spec-msk.test", + "spec-us.test", + "all.test", + }, hosts, "nil worker must bypass the routing filter") +} + +// TestRegionRouting_LoadBalanceImplicit confirms the SKIP LOCKED implicit +// load-balancing story: when two workers in the same region race for a pool +// of pending checks, each of them receives a non-empty disjoint slice. The +// two polls run in parallel goroutines so the FOR UPDATE SKIP LOCKED race +// window is actually exercised. +// +// IMPORTANT: SKIP LOCKED with a large LIMIT is unfair — whichever +// transaction's SELECT fires first grabs everything. The test therefore +// uses LIMIT=4 with 10 pending rows so each worker is forced to leave some +// rows unlocked for the other worker to pick up. Together they must cover +// at most 8 rows (LIMIT × workers) without overlap; the remaining rows are +// intentionally left for a future poll cycle, which mirrors production +// behavior where workers continually drain a backlog. +func TestRegionRouting_LoadBalanceImplicit(t *testing.T) { + models.Drop() + models.Migrate() + + seedRegion(t, "shared") + plan := models.Plan{Name: "lb-plan", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + acc := models.Account{Name: "lb", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + group := &models.Group{AccountID: acc.ID, Name: "lb-g"} + require.NoError(t, models.DB().Create(group).Error) + + enTrue := true + for i := 0; i < 10; i++ { + host := "lb-" + string(rune('a'+i)) + ".test" + mon := models.Monitor{ + Name: stringPtrRouter(host), + Host: host, + GroupID: group.ID, + Enabled: true, + } + require.NoError(t, models.DB().Create(&mon).Error) + ck := models.Check{ + MonitorID: mon.ID, Kind: "http", Interval: 60, + Enabled: &enTrue, State: "UNK", + Settings: datatypes.JSON([]byte(`{}`)), + } + require.NoError(t, models.DB().Create(&ck).Error) + } + + w1 := seedWorker(t, "lb-w1", "shared") + w2 := seedWorker(t, "lb-w2", "shared") + + const limitPerWorker = 4 + var ( + wg sync.WaitGroup + aChecks, bChecks []*models.Check + ) + wg.Add(2) + go func() { + defer wg.Done() + aChecks = models.ChecksForWorker(w1, []string{"http"}, limitPerWorker) + }() + go func() { + defer wg.Done() + bChecks = models.ChecksForWorker(w2, []string{"http"}, limitPerWorker) + }() + wg.Wait() + + assert.Greater(t, len(aChecks), 0, "worker 1 must receive at least one check") + assert.Greater(t, len(bChecks), 0, "worker 2 must receive at least one check") + assert.LessOrEqual(t, len(aChecks)+len(bChecks), 2*limitPerWorker, + "two concurrent workers with LIMIT each can lease at most LIMIT*2 rows per cycle") + assert.Empty(t, intersectHosts(aChecks, bChecks), + "the two slices must be disjoint (FOR UPDATE SKIP LOCKED must not double-lease)") +} + +// TestMonitorValidateRegionMode exercises the documented enum on the +// Monitor type so the validator surface does not regress. +func TestMonitorValidateRegionMode(t *testing.T) { + cases := []struct { + mode string + wantErr bool + }{ + {"", false}, + {"any", false}, + {"specific", false}, + {"all", false}, + {"round-robin", true}, + {"RANDOM", true}, + } + for _, c := range cases { + t.Run("mode="+c.mode, func(t *testing.T) { + m := models.Monitor{RegionMode: c.mode} + err := m.ValidateRegionMode() + if c.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestMonitorWantsRegion pins the public WantsRegion helper that powers the +// non-SQL callers (UI preview, plan validation). Phase 3 will swap the +// 'all' branch's behavior, so the table covers all three modes today. +func TestMonitorWantsRegion(t *testing.T) { + cases := []struct { + name string + mode string + regions []string + workerRC string + want bool + }{ + {"any_always_true", "any", []string{"ru-msk"}, "us-east", true}, + {"any_empty_pref_still_true", "any", nil, "us-east", true}, + {"specific_match", "specific", []string{"ru-msk", "eu-west"}, "ru-msk", true}, + {"specific_no_match", "specific", []string{"ru-msk", "eu-west"}, "us-east", false}, + {"specific_empty_pref_fallback", "specific", nil, "us-east", true}, + {"all_placeholder_true", "all", []string{"ru-msk", "us-east"}, "ru-msk", true}, + {"all_placeholder_foreign_region", "all", []string{"ru-msk", "us-east"}, "eu-west", true}, + {"empty_mode_defaults_to_any", "", nil, "us-east", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m := models.Monitor{ + RegionMode: c.mode, + PreferredRegions: models.RegionCodesFromSlice(c.regions), + } + assert.Equal(t, c.want, m.WantsRegion(c.workerRC)) + }) + } +} + +// hostsOf extracts the hostnames from the assigned checks for readable +// assertions in table-driven tests. +func hostsOf(checks []*models.Check) []string { + out := make([]string, 0, len(checks)) + for _, c := range checks { + if c.Monitor == nil { + continue + } + out = append(out, c.Monitor.Host) + } + return out +} + +// intersectHosts returns the hostnames present in both slices — used to +// prove two concurrent workers did not lease the same check twice. +func intersectHosts(a, b []*models.Check) []string { + set := make(map[string]struct{}, len(a)) + for _, c := range a { + if c.Monitor != nil { + set[c.Monitor.Host] = struct{}{} + } + } + var out []string + for _, c := range b { + if c.Monitor == nil { + continue + } + if _, ok := set[c.Monitor.Host]; ok { + out = append(out, c.Monitor.Host) + } + } + return out +} diff --git a/app/models/check_metric_test.go b/app/models/check_metric_test.go new file mode 100644 index 0000000..552c4cf --- /dev/null +++ b/app/models/check_metric_test.go @@ -0,0 +1,42 @@ +package models + +import ( + "testing" + "unicode" + + "github.com/stretchr/testify/assert" +) + +func TestCheckMetricName(t *testing.T) { + tests := []struct { + kind string + want string + }{ + {"http", "chttp"}, + {"ssl", "cssl"}, + {"bssl", "cbssl"}, + {"ssh", "cssh"}, + {"ftp", "cftp"}, + {"dns", "cdns"}, + {"whois", "cwhois"}, + {"rkn", "crkn"}, + {"llm", "cllm"}, + {"llm-http", "cllm_http"}, + {"weird-kind", "cweird_kind"}, + } + + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + c := &Check{Kind: tt.kind, ID: 1} + got := c.MetricName() + assert.Equal(t, tt.want, got, "MetricName() for kind %q", tt.kind) + + metric := got + for _, ch := range metric { + if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) && ch != '_' && ch != ':' { + t.Errorf("metric name %q contains invalid character %q", metric, ch) + } + } + }) + } +} diff --git a/app/models/check_region_result.go b/app/models/check_region_result.go new file mode 100644 index 0000000..994e967 --- /dev/null +++ b/app/models/check_region_result.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// CheckRegionResult stores check results from distributed workers by region +type CheckRegionResult struct { + concerns.Model + CheckID int64 `gorm:"index;not null" json:"check_id"` + Check *Check `gorm:"foreignKey:CheckID" json:"check,omitempty"` + RegionCode string `gorm:"size:20;not null;index" json:"region_code"` + Region *Region `gorm:"foreignKey:RegionCode;references:Code" json:"region,omitempty"` + WorkerNodeID *int64 `gorm:"index" json:"worker_node_id"` + WorkerNode *WorkerNode `gorm:"foreignKey:WorkerNodeID" json:"worker_node,omitempty"` + ExecutedAt time.Time `gorm:"not null" json:"executed_at"` + State string `gorm:"size:10;not null" json:"state"` + DurationMs int64 `json:"duration_ms"` + Error *string `json:"error"` + + // AggregatedAt is set by app/models/check_aggregator.go once a row has + // been folded into a Check.State decision. NULL means "still waiting + // for the aggregator"; non-NULL means "this row has already been + // counted in a quorum decision and must not be re-counted". The + // column is indexed (see check_aggregator.go index helper) so the + // per-tick SELECT that finds unaggregated rows is O(matching rows) + // rather than scanning the whole table. + AggregatedAt *time.Time `gorm:"index" json:"aggregated_at,omitempty"` + + concerns.Timestamped +} diff --git a/app/models/check_settings.go b/app/models/check_settings.go new file mode 100644 index 0000000..ba7a9b9 --- /dev/null +++ b/app/models/check_settings.go @@ -0,0 +1,155 @@ +package models + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +// CheckHeader provides functionality. +type CheckHeader struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// CheckSettings provides functionality. +type CheckSettings struct { + // HTTP Basic Auth Username + HTTPUsername string `json:"http_username,omitempty"` + // HTTP Basic Auth Password + HTTPPassword string `json:"http_password,omitempty"` + + // ExpectedAnswer (default, redirect, custom) + ExpectedAnswer string `json:"expected_answer,omitempty"` + // Expected redirect location + ExpectedLocation string `json:"expected_location,omitempty"` + // Expected HTTP status codes for custom + ExpectedHTTPCode int `json:"expected_http_code,omitempty"` + + // Keyword search type (off, present or absent) + KeywordType string `json:"keyword_type,omitempty"` + // Keyword to search for + KeywordValue string `json:"keyword_value,omitempty"` + + SlowTime int `json:"slow_time"` + Timeout int `json:"timeout"` + // Request Settings + CheckIp bool `json:"checkip"` //nolint:revive // accepted lint exception + CheckIPv6 bool `json:"checkipv6"` + RequestHeader []CheckHeader `json:"request_headers"` + RequestMethod string `json:"request_method"` + RequestType string `json:"request_type"` + RequestContent string `json:"request_content"` + // SSH Settings + Port string `json:"port"` + + // Host optionally overrides the monitor host for checks that do + // not naturally target the Monitor.Host (e.g. an ICMP/TCP/UDP + // probe to a separate machine, or a different IP family). Used by + // cping/ctcp/cudp. + Host string `json:"host,omitempty"` + + // Count is the per-check packet count for ping. Defaults to 1 in + // cping when zero or negative. + Count int `json:"count,omitempty"` + + // PacketSize is the ICMP payload size for ping (bytes). Defaults + // to 56 in cping when zero or negative. + PacketSize int `json:"packet_size,omitempty"` + + // Distributed marks this check as eligible for execution on the + // distributed worker pool (multi-region, multi-worker) instead of + // only the in-process scheduler. Reserved for paid plans; the + // controller layer enforces the plan check. + Distributed bool `json:"distributed"` +} + +// redirectCodes contains all HTTP redirect status codes. +var redirectCodes = []int{300, 301, 302, 303, 307, 308} + +// CheckKeyword provides functionality. +func (s *CheckSettings) CheckKeyword(body []byte, warns []string) ([]string, error) { + var err error + + switch s.KeywordType { + case "", "off": + return warns, nil + case "present": + if !strings.Contains(string(body), s.KeywordValue) { + err = errors.New("expected keyword " + s.KeywordValue + " not found") + } + case "absent": + if strings.Contains(string(body), s.KeywordValue) { + err = errors.New("unexpected keyword " + s.KeywordValue + " found") + } + } + + return warns, err +} + +// CheckAnswer provides functionality. +func (s *CheckSettings) CheckAnswer(resp *http.Response, body []byte) ([]string, error) { + // log.Println("check answer, settings:") + // spew.Dump(s) + + isRedirect := false + + for _, rc := range redirectCodes { + if resp.StatusCode == rc { + isRedirect = true + } + } + + location := resp.Header.Get("location") + + // log.Println("redirect?", isRedirect, location) + + switch s.ExpectedAnswer { + case "", "default": + if isRedirect { + return s.CheckKeyword(body, []string{"redirect: " + location}) + } else if resp.StatusCode != 200 { + return []string{}, fmt.Errorf("bad status code: %d", resp.StatusCode) + } + + case "redirect": + if !isRedirect { + return []string{}, fmt.Errorf("bad status code: %d (expected redirect)", resp.StatusCode) + } + + if s.ExpectedLocation != "" { + if location != s.ExpectedLocation { + return []string{}, fmt.Errorf( + "bad location: %s (expected %s)", + location, + s.ExpectedLocation, + ) + } + } + + case "custom": + if s.ExpectedHTTPCode == 0 { + s.ExpectedHTTPCode = 200 + } + if resp.StatusCode != s.ExpectedHTTPCode { + return []string{}, fmt.Errorf("bad status code: %d (expected %d)", resp.StatusCode, s.ExpectedHTTPCode) + } + + if s.ExpectedLocation != "" { + if location != s.ExpectedLocation { + return []string{}, fmt.Errorf( + "bad location: %s (expected %s)", + location, + s.ExpectedLocation, + ) + } + } + + default: + panic("bad expectedAnswer") + } + + // return []string{}, nil + return s.CheckKeyword(body, []string{}) +} diff --git a/app/models/check_settings_test.go b/app/models/check_settings_test.go new file mode 100644 index 0000000..66af977 --- /dev/null +++ b/app/models/check_settings_test.go @@ -0,0 +1,60 @@ +package models + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCheckSettingsDistributed_Default(t *testing.T) { + s := CheckSettings{} + assert.False(t, s.Distributed, "new CheckSettings should default to Distributed=false") +} + +func TestCheckSettingsDistributed_JSON(t *testing.T) { + original := CheckSettings{ + HTTPUsername: "user", + HTTPPassword: "pass", + ExpectedAnswer: "default", + Timeout: 30, + Distributed: true, + } + + data, err := json.Marshal(original) + assert.NoError(t, err) + assert.Contains(t, string(data), `"distributed":true`) + + var decoded CheckSettings + err = json.Unmarshal(data, &decoded) + assert.NoError(t, err) + assert.Equal(t, original, decoded) + assert.True(t, decoded.Distributed) +} + +func TestCheckSettingsDistributed_OmitFalse(t *testing.T) { + s := CheckSettings{HTTPUsername: "user"} + data, err := json.Marshal(s) + assert.NoError(t, err) + assert.Contains(t, string(data), `"distributed":false`) +} + +func TestCheckSettingsDistributed_CheckUnmarshal(t *testing.T) { + c := &Check{ + Settings: []byte(`{"distributed": true, "timeout": 60}`), + } + got := c.GetSettings() + assert.True(t, got.Distributed) + assert.Equal(t, 60, got.Timeout) +} + +func TestPlanAllowsDistributed(t *testing.T) { + var nilPlan *Plan + assert.False(t, nilPlan.AllowsDistributed(), "nil plan must not allow distributed") + + free := &Plan{Price: 0} + assert.False(t, free.AllowsDistributed(), "free plan must not allow distributed") + + paid := &Plan{Price: 100} + assert.True(t, paid.AllowsDistributed(), "paid plan must allow distributed") +} diff --git a/app/models/cleanup_stale_test.go b/app/models/cleanup_stale_test.go new file mode 100644 index 0000000..1be597f --- /dev/null +++ b/app/models/cleanup_stale_test.go @@ -0,0 +1,181 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// makeStaleUser inserts a user whose LastActiveAt is older than the +// 3-month cutoff used by the cleanup filter. The returned user is what +// the candidates query should pick up. +func makeStaleUser(t *testing.T, email string, lastActive *time.Time) *models.User { + t.Helper() + u := &models.User{ + Email: &email, + Name: "stale " + email, + Enabled: true, + Confirmed: true, + LastActiveAt: lastActive, + } + require.NoError(t, models.DB().Create(u).Error) + return u +} + +// TestFindStaleAccounts_EmptyWhenNoCandidates checks the obvious +// negative case: a fresh account with an active owner is not eligible. +func TestFindStaleAccounts_EmptyWhenNoCandidates(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + acc := models.Account{Name: "fresh", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + + // Group + owner access + active user + group := models.Group{AccountID: acc.ID, Name: "default"} + require.NoError(t, models.DB().Create(&group).Error) + + recent := time.Now().Add(-1 * time.Hour) + user := makeStaleUser(t, "active@example.com", &recent) + access := models.Access{AccountID: acc.ID, UserID: &user.ID, Kind: "account", Role: "owner"} + require.NoError(t, models.DB().Create(&access).Error) + + got, err := models.FindStaleAccounts() + require.NoError(t, err) + assert.Empty(t, got, "an account with an active owner is not stale") +} + +// TestFindStaleAccounts_PicksStaleEmptyAccount checks the happy path: +// account with no monitors + single user + last login > 3 months ago. +func TestFindStaleAccounts_PicksStaleEmptyAccount(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + acc := models.Account{Name: "ghost", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + + group := models.Group{AccountID: acc.ID, Name: "default"} + require.NoError(t, models.DB().Create(&group).Error) + + neverLoggedIn := (*time.Time)(nil) + user := makeStaleUser(t, "ghost@example.com", neverLoggedIn) + access := models.Access{AccountID: acc.ID, UserID: &user.ID, Kind: "account", Role: "owner"} + require.NoError(t, models.DB().Create(&access).Error) + + got, err := models.FindStaleAccounts() + require.NoError(t, err) + require.Len(t, got, 1, "the empty stale account should be picked up") + assert.Equal(t, acc.ID, got[0].AccountID) + assert.Equal(t, user.ID, got[0].UserID) +} + +// TestFindStaleAccounts_SkipsAccountWithMonitors makes sure the +// "zero monitors" gate is enforced. +func TestFindStaleAccounts_SkipsAccountWithMonitors(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + acc := models.Account{Name: "active", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + + group := models.Group{AccountID: acc.ID, Name: "default"} + require.NoError(t, models.DB().Create(&group).Error) + + // One monitor → account is NOT eligible even if the user is stale. + monitor := models.Monitor{GroupID: group.ID, Host: "example.com"} + require.NoError(t, models.DB().Create(&monitor).Error) + + stale := time.Now().Add(-365 * 24 * time.Hour) + user := makeStaleUser(t, "owner@example.com", &stale) + access := models.Access{AccountID: acc.ID, UserID: &user.ID, Kind: "account", Role: "owner"} + require.NoError(t, models.DB().Create(&access).Error) + + got, err := models.FindStaleAccounts() + require.NoError(t, err) + assert.Empty(t, got) +} + +// TestFindStaleAccounts_SkipsUserWithMultipleAccounts verifies that a +// user holding two accounts disqualifies BOTH accounts. +func TestFindStaleAccounts_SkipsUserWithMultipleAccounts(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + acc1 := models.Account{Name: "acc1", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc1).Error) + acc2 := models.Account{Name: "acc2", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc2).Error) + models.DB().Create(&models.Group{AccountID: acc1.ID, Name: "g1"}) + models.DB().Create(&models.Group{AccountID: acc2.ID, Name: "g2"}) + + stale := time.Now().Add(-365 * 24 * time.Hour) + user := makeStaleUser(t, "shared@example.com", &stale) + require.NoError(t, models.DB().Create(&models.Access{AccountID: acc1.ID, UserID: &user.ID, Kind: "account", Role: "owner"}).Error) + require.NoError(t, models.DB().Create(&models.Access{AccountID: acc2.ID, UserID: &user.ID, Kind: "account", Role: "owner"}).Error) + + got, err := models.FindStaleAccounts() + require.NoError(t, err) + assert.Empty(t, got, "user with two accounts disqualifies both accounts") +} + +// TestCleanupStaleAccounts_HardDeletesEligibleAndOrphans verifies the +// end-to-end cleanup: matching account + user are removed, and +// recently-active accounts survive. +func TestCleanupStaleAccounts_HardDeletesEligibleAndOrphans(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + // Stale account with a stale user that has only this one account. + staleAcc := models.Account{Name: "ghost", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&staleAcc).Error) + staleGroup := models.Group{AccountID: staleAcc.ID, Name: "g"} + require.NoError(t, models.DB().Create(&staleGroup).Error) + staleUser := makeStaleUser(t, "ghost@example.com", nil) + require.NoError(t, models.DB().Create(&models.Access{AccountID: staleAcc.ID, UserID: &staleUser.ID, Kind: "account", Role: "owner"}).Error) + + // Active account with a recent user — must NOT be touched. + freshAcc := models.Account{Name: "fresh", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&freshAcc).Error) + require.NoError(t, models.DB().Create(&models.Group{AccountID: freshAcc.ID, Name: "g"}).Error) + recent := time.Now().Add(-1 * time.Hour) + freshUser := makeStaleUser(t, "fresh@example.com", &recent) + require.NoError(t, models.DB().Create(&models.Access{AccountID: freshAcc.ID, UserID: &freshUser.ID, Kind: "account", Role: "owner"}).Error) + + deleted, err := models.CleanupStaleAccounts() + require.NoError(t, err) + assert.Equal(t, 1, deleted, "only the stale empty account should be deleted") + + // Stale account is gone. + var count int64 + require.NoError(t, models.DB().Model(&models.Account{}).Where("id = ?", staleAcc.ID).Count(&count).Error) + assert.Equal(t, int64(0), count) + + // Stale user is orphaned → also hard-deleted by the cleanup pass. + require.NoError(t, models.DB().Model(&models.User{}).Where("id = ?", staleUser.ID).Count(&count).Error) + assert.Equal(t, int64(0), count) + + // Fresh account and user survive. + require.NoError(t, models.DB().Model(&models.Account{}).Where("id = ?", freshAcc.ID).Count(&count).Error) + assert.Equal(t, int64(1), count) + require.NoError(t, models.DB().Model(&models.User{}).Where("id = ?", freshUser.ID).Count(&count).Error) + assert.Equal(t, int64(1), count) +} diff --git a/app/models/concerns/has_token.go b/app/models/concerns/has_token.go new file mode 100644 index 0000000..a846ce5 --- /dev/null +++ b/app/models/concerns/has_token.go @@ -0,0 +1,41 @@ +// Package concerns provides functionality. +package concerns + +import ( + "bytes" + "crypto/rand" + "encoding/base64" +) + +// HasToken provides functionality. +type HasToken struct { + Token string `json:"-" gorm:"unique_index"` +} + +// SetToken provides functionality. +func (m *HasToken) SetToken() { + tk := RandomToken(32) + m.Token = base64.RawURLEncoding.EncodeToString(tk) + if m.Token == "" { + panic("RandomToken failed: token not set") + } + if len(m.Token) < 32 { + panic("RandomToken failed: token too short") + } +} + +// RandomToken provides functionality. +func RandomToken(tokenLen int) []byte { + b := make([]byte, tokenLen) + n, err := rand.Read(b) + if err != nil { + panic(err) + } + if n != tokenLen { + panic("RandomToken failed: bad len") + } + if bytes.Equal(b, make([]byte, tokenLen)) { + panic("RandomToken failed: generated empty token") + } + return b +} diff --git a/app/models/concerns/model.go b/app/models/concerns/model.go new file mode 100644 index 0000000..748a8da --- /dev/null +++ b/app/models/concerns/model.go @@ -0,0 +1,14 @@ +// Source: https://gorm.io/gorm/blob/master/model.go +// Use 64 bit keys + +package concerns + +// Model base model definition, including fields `ID`, `CreatedAt`, `UpdatedAt`, `DeletedAt`, which could be embedded in your models +// type User struct { +// gorm.Model +// } + +// Model provides functionality. +type Model struct { + ID int64 `gorm:"primarykey" json:"id"` +} diff --git a/app/models/concerns/renderable.go b/app/models/concerns/renderable.go new file mode 100644 index 0000000..9ef6fd5 --- /dev/null +++ b/app/models/concerns/renderable.go @@ -0,0 +1,7 @@ +package concerns + +// Renderable provides functionality. +type Renderable struct { + Raw string + Rendered string +} diff --git a/app/models/concerns/soft_delete.go b/app/models/concerns/soft_delete.go new file mode 100644 index 0000000..7f5a0dc --- /dev/null +++ b/app/models/concerns/soft_delete.go @@ -0,0 +1,12 @@ +package concerns + +import ( + "time" +) + +// SoftDelete provides functionality. +type SoftDelete struct { + DeletedAt *time.Time `gorm:"index" json:"-"` + DeleterID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"-"` + // Deleter *User `json:"-"` +} diff --git a/app/models/concerns/timestamped.go b/app/models/concerns/timestamped.go new file mode 100644 index 0000000..0a5f8b4 --- /dev/null +++ b/app/models/concerns/timestamped.go @@ -0,0 +1,11 @@ +package concerns + +import ( + "time" +) + +// Timestamped provides functionality. +type Timestamped struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/app/models/contact.go b/app/models/contact.go new file mode 100644 index 0000000..fafcf84 --- /dev/null +++ b/app/models/contact.go @@ -0,0 +1,86 @@ +package models + +import ( + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Contact represents a notification contact. +// +// Ownership / tenancy: +// +// - A Contact belongs to exactly one Account (tenant). AccountID may be +// nil for system-level admin contacts (see SystemContacts). +// - A Contact may belong to at most one User. UserID is set when the +// contact was created on behalf of a specific user (the typical +// case for self-service "My email" / "My Telegram" contacts) and is +// nil for account-wide contacts. +// +// `User` is omitempty because most list payloads don't preload it; the +// `/settings/users` page loads it server-side via the AccountUserRow +// payload. +type Contact struct { + concerns.Model + + AccountID *int64 `json:"account_id" gorm:"type:bigint REFERENCES accounts(id)"` + Account *Account `json:"-"` + UserID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"user_id"` + User *User `json:"user,omitempty"` + Name string `json:"name" gorm:"not null"` + Kind string `json:"kind" gorm:"not null;index:contact_value"` + Value string `json:"value" gorm:"index:contact_value"` + Enabled bool `json:"enabled" gorm:"not null;default:true"` + Data datatypes.JSON `json:"data"` + IsSystem *bool `json:"is_system" gorm:"default:false"` + Notifications []Notification `json:"-" gorm:"many2many:notification_contacts;"` + NotificationsCount int `gorm:"-:all" json:"notifications_count"` + MonitorsCount int `gorm:"-:all" json:"monitors_count"` + Messages []Message `json:"-"` + + concerns.Timestamped + concerns.HasToken + Audited +} + +// SystemContacts returns all contacts marked as system/admin (is_system=true). +// These are the contacts distributed workers notify directly when the main +// API is unreachable (see docs/worker-protocol.md "System Selfcheck"). +func SystemContacts() ([]Contact, error) { + var contacts []Contact + err := DB().Where("is_system = ? AND enabled = ?", true, true).Find(&contacts).Error + return contacts, err +} + +// ContactsCounts fills NotificationsCount and MonitorsCount for each contact. +func ContactsCounts(contacts *[]Contact) { + groupIDs := make(map[int64]bool, 0) + groupCount := make(map[int64]int, 0) + + for i, c := range *contacts { //nolint:gocritic // range copy is acceptable here + (*contacts)[i].NotificationsCount = len(c.Notifications) + for _, n := range c.Notifications { //nolint:gocritic // range copy is acceptable here + for _, g := range n.Groups { //nolint:gocritic // range copy is acceptable here + groupIDs[g.ID] = true + groupCount[g.ID] = 0 + } + } + } + + gids := make([]int64, 0, len(groupIDs)) + for g := range groupIDs { + gids = append(gids, g) + } + + CountGroups(gids, &groupCount) + + for i, c := range *contacts { //nolint:gocritic // range copy is acceptable here + cnt := 0 + for _, n := range c.Notifications { //nolint:gocritic // range copy is acceptable here + for _, g := range n.Groups { //nolint:gocritic // range copy is acceptable here + cnt += groupCount[g.ID] + } + } + (*contacts)[i].MonitorsCount = cnt + } +} diff --git a/app/models/contact_test.go b/app/models/contact_test.go new file mode 100644 index 0000000..bbe974c --- /dev/null +++ b/app/models/contact_test.go @@ -0,0 +1,65 @@ +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// TestSystemContacts verifies that SystemContacts returns only contacts with +// is_system=true and ignores contacts with is_system=false or nil. +func TestSystemContacts(t *testing.T) { + models.Drop() + models.Migrate() + + account := &models.Account{Name: "test-account"} + require.NoError(t, models.DB().Create(account).Error) + accountID := account.ID + + trueVal, falseVal := true, false + + systemContact := &models.Contact{ + AccountID: &accountID, + Name: "system-admin", + Kind: "email", + Value: "ops@example.com", + IsSystem: &trueVal, + } + regularContact := &models.Contact{ + AccountID: &accountID, + Name: "regular", + Kind: "email", + Value: "user@example.com", + IsSystem: &falseVal, + } + nilSystemContact := &models.Contact{ + AccountID: &accountID, + Name: "nil-system", + Kind: "email", + Value: "nil@example.com", + } + + require.NoError(t, models.DB().Create(systemContact).Error) + require.NoError(t, models.DB().Create(regularContact).Error) + require.NoError(t, models.DB().Create(nilSystemContact).Error) + + got, err := models.SystemContacts() + require.NoError(t, err) + + var ids []int64 + var names []string + for _, c := range got { + ids = append(ids, c.ID) + names = append(names, c.Name) + } + + assert.Contains(t, names, "system-admin") + assert.NotContains(t, names, "regular") + assert.NotContains(t, names, "nil-system") + assert.Contains(t, ids, systemContact.ID) + assert.NotContains(t, ids, regularContact.ID) + assert.NotContains(t, ids, nilSystemContact.ID) +} diff --git a/app/models/credential_crypto.go b/app/models/credential_crypto.go new file mode 100644 index 0000000..6798e8f --- /dev/null +++ b/app/models/credential_crypto.go @@ -0,0 +1,97 @@ +package models + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "os" + "strings" + + "rsgit.ru/rsmon/rsmon/config/secrets" +) + +const credentialKeyEnv = "RSMON_CRED_KEY" + +// encryptSecret encrypts plaintext with AES-GCM. If no key is configured +// (dev/test), stores the value with a "plain:" prefix. Production deployments +// must set RSMON_CRED_KEY or config/secrets.yml crypto.pepper. +func encryptSecret(plaintext string) (string, error) { + key := credentialKey() + if key == "" { + return "plain:" + plaintext, nil + } + block, err := aes.NewCipher(deriveKey(key)) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return "enc:" + base64.StdEncoding.EncodeToString(sealed), nil +} + +// decryptSecret decrypts a value produced by encryptSecret. Values without a +// known prefix are returned as-is for backward compatibility with legacy +// plaintext records. +func decryptSecret(stored string) (string, error) { + if stored == "" { + return "", nil + } + switch { + case strings.HasPrefix(stored, "plain:"): + return strings.TrimPrefix(stored, "plain:"), nil + case strings.HasPrefix(stored, "enc:"): + key := credentialKey() + if key == "" { + return "", errors.New("credential key not configured but secret is encrypted") + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, "enc:")) + if err != nil { + return "", err + } + block, err := aes.NewCipher(deriveKey(key)) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("ciphertext too short") + } + nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil + default: + return stored, nil + } +} + +func deriveKey(s string) []byte { + h := sha256.Sum256([]byte(s)) + return h[:32] +} + +func credentialKey() string { + if k := os.Getenv(credentialKeyEnv); k != "" { + return k + } + if secrets.Crypto != nil && secrets.Crypto.Pepper != "" { + return secrets.Crypto.Pepper + } + return "" +} diff --git a/app/models/dead_worker_reaper.go b/app/models/dead_worker_reaper.go new file mode 100644 index 0000000..514cbf4 --- /dev/null +++ b/app/models/dead_worker_reaper.go @@ -0,0 +1,120 @@ +package models + +import ( + "context" + "log" + "time" +) + +// DeadWorkerHeartbeatTimeout is the threshold for ReapDeadWorkers — a worker +// whose last_seen is older than this is considered dead and any leased tasks +// it owns are reassigned to the pool. Five minutes mirrors the StaleWorkers() +// check in check_jobs.go so the two reapers cannot disagree about who is +// dead. See docs/todo.md Phase 4 §5. +const DeadWorkerHeartbeatTimeout = 5 * time.Minute + +// ReapDeadWorkers marks any non-dead worker whose last_seen is older than +// DeadWorkerHeartbeatTimeout as "dead", then reassigns its leased tasks +// back to the queued pool so other workers (or freshly registered ones) +// can pick them up. It mirrors the structure of ReapExpiredTasks — two +// short UPDATE statements, cheap enough to run from the web process +// every 30s. +// +// The returned tuple is (reaped, reassigned, err): reaped counts the +// workers that flipped to dead during this call; reassigned counts the +// leased tasks that were given back to the pool. A zero count on either +// is normal — the reaper is idempotent and the call is silent when +// nothing is due. +func ReapDeadWorkers() (reaped int, reassigned int, err error) { + now := time.Now() + cutoff := now.Add(-DeadWorkerHeartbeatTimeout) + + // First flip the workers to dead so the second UPDATE can match the + // freshly-stamped ids without having to re-derive them in Go. + r := DB().Exec(` + UPDATE worker_nodes + SET status = ?, updated_at = ? + WHERE status <> ? AND last_seen IS NOT NULL AND last_seen < ?`, + "dead", now, "dead", cutoff, + ) + if r.Error != nil { + return 0, 0, r.Error + } + reaped = int(r.RowsAffected) + + // Nothing flipped → no tasks to return. Cheaper than running an + // UPDATE that touches 0 rows on every tick when the fleet is healthy. + if reaped == 0 { + return 0, 0, nil + } + + // Second: clear any leased tasks owned by the now-dead workers. + // The selector stashes the worker's WorkerID string in lease_owner; + // matching against the (now-stale) worker row's WorkerID is the + // same identifier the selector uses, so we don't need an extra + // join. Tasks in other states (queued, succeeded, dead, …) are + // unaffected — only leased work the dead worker still owned has + // to go back to the queue. + r2 := DB().Exec(` + UPDATE tasks + SET state = ?, lease_owner = '', lease_expires_at = NULL, updated_at = ? + WHERE state = ? AND lease_owner IN ( + SELECT worker_id FROM worker_nodes WHERE status = ? + )`, + TaskStateQueued, now, TaskStateLeased, "dead", + ) + if r2.Error != nil { + return reaped, 0, r2.Error + } + reassigned = int(r2.RowsAffected) + return reaped, reassigned, nil +} + +// StartDeadWorkerReaper launches a goroutine that runs ReapDeadWorkers +// on the given interval until ctx is canceled. Mirrors StartTaskReaper +// in this package: same ticker pattern, same logging style, same +// recover() safety net so a malformed row cannot crash the web process. +// +// The default interval is 30s; values <= 0 fall back to the default so +// the helper is safe to call from any call site without a guard. Wire +// from main.init() once per process — the reaper uses short row-level +// locks and is cheap under load (two indexed UPDATEs of <= a few +// hundred rows in steady state). +// +// Passing a nil context falls back to context.Background() so callers +// can write `models.StartDeadWorkerReaper(nil, ...)` in one-liners +// (main, tests) without having to import "context" first. +func StartDeadWorkerReaper(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = 30 * time.Second + } + if ctx == nil { + ctx = context.Background() + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("dead_worker_reaper: panic recovered: %v", r) + } + }() + reaped, reassigned, err := ReapDeadWorkers() + if err != nil { + log.Printf("dead_worker_reaper: error: %v", err) + return + } + if reaped > 0 || reassigned > 0 { + log.Printf("dead_worker_reaper: reaped=%d reassigned=%d", reaped, reassigned) + } + }() + } + } + }() +} diff --git a/app/models/dead_worker_reaper_test.go b/app/models/dead_worker_reaper_test.go new file mode 100644 index 0000000..f2485a0 --- /dev/null +++ b/app/models/dead_worker_reaper_test.go @@ -0,0 +1,274 @@ +package models_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// TestReapDeadWorkers_SkipsHealthyAndKillsSilent mirrors the +// reaper-shape test for ReapExpiredTasks in task_test.go: seed three +// workers (heartbeat fresh / heartbeat stale / already dead) plus two +// leased tasks owned by the stale worker and one leased task on a +// healthy worker (which must NOT be touched). Then assert: +// +// - the fresh worker is left active +// - the silent worker flips to "dead" +// - the already-dead worker is left dead (idempotent) +// - the silent worker's leased tasks are reset to queued +// - the healthy worker's leased task is untouched +func TestReapDeadWorkers_SkipsHealthyAndKillsSilent(t *testing.T) { + models.Drop() + models.Migrate() + + seedRegion(t, "test") + + healthy := &models.WorkerNode{ + WorkerID: "w-healthy", + RegionCode: "test", + Status: "active", + AuthToken: "tok-healthy", + LastSeen: timePtr(time.Now()), + } + stale := &models.WorkerNode{ + WorkerID: "w-stale", + RegionCode: "test", + Status: "active", + AuthToken: "tok-stale", + LastSeen: timePtr(time.Now().Add(-models.DeadWorkerHeartbeatTimeout - time.Minute)), + } + alreadyDead := &models.WorkerNode{ + WorkerID: "w-dead", + RegionCode: "test", + Status: "dead", + AuthToken: "tok-dead", + LastSeen: timePtr(time.Now().Add(-time.Hour)), + } + require.NoError(t, models.DB().Create(healthy).Error) + require.NoError(t, models.DB().Create(stale).Error) + require.NoError(t, models.DB().Create(alreadyDead).Error) + + // Two leased tasks on the stale worker — both must come back. + task1 := mustLeaseTask(t, stale.WorkerID, "test-acct") + task2 := mustLeaseTask(t, stale.WorkerID, "test-acct") + // One leased task on the healthy worker — must stay leased. + healthyTask := mustLeaseTask(t, healthy.WorkerID, "test-acct") + // Already-dead worker with a leased task — not part of THIS reaper's + // reaping set (it would only be touched by a fresh reaper pass), so + // leave it leased to demonstrate that we don't accidentally reassign + // from prior-dead leases too. + deadPriorTask := mustLeaseTask(t, alreadyDead.WorkerID, "test-acct") + + reaped, reassigned, err := models.ReapDeadWorkers() + require.NoError(t, err) + assert.Equal(t, 1, reaped, "only the stale worker should flip (already-dead is left untouched)") + // 3 tasks come back: the 2 leased by the stale worker (just flipped + // to dead) + the 1 leased by the prior-dead worker, which had never + // been cleaned up because no previous reaper ran. The reaper matches + // dead workers by status, so any leased task on a dead worker is a + // stranded lease and must be returned to the pool regardless of when + // the worker flipped. + assert.Equal(t, 3, reassigned) + + var healthyRow, staleRow, deadRow models.WorkerNode + require.NoError(t, models.DB().First(&healthyRow, healthy.ID).Error) + assert.Equal(t, "active", healthyRow.Status, "healthy worker must stay active") + + require.NoError(t, models.DB().First(&staleRow, stale.ID).Error) + assert.Equal(t, "dead", staleRow.Status, "stale worker should flip to dead") + + require.NoError(t, models.DB().First(&deadRow, alreadyDead.ID).Error) + assert.Equal(t, "dead", deadRow.Status, "already-dead worker should not be touched") + + got := func(id int64) models.Task { + var row models.Task + require.NoError(t, models.DB().First(&row, id).Error) + return row + } + + t1 := got(task1.ID) + assert.Equal(t, models.TaskStateQueued, t1.State, "stale worker task must come back to queued") + assert.Empty(t, t1.LeaseOwner) + assert.Nil(t, t1.LeaseExpiresAt) + + t2 := got(task2.ID) + assert.Equal(t, models.TaskStateQueued, t2.State) + assert.Empty(t, t2.LeaseOwner) + assert.Nil(t, t2.LeaseExpiresAt) + + ht := got(healthyTask.ID) + assert.Equal(t, models.TaskStateLeased, ht.State, "healthy worker's lease must be untouched") + assert.Equal(t, healthy.WorkerID, ht.LeaseOwner) + + dt := got(deadPriorTask.ID) + assert.Equal(t, models.TaskStateQueued, dt.State, + "prior-dead task must also be returned to the pool — any leased task on a dead worker is a stranded lease") + assert.Empty(t, dt.LeaseOwner) +} + +// TestReapDeadWorkers_NoOpOnHealthyFleet verifies the cheap path: when +// no workers are stale the reaper returns (0, 0, nil) without doing any +// work. Mirrors the "reaped = int(r.RowsAffected)" branch in +// ReapExpiredTasks. +func TestReapDeadWorkers_NoOpOnHealthyFleet(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + + w := &models.WorkerNode{ + WorkerID: "w-only-healthy", + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + LastSeen: timePtr(time.Now()), + } + require.NoError(t, models.DB().Create(w).Error) + + reaped, reassigned, err := models.ReapDeadWorkers() + require.NoError(t, err) + assert.Equal(t, 0, reaped) + assert.Equal(t, 0, reassigned) + + var row models.WorkerNode + require.NoError(t, models.DB().First(&row, w.ID).Error) + assert.Equal(t, "active", row.Status) +} + +// TestReapDeadWorkers_OnlyReassignsLeasedNotOthers ensures that the +// reaper does not touch queued/succeeded/failed_retry tasks on the dead +// worker — only leased ones need to be returned to the queue. Tasks in +// other states either belong to no one (queued) or are terminal/semi- +// terminal and have their own audit trail. +func TestReapDeadWorkers_OnlyReassignsLeasedNotOthers(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + + now := time.Now().Add(-models.DeadWorkerHeartbeatTimeout - time.Minute) + w := &models.WorkerNode{ + WorkerID: "w-mix", + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + LastSeen: &now, + } + require.NoError(t, models.DB().Create(w).Error) + + leased := mustLeaseTask(t, w.WorkerID, "acct-mix") + succeeded := mustInsertTask(t, w.WorkerID, "acct-mix", models.TaskStateSucceeded) + failedRetry := mustInsertTask(t, w.WorkerID, "acct-mix", models.TaskStateFailedRetry) + failedPerm := mustInsertTask(t, w.WorkerID, "acct-mix", models.TaskStateFailedPerm) + otherWorker := mustLeaseTask(t, "w-other", "acct-mix") + + reaped, reassigned, err := models.ReapDeadWorkers() + require.NoError(t, err) + assert.Equal(t, 1, reaped) + assert.Equal(t, 1, reassigned, "exactly the one leased task on the dead worker") + + got := func(id int64) string { + var row models.Task + require.NoError(t, models.DB().First(&row, id).Error) + return row.State + } + assert.Equal(t, models.TaskStateQueued, got(leased.ID)) + assert.Equal(t, models.TaskStateSucceeded, got(succeeded.ID), "succeeded must not move") + assert.Equal(t, models.TaskStateFailedRetry, got(failedRetry.ID), "failed_retry must not move") + assert.Equal(t, models.TaskStateFailedPerm, got(failedPerm.ID), "failed_perm must not move") + assert.Equal(t, models.TaskStateLeased, got(otherWorker.ID), + "tasks leased by another worker must not move") +} + +// TestStartDeadWorkerReaper_TickerFiresOnce is a smoke test for the +// background helper: spin up the reaper with a tight 10ms ticker and +// a cancellable context, wait for the first tick, then cancel the +// context so the goroutine exits cleanly without leaking. Mirrors the +// shape of how main.init() uses StartTaskReaper (it can't be torn +// down, but for tests we always pass a cancellable context). +func TestStartDeadWorkerReaper_TickerFiresOnce(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + + w := &models.WorkerNode{ + WorkerID: "w-ticker", + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + LastSeen: timePtr(time.Now().Add(-2 * models.DeadWorkerHeartbeatTimeout)), + } + require.NoError(t, models.DB().Create(w).Error) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + models.StartDeadWorkerReaper(ctx, 10*time.Millisecond) + + deadline := time.Now().Add(2 * time.Second) + var got models.WorkerNode + for time.Now().Before(deadline) { + require.NoError(t, models.DB().First(&got, w.ID).Error) + if got.Status == "dead" { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("reaper goroutine did not flip worker to dead within 2s; last status=%q", got.Status) +} + +// --------------------------------------------------------------------------- +// test helpers +// --------------------------------------------------------------------------- + +func timePtr(t time.Time) *time.Time { return &t } + +// mustLeaseTask inserts a leased Task row owned by workerID. The state +// is the only field that matters for reaper tests; payload/idempotency +// are stubs. +func mustLeaseTask(t *testing.T, workerID, accountLabel string) models.Task { + t.Helper() + return mustInsertTask(t, workerID, accountLabel, models.TaskStateLeased) +} + +func mustInsertTask(t *testing.T, workerID, accountLabel string, state string) models.Task { + t.Helper() + leaseUntil := time.Now().Add(time.Hour) + stamp := time.Now().UnixNano() + jobID := fmt.Sprintf("reap-%s-%s-%d", state, workerID, stamp) + // Cap at 64 chars (tasks.job_id VARCHAR(64)). The components above + // already stay under the limit because mustLeaseTask keeps workerID + // short ("w-mix", "w-stale", …) and state is bounded. + if len(jobID) > 64 { + jobID = jobID[:64] + } + idemp := fmt.Sprintf("reap:%s:%d", workerID, stamp) + if len(idemp) > 255 { + idemp = idemp[:255] + } + task := models.Task{ + JobID: jobID, + Kind: models.TaskKindNotification, + State: state, + AccountID: 1, + Payload: datatypes.JSON([]byte(`{"method":"email"}`)), + NotBefore: time.Now().Add(-time.Minute), + LeaseOwner: workerID, + LeaseExpiresAt: &leaseUntil, + Attempts: 1, + MaxAttempts: 5, + IdempotencyKey: idemp, + } + if state != models.TaskStateLeased { + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + } + require.NoError(t, models.DB().Create(&task).Error) + require.NotZero(t, task.ID) + return task +} diff --git a/app/models/deletion.go b/app/models/deletion.go new file mode 100644 index 0000000..77129a3 --- /dev/null +++ b/app/models/deletion.go @@ -0,0 +1,582 @@ +package models + +import ( + "errors" + "log" + "time" + + "gorm.io/gorm" +) + +// OnUserCacheInvalidate is called when a user is mutated in a way that +// invalidates the cached representation in auth/cache.go. It is wired up +// from the auth package during init() to avoid an import cycle. +var OnUserCacheInvalidate func(userID int64) + +// DeletionGracePeriod is the time between a user requesting account deletion +// and the scheduled hard-delete. During this period the user can cancel the +// request and monitoring is paused for all of the user's accounts. +const DeletionGracePeriod = 7 * 24 * time.Hour + +// RequestUserDeletion sets the user's DeletionRequestedAt to now, +// starting the 7-day grace period. +func RequestUserDeletion(user *User) error { + if user == nil { + return errors.New("nil user") + } + if user.DeletionPending() { + return nil + } + now := time.Now() + user.DeletionRequestedAt = &now + if err := DB().Save(user).Error; err != nil { + return err + } + if OnUserCacheInvalidate != nil { + OnUserCacheInvalidate(user.ID) + } + log.Printf("user %d requested account deletion (grace until %s)", user.ID, now.Add(DeletionGracePeriod)) + return nil +} + +// CancelUserDeletion clears the user's DeletionRequestedAt field, +// canceling the pending account deletion. +func CancelUserDeletion(user *User) error { + if user == nil { + return errors.New("nil user") + } + if !user.DeletionPending() { + return nil + } + user.DeletionRequestedAt = nil + if err := DB().Save(user).Error; err != nil { + return err + } + if OnUserCacheInvalidate != nil { + OnUserCacheInvalidate(user.ID) + } + log.Printf("user %d canceled account deletion", user.ID) + return nil +} + +// HardDeleteAccount removes an account and all of its data (monitors, checks, +// events, messages, contacts, groups, notifications, etc.) in a single +// transaction. Accesses and invites pointing at the account are also cleaned +// up. Users themselves are kept (they may belong to other accounts). This is +// the admin "purge" path used to remove spam/error accounts immediately. +func HardDeleteAccount(accountID int64) error { + return DB().Transaction(func(tx *gorm.DB) error { + // Remove accesses pointing at this account first (FK constraint). + if err := tx.Where("account_id = ?", accountID).Delete(&Access{}).Error; err != nil { + return err + } + // Remove invites for this account. + if err := tx.Where("account_id = ?", accountID).Delete(&Invite{}).Error; err != nil { + return err + } + // Delete all account data and the account row itself. + return deleteAccountData(tx, accountID) + }) +} + +// HardDeleteUser removes the user and all of their owned data: accesses, +// contacts, accounts, monitors, checks, events and messages. The deletion +// is wrapped in a transaction to make sure partial failures don't leave +// the database in a broken state. +func HardDeleteUser(userID int64) error { + return DB().Transaction(func(tx *gorm.DB) error { + user := User{} + if err := tx.First(&user, userID).Error; err != nil { + return err + } + + // 1) Find all accounts the user has any access to. + accesses := make([]Access, 0) + if err := tx.Where("user_id = ?", userID).Find(&accesses).Error; err != nil { + return err + } + accountIDs := make([]int64, 0, len(accesses)) + for i := range accesses { + accountIDs = append(accountIDs, accesses[i].AccountID) + } + + // 2) Find all contacts that belong to those accounts or directly to the user. + if err := tx.Where("user_id = ?", userID).Delete(&Contact{}).Error; err != nil { + return err + } + + // 3) For each account: delete the related data, then the account itself. + for _, accountID := range accountIDs { + if err := deleteAccountData(tx, accountID); err != nil { + return err + } + } + + // 4) Remove accesses. + if err := tx.Where("user_id = ?", userID).Delete(&Access{}).Error; err != nil { + return err + } + + // 5) Remove invites issued by or for this user. + if err := tx.Exec("DELETE FROM invites WHERE invitee_id = ? OR account_id IN (?)", + userID, accountIDs).Error; err != nil { + return err + } + + // 6) Remove auth identities (password, social). + if err := tx.Exec("DELETE FROM identities WHERE user_id = ?", userID).Error; err != nil { + return err + } + + // 7) Remove api keys owned by the user. + if err := tx.Where("user_id = ?", userID).Delete(&ApiKey{}).Error; err != nil { + return err + } + + // 8) Remove sessions. + if err := tx.Exec("DELETE FROM remember_tokens WHERE user_id = ?", userID).Error; err != nil { + return err + } + + // 9) Finally remove the user row. + if err := tx.Delete(&user).Error; err != nil { + return err + } + + log.Printf("user %d hard-deleted (cascade accounts=%v)", userID, accountIDs) + return nil + }) +} + +// deleteAccountData removes everything associated with a single account: +// contacts, monitors, checks, events, messages, notifications, groups and +// the account itself. The user accesses are removed separately. +func deleteAccountData(tx *gorm.DB, accountID int64) error { + // All contacts that reference this account (both account-scoped ones + // with user_id IS NULL and per-user ones created in + // CreateAccountForUser that set both account_id and user_id). + // User-only contacts (account_id IS NULL) are independent of the + // account and survive the deletion. contacts.account_id has a FK + // to accounts(id) without ON DELETE CASCADE, so all referencing + // rows must be removed before the account row goes away. + contactIDs := make([]int64, 0) + if err := tx.Model(&Contact{}).Where("account_id = ?", accountID).Pluck("id", &contactIDs).Error; err != nil { + return err + } + notificationIDs := make([]int64, 0) + if err := tx.Model(&Notification{}).Where("account_id = ?", accountID).Pluck("id", ¬ificationIDs).Error; err != nil { + return err + } + if len(contactIDs) > 0 { + if err := tx.Exec("DELETE FROM notification_contacts WHERE contact_id IN (?)", contactIDs).Error; err != nil { + return err + } + } + if len(notificationIDs) > 0 { + if err := tx.Exec("DELETE FROM notification_contacts WHERE notification_id IN (?)", notificationIDs).Error; err != nil { + return err + } + } + + messageQuery := tx.Model(&Message{}) + switch { + case len(contactIDs) > 0 && len(notificationIDs) > 0: + messageQuery = messageQuery.Where("contact_id IN (?) OR notification_id IN (?)", contactIDs, notificationIDs) + case len(contactIDs) > 0: + messageQuery = messageQuery.Where("contact_id IN (?)", contactIDs) + case len(notificationIDs) > 0: + messageQuery = messageQuery.Where("notification_id IN (?)", notificationIDs) + default: + messageQuery = nil + } + if messageQuery != nil { + messageIDs := make([]int64, 0) + if err := messageQuery.Pluck("id", &messageIDs).Error; err != nil { + return err + } + if len(messageIDs) > 0 { + if err := tx.Exec("DELETE FROM event_messages WHERE message_id IN (?)", messageIDs).Error; err != nil { + return err + } + if err := tx.Where("id IN (?)", messageIDs).Delete(&Message{}).Error; err != nil { + return err + } + } + } + if err := tx.Where("account_id = ?", accountID).Delete(&Contact{}).Error; err != nil { + return err + } + + // Monitors (and their checks/events/messages via cascade below). + monitors := make([]Monitor, 0) + if err := tx.Joins("JOIN groups ON monitors.group_id = groups.id"). + Where("groups.account_id = ?", accountID).Find(&monitors).Error; err != nil { + return err + } + monitorIDs := make([]int64, 0, len(monitors)) + for i := range monitors { + monitorIDs = append(monitorIDs, monitors[i].ID) + } + + if len(monitorIDs) > 0 { + // Checks + checks := make([]Check, 0) + if err := tx.Where("monitor_id IN (?)", monitorIDs).Find(&checks).Error; err != nil { + return err + } + checkIDs := make([]int64, 0, len(checks)) + for i := range checks { + checkIDs = append(checkIDs, checks[i].ID) + } + + // Events + events := make([]Event, 0) + if err := tx.Where("monitor_id IN (?)", monitorIDs).Find(&events).Error; err != nil { + return err + } + eventIDs := make([]int64, 0, len(events)) + for i := range events { + eventIDs = append(eventIDs, events[i].ID) + } + + // Join tables first to avoid FK violations + if len(checkIDs) > 0 { + if err := tx.Exec("DELETE FROM event_checks WHERE check_id IN (?)", checkIDs).Error; err != nil { + return err + } + // check_region_results.check_id has a FK to checks(id) without + // ON DELETE CASCADE, so it must be purged before checks go away. + if err := tx.Exec("DELETE FROM check_region_results WHERE check_id IN (?)", checkIDs).Error; err != nil { + return err + } + } + if len(eventIDs) > 0 { + if err := tx.Exec("DELETE FROM event_messages WHERE event_id IN (?)", eventIDs).Error; err != nil { + return err + } + } + + if len(checkIDs) > 0 { + if err := tx.Where("id IN (?)", checkIDs).Delete(&Check{}).Error; err != nil { + return err + } + } + if len(eventIDs) > 0 { + if err := tx.Where("id IN (?)", eventIDs).Delete(&Event{}).Error; err != nil { + return err + } + } + // DNS records + if err := tx.Where("monitor_id IN (?)", monitorIDs).Delete(&DNSRecord{}).Error; err != nil { + return err + } + + // Monitors themselves + if err := tx.Where("id IN (?)", monitorIDs).Delete(&Monitor{}).Error; err != nil { + return err + } + } + + // Notification <-> group links + if err := tx.Exec( + "DELETE FROM notification_groups WHERE group_id IN (SELECT id FROM groups WHERE account_id = ?)", + accountID, + ).Error; err != nil { + return err + } + // Notifications + if err := tx.Where("account_id = ?", accountID).Delete(&Notification{}).Error; err != nil { + return err + } + + // LLMs scoped to this account. worker_llms.llm_id has a FK to llms(id) + // without ON DELETE CASCADE, so the join rows must go first. + if err := tx.Exec( + "DELETE FROM worker_llms WHERE llm_id IN (SELECT id FROM llms WHERE account_id = ?)", + accountID, + ).Error; err != nil { + return err + } + if err := tx.Where("account_id = ?", accountID).Delete(&LLM{}).Error; err != nil { + return err + } + + // Groups + if err := tx.Where("account_id = ?", accountID).Delete(&Group{}).Error; err != nil { + return err + } + + // Inventory entities (docs/plans/inventory-management.md §6). All + // four tables hold account_id FKs to accounts(id) without ON + // DELETE CASCADE, so we wipe them in dependency order: deployments + // and domains first (both FK into sites and servers), then + // server_ips, then sites, then servers. Anything the account does + // not own is left alone (e.g. shared infra servers are filtered + // by account_id and survive). + if err := tx.Where("account_id = ?", accountID).Delete(&Deployment{}).Error; err != nil { + return err + } + if err := tx.Where("account_id = ?", accountID).Delete(&Domain{}).Error; err != nil { + return err + } + if err := tx.Exec( + "DELETE FROM server_ips WHERE server_id IN (SELECT id FROM servers WHERE account_id = ?)", + accountID, + ).Error; err != nil { + return err + } + if err := tx.Where("account_id = ?", accountID).Delete(&Site{}).Error; err != nil { + return err + } + if err := tx.Where("account_id = ?", accountID).Delete(&Server{}).Error; err != nil { + return err + } + + // Private workers scoped to this account (NULL account_id workers + // are platform-operated and survive account deletion). The + // worker_llms and check_region_results joins both FK to + // worker_nodes(id) without ON DELETE CASCADE, so the join rows + // must be cleared before the worker rows go away. + if err := tx.Exec( + "DELETE FROM worker_llms WHERE worker_node_id IN (SELECT id FROM worker_nodes WHERE account_id = ?)", + accountID, + ).Error; err != nil { + return err + } + if err := tx.Exec( + "DELETE FROM check_region_results WHERE worker_node_id IN (SELECT id FROM worker_nodes WHERE account_id = ?)", + accountID, + ).Error; err != nil { + return err + } + if err := tx.Where("account_id = ?", accountID).Delete(&WorkerNode{}).Error; err != nil { + return err + } + + // The account itself + return tx.Delete(&Account{}, accountID).Error +} + +// ProcessPendingDeletions hard-deletes users whose 7-day grace period has +// elapsed. Designed to be called from a periodic scheduler. +func ProcessPendingDeletions() (int, error) { + cutoff := time.Now().Add(-DeletionGracePeriod) + users := make([]User, 0) + if err := DB().Where("deletion_requested_at IS NOT NULL AND deletion_requested_at < ?", cutoff). + Find(&users).Error; err != nil { + return 0, err + } + deleted := 0 + for i := range users { + if err := HardDeleteUser(users[i].ID); err != nil { + log.Printf("ProcessPendingDeletions: failed to delete user %d: %v", users[i].ID, err) + continue + } + deleted++ + } + if deleted > 0 { + log.Printf("ProcessPendingDeletions: hard-deleted %d user(s)", deleted) + } + return deleted, nil +} + +// StaleAccountInactivity is the minimum inactivity window before a stale +// account becomes eligible for admin cleanup. Picked at 3 months per the +// /admin/accounts "Удалить старые" button. +const StaleAccountInactivity = 90 * 24 * time.Hour + +// StaleAccountCandidate describes an account that matched the +// admin-cleanup eligibility filter but has not yet been deleted. The +// snapshot is what the frontend shows in the confirmation dialog. +type StaleAccountCandidate struct { + AccountID int64 `json:"account_id"` + AccountName string `json:"account_name"` + UserID int64 `json:"user_id"` + UserEmail *string `json:"user_email"` + LastActiveAt *time.Time `json:"last_active_at"` +} + +// FindStaleAccounts returns the accounts that are eligible for the +// admin "Удалить старые" cleanup: +// +// - the account has zero monitors configured (via group.account_id) +// - every user with access to the account has exactly one account +// membership total (so removing the account also orphans them) +// - every such user's last_active_at is older than +// StaleAccountInactivity (3 months). Users who have never logged in +// (last_active_at IS NULL) are also eligible. +func FindStaleAccounts() ([]StaleAccountCandidate, error) { + cutoff := time.Now().Add(-StaleAccountInactivity) + + // Step 1: account IDs that have zero monitors. + accountsWithMonitors := make([]int64, 0) + if err := DB(). + Table("monitors"). + Select("DISTINCT groups.account_id"). + Joins("JOIN groups ON groups.id = monitors.group_id"). + Scan(&accountsWithMonitors).Error; err != nil { + return nil, err + } + + accounts := make([]Account, 0) + q := DB().Order("id ASC") + if len(accountsWithMonitors) > 0 { + q = q.Where("id NOT IN (?)", accountsWithMonitors) + } + if err := q.Find(&accounts).Error; err != nil { + return nil, err + } + + out := make([]StaleAccountCandidate, 0, len(accounts)) + for i := range accounts { + acc := accounts[i] + + // Step 2: every user with access to this account must have + // exactly one account membership total. + type userAccessCount struct { + UserID int64 + Cnt int + } + counts := make([]userAccessCount, 0) + err := DB(). + Table("accesses AS a1"). + Select("a1.user_id AS user_id, (SELECT COUNT(*) FROM accesses AS a2 WHERE a2.user_id = a1.user_id) AS cnt"). + Where("a1.account_id = ? AND a1.user_id IS NOT NULL", acc.ID). + Group("a1.user_id"). + Scan(&counts).Error + if err != nil { + return nil, err + } + if len(counts) == 0 { + // An account with no user accesses is a config bug + // (the owner access should always exist). Skip. + continue + } + allSingle := true + for _, c := range counts { + if c.Cnt != 1 { + allSingle = false + break + } + } + if !allSingle { + continue + } + + // Step 3: every such user must be inactive beyond the cutoff. + userIDs := make([]int64, 0, len(counts)) + for _, c := range counts { + userIDs = append(userIDs, c.UserID) + } + users := make([]User, 0) + if err := DB().Where("id IN (?)", userIDs).Find(&users).Error; err != nil { + return nil, err + } + allStale := true + for j := range users { + u := users[j] + if u.LastActiveAt != nil && u.LastActiveAt.After(cutoff) { + allStale = false + break + } + } + if !allStale { + continue + } + + // All gates passed — emit one candidate per user so the UI + // can list which specific accounts would be removed. + for j := range users { + out = append(out, StaleAccountCandidate{ + AccountID: acc.ID, + AccountName: acc.Name, + UserID: users[j].ID, + UserEmail: users[j].Email, + LastActiveAt: users[j].LastActiveAt, + }) + } + } + return out, nil +} + +// CleanupStaleAccounts hard-deletes every account eligible for the +// admin "Удалить старые" sweep. Returns the number of accounts that +// were deleted. The matching users (each of whom only belonged to one +// account) are deleted by HardDeleteAccount's cascading access cleanup +// only if they no longer have any other account — that final teardown +// is done here. +func CleanupStaleAccounts() (int, error) { + candidates, err := FindStaleAccounts() + if err != nil { + return 0, err + } + + accountIDs := make([]int64, 0, len(candidates)) + seen := make(map[int64]bool, len(candidates)) + for _, c := range candidates { + if !seen[c.AccountID] { + seen[c.AccountID] = true + accountIDs = append(accountIDs, c.AccountID) + } + } + + deleted := 0 + for _, accountID := range accountIDs { + if err := HardDeleteAccount(accountID); err != nil { + log.Printf("CleanupStaleAccounts: failed to delete account %d: %v", accountID, err) + continue + } + deleted++ + } + + // Users that no longer have any accesses after the cascade are + // clearly orphaned — purge them so the auth layer doesn't keep + // dangling rows around. We bypass HardDeleteUser here because the + // account-level data (monitors, checks, groups, etc.) was already + // removed by the HardDeleteAccount loop above, so only the user + // row and the dangling identities / contacts need cleanup. + if deleted > 0 { + orphans := make([]int64, 0) + err := DB(). + Table("users"). + Select("users.id"). + Joins("LEFT JOIN accesses ON accesses.user_id = users.id"). + Where("accesses.id IS NULL"). + Pluck("users.id", &orphans).Error + if err != nil { + log.Printf("CleanupStaleAccounts: orphan user scan failed: %v", err) + return deleted, nil + } + for _, userID := range orphans { + tx := DB().Begin() + if err := tx.Exec("DELETE FROM identities WHERE user_id = ?", userID).Error; err != nil { + tx.Rollback() + log.Printf("CleanupStaleAccounts: identities delete failed for user %d: %v", userID, err) + continue + } + if err := tx.Where("user_id = ? AND account_id IS NULL", userID).Delete(&Contact{}).Error; err != nil { + tx.Rollback() + log.Printf("CleanupStaleAccounts: contacts delete failed for user %d: %v", userID, err) + continue + } + if err := tx.Where("user_id = ?", userID).Delete(&ApiKey{}).Error; err != nil { + tx.Rollback() + log.Printf("CleanupStaleAccounts: api_keys delete failed for user %d: %v", userID, err) + continue + } + if err := tx.Delete(&User{}, userID).Error; err != nil { + tx.Rollback() + log.Printf("CleanupStaleAccounts: user delete failed for %d: %v", userID, err) + continue + } + if err := tx.Commit().Error; err != nil { + log.Printf("CleanupStaleAccounts: commit failed for user %d: %v", userID, err) + } + } + } + + if deleted > 0 { + log.Printf("CleanupStaleAccounts: hard-deleted %d stale account(s)", deleted) + } + return deleted, nil +} diff --git a/app/models/deletion_test.go b/app/models/deletion_test.go new file mode 100644 index 0000000..8bfeea9 --- /dev/null +++ b/app/models/deletion_test.go @@ -0,0 +1,352 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/icrowley/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" +) + +var gormErrRecordNotFound = gorm.ErrRecordNotFound + +func deletionStringPtr(s string) *string { return &s } +func deletionBoolPtr(b bool) *bool { return &b } + +func init() { + database.Init() +} + +// TestUserDeletionPending tests the DeletionPending method +func TestUserDeletionPending(t *testing.T) { + t.Run("returns false when no deletion requested", func(t *testing.T) { + user := models.User{} + assert.False(t, user.DeletionPending()) + }) + + t.Run("returns true when deletion requested", func(t *testing.T) { + now := time.Now() + user := models.User{DeletionRequestedAt: &now} + assert.True(t, user.DeletionPending()) + }) +} + +// TestUserAsJSONIncludesDeletionField verifies that the AsJSON output +// contains the deletion_requested_at field so the frontend can react to it. +func TestUserAsJSONIncludesDeletionField(t *testing.T) { + email := "test@example.com" + now := time.Now() + user := models.User{ + ID: 42, + Email: &email, + Name: "Test User", + DeletionRequestedAt: &now, + } + + result := user.AsJSON() + assert.NotNil(t, result) + assert.Contains(t, result, "deletion_requested_at") + assert.Equal(t, &now, result["deletion_requested_at"]) +} + +// TestRequestUserDeletionNilUser ensures nil-safety +func TestRequestUserDeletionNilUser(t *testing.T) { + err := models.RequestUserDeletion(nil) + assert.Error(t, err) +} + +// TestCancelUserDeletionNilUser ensures nil-safety +func TestCancelUserDeletionNilUser(t *testing.T) { + err := models.CancelUserDeletion(nil) + assert.Error(t, err) +} + +// TestCacheInvalidationHook_CalledByRequestDeletion verifies that +// RequestUserDeletion calls OnUserCacheInvalidate with the correct user ID. +func TestCacheInvalidationHook_CalledByRequestDeletion(t *testing.T) { + models.Drop() + models.Migrate() + + email := fake.EmailAddress() + user := models.User{Email: &email, Name: "CacheHookTest"} + assert.NoError(t, models.DB().Create(&user).Error) + + var calledID int64 + originalHook := models.OnUserCacheInvalidate + models.OnUserCacheInvalidate = func(userID int64) { + calledID = userID + } + defer func() { models.OnUserCacheInvalidate = originalHook }() + + assert.NoError(t, models.RequestUserDeletion(&user)) + assert.Equal(t, user.ID, calledID) + + models.DB().Unscoped().Delete(&user) +} + +// TestCacheInvalidationHook_CalledByCancelUserDeletion verifies that +// CancelUserDeletion calls OnUserCacheInvalidate with the correct user ID. +func TestCacheInvalidationHook_CalledByCancelUserDeletion(t *testing.T) { + models.Drop() + models.Migrate() + + email := fake.EmailAddress() + now := time.Now() + user := models.User{Email: &email, Name: "CacheHookCancel", DeletionRequestedAt: &now} + assert.NoError(t, models.DB().Create(&user).Error) + + var calledID int64 + originalHook := models.OnUserCacheInvalidate + models.OnUserCacheInvalidate = func(userID int64) { + calledID = userID + } + defer func() { models.OnUserCacheInvalidate = originalHook }() + + assert.NoError(t, models.CancelUserDeletion(&user)) + assert.Equal(t, user.ID, calledID) + + models.DB().Unscoped().Delete(&user) +} + +// TestHardDeleteAccount_FKCascadeRegression covers the FK regressions +// that surfaced as a series of distinct SQL errors when an operator +// purged an account that owned monitors with distributed-worker +// activity or per-user contacts: +// +// 1. check_region_results.check_id → checks(id) had no ON DELETE +// CASCADE, so deleting a check while it still had region-result +// rows raised 23503. +// 2. worker_nodes did not have an account_id column, so the +// "Workers scoped to this account" delete raised 42703. +// 3. contacts.account_id → accounts(id) had no ON DELETE CASCADE, +// and the original delete filter required user_id IS NULL, so +// per-user contacts created in CreateAccountForUser (both +// account_id and user_id set) survived and blocked the account +// delete with 23503. +// +// The test seeds an account with a monitor, a check with a region +// result row, an account-scoped LLM, a per-user contact, and both a +// private worker (with account_id) and an operated worker (NULL +// account_id), then runs HardDeleteAccount and asserts that the +// account, the private worker, the LLM, the monitor/check/region +// result, and the per-user contact all disappear, while the operated +// worker and an unrelated user-only contact survive. +func TestHardDeleteAccount_FKCascadeRegression(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "hard-delete-regression"} + require.NoError(t, models.DB().Create(&plan).Error) + account := models.Account{Name: "victim", Timezone: "UTC", Language: "en", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&account).Error) + otherAccount := models.Account{Name: "survivor", Timezone: "UTC", Language: "en", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&otherAccount).Error) + + owner := models.User{Name: "owner", Email: deletionStringPtr("owner@example.com"), Timezone: "UTC"} + require.NoError(t, models.DB().Create(&owner).Error) + stranger := models.User{Name: "stranger", Email: deletionStringPtr("stranger@example.com"), Timezone: "UTC"} + require.NoError(t, models.DB().Create(&stranger).Error) + + region := models.Region{} + if err := models.DB().Where("code = ?", "test").First(®ion).Error; err != nil { + require.NoError(t, models.DB().Create(&models.Region{Code: "test", Name: "test", Enabled: true}).Error) + } + + group := models.Group{Name: "g", AccountID: account.ID} + require.NoError(t, models.DB().Create(&group).Error) + monitor := models.Monitor{ + Name: deletionStringPtr("m"), + Host: "example.com", + GroupID: group.ID, + Enabled: true, + } + require.NoError(t, models.DB().Create(&monitor).Error) + check := models.Check{ + MonitorID: monitor.ID, + Kind: "http", + Interval: 60, + Settings: datatypes.JSON([]byte(`{}`)), + Enabled: deletionBoolPtr(true), + State: "UNK", + } + require.NoError(t, models.DB().Create(&check).Error) + + // Per-user contact that references both the victim account and a + // user. CreateAccountForUser writes a contact in this shape, and + // the old "user_id IS NULL" filter would let it survive and block + // the account delete with contacts_account_id_fkey 23503. + ownerContact := models.Contact{ + AccountID: &account.ID, + UserID: &owner.ID, + Name: "owner-email", + Kind: "email", + Value: "owner@example.com", + } + require.NoError(t, models.DB().Create(&ownerContact).Error) + // Account-only contact (user_id IS NULL) — also tied to the account + // via FK and must be removed. + accountContact := models.Contact{ + AccountID: &account.ID, + Name: "ops", + Kind: "email", + Value: "ops@example.com", + } + require.NoError(t, models.DB().Create(&accountContact).Error) + notification := models.Notification{AccountID: account.ID, Name: "alerts", Enabled: true} + require.NoError(t, models.DB().Create(¬ification).Error) + require.NoError(t, models.DB().Model(¬ification).Association("Contacts").Append(&accountContact)) + message := models.Message{ + NotificationID: notification.ID, + ContactID: accountContact.ID, + Kind: "test", + State: "OK", + CreatedAt: time.Now(), + SentAt: time.Now(), + } + require.NoError(t, models.DB().Create(&message).Error) + // User-only contact on a stranger — must survive account deletion. + userOnlyContact := models.Contact{ + UserID: &stranger.ID, + Name: "stranger", + Kind: "email", + Value: "stranger@example.com", + } + require.NoError(t, models.DB().Create(&userOnlyContact).Error) + + // Region result row: this is the row that used to trigger the + // fk_check_region_results_check FK violation when Check was + // deleted. Without the fix the entire HardDeleteAccount would + // fail here. + privateWorker := &models.WorkerNode{ + WorkerID: "private-" + uuid.New().String(), + RegionCode: "test", + Status: "active", + AuthToken: "priv-tok-" + uuid.New().String(), + AccountID: &account.ID, + } + require.NoError(t, models.DB().Create(privateWorker).Error) + operatedWorker := &models.WorkerNode{ + WorkerID: "operated-" + uuid.New().String(), + RegionCode: "test", + Status: "active", + AuthToken: "op-tok-" + uuid.New().String(), + AccountID: nil, + } + require.NoError(t, models.DB().Create(operatedWorker).Error) + require.NoError(t, models.DB().Create(&models.CheckRegionResult{ + CheckID: check.ID, + RegionCode: "test", + WorkerNodeID: &privateWorker.ID, + ExecutedAt: time.Now(), + State: "OK", + }).Error) + + // LLM scoped to the victim account + linked to the private worker. + // worker_llms.llm_id has a FK to llms(id) without ON DELETE + // CASCADE, so the join row used to block LLM deletion too. + llm := models.LLM{ + AccountID: &account.ID, + Name: "private-llm", + URL: "https://llm.example.com", + ModelName: "gpt-test", + APIKey: "secret", + Kind: "openai", + } + require.NoError(t, models.DB().Create(&llm).Error) + require.NoError(t, models.DB().Exec( + "INSERT INTO worker_llms (worker_node_id, llm_id) VALUES (?, ?)", + privateWorker.ID, llm.ID, + ).Error) + + // Inventory entities scoped to the victim account. Each has an + // account_id FK to accounts(id) without ON DELETE CASCADE so they + // must be removed before the account row goes away. The + // shared-infra server belongs to another account and must + // survive. + victimServer := models.Server{Name: "victim-srv", AccountID: account.ID} + require.NoError(t, models.DB().Create(&victimServer).Error) + victimServerIP := models.ServerIp{ServerID: victimServer.ID, Address: "10.0.0.1"} + require.NoError(t, models.DB().Create(&victimServerIP).Error) + victimSite := models.Site{ + AccountID: account.ID, ServerID: &victimServer.ID, + Slug: "victim-site", Name: "victim-site", Kind: "production", IsActive: true, + } + require.NoError(t, models.DB().Create(&victimSite).Error) + victimDeployment := models.Deployment{ + AccountID: account.ID, ServerID: &victimServer.ID, SiteID: &victimSite.ID, + Kind: "production", Mode: "compose", + } + require.NoError(t, models.DB().Create(&victimDeployment).Error) + victimDomain := models.Domain{ + AccountID: account.ID, ServerID: &victimServer.ID, SiteID: &victimSite.ID, + Name: "victim.example.com", + } + require.NoError(t, models.DB().Create(&victimDomain).Error) + otherServer := models.Server{Name: "shared-srv", AccountID: otherAccount.ID} + require.NoError(t, models.DB().Create(&otherServer).Error) + + require.NoError(t, models.HardDeleteAccount(account.ID)) + + // Account and all account-scoped rows must be gone. + assert.ErrorIs(t, models.DB().First(&models.Account{}, account.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Check{}, check.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Monitor{}, monitor.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Group{}, group.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Notification{}, notification.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Message{}, message.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.LLM{}, llm.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Contact{}, ownerContact.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Contact{}, accountContact.ID).Error, gormErrRecordNotFound) + var notificationContactCount int64 + require.NoError(t, models.DB().Table("notification_contacts"). + Where("notification_id = ? OR contact_id = ?", notification.ID, accountContact.ID). + Count(¬ificationContactCount).Error) + assert.Zero(t, notificationContactCount) + + // Private worker must be gone; operated worker must survive. + assert.ErrorIs(t, models.DB().First(&models.WorkerNode{}, privateWorker.ID).Error, gormErrRecordNotFound) + var stillOperated models.WorkerNode + require.NoError(t, models.DB().First(&stillOperated, operatedWorker.ID).Error) + assert.Nil(t, stillOperated.AccountID, "operated worker account_id must remain NULL") + + // User-only contact (no account_id) must survive. + var stillUserOnly models.Contact + require.NoError(t, models.DB().First(&stillUserOnly, userOnlyContact.ID).Error) + + // Inventory entities scoped to the account must be gone. + assert.ErrorIs(t, models.DB().First(&models.Server{}, victimServer.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Site{}, victimSite.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Deployment{}, victimDeployment.ID).Error, gormErrRecordNotFound) + assert.ErrorIs(t, models.DB().First(&models.Domain{}, victimDomain.ID).Error, gormErrRecordNotFound) + var leftover int64 + require.NoError(t, models.DB().Model(&models.ServerIp{}). + Where("server_id = ?", victimServer.ID).Count(&leftover).Error) + assert.Zero(t, leftover, "server_ips for the deleted server must be cleaned up") + // Other-account inventory must survive. + var stillOtherServer models.Server + require.NoError(t, models.DB().First(&stillOtherServer, otherServer.ID).Error) + + // Region-result rows for the deleted check must be gone. + require.NoError(t, models.DB().Model(&models.CheckRegionResult{}). + Where("check_id = ?", check.ID).Count(&leftover).Error) + assert.Zero(t, leftover, "check_region_results must be cleaned up before checks") + require.NoError(t, models.DB().Model(&models.CheckRegionResult{}). + Where("worker_node_id = ?", privateWorker.ID).Count(&leftover).Error) + assert.Zero(t, leftover, "check_region_results referencing a private worker must be cleaned up") + require.NoError(t, models.DB().Raw( + "SELECT COUNT(*) FROM worker_llms WHERE llm_id = ? OR worker_node_id = ?", + llm.ID, privateWorker.ID, + ).Scan(&leftover).Error) + assert.Zero(t, leftover, "worker_llms rows for the deleted LLM and private worker must be gone") + + // Sanity: the other account and its data are untouched. + var stillOther models.Account + require.NoError(t, models.DB().First(&stillOther, otherAccount.ID).Error) +} diff --git a/app/models/deployment.go b/app/models/deployment.go new file mode 100644 index 0000000..cf9bc95 --- /dev/null +++ b/app/models/deployment.go @@ -0,0 +1,240 @@ +package models + +import ( + "database/sql/driver" + "fmt" + "time" + + "github.com/lib/pq" + "gorm.io/datatypes" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// DeploymentKind is the rstuff-mirrored lifecycle label for a +// deployment (production / production_prev / production_next / +// internal / staging / old). See +// docs/parity/rstuff-inventory.md §6.1 for the byte-stable numeric +// mapping (which Postgres stores alphabetically, not numerically). +type DeploymentKind string + +// Lifecycle labels for a Deployment row. See DeploymentKind for +// the matching rstuff enum. The label set is closed; new values +// require adding a Postgres enum value via app/models/migrate.go. +const ( + // DeploymentKindProduction is the customer-facing "live" deployment. + DeploymentKindProduction DeploymentKind = "production" + DeploymentKindProductionPrev DeploymentKind = "production_prev" + DeploymentKindProductionNext DeploymentKind = "production_next" + // DeploymentKindInternal is for ops/admin tooling (not customer-facing). + DeploymentKindInternal DeploymentKind = "internal" + DeploymentKindStaging DeploymentKind = "staging" + DeploymentKindOld DeploymentKind = "old" +) + +// DeploymentMode is the host-side lifecycle label (kubernetes / +// compose / dedicated / vds / user). `dedicated` covers a single +// nginx vhost; `compose` covers a Docker Compose project. +type DeploymentMode string + +// DeploymentMode values map onto rstuff's Deployment.mode enum. +// `dedicated` covers a single nginx vhost; `compose` covers a +// Docker Compose project; the others are reserved for future +// v2 surfaces (Kubernetes, VDS, user-owned). +const ( + DeploymentModeKubernetes DeploymentMode = "kubernetes" + DeploymentModeCompose DeploymentMode = "compose" + DeploymentModeDedicated DeploymentMode = "dedicated" + DeploymentModeVDS DeploymentMode = "vds" + DeploymentModeUser DeploymentMode = "user" +) + +// DeploymentAction is the reconciliation state. Updated by the +// deploymentd receiver on every POST and by the 90s reconcile sweep +// (see app/models/deployment.go ReconcileMissing). +type DeploymentAction string + +// DeploymentAction values. Pending/PendingMove/PendingDrop are +// transient (operator or receiver-initiated); Deleted/Missing are +// sticky until the deployment shows up again on a future POST. +const ( + DeploymentActionOk DeploymentAction = "ok" + DeploymentActionPending DeploymentAction = "pending" + DeploymentActionPendingMove DeploymentAction = "pending_move" + DeploymentActionPendingDrop DeploymentAction = "pending_drop" + DeploymentActionDeleted DeploymentAction = "deleted" + DeploymentActionMissing DeploymentAction = "missing" +) + +// Deployment represents a single host-side binding: one nginx +// vhost, one Docker Compose service, or one Kubernetes service. The +// shape mirrors rstuff's `deployments` table. See +// docs/plans/inventory-management.md §4 / §6.1. +type Deployment struct { + concerns.Model + + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` + Account *Account `json:"-"` + ServerID *int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE SET NULL;index" json:"server_id,omitempty"` + Server *Server `json:"-"` + SiteID *int64 `gorm:"type:bigint REFERENCES sites(id) ON DELETE SET NULL;index" json:"site_id,omitempty"` + Site *Site `json:"site,omitempty"` + ExtID *string `gorm:"size:64" json:"ext_id,omitempty"` + ServiceName *string `gorm:"size:120" json:"service_name,omitempty"` + + Kind DeploymentKind `gorm:"type:deployment_kind;not null;default:'production'" json:"kind"` + Mode DeploymentMode `gorm:"type:deployment_mode;not null;default:'dedicated'" json:"mode"` + Action DeploymentAction `gorm:"type:deployment_action;not null;default:'ok'" json:"action"` + + URL *string `gorm:"type:text" json:"url,omitempty"` + SSHUser *string `gorm:"size:64" json:"ssh_user,omitempty"` + RootPath *string `gorm:"type:text" json:"root_path,omitempty"` + ConfigPath *string `gorm:"type:text" json:"config_path,omitempty"` + IP *string `gorm:"type:inet" json:"ip,omitempty"` + Listen pq.StringArray `gorm:"type:varchar(64)[];not null;default:'{}'" json:"listen"` + ServerName pq.StringArray `gorm:"type:varchar(255)[];not null;default:'{}'" json:"server_name"` + Auth bool `gorm:"not null;default:false" json:"auth"` + IsProxied bool `gorm:"not null;default:false" json:"is_proxied"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + Meta datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"meta"` + + concerns.Timestamped + Audited +} + +// TableName provides functionality. +func (Deployment) TableName() string { return "deployments" } + +// Scan implements sql.Scanner so a Postgres enum value can land in +// our typed string alias without a code-generation step. +func (d *DeploymentKind) Scan(src any) error { + if src == nil { + *d = "" + return nil + } + switch v := src.(type) { + case string: + *d = DeploymentKind(v) + case []byte: + *d = DeploymentKind(string(v)) + default: + return fmt.Errorf("deployment_kind: cannot scan %T", src) + } + return nil +} + +// Value implements driver.Valuer for the inverse direction. +func (d DeploymentKind) Value() (driver.Value, error) { + if d == "" { + return nil, nil + } + return string(d), nil +} + +// Scan implements sql.Scanner for DeploymentMode. +func (d *DeploymentMode) Scan(src any) error { + if src == nil { + *d = "" + return nil + } + switch v := src.(type) { + case string: + *d = DeploymentMode(v) + case []byte: + *d = DeploymentMode(string(v)) + default: + return fmt.Errorf("deployment_mode: cannot scan %T", src) + } + return nil +} + +// Value implements driver.Valuer for DeploymentMode. +func (d DeploymentMode) Value() (driver.Value, error) { + if d == "" { + return nil, nil + } + return string(d), nil +} + +// Scan implements sql.Scanner for DeploymentAction. +func (d *DeploymentAction) Scan(src any) error { + if src == nil { + *d = "" + return nil + } + switch v := src.(type) { + case string: + *d = DeploymentAction(v) + case []byte: + *d = DeploymentAction(string(v)) + default: + return fmt.Errorf("deployment_action: cannot scan %T", src) + } + return nil +} + +// Value implements driver.Valuer for DeploymentAction. +func (d DeploymentAction) Value() (driver.Value, error) { + if d == "" { + return nil, nil + } + return string(d), nil +} + +// ReconcileMissingDeployments flips action='missing' on every Deployment for the +// given server whose last_seen_at is older than cutoff. Called by +// the deploymentd receiver after every successful upsert so the +// "missing" badge appears within one POST cycle. +// +// Idempotent: re-running with the same cutoff is a no-op. +func ReconcileMissingDeployments(serverID int64, mode DeploymentMode, cutoff time.Time) (int64, error) { + res := DB().Model(&Deployment{}). + Where("server_id = ? AND mode = ? AND action NOT IN ?", serverID, mode, + []DeploymentAction{DeploymentActionDeleted, DeploymentActionMissing, DeploymentActionPendingDrop}). + Where("last_seen_at IS NULL OR last_seen_at < ?", cutoff). + Update("action", DeploymentActionMissing) + return res.RowsAffected, res.Error +} + +// UpsertNginxDeployment finds or creates a Deployment by +// (server_id, config_path) for an nginx vhost. The caller fills in +// the lifecycle fields (listen, server_name, etc.) after the upsert +// returns. The return value is the row to mutate; the caller MUST +// also touch last_seen_at and save. +func UpsertNginxDeployment(tx *gorm.DB, accountID int64, serverID int64, configPath string) (*Deployment, error) { + if tx == nil { + tx = DB() + } + var d Deployment + err := tx.Where("server_id = ? AND config_path = ?", serverID, configPath).First(&d).Error + if err == nil { + return &d, nil + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + d = Deployment{ + AccountID: accountID, + ServerID: &serverID, + Kind: DeploymentKindProduction, + Mode: DeploymentModeDedicated, + Action: DeploymentActionOk, + ConfigPath: &configPath, + } + if err := tx.Create(&d).Error; err != nil { + return nil, err + } + return &d, nil +} + +// RotateServerToken sets a new random token for a server and returns +// the plaintext. Called by the operator-only +// POST /api/v1/servers/:id/rotate-token endpoint. The plaintext is +// returned exactly once — it is not stored anywhere recoverable. +func RotateServerToken(tx *gorm.DB, serverID int64, newToken string) error { + if tx == nil { + tx = DB() + } + return tx.Model(&Server{}).Where("id = ?", serverID).Update("token", newToken).Error +} diff --git a/app/models/dns_record.go b/app/models/dns_record.go new file mode 100644 index 0000000..41f781e --- /dev/null +++ b/app/models/dns_record.go @@ -0,0 +1,80 @@ +package models + +import ( + "log" + + "rsgit.ru/rsmon/rsmon/internal/netaddr" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// DNSRecord provides functionality. +type DNSRecord struct { + concerns.Model + MonitorID int64 `gorm:"type:bigint REFERENCES monitors(id)" json:"monitor_id,omitempty"` + Monitor *Monitor `json:"-"` + + Name string `json:"name"` + Kind string `json:"kind"` + Value netaddr.Inet `json:"value" gorm:"type:bytea;"` +} + +// SaveIps provides functionality. +func SaveIps(m *Monitor, _ Check, ips []NSRecord) { //nolint:gocritic // hugeParam: accepted for interface compatibility + // log.Println("save ips for monitor") + // spew.Dump(m) + var err error + tx := DB().Begin() + + currentRecords := make([]DNSRecord, 0) + _ = tx.Model(&m).Association("DNSRecords").Find(¤tRecords) + + recordHash := make(map[string]DNSRecord, 0) + for _, record := range currentRecords { + recordHash[record.Name] = record + } + nextRecords := make(map[string]bool, 0) + + for _, ip := range ips { + if record, ok := recordHash[ip.Name]; ok { + // log.Println("old value:", record) + record.MonitorID = m.ID + record.Value = ip.Value + record.Kind = ip.Kind + err = tx.Model(&m).Association("DNSRecords").Replace(&record) + if err != nil { + log.Println("fatal error in saveips", err) + return + } + } else { + record = DNSRecord{ + MonitorID: m.ID, + Name: ip.Name, + Kind: ip.Kind, + Value: ip.Value, + } + if _, ok := nextRecords[ip.Name]; !ok { + nextRecords[ip.Name] = true + err = tx.Model(&m).Association("DNSRecords").Append(&record) + if err != nil { + log.Println("fatal error in saveips", err) + return + } + } + } + } + // spew.Dump(ips_hash) + + err = tx.Commit().Error + if err != nil { + log.Println("fatal error in saveips", err) + return + } +} + +// NSRecord provides functionality. +type NSRecord struct { + Name string + Kind string + Value netaddr.Inet +} diff --git a/app/models/domain.go b/app/models/domain.go new file mode 100644 index 0000000..2ad2051 --- /dev/null +++ b/app/models/domain.go @@ -0,0 +1,32 @@ +package models + +import ( + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Domain represents a customer-facing DNS name. The shape mirrors +// rstuff's `domains` table — see docs/parity/rstuff-inventory.md §2 +// and docs/plans/inventory-management.md §4. +// +// Distinct from RknDomain (the RKN blocklist cache, see +// app/models/rkn_domain.go): RknDomain is read-only data about +// blocked domains; Domain is the customer-side name→site/server +// pointer that monitoring reasons about. +type Domain struct { + concerns.Model + + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` + Account *Account `json:"-"` + ServerID *int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE SET NULL;index" json:"server_id,omitempty"` + Server *Server `json:"-"` + SiteID *int64 `gorm:"type:bigint REFERENCES sites(id) ON DELETE SET NULL;index" json:"site_id,omitempty"` + Site *Site `json:"site,omitempty"` + Name string `gorm:"size:255;not null" json:"name"` + Env string `gorm:"size:32;not null;default:'production'" json:"env"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` + + concerns.Timestamped +} + +// TableName provides functionality. +func (Domain) TableName() string { return "domains" } diff --git a/app/models/drop.go b/app/models/drop.go new file mode 100644 index 0000000..d688236 --- /dev/null +++ b/app/models/drop.go @@ -0,0 +1,96 @@ +package models + +import ( + "fmt" + "strings" + + "rsgit.ru/rsmon/rsmon/app/models/authidentity" +) + +// Drop removes all test data from the database. +// It uses TRUNCATE ... CASCADE for join tables and deletes leaf-to-root for main tables. +// Safe to call from multiple goroutines within a single test binary; concurrent calls +// from separate test binaries are serialized by the advisory lock in Migrate(). +func Drop() { + var dbname string + if err := DB().Raw("SELECT current_database()").Scan(&dbname).Error; err != nil { + panic(fmt.Sprintf("Drop: cannot read database name: %v", err)) + } + if !strings.Contains(dbname, "test") { + panic(fmt.Sprintf( + "Drop() refused: database '%s' is not a test database. "+ + "Set DATABASE_NAME=rsmon_test to run tests safely.", dbname, + )) + } + + // Truncate many2many join tables first to avoid FK violations. + DB().Exec("TRUNCATE event_checks, event_messages, notification_contacts, notification_groups RESTART IDENTITY CASCADE") + + // Delete main tables in dependency order (leaf tables first). + DB().Where("1=1").Delete(&NotificationDelivery{}) + DB().Where("1=1").Delete(&TaskReplay{}) + DB().Where("1=1").Delete(&TelegramBotMessage{}) + DB().Where("1=1").Delete(&TelegramBotStatus{}) + DB().Where("1=1").Delete(&Task{}) + DB().Where("1=1").Delete(&Access{}) + DB().Where("1=1").Delete(&Invite{}) + DB().Where("1=1").Delete(&Message{}) + DB().Where("1=1").Delete(&Event{}) + // Durable network-diagnostic rows reference checks, monitors, and workers. + DB().Where("1=1").Delete(&DiagnosticAuditEvent{}) + DB().Where("1=1").Delete(&CheckAttempt{}) + // CheckRegionResult FKs check_id; it must be cleared before Check. + DB().Where("1=1").Delete(&CheckRegionResult{}) + DB().Where("1=1").Delete(&Check{}) + DB().Where("1=1").Delete(&Notification{}) + // DNSRecord FKs monitor_id; it must be cleared before Monitor. + DB().Where("1=1").Delete(&DNSRecord{}) + DB().Where("1=1").Delete(&Monitor{}) + DB().Where("1=1").Delete(&Group{}) + DB().Where("1=1").Delete(&Contact{}) + DB().Where("1=1").Delete(&NotificationCredential{}) + DB().Where("1=1").Delete(&WorkerLogEvent{}) + DB().Where("1=1").Delete(&WorkerNode{}) + DB().Where("1=1").Delete(&LLM{}) + DB().Where("1=1").Delete(&Region{}) + // Inventory (docs/plans/inventory-management.md §6): leaf tables + // (sites, deployments, server_ips) reference accounts/servers, so + // they must be cleared before Server is deleted. + DB().Where("1=1").Delete(&Deployment{}) + DB().Where("1=1").Delete(&SiteRepo{}) + DB().Where("1=1").Delete(&Site{}) + DB().Where("1=1").Delete(&Repo{}) + DB().Where("1=1").Delete(&ServerIp{}) + DB().Where("1=1").Delete(&Domain{}) + DB().Where("1=1").Delete(&Server{}) + // Account-scoped tag metadata (account_id FK to accounts(id)). + // Cleared before Account so a future cascade change cannot orphan + // rows mid-truncate. + DB().Where("1=1").Delete(&Tag{}) + // Status pages (docs/plans/status-pages.md §3.1–3.5). Children + // reference status_pages(id) with ON DELETE CASCADE so GORM + // ordering would already wipe them, but we delete them + // explicitly so the test DB stays clean even if a future model + // change drops the cascade. + DB().Where("1=1").Delete(&StatusPageDomain{}) + DB().Unscoped().Where("1=1").Delete(&Maintenance{}) + DB().Where("1=1").Delete(&StatusPageMaintenance{}) + DB().Where("1=1").Delete(&StatusPageIncident{}) + DB().Where("1=1").Delete(&StatusPageDelivery{}) + DB().Where("1=1").Delete(&StatusPageDigestSchedule{}) + DB().Where("1=1").Delete(&StatusPageSubscriber{}) + DB().Unscoped().Where("1=1").Delete(&StatusPage{}) + DB().Where("1=1").Delete(&SubscriptionEvent{}) + DB().Where("1=1").Delete(&Subscription{}) + DB().Where("1=1").Delete(&Account{}) + + DB().Unscoped().Where("1=1").Delete(&authidentity.AuthIdentity{}) + // User has a unique email index, so test fixtures must be physically + // removed rather than soft-deleted between tests. + DB().Unscoped().Where("1=1").Delete(&User{}) + // A few audited tables added by feature migrations can retain a user FK that + // is intentionally not modeled as an association. CASCADE keeps fixture + // cleanup deterministic instead of silently leaving unique emails behind. + DB().Exec("TRUNCATE users CASCADE") + DB().Exec("TRUNCATE regions CASCADE") +} diff --git a/app/models/event.go b/app/models/event.go new file mode 100644 index 0000000..0aa4e72 --- /dev/null +++ b/app/models/event.go @@ -0,0 +1,83 @@ +package models + +import ( + "fmt" + "time" + + "github.com/lib/pq" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" + "rsgit.ru/rsmon/rsmon/internal/util" +) + +// Event provides functionality. +type Event struct { + concerns.Model + + MonitorID int64 `gorm:"type:bigint REFERENCES monitors(id)" json:"monitor_id"` + Monitor *Monitor `json:"monitor,omitempty"` + + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + Duration int `json:"duration"` + Errors int `json:"errors"` + Oks int `json:"oks"` + State string `gorm:"index" json:"state"` + Reason string `json:"reason"` + + Messages []Message `json:"messages" gorm:"many2many:event_messages;"` + + ChecksDown pq.StringArray `gorm:"type:varchar(255)[]" json:"checks_down"` + Checks []Check `json:"-" gorm:"many2many:event_checks;"` + + ExpiresAt *time.Time `json:"-"` + + Audited +} + +// EventScope provides functionality. +func EventScope(q *gorm.DB) *gorm.DB { + return q.Where("state IN ('current', 'ended')"). + Preload("Checks"). + Preload("Monitor"). + Preload("Monitor.Group"). + Preload("Monitor.Group.Notifications"). + Preload("Monitor.Group.Notifications.Contacts") +} + +// GetDuration provides functionality. +func (e *Event) GetDuration(tn time.Time) int64 { + endTime := e.EndTime + if endTime == nil { + endTime = &tn + } + return int64(endTime.Sub(*e.StartTime) / time.Second) +} + +// FormatDuration provides functionality. +func (e *Event) FormatDuration() string { + d := e.GetDuration(time.Now()) + return util.FormatDuration(d) +} + +// Inspect provides functionality. +func (e *Event) Inspect() string { + var st, et string + if e.StartTime != nil { + st = e.StartTime.Format("2006-01-02 15:04:05") + } + if e.EndTime != nil { + et = e.EndTime.Format("2006-01-02 15:04:05") + } + + return fmt.Sprintf( + "Event", + e.ID, + e.MonitorID, + st, + et, + e.Reason, + e.Duration, + ) +} diff --git a/app/models/group.go b/app/models/group.go new file mode 100644 index 0000000..21b81c0 --- /dev/null +++ b/app/models/group.go @@ -0,0 +1,76 @@ +package models + +import "rsgit.ru/rsmon/rsmon/app/models/concerns" + +// Group represents a monitor group. +type Group struct { + concerns.Model + + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id)" json:"account_id"` + Account *Account `json:"-"` + Name string `json:"name" gorm:"not null"` + IsSystem *bool `json:"is_system" gorm:"default:false"` + MonitorsCount int `gorm:"-:all" json:"monitors_count"` + Monitors []Monitor `json:"-"` + Notifications []Notification `gorm:"many2many:notification_groups;" json:"-"` + + concerns.Timestamped `json:"-"` + Audited +} + +// SystemGroups returns all groups marked as system/internal (is_system=true). +// These groups are converted to distributed monitors (system checks running +// on the distributed worker pool). +func SystemGroups() ([]Group, error) { + var groups []Group + err := DB().Where("is_system = ?", true).Find(&groups).Error + return groups, err +} + +// GroupIdsForAccountId returns all group IDs for the given account. +func GroupIdsForAccountId(accountID int64) []int64 { //nolint:revive // accepted lint exception + rows, err := DB().Raw("SELECT id FROM groups WHERE account_id = ?", accountID).Rows() + if err != nil { + panic(err) + } + defer rows.Close() //nolint:errcheck // accepted lint exception + var cid int64 + groupIDs := make([]int64, 0) + for rows.Next() { + rows.Scan(&cid) //nolint:errcheck // accepted lint exception + groupIDs = append(groupIDs, cid) + } + return groupIDs +} + +// CountGroups counts monitors per group. +func CountGroups(groupIDs []int64, groupCount *map[int64]int) { //nolint:gocritic // ptrToRefParam: accepted pattern + rows, err := DB().Raw("select group_id, count(id) from monitors where group_id IN (?) group by group_id ", groupIDs).Rows() + if err != nil { + panic(err) + } + defer rows.Close() //nolint:errcheck // accepted lint exception + + var gid int64 + var count int + for rows.Next() { + rows.Scan(&gid, &count) //nolint:errcheck // accepted lint exception + (*groupCount)[gid] = count + } +} + +// GroupsCounts fills MonitorsCount for each group. +func GroupsCounts(groups *[]Group) { + groupIDs := make([]int64, len(*groups)) + groupCount := make(map[int64]int, len(*groups)) + for i, g := range *groups { //nolint:gocritic // range copy is acceptable here + groupIDs[i] = g.ID + groupCount[g.ID] = 0 + } + + CountGroups(groupIDs, &groupCount) + + for i, g := range *groups { //nolint:gocritic // range copy is acceptable here + (*groups)[i].MonitorsCount = groupCount[g.ID] + } +} diff --git a/app/models/group_test.go b/app/models/group_test.go new file mode 100644 index 0000000..195c2d6 --- /dev/null +++ b/app/models/group_test.go @@ -0,0 +1,58 @@ +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// TestSystemGroups verifies that SystemGroups returns only groups with +// is_system=true and ignores groups with is_system=false or nil. +func TestSystemGroups(t *testing.T) { + models.Drop() + models.Migrate() + + account := &models.Account{Name: "test-account"} + require.NoError(t, models.DB().Create(account).Error) + + trueVal, falseVal := true, false + + systemGroup := &models.Group{ + AccountID: account.ID, + Name: "system-internal", + IsSystem: &trueVal, + } + regularGroup := &models.Group{ + AccountID: account.ID, + Name: "regular", + IsSystem: &falseVal, + } + nilSystemGroup := &models.Group{ + AccountID: account.ID, + Name: "nil-system", + } + + require.NoError(t, models.DB().Create(systemGroup).Error) + require.NoError(t, models.DB().Create(regularGroup).Error) + require.NoError(t, models.DB().Create(nilSystemGroup).Error) + + got, err := models.SystemGroups() + require.NoError(t, err) + + var ids []int64 + var names []string + for _, g := range got { + ids = append(ids, g.ID) + names = append(names, g.Name) + } + + assert.Contains(t, names, "system-internal") + assert.NotContains(t, names, "regular") + assert.NotContains(t, names, "nil-system") + assert.Contains(t, ids, systemGroup.ID) + assert.NotContains(t, ids, regularGroup.ID) + assert.NotContains(t, ids, nilSystemGroup.ID) +} diff --git a/app/models/init.go b/app/models/init.go new file mode 100644 index 0000000..f6659f5 --- /dev/null +++ b/app/models/init.go @@ -0,0 +1,30 @@ +package models + +import ( + "context" + + "github.com/fatih/structs" + "gorm.io/gorm" +) + +func init() { + structs.DefaultTagName = "json" +} + +// db Gorm DB +var db *gorm.DB + +// DB provides functionality. +func DB() *gorm.DB { + return db.WithContext(context.TODO()) +} + +// SetDB provides functionality. +func SetDB(newDb *gorm.DB) { + db = newDb +} + +// IsDBAvailable returns true if the database has been initialized +func IsDBAvailable() bool { + return db != nil +} diff --git a/app/models/inventory_test.go b/app/models/inventory_test.go new file mode 100644 index 0000000..e3e0322 --- /dev/null +++ b/app/models/inventory_test.go @@ -0,0 +1,222 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestServer_RotateToken(t *testing.T) { + models.Drop() + models.Migrate() + + acc := &models.Account{Name: "rotate-token-account"} + require.NoError(t, models.DB().Create(acc).Error) + + srv := &models.Server{ + AccountID: acc.ID, + Name: "rotate-target", + Slug: "rotate-target", + Region: "local", + } + require.NoError(t, models.DB().Create(srv).Error) + + t1 := models.GenerateServerToken() + require.NoError(t, models.RotateServerToken(nil, srv.ID, t1)) + + got, err := models.FindServerByToken(t1) + require.NoError(t, err) + assert.Equal(t, srv.ID, got.ID) + + t2 := models.GenerateServerToken() + require.NoError(t, models.RotateServerToken(nil, srv.ID, t2)) + + // Old token no longer matches. + _, err = models.FindServerByToken(t1) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + + got2, err := models.FindServerByToken(t2) + require.NoError(t, err) + assert.Equal(t, srv.ID, got2.ID) + + // Generated tokens are hex-encoded 32 bytes (64 chars). + assert.Len(t, t1, 64) + assert.NotEqual(t, t1, t2) +} + +func TestServer_InventoryFields_Default(t *testing.T) { + models.Drop() + models.Migrate() + + acc := &models.Account{Name: "inv-defaults"} + require.NoError(t, models.DB().Create(acc).Error) + + srv := &models.Server{ + AccountID: acc.ID, + Name: "fresh-server", + Slug: "fresh-server", + Region: "local", + } + require.NoError(t, models.DB().Create(srv).Error) + + got := models.Server{} + require.NoError(t, models.DB().First(&got, srv.ID).Error) + assert.Equal(t, models.ServerKindProduction, got.Kind) + assert.Equal(t, 0, got.PriceCents) + assert.False(t, got.Paused) +} + +func TestServer_KindEnum_OnlyAllowsValidValues(t *testing.T) { + models.Drop() + models.Migrate() + + acc := &models.Account{Name: "inv-enum"} + require.NoError(t, models.DB().Create(acc).Error) + + srv := &models.Server{ + AccountID: acc.ID, + Name: "kinder", + Slug: "kinder", + Region: "local", + Kind: models.ServerKindStaging, + } + require.NoError(t, models.DB().Create(srv).Error) + + got := models.Server{} + require.NoError(t, models.DB().First(&got, srv.ID).Error) + assert.Equal(t, models.ServerKindStaging, got.Kind) + + // Inserting an invalid value via raw SQL fails the enum check. + err := models.DB().Exec( + "INSERT INTO servers (account_id, name, slug, region, kind) VALUES (?, ?, ?, ?, ?)", + acc.ID, "bad-kinder", "bad-kinder", "local", "scrapped", + ).Error + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid input value for enum") +} + +func TestSite_Slugify(t *testing.T) { + cases := []struct { + in, want string + }{ + {"Cafe", "cafe"}, // cyrillic stripped (latin-only rule, see Slugify) + {" Spaces Everywhere ", "spaces-everywhere"}, + {"dots.and-dashes_and spaces", "dots-and-dashes-and-spaces"}, + {"", "site"}, + {"-leading-and-trailing-", "leading-and-trailing"}, + {"mix_of.dots-dashes spaces", "mix-of-dots-dashes-spaces"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + assert.Equal(t, c.want, models.SiteSlugify(c.in)) + }) + } +} + +func TestSite_FindOrCreateBySlug(t *testing.T) { + models.Drop() + models.Migrate() + + acc := &models.Account{Name: "site-foc"} + require.NoError(t, models.DB().Create(acc).Error) + + got, err := models.FindOrCreateSiteBySlug(nil, acc.ID, "my-site") + require.NoError(t, err) + require.NotZero(t, got.ID, "row should be persisted") + assert.Equal(t, "my-site", got.Slug) + assert.Equal(t, "production", got.Kind) + assert.True(t, got.IsActive) + + // Second call returns the same row (idempotent). + got2, err := models.FindOrCreateSiteBySlug(nil, acc.ID, "my-site") + require.NoError(t, err) + assert.Equal(t, got.ID, got2.ID) +} + +func TestDeployment_UpsertNginx_MatchesByConfigPath(t *testing.T) { + models.Drop() + models.Migrate() + + acc := &models.Account{Name: "dep-upsert"} + require.NoError(t, models.DB().Create(acc).Error) + srv := &models.Server{ + AccountID: acc.ID, Name: "host1", Slug: "host1", Region: "local", + } + require.NoError(t, models.DB().Create(srv).Error) + + d1, err := models.UpsertNginxDeployment(nil, acc.ID, srv.ID, "/etc/nginx/sites-enabled/a.conf") + require.NoError(t, err) + require.NoError(t, models.DB().Save(d1).Error) + + // Second call with the same config_path returns the existing row. + d2, err := models.UpsertNginxDeployment(nil, acc.ID, srv.ID, "/etc/nginx/sites-enabled/a.conf") + require.NoError(t, err) + assert.Equal(t, d1.ID, d2.ID, "should reuse the same row on identical config_path") + + // Different config_path creates a new row. + d3, err := models.UpsertNginxDeployment(nil, acc.ID, srv.ID, "/etc/nginx/sites-enabled/b.conf") + require.NoError(t, err) + assert.NotEqual(t, d1.ID, d3.ID) +} + +func TestDeployment_ReconcileMissing_FlipsAction(t *testing.T) { + models.Drop() + models.Migrate() + + acc := &models.Account{Name: "reconcile"} + require.NoError(t, models.DB().Create(acc).Error) + srv := &models.Server{ + AccountID: acc.ID, Name: "rec", Slug: "rec", Region: "local", + } + require.NoError(t, models.DB().Create(srv).Error) + + old := time.Now().Add(-2 * time.Hour) + fresh := models.Deployment{ + AccountID: acc.ID, + ServerID: &srv.ID, + Kind: models.DeploymentKindProduction, + Mode: models.DeploymentModeDedicated, + Action: models.DeploymentActionOk, + ConfigPath: ptr("/etc/nginx/old.conf"), + LastSeenAt: &old, + } + require.NoError(t, models.DB().Create(&fresh).Error) + + recent := models.Deployment{ + AccountID: acc.ID, + ServerID: &srv.ID, + Kind: models.DeploymentKindProduction, + Mode: models.DeploymentModeDedicated, + Action: models.DeploymentActionOk, + ConfigPath: ptr("/etc/nginx/recent.conf"), + LastSeenAt: ptrTime(time.Now()), + } + require.NoError(t, models.DB().Create(&recent).Error) + + cutoff := time.Now().Add(-90 * time.Second) + marked, err := models.ReconcileMissingDeployments(srv.ID, models.DeploymentModeDedicated, cutoff) + require.NoError(t, err) + assert.EqualValues(t, 1, marked, "only the old row should flip") + + var oldAfter models.Deployment + require.NoError(t, models.DB().First(&oldAfter, fresh.ID).Error) + assert.Equal(t, models.DeploymentActionMissing, oldAfter.Action) + + var recentAfter models.Deployment + require.NoError(t, models.DB().First(&recentAfter, recent.ID).Error) + assert.Equal(t, models.DeploymentActionOk, recentAfter.Action, "fresh row stays ok") + + // Re-running with the same cutoff is a no-op. + marked2, err := models.ReconcileMissingDeployments(srv.ID, models.DeploymentModeDedicated, cutoff) + require.NoError(t, err) + assert.EqualValues(t, 0, marked2) +} + +func ptr(s string) *string { return &s } + +func ptrTime(t time.Time) *time.Time { return &t } diff --git a/app/models/invite.go b/app/models/invite.go new file mode 100644 index 0000000..f53a75b --- /dev/null +++ b/app/models/invite.go @@ -0,0 +1,52 @@ +package models + +import ( + "time" + + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Invite provides functionality. +type Invite struct { + concerns.Model + + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id)" json:"account_id"` + Account *Account `json:"-"` + InviterID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"inviter_id"` + Inviter *User `json:"inviter"` + InviteeID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"invitee_id"` + Invitee *User `json:"invitee"` + + Name string `json:"name"` + Email string `json:"email"` + + // invite state, FAIL - failed to send, SENT - not regestired, OK - registered + State string `gorm:"not null;default:'UNK'" json:"state"` + + CreatedAt time.Time `json:"created_at"` + RegisteredAt time.Time `json:"registered_at"` + SentAt time.Time `json:"sent_at"` + + Accesses []Access `json:"accesses" gorm:"foreignkey:invite_id"` + + concerns.HasToken + Audited +} + +// BeforeCreate runs before creating an Invite record. +func (i *Invite) BeforeCreate(_ *gorm.DB) error { + i.SetToken() + return nil +} + +// FillAccesses provides functionality. +func (i *Invite) FillAccesses() { + for k, a := range i.Accesses { //nolint:gocritic // range copy is acceptable here + if a.ID <= 0 { + i.Accesses[k].ID = 0 + } + i.Accesses[k].AccountID = i.AccountID + } +} diff --git a/app/models/llm.go b/app/models/llm.go new file mode 100644 index 0000000..a863d4e --- /dev/null +++ b/app/models/llm.go @@ -0,0 +1,17 @@ +package models + +import "rsgit.ru/rsmon/rsmon/app/models/concerns" + +// LLM stores an OpenAI-compatible LLM endpoint available to checks. +type LLM struct { + concerns.Model + AccountID *int64 `json:"account_id" gorm:"type:bigint REFERENCES accounts(id);index"` + Account *Account `json:"-"` + Name string `json:"name" gorm:"not null"` + URL string `json:"url" gorm:"not null"` + ModelName string `json:"model" gorm:"column:model;not null"` + APIKey string `json:"-" gorm:"not null"` + Kind string `json:"kind" gorm:"not null;default:'openai'"` + Workers []WorkerNode `json:"-" gorm:"many2many:worker_llms;"` + concerns.Timestamped +} diff --git a/app/models/maintenance.go b/app/models/maintenance.go new file mode 100644 index 0000000..a5c2fd5 --- /dev/null +++ b/app/models/maintenance.go @@ -0,0 +1,324 @@ +package models + +import ( + "fmt" + "strings" + "time" + + "github.com/lib/pq" + "github.com/robfig/cron/v3" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +const ( + MaintenanceManual = "manual" + MaintenanceSingle = "single" + MaintenanceCron = "cron" + MaintenanceRecurringInterval = "recurring-interval" + MaintenanceRecurringWeekday = "recurring-weekday" + MaintenanceRecurringDayOfMonth = "recurring-day-of-month" +) + +// Maintenance is account-owned planned downtime. Times are stored as UTC; +// Timezone only defines how recurring wall-clock fields are interpreted. +type Maintenance struct { + concerns.Model + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id) ON DELETE CASCADE;not null;index" json:"account_id"` + Account *Account `json:"-"` + Title string `gorm:"size:200;not null" json:"title"` + Description string `gorm:"type:text;not null;default:''" json:"description"` + Strategy string `gorm:"size:32;not null" json:"strategy"` + Cron string `gorm:"type:text;not null;default:''" json:"cron"` + DurationSec int `gorm:"not null;default:0" json:"duration_sec"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` + StartTime string `gorm:"size:5;not null;default:''" json:"start_time"` + EndTime string `gorm:"size:5;not null;default:''" json:"end_time"` + Weekdays pq.Int64Array `gorm:"type:bigint[];not null;default:'{}'" json:"weekdays"` + DaysOfMonth pq.StringArray `gorm:"type:text[];not null;default:'{}'" json:"days_of_month"` + IntervalDay int `gorm:"not null;default:1" json:"interval_day"` + Timezone string `gorm:"size:64;not null;default:'UTC'" json:"timezone"` + Active bool `gorm:"not null;default:true" json:"active"` + LastStartDate *time.Time `json:"last_start_date,omitempty"` + LegacyStatusPageMaintenanceID *int64 `gorm:"uniqueIndex" json:"-"` + ShowOnAllStatusPages bool `gorm:"not null;default:true" json:"show_on_all_status_pages"` + Monitors []Monitor `gorm:"many2many:maintenance_monitors;constraint:OnDelete:CASCADE" json:"monitors,omitempty"` + StatusPages []StatusPage `gorm:"many2many:maintenance_status_pages;constraint:OnDelete:CASCADE" json:"status_pages,omitempty"` + concerns.Timestamped + Audited + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Maintenance) TableName() string { return "maintenances" } + +func (m *Maintenance) location() (*time.Location, error) { + if m.Timezone == "" || m.Timezone == "SAME_AS_SERVER" { + return time.UTC, nil + } + return time.LoadLocation(m.Timezone) +} + +func (m *Maintenance) generatedCron() (string, error) { + if m.Strategy == MaintenanceCron { + return m.Cron, nil + } + if m.Strategy == MaintenanceManual || m.Strategy == MaintenanceSingle { + return "", nil + } + parts := strings.Split(m.StartTime, ":") + if len(parts) != 2 { + return "", fmt.Errorf("start_time must be HH:MM") + } + base := parts[1] + " " + parts[0] + switch m.Strategy { + case MaintenanceRecurringInterval: + return "", nil + case MaintenanceRecurringWeekday: + if len(m.Weekdays) == 0 { + return "", fmt.Errorf("at least one weekday is required") + } + values := make([]string, len(m.Weekdays)) + for i, day := range m.Weekdays { + if day < 0 || day > 6 { + return "", fmt.Errorf("weekday must be 0 through 6") + } + values[i] = fmt.Sprint(day) + } + return base + " * * " + strings.Join(values, ","), nil + case MaintenanceRecurringDayOfMonth: + if len(m.DaysOfMonth) == 0 { + return "", fmt.Errorf("at least one day of month is required") + } + values := make([]string, 0, len(m.DaysOfMonth)) + for _, day := range m.DaysOfMonth { + if day == "lastDay1" { + values = append(values, "28-31") + } else { + values = append(values, day) + } + } + return base + " " + strings.Join(values, ",") + " * *", nil + default: + return "", fmt.Errorf("unknown maintenance strategy %q", m.Strategy) + } +} + +// Validate normalizes generated schedules and rejects ambiguous or invalid +// input before it can reach the scheduler. +func (m *Maintenance) Validate() error { + m.Title = strings.TrimSpace(m.Title) + if m.Title == "" || len(m.Title) > 200 { + return fmt.Errorf("title is required and must be at most 200 characters") + } + if _, err := m.location(); err != nil { + return fmt.Errorf("invalid timezone: %w", err) + } + switch m.Strategy { + case MaintenanceManual: + return nil + case MaintenanceSingle: + if m.StartDate == nil || m.EndDate == nil || !m.EndDate.After(*m.StartDate) { + return fmt.Errorf("single maintenance requires end_date after start_date") + } + m.DurationSec = int(m.EndDate.Sub(*m.StartDate).Seconds()) + return nil + case MaintenanceRecurringInterval: + if m.DurationSec <= 0 || m.IntervalDay <= 0 || (m.IntervalDay > 1 && m.StartDate == nil) { + return fmt.Errorf("recurring interval requires positive duration_sec and interval_day; intervals over one day require start_date") + } + if _, _, err := parseMaintenanceTime(m.StartTime); err != nil { + return err + } + m.Cron = "" + return nil + case MaintenanceCron, MaintenanceRecurringWeekday, MaintenanceRecurringDayOfMonth: + if m.DurationSec <= 0 { + return fmt.Errorf("duration_sec must be positive") + } + cronText, err := m.generatedCron() + if err != nil { + return err + } + if _, err := cron.ParseStandard(cronText); err != nil { + return fmt.Errorf("invalid cron: %w", err) + } + m.Cron = cronText + return nil + default: + return fmt.Errorf("unknown maintenance strategy %q", m.Strategy) + } +} + +func (m *Maintenance) BeforeSave(_ *gorm.DB) error { return m.Validate() } + +// IsUnderMaintenance evaluates durable data only. This intentionally avoids +// scheduler-owned state so a process restart and multiple web pods agree. +func (m *Maintenance) IsUnderMaintenance(now time.Time) bool { + if !m.Active { + return false + } + if m.Strategy == MaintenanceManual { + return true + } + if m.Strategy == MaintenanceSingle { + return m.StartDate != nil && m.EndDate != nil && !now.Before(*m.StartDate) && now.Before(*m.EndDate) + } + if m.Strategy == MaintenanceRecurringInterval { + return m.isUnderInterval(now) + } + if m.DurationSec <= 0 { + return false + } + loc, err := m.location() + if err != nil { + return false + } + schedule, err := cron.ParseStandard(m.Cron) + if err != nil { + return false + } + // Ask cron for each candidate since the earliest possible active start. + // Cron is minute-granular, hence the extra minute catches exact boundaries. + from := now.In(loc).Add(-time.Duration(m.DurationSec)*time.Second - time.Minute) + to := now.In(loc) + for candidate := schedule.Next(from); !candidate.After(to); candidate = schedule.Next(candidate) { + if !m.allowsRecurringCandidate(candidate.In(loc)) { + continue + } + start := candidate.UTC() + if !now.Before(start) && now.Before(start.Add(time.Duration(m.DurationSec)*time.Second)) { + return true + } + } + return false +} + +func parseMaintenanceTime(value string) (int, int, error) { + parsed, err := time.Parse("15:04", value) + if err != nil { + return 0, 0, fmt.Errorf("start_time must be HH:MM") + } + return parsed.Hour(), parsed.Minute(), nil +} + +func (m *Maintenance) intervalStartOn(date time.Time, loc *time.Location) (time.Time, bool) { + if m.IntervalDay <= 0 { + return time.Time{}, false + } + hour, minute, err := parseMaintenanceTime(m.StartTime) + if err != nil { + return time.Time{}, false + } + if m.IntervalDay == 1 && m.StartDate == nil { + return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, loc), true + } + if m.StartDate == nil { + return time.Time{}, false + } + anchor := m.StartDate.In(loc) + anchorDay := time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, loc) + candidateDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc) + // Compare civil dates rather than elapsed hours: a local day can be 23 or + // 25 hours when the maintenance timezone crosses a DST boundary. + days := civilDaysBetween(anchorDay, candidateDay) + if days < 0 || days%m.IntervalDay != 0 { + return time.Time{}, false + } + return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, loc), true +} + +func civilDaysBetween(from, to time.Time) int { + fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC) + toDay := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC) + return int(toDay.Sub(fromDay) / (24 * time.Hour)) +} + +func (m *Maintenance) isUnderInterval(now time.Time) bool { + loc, err := m.location() + if err != nil || m.DurationSec <= 0 { + return false + } + localNow := now.In(loc) + for day := 0; day <= int(time.Duration(m.DurationSec)/24/time.Hour)+1; day++ { + start, ok := m.intervalStartOn(localNow.AddDate(0, 0, -day), loc) + if ok && !now.Before(start.UTC()) && now.Before(start.UTC().Add(time.Duration(m.DurationSec)*time.Second)) { + return true + } + } + return false +} + +// robfig/cron cannot express "last day". The generated 28-31 range is only +// a candidate generator; this final predicate makes lastDay1 exact. +func (m *Maintenance) allowsRecurringCandidate(candidate time.Time) bool { + if m.Strategy != MaintenanceRecurringDayOfMonth { + return true + } + lastDay := candidate.AddDate(0, 0, 1).Month() != candidate.Month() + for _, value := range m.DaysOfMonth { + if value == "lastDay1" && lastDay { + return true + } + if value == fmt.Sprint(candidate.Day()) { + return true + } + } + return false +} + +func (m *Maintenance) NextRun(now time.Time) *time.Time { + if !m.Active || m.Strategy == MaintenanceManual { + return nil + } + if m.Strategy == MaintenanceSingle { + if m.StartDate != nil && m.StartDate.After(now) { + return m.StartDate + } + return nil + } + if m.Strategy == MaintenanceRecurringInterval { + loc, err := m.location() + if err != nil { + return nil + } + localNow := now.In(loc) + for day := 0; day <= m.IntervalDay; day++ { + if next, ok := m.intervalStartOn(localNow.AddDate(0, 0, day), loc); ok && next.After(localNow) { + result := next.UTC() + return &result + } + } + return nil + } + loc, err := m.location() + if err != nil { + return nil + } + s, err := cron.ParseStandard(m.Cron) + if err != nil { + return nil + } + for candidate := s.Next(now.In(loc)); ; candidate = s.Next(candidate) { + if m.allowsRecurringCandidate(candidate.In(loc)) { + next := candidate.UTC() + return &next + } + } +} + +// MonitorUnderMaintenance is the notifier/public-page lookup. +func MonitorUnderMaintenance(monitorID int64, now time.Time) (bool, error) { + var rows []Maintenance + err := DB().Joins("JOIN maintenance_monitors mm ON mm.maintenance_id = maintenances.id").Where("mm.monitor_id = ? AND maintenances.active = TRUE", monitorID).Find(&rows).Error + if err != nil { + return false, err + } + for i := range rows { + if rows[i].IsUnderMaintenance(now) { + return true, nil + } + } + return false, nil +} diff --git a/app/models/maintenance_migration_test.go b/app/models/maintenance_migration_test.go new file mode 100644 index 0000000..2a9f0b4 --- /dev/null +++ b/app/models/maintenance_migration_test.go @@ -0,0 +1,33 @@ +package models + +import ( + "testing" + "time" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestMigratePreservesEveryLegacyStatusPageMaintenance(t *testing.T) { + Drop() + Migrate() + plan := Plan{Name: "legacy migration plan"} + require.NoError(t, DB().Create(&plan).Error) + account := Account{Name: "legacy migration account", PlanID: &plan.ID} + require.NoError(t, DB().Create(&account).Error) + page := StatusPage{AccountID: account.ID, Slug: "legacy-maintenance-migration", Name: "Legacy"} + require.NoError(t, DB().Create(&page).Error) + start := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + for i := 0; i < 2; i++ { + row := StatusPageMaintenance{StatusPageID: page.ID, Title: "same", StartsAt: start, EndsAt: start.Add(time.Hour), MonitorIDs: pq.Int64Array{}} + require.NoError(t, DB().Create(&row).Error) + } + + Migrate() + var migrated []Maintenance + require.NoError(t, DB().Where("legacy_status_page_maintenance_id IS NOT NULL").Find(&migrated).Error) + require.Len(t, migrated, 2) + var joins int64 + require.NoError(t, DB().Table("maintenance_status_pages").Where("status_page_id = ?", page.ID).Count(&joins).Error) + require.EqualValues(t, 2, joins) +} diff --git a/app/models/maintenance_notifications.go b/app/models/maintenance_notifications.go new file mode 100644 index 0000000..4c7f9f2 --- /dev/null +++ b/app/models/maintenance_notifications.go @@ -0,0 +1,44 @@ +package models + +import ( + "fmt" + "time" + + "gorm.io/gorm" +) + +const maintenanceStartNotificationPrefix = "maintenance:%d:start:" + +// MaintenanceStartNotificationKey uniquely identifies one contact's warning +// for one schedule revision and occurrence. Updated windows get a new revision +// while concurrent scheduler replicas share the same key. +func MaintenanceStartNotificationKey(maintenanceID int64, revision, startsAt time.Time, timezone string, notificationID, contactID int64) string { + loc, err := time.LoadLocation(timezone) + if err != nil || timezone == "SAME_AS_SERVER" || timezone == "" { + loc = time.UTC + } + // A fall-back hour can have two UTC instants for one wall-clock occurrence. + // Warnings are once per civil occurrence, matching the recurrence editor. + civilOccurrence := startsAt.In(loc).Format("200601021504") + return fmt.Sprintf("maintenance:%d:start:%d:%s:notification:%d:contact:%d", maintenanceID, revision.UnixNano(), civilOccurrence, notificationID, contactID) +} + +// CancelMaintenanceStartNotificationsTx prevents queued warnings from being +// delivered after an operator pauses, changes, or deletes the maintenance. +// Leased work may already be executing and cannot be recalled from a worker. +func CancelMaintenanceStartNotificationsTx(tx *gorm.DB, maintenanceID int64, reason string) error { + prefix := fmt.Sprintf(maintenanceStartNotificationPrefix, maintenanceID) + "%" + var tasks []Task + if err := tx.Clauses(SkipLockedClause).Where("idempotency_key LIKE ? AND state IN ?", prefix, []string{TaskStateQueued, TaskStateFailedRetry}).Find(&tasks).Error; err != nil { + return err + } + for i := range tasks { + if err := tx.Model(&Task{}).Where("id = ? AND state IN ?", tasks[i].ID, []string{TaskStateQueued, TaskStateFailedRetry}).Updates(map[string]any{"state": TaskStateDead, "last_error": "canceled: " + reason, "payload": []byte(`{}`), "lease_owner": "", "lease_token": "", "lease_expires_at": nil}).Error; err != nil { + return err + } + if err := FinalizeNotificationTaskTx(tx, &tasks[i], "canceled", "canceled: "+reason); err != nil { + return err + } + } + return nil +} diff --git a/app/models/maintenance_test.go b/app/models/maintenance_test.go new file mode 100644 index 0000000..fcf2c9c --- /dev/null +++ b/app/models/maintenance_test.go @@ -0,0 +1,105 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestMaintenanceValidateGeneratesRecurringCron(t *testing.T) { + tests := []struct { + name string + m models.Maintenance + want string + }{ + {"interval", models.Maintenance{Title: "interval", Strategy: models.MaintenanceRecurringInterval, StartDate: maintenanceTimePtr(time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)), StartTime: "02:30", IntervalDay: 3, DurationSec: 60, Timezone: "UTC"}, ""}, + {"weekday", models.Maintenance{Title: "weekdays", Strategy: models.MaintenanceRecurringWeekday, StartTime: "02:30", Weekdays: []int64{1, 5}, DurationSec: 60, Timezone: "UTC"}, "30 02 * * 1,5"}, + {"month", models.Maintenance{Title: "month", Strategy: models.MaintenanceRecurringDayOfMonth, StartTime: "02:30", DaysOfMonth: []string{"1", "lastDay1"}, DurationSec: 60, Timezone: "UTC"}, "30 02 1,28-31 * *"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { require.NoError(t, test.m.Validate()); assert.Equal(t, test.want, test.m.Cron) }) + } +} + +func TestMaintenanceIsUnderMaintenanceBoundariesAndTimezone(t *testing.T) { + start := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + end := start.Add(time.Hour) + single := models.Maintenance{Title: "single", Strategy: models.MaintenanceSingle, StartDate: &start, EndDate: &end, Active: true, Timezone: "UTC"} + assert.True(t, single.IsUnderMaintenance(start)) + assert.True(t, single.IsUnderMaintenance(end.Add(-time.Nanosecond))) + assert.False(t, single.IsUnderMaintenance(end)) + cron := models.Maintenance{Title: "moscow", Strategy: models.MaintenanceCron, Cron: "0 12 * * *", DurationSec: 3600, Active: true, Timezone: "Europe/Moscow"} + require.NoError(t, cron.Validate()) + assert.True(t, cron.IsUnderMaintenance(time.Date(2026, 7, 1, 9, 30, 0, 0, time.UTC)), "12:30 Moscow is 09:30 UTC in July") + assert.False(t, cron.IsUnderMaintenance(time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC))) +} + +func TestMaintenanceValidationRejectsInvalidSchedules(t *testing.T) { + badCron := models.Maintenance{Title: "bad", Strategy: models.MaintenanceCron, Cron: "not cron", DurationSec: 1, Timezone: "UTC"} + badSingle := models.Maintenance{Title: "bad", Strategy: models.MaintenanceSingle, Timezone: "UTC"} + assert.Error(t, badCron.Validate()) + assert.Error(t, badSingle.Validate()) +} + +func TestMaintenanceLastDayIsNotEveryDayInExpandedCronRange(t *testing.T) { + m := models.Maintenance{Title: "last", Strategy: models.MaintenanceRecurringDayOfMonth, StartTime: "12:00", DaysOfMonth: []string{"lastDay1"}, DurationSec: 3600, Active: true, Timezone: "UTC"} + require.NoError(t, m.Validate()) + assert.False(t, m.IsUnderMaintenance(time.Date(2026, 3, 28, 12, 30, 0, 0, time.UTC))) + assert.True(t, m.IsUnderMaintenance(time.Date(2026, 3, 31, 12, 30, 0, 0, time.UTC))) +} + +func TestMaintenanceRecurringIntervalUsesAnchorAndIntervalDay(t *testing.T) { + anchor := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + m := models.Maintenance{Title: "every three days", Strategy: models.MaintenanceRecurringInterval, StartDate: &anchor, StartTime: "12:00", IntervalDay: 3, DurationSec: 3600, Active: true, Timezone: "UTC"} + require.NoError(t, m.Validate()) + assert.True(t, m.IsUnderMaintenance(time.Date(2026, 7, 4, 12, 30, 0, 0, time.UTC))) + assert.False(t, m.IsUnderMaintenance(time.Date(2026, 7, 5, 12, 30, 0, 0, time.UTC))) + next := m.NextRun(time.Date(2026, 7, 2, 13, 0, 0, 0, time.UTC)) + require.NotNil(t, next) + assert.Equal(t, time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC), *next) +} + +func TestMaintenanceRecurringIntervalRequiresAnchorForMultiDayAndPositiveInterval(t *testing.T) { + m := models.Maintenance{Title: "invalid", Strategy: models.MaintenanceRecurringInterval, StartTime: "12:00", IntervalDay: 2, DurationSec: 60, Timezone: "UTC"} + assert.Error(t, m.Validate()) +} + +func TestMaintenanceRecurringIntervalKeepsCivilDayAcrossDST(t *testing.T) { + loc, err := time.LoadLocation("Europe/Berlin") + require.NoError(t, err) + // March 29, 2026 is the spring-forward day in Berlin. The second run is + // still two civil days after the anchor, not one because a day was 23h. + anchor := time.Date(2026, 3, 27, 0, 0, 0, 0, loc) + m := models.Maintenance{Title: "DST", Strategy: models.MaintenanceRecurringInterval, StartDate: &anchor, StartTime: "03:30", IntervalDay: 2, DurationSec: 3600, Active: true, Timezone: "Europe/Berlin"} + require.NoError(t, m.Validate()) + assert.True(t, m.IsUnderMaintenance(time.Date(2026, 3, 29, 4, 0, 0, 0, loc).UTC())) +} + +func TestMaintenanceStartNotificationKeyIncludesRevisionAndOccurrence(t *testing.T) { + revision := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + start := revision.Add(5 * time.Minute) + key := models.MaintenanceStartNotificationKey(7, revision, start, "Europe/Berlin", 11, 13) + assert.Equal(t, key, models.MaintenanceStartNotificationKey(7, revision, start, "Europe/Berlin", 11, 13)) + assert.NotEqual(t, key, models.MaintenanceStartNotificationKey(7, revision.Add(time.Second), start, "Europe/Berlin", 11, 13)) +} + +func TestMaintenanceStartNotificationKeyDeduplicatesDSTFallbackCivilOccurrence(t *testing.T) { + revision := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + first := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC) + second := first.Add(time.Hour) + assert.Equal(t, models.MaintenanceStartNotificationKey(7, revision, first, "Europe/Berlin", 11, 13), models.MaintenanceStartNotificationKey(7, revision, second, "Europe/Berlin", 11, 13)) +} + +func TestMaintenanceNextRunSkipsNonFinalLastDayCandidates(t *testing.T) { + m := models.Maintenance{Title: "last", Strategy: models.MaintenanceRecurringDayOfMonth, StartTime: "12:00", DaysOfMonth: []string{"lastDay1"}, DurationSec: 60, Active: true, Timezone: "UTC"} + require.NoError(t, m.Validate()) + next := m.NextRun(time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)) + require.NotNil(t, next) + assert.Equal(t, time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC), *next) +} + +func maintenanceTimePtr(value time.Time) *time.Time { return &value } diff --git a/app/models/message.go b/app/models/message.go new file mode 100644 index 0000000..e63ecd5 --- /dev/null +++ b/app/models/message.go @@ -0,0 +1,48 @@ +package models + +import ( + "time" + + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Message info about performed notification +type Message struct { + concerns.Model + + NotificationID int64 `json:"notification_id"` + Notification *Notification `json:"notification,omitempty"` + + ContactID int64 `gorm:"index;type:bigint REFERENCES contacts(id)" json:"contact_id"` + Contact *Contact `json:"contact,omitempty"` + + // Events are for up/down messages + Events []Event `json:"events" gorm:"many2many:event_messages;"` + + // Checks are for expires messages + CheckID *int64 `gorm:"index;type:bigint REFERENCES checks(id)" json:"check_id"` + Check *Check `json:"check,omitempty"` + + Kind string `json:"kind"` + State string `json:"state"` + Error *string `json:"error"` + Response *string `json:"response"` + Tries int `json:"-"` + + CreatedAt time.Time `json:"created_at"` + SentAt time.Time `json:"sent_at"` +} + +// MessageScope provides functionality. +func MessageScope(q *gorm.DB) *gorm.DB { + return q. + Preload("Events"). + Preload("Events.Checks"). + Preload("Events.Monitor"). + Preload("Notification"). + Preload("Contact"). + Preload("Check"). + Preload("Check.Monitor") +} diff --git a/app/models/migrate.go b/app/models/migrate.go new file mode 100644 index 0000000..dd9f4ad --- /dev/null +++ b/app/models/migrate.go @@ -0,0 +1,750 @@ +package models + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "sync" + "time" + + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/authidentity" +) + +// isTypeExistsError returns true if the error is a Postgres "type already exists" error. +// This happens when AutoMigrate is called concurrently from multiple test processes. +func isTypeExistsError(err error) bool { + if err == nil { + return false + } + s := err.Error() + // SQLSTATE 42710 = duplicate_object (type already exists) + return strings.Contains(s, "42710") || + strings.Contains(s, "already exists") || + strings.Contains(s, "pg_type_typname_nsp_index") +} + +// migrateOrIgnoreTypeExists runs AutoMigrate and ignores "type already exists" errors +// that can occur when parallel test processes both try to create the same Postgres types. +func migrateOrIgnoreTypeExists(models ...interface{}) { + err := DB().AutoMigrate(models...) + if err != nil && !isTypeExistsError(err) { + panic(err) + } + if err != nil { + log.Printf("migrate: ignoring type-exists error (expected during parallel test runs): %v", err) + } +} + +// ensureSingleCurrentEventInvariant pins cleanup and index creation to one +// transaction/connection. The global migration lock is session-scoped through a +// pool, so it is not sufficient for this multi-statement invariant by itself. +func ensureSingleCurrentEventInvariant() error { + return DB().Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(1234567892)).Error; err != nil { + return err + } + if err := tx.Exec(`WITH ranked AS ( + SELECT id, row_number() OVER (PARTITION BY monitor_id ORDER BY start_time DESC NULLS LAST, id DESC) AS n + FROM events WHERE state = 'current' + ) UPDATE events SET state = 'ended', end_time = COALESCE(end_time, now()) + FROM ranked WHERE events.id = ranked.id AND ranked.n > 1`).Error; err != nil { + return err + } + return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS events_one_current_per_monitor + ON events (monitor_id) WHERE state = 'current'`).Error + }) +} + +// Migrate run db migration +var migrateMu sync.Mutex + +func Migrate() { + migrateMu.Lock() + defer migrateMu.Unlock() + withMigrationAdvisoryLock(migrateLocked) +} + +// withMigrationAdvisoryLock holds the session lock on a dedicated connection +// while migration work uses GORM's normal pool. Reusing the lock connection +// for GORM transactions can leave its *sql.Conn closed after commit. +func withMigrationAdvisoryLock(migrate func()) { + const migrateAdvisoryLock = int64(1234567890) + sqlDB, err := DB().DB() + if err != nil { + panic(fmt.Sprintf("migrate: database handle: %v", err)) + } + ctx := context.Background() + conn, err := sqlDB.Conn(ctx) + if err != nil { + panic(fmt.Sprintf("migrate: lock connection: %v", err)) + } + defer conn.Close() //nolint:errcheck // closing releases the session lock after a migration panic + if _, err = conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrateAdvisoryLock); err != nil { + panic(fmt.Sprintf("migrate: advisory lock: %v", err)) + } + unlocked := false + defer func() { + if unlocked { + return + } + if _, unlockErr := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrateAdvisoryLock); unlockErr != nil { + log.Printf("migrate: unlock after failure: %v", unlockErr) + } + }() + + migrate() + if _, err = conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrateAdvisoryLock); err != nil { + panic(fmt.Sprintf("migrate: unlock: %v", err)) + } + unlocked = true +} + +func migrateLocked() { + // M0 replaces the old flat plans table without rewriting historical rows. + // Rename before AutoMigrate so GORM creates the canonical table rather than + // adding columns to the incompatible legacy schema. + if err := prepareCanonicalPlansTable(); err != nil { + panic(fmt.Sprintf("migrate: prepare canonical plans: %v", err)) + } + + // Step 0: create inventory Postgres enum types FIRST. The DO/EXCEPTION + // blocks are idempotent so concurrent migrateOrIgnoreTypeExists + // reruns from parallel test binaries are safe (the type already + // exists → duplicate_object is swallowed). The enum types MUST + // exist before any AutoMigrate below because GORM emits + // `kind server_kind` literals in CREATE TABLE for the Server + // struct (referenced transitively from Monitor.Site → Site → Server). + for _, ddl := range []string{ + `DO $$ BEGIN + CREATE TYPE server_kind AS ENUM ('production','staging','old'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + `DO $$ BEGIN + CREATE TYPE deployment_kind AS ENUM + ('production','production_prev','production_next','internal','staging','old'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + `DO $$ BEGIN + CREATE TYPE deployment_mode AS ENUM + ('kubernetes','compose','dedicated','vds','user'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + `DO $$ BEGIN + CREATE TYPE deployment_action AS ENUM + ('ok','pending','pending_move','pending_drop','deleted','missing'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + } { + if err := DB().Exec(ddl).Error; err != nil { + panic(fmt.Sprintf("migrate: enum creation: %v", err)) + } + } + + var err error + + // Step 1: Migrate core models (User, Plan, Account, ApiKey, AuthIdentity, Invite) + // Note: Access is moved to Step 2 because it has FKs to groups/monitors + // Seed and remap plans before Account migration recreates its plan FK. + migrateOrIgnoreTypeExists(&Plan{}) + if err := seedCanonicalPlansAndBackfill(true); err != nil { + panic(fmt.Sprintf("migrate: billing catalog: %v", err)) + } + migrateOrIgnoreTypeExists( + &User{}, + &Account{}, + &ApiKey{}, + &authidentity.AuthIdentity{}, // After User (has FK to users) + &Invite{}, // After Account/User (has FKs to accounts/users) + &Subscription{}, // After Account/Plan + &SubscriptionEvent{}, // After Subscription + ) + + // Monitor and metric rows reference worker_nodes, while worker_nodes itself + // references servers. Create the two roots without their associations before + // migrating Monitor/ServerMetric on a fresh database. + migrateOrIgnoreTypeExists(&Region{}, &LLM{}) + if err = DB().Omit("Monitors", "Workers").AutoMigrate(&Server{}); err != nil { + panic(err) + } + migrateOrIgnoreTypeExists(&WorkerNode{}) + + // Step 2: Migrate Group, Monitor, and Access (which has FKs to groups/monitors) + // This ensures the groups table exists when GORM creates foreign keys + migrateOrIgnoreTypeExists( + &Group{}, + &Monitor{}, + &Check{}, + // Server is a customer-facing logical host, distinct from the + // WorkerNode executor. Keep the join/cache models here so a fresh + // database gets the complete server metrics schema in one migration. + &Server{}, + &AccountMCPToken{}, + &MonitorServer{}, + &ServerMetric{}, + &ServerAlertRule{}, + &ServerAlertEvent{}, + &RknIP{}, + &RknDomain{}, + &DNSRecord{}, + &Contact{}, + &Whois{}, + &Payment{}, + &Message{}, + &TelegramBotMessage{}, + &TelegramBotStatus{}, + &Event{}, + &SelfCheck{}, + &Notification{}, // After Group/Monitor so notification_groups FK works + &Access{}, // After Group/Monitor so access FKs work + &NotificationCredential{}, // No FKs to other domain tables; safe here. + ) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS servers_account_slug_unique ON servers (account_id, slug)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS monitor_servers_position_idx ON monitor_servers (server_id, position)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS server_metrics_server_recent_idx ON server_metrics (server_id, id DESC)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS server_metrics_worker_idx ON server_metrics (worker_id)`) + + // Step 3: Clean up orphaned references (now that all tables exist) + // Fix typo in old table name (only present on DBs migrated from older versions) + err = DB().Exec("ALTER TABLE IF EXISTS envent_checks RENAME TO event_checks;").Error + if err != nil { + log.Println(err) + } + err = DB().Exec("DROP TABLE IF EXISTS envent_messages;").Error + if err != nil { + log.Println(err) + } + + // Defense-in-depth: make sure the columns that the in-process notifier + // scheduler eagerly queries at startup exist, even if AutoMigrate above + // was skipped or the column was dropped by a manual operation. Without + // these, a fresh restore from a pre-soft-delete production dump will + // panic the first time RunExp preloads Contacts or + // ProcessPendingDeletions queries Users (see internal/notifier for the + // defensive recover() that catches the resulting query errors). + err = DB().Exec( + "ALTER TABLE contacts ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT FALSE", + ).Error + if err != nil { + log.Println(err) + } + err = DB().Exec( + "ALTER TABLE contacts ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE", + ).Error + if err != nil { + log.Println(err) + } + err = DB().Exec( + "ALTER TABLE accounts ADD COLUMN IF NOT EXISTS disabled BOOLEAN NOT NULL DEFAULT FALSE", + ).Error + if err != nil { + log.Println(err) + } + err = DB().Exec( + "ALTER TABLE accounts ADD COLUMN IF NOT EXISTS blocked BOOLEAN NOT NULL DEFAULT FALSE", + ).Error + if err != nil { + log.Println(err) + } + err = DB().Exec( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS deletion_requested_at TIMESTAMPTZ", + ).Error + if err != nil { + log.Println(err) + } + err = DB().Exec( + "ALTER TABLE notification_credentials ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(128)", + ).Error + if err != nil { + log.Println(err) + } + var telegramCreds []NotificationCredential + if err = DB().Where("kind = ? AND (webhook_token IS NULL OR webhook_token = '')", CredentialKindTelegram).Find(&telegramCreds).Error; err != nil { + log.Println(err) + } + for i := range telegramCreds { + telegramCreds[i].EnsureWebhookToken() + if err = DB().Save(&telegramCreds[i]).Error; err != nil { + log.Println(err) + } + } + err = DB().Exec("DROP INDEX IF EXISTS idx_notification_credentials_webhook_token").Error + if err != nil { + log.Println(err) + } + err = DB().Exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_credentials_webhook_token ON notification_credentials (webhook_token) WHERE webhook_token IS NOT NULL AND webhook_token <> ''", + ).Error + if err != nil { + log.Println(err) + } + // Replace the early M0 per-event-id index with provider-scoped webhook + // idempotency: PSP event IDs are only unique inside a provider. + if err = DB().Exec("DROP INDEX IF EXISTS idx_subscription_events_provider_event_id").Error; err != nil { + log.Println(err) + } + if err = DB().Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_subscription_events_provider_event ON subscription_events (provider, provider_event_id) WHERE provider_event_id IS NOT NULL").Error; err != nil { + log.Println(err) + } + + // Clean up orphaned event_checks + err = DB().Exec("DELETE FROM event_checks where event_id NOT IN (select id from events)").Error + if err != nil { + panic(err) + } + err = DB().Exec("DELETE FROM event_checks where check_id NOT IN (select id from checks)").Error + if err != nil { + panic(err) + } + + // Clean up orphaned event_messages + err = DB().Exec("DELETE FROM event_messages where event_id NOT IN (select id from events)").Error + if err != nil { + panic(err) + } + err = DB().Exec("DELETE FROM event_messages where message_id NOT IN (select id from messages)").Error + if err != nil { + panic(err) + } + + // Clean up orphaned notification_contacts + err = DB().Exec("DELETE FROM notification_contacts where notification_id NOT IN (select id from notifications)").Error + if err != nil { + panic(err) + } + err = DB().Exec("DELETE FROM notification_contacts where contact_id NOT IN (select id from contacts)").Error + if err != nil { + panic(err) + } + + // Clean up orphaned notification_groups (now safe - groups table exists) + err = DB().Exec("DELETE FROM notification_groups where notification_id NOT IN (select id from notifications)").Error + if err != nil { + panic(err) + } + err = DB().Exec("DELETE FROM notification_groups where group_id NOT IN (select id from groups)").Error + if err != nil { + panic(err) + } + + DB().Raw("CREATE INDEX IF NOT EXISTS not_old_events on events (monitor_id, id) where state != 'old'") + DB().Raw("CREATE INDEX IF NOT EXISTS current_events ON event (monitor_id, start_time) WHERE state = 'current'") + DB().Raw("CREATE INDEX IF NOT EXISTS ended_events ON event (monitor_id, start_time) WHERE state = 'ended'") + + DB().Raw("CREATE INDEX IF NOT EXISTS queued_messages ON message (id) WHERE state = 'queued'") + + DB().Raw("CREATE UNIQUE INDEX IF NOT EXISTS access_accounts ON accesses (user_id, account_id)") + DB().Raw("CREATE UNIQUE INDEX IF NOT EXISTS access_accounts ON accesses (user_id, group_id)") + + DB().Raw("CREATE UNIQUE INDEX IF NOT EXISTS invite_email ON invites (account_id, email)") + + // RKN indexes — see app/models/rkn_ip.go EnsureRknIndexes. GORM + // AutoMigrate above declared the uniqueIndex on RknDomain.Domain + // and the cidr column type on RknIP, but GiST on rkn_ips.network + // is not expressible via the GORM tag language; we add it here so + // the (>>=) containment operator used by IsRknIPBlocked has an + // index to back it. + if err := EnsureRknIndexes(); err != nil { + log.Printf("migrate: EnsureRknIndexes failed: %v", err) + } + + // Distributed worker models + migrateOrIgnoreTypeExists( + &WorkerLogEvent{}, + &CheckAttempt{}, + &DiagnosticAuditEvent{}, + &CheckRegionResult{}, + &Task{}, + &TaskReplay{}, + &NotificationDelivery{}, + &Tag{}, + ) + + // Tags — (account_id, name) is the unique key so a single account + // cannot register two metadata rows for the same tag string. The + // tag name itself is also the join key against monitors.tags, so + // uniqueness is enforced at the table level (not just on the + // metadata row). + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS tags_account_name_unique ON tags (account_id, name)`) + + // Status pages use globally unique public slugs. A public URL has no + // account component, so account-scoped uniqueness would make /status/:slug + // ambiguous. Soft-deleted rows do not reserve their slug. + // subscriber email index uses lower(email) for case-insensitive + // matching (the codebase does not adopt citext). All five tables + // are created together so M0 ships a consistent schema baseline + // regardless of which milestone first writes rows. + migrateOrIgnoreTypeExists( + &StatusPage{}, + &StatusPageSubscriber{}, + &StatusPageIncident{}, + &StatusPageMaintenance{}, + &StatusPageDomain{}, + &StatusPageDelivery{}, + &StatusPageDigestSchedule{}, + &Maintenance{}, + ) + DB().Exec(`DROP INDEX IF EXISTS status_pages_account_slug_unique`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS status_pages_slug_unique + ON status_pages (slug) WHERE deleted_at IS NULL`) + if err := ensureSingleCurrentEventInvariant(); err != nil { + panic(fmt.Sprintf("migrate: current event invariant: %v", err)) + } + DB().Exec( + `CREATE UNIQUE INDEX IF NOT EXISTS status_page_subscribers_active_email + ON status_page_subscribers (status_page_id, lower(email)) + WHERE unsubscribed_at IS NULL`, + ) + // Existing installations can already have subscriber rows. Keep the legacy + // token column during the nullable transition: outstanding links remain + // valid, while each resend/confirmation rotates it into a hash. + DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS confirm_token_hash varchar(64)`) + DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS token_expires_at timestamptz`) + DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS unsubscribe_token_hash varchar(64)`) + DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS contact_id bigint REFERENCES contacts(id) ON DELETE SET NULL`) + DB().Exec(`UPDATE status_page_subscribers SET token_expires_at = created_at + interval '24 hours' WHERE token_expires_at IS NULL`) + for { + var subscribers []StatusPageSubscriber + if err := DB().Where("confirm_token_hash IS NULL AND confirm_token IS NOT NULL").Limit(500).Find(&subscribers).Error; err != nil || len(subscribers) == 0 { + break + } + for i := range subscribers { + subscribers[i].ConfirmTokenHash = HashStatusPageToken(*subscribers[i].LegacyConfirmToken) + _ = DB().Model(&subscribers[i]).Update("confirm_token_hash", subscribers[i].ConfirmTokenHash).Error + } + } + DB().Exec(`UPDATE status_page_subscribers SET confirm_token_hash = '' WHERE confirm_token_hash IS NULL`) + DB().Exec(`UPDATE status_page_subscribers SET unsubscribe_token_hash = '' WHERE unsubscribe_token_hash IS NULL`) + // A page may expose more than one verified hostname; older schema reserved + // only one domain per page. + DB().Exec(`DROP INDEX IF EXISTS idx_status_page_domains_status_page_id`) + DB().Exec( + `CREATE INDEX IF NOT EXISTS status_page_incidents_started + ON status_page_incidents (status_page_id, started_at DESC)`, + ) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS status_page_incidents_event_unique + ON status_page_incidents (status_page_id, event_id) WHERE event_id IS NOT NULL`) + DB().Exec( + `CREATE INDEX IF NOT EXISTS status_page_maintenance_starts + ON status_page_maintenance (status_page_id, starts_at DESC)`, + ) + DB().Exec(`CREATE INDEX IF NOT EXISTS maintenances_account_active ON maintenances (account_id) WHERE deleted_at IS NULL`) + // Legacy status-page rows predate account-scoped maintenance. Keep their + // source IDs so equal title/start rows remain distinct and reruns can safely + // preserve every window and each of its joins. + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS maintenances_legacy_status_page_maintenance_unique + ON maintenances (legacy_status_page_maintenance_id) WHERE legacy_status_page_maintenance_id IS NOT NULL`) + DB().Exec(`INSERT INTO maintenances (account_id, title, description, strategy, duration_sec, start_date, end_date, timezone, active, show_on_all_status_pages, legacy_status_page_maintenance_id, created_at, updated_at) + SELECT sp.account_id, old.title, old.description, 'single', EXTRACT(EPOCH FROM (old.ends_at - old.starts_at))::int, old.starts_at, old.ends_at, 'UTC', TRUE, FALSE, old.id, old.created_at, old.updated_at + FROM status_page_maintenance old JOIN status_pages sp ON sp.id = old.status_page_id + ON CONFLICT (legacy_status_page_maintenance_id) WHERE legacy_status_page_maintenance_id IS NOT NULL DO NOTHING`) + DB().Exec(`INSERT INTO maintenance_status_pages (maintenance_id, status_page_id) + SELECT m.id, old.status_page_id FROM status_page_maintenance old JOIN maintenances m ON m.legacy_status_page_maintenance_id = old.id + ON CONFLICT DO NOTHING`) + DB().Exec(`INSERT INTO maintenance_monitors (maintenance_id, monitor_id) + SELECT m.id, monitor_id FROM status_page_maintenance old JOIN status_pages sp ON sp.id = old.status_page_id JOIN maintenances m ON m.legacy_status_page_maintenance_id = old.id, + LATERAL unnest(CASE WHEN cardinality(old.monitor_ids) > 0 THEN old.monitor_ids ELSE sp.monitor_ids END) AS monitor_id + ON CONFLICT DO NOTHING`) + + // Worker-driven task queue indexes. + DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_queued_due ON tasks (kind, not_before) WHERE state = 'queued'`) + DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_leased_expires ON tasks (lease_expires_at) WHERE state = 'leased'`) + DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_failed_retry_due ON tasks (not_before) WHERE state = 'failed_retry'`) + DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_dead_kind ON tasks (kind, account_id) WHERE state = 'dead'`) + DB().Exec(`CREATE INDEX IF NOT EXISTS check_attempts_worker_finished ON check_attempts (worker_node_id, finished_at DESC)`) + err = DB().Exec("ALTER TABLE worker_nodes ALTER COLUMN concurrency SET DEFAULT 10").Error + if err != nil { + log.Println(err) + } + + err = DB().Exec("ALTER TABLE checks ALTER COLUMN settings SET DEFAULT '{}'::jsonb").Error + if err != nil { + panic(err) + } + err = DB().Exec("UPDATE checks SET settings = '{}'::jsonb WHERE settings IS NULL").Error + if err != nil { + panic(err) + } + + err = DB().Exec("ALTER TABLE checks ALTER COLUMN settings SET NOT NULL").Error + if err != nil { + panic(err) + } + + // Inventory models (docs/plans/inventory-management.md §6, §10 M0). + // Enum types are created at Step 0 above so they exist before any + // AutoMigrate. The numeric labels of each enum value are taken + // verbatim from rstuff (`/data/int/rstuff/app/models/*.rb`) so a + // future sync layer does not need a value-mapping table — see + // docs/parity/rstuff-inventory.md §6.1. + + // Servers — extend with the rstuff-shaped inventory fields. All + // statements are IF NOT EXISTS so existing rows keep working + // untouched (ext_id/token/price_cents default sensibly; meta gets + // an empty jsonb). + DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS ext_id VARCHAR(64)`) + DB().Raw(`CREATE UNIQUE INDEX IF NOT EXISTS servers_ext_id_unique ON servers (ext_id) WHERE ext_id IS NOT NULL`) + DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS kind server_kind NOT NULL DEFAULT 'production'`) + DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS token VARCHAR(64)`) + DB().Raw(`CREATE UNIQUE INDEX IF NOT EXISTS servers_token_unique ON servers (token) WHERE token IS NOT NULL`) + DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS price_cents INTEGER NOT NULL DEFAULT 0`) + DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS comment TEXT`) + DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}'::jsonb`) + + // WorkerNodes — optional server_id join for inventory correlation. + DB().Exec(`ALTER TABLE worker_nodes ADD COLUMN IF NOT EXISTS server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL`) + DB().Exec(`CREATE INDEX IF NOT EXISTS worker_nodes_server_idx ON worker_nodes (server_id)`) + + // WorkerNodes — optional account_id FK marking a private (customer- + // operated) worker per docs/distributed/private-workers.md. NULL + // rows are platform-operated workers eligible to serve any account; + // non-NULL rows are pinned to a single account and are removed by + // HardDeleteAccount before the account row itself is dropped. + DB().Exec(`ALTER TABLE worker_nodes ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL`) + DB().Exec(`CREATE INDEX IF NOT EXISTS worker_nodes_account_idx ON worker_nodes (account_id)`) + + // Notification credentials are either platform-managed (account_id IS NULL) + // or owned by one account. Replace the legacy global name constraint with + // scope-aware unique indexes. + DB().Exec(`ALTER TABLE notification_credentials ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE CASCADE`) + DB().Exec(`CREATE INDEX IF NOT EXISTS notification_credentials_account_idx ON notification_credentials (account_id)`) + DB().Exec(`DROP INDEX IF EXISTS cred_kind_name`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS notification_credentials_system_kind_name_unique ON notification_credentials (kind, name) WHERE account_id IS NULL`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS notification_credentials_account_kind_name_unique ON notification_credentials (account_id, kind, name) WHERE account_id IS NOT NULL`) + + // Inventory entities. Order matters: server_ips before sites + // (FK), sites before deployments (FK), deployments before + // domains (FK). Audit columns (creator_id/updater_id) use the + // Audited mixin via concerns.Timestamped + Audited. + migrateOrIgnoreTypeExists( + &ServerIp{}, + &Repo{}, + &Site{}, + &Deployment{}, + &SiteRepo{}, + &Domain{}, + ) + // Monitors is migrated earlier for historical FK ordering. Add the optional + // site reference only after sites exists on fresh databases. + DB().Exec(`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS site_id BIGINT REFERENCES sites(id) ON DELETE SET NULL`) + DB().Exec(`CREATE INDEX IF NOT EXISTS monitors_site_idx ON monitors (site_id)`) + + // Indexes — kept here so the AutoMigrate path stays the single + // source of truth. IF NOT EXISTS guards the rerun case. + DB().Exec(`CREATE INDEX IF NOT EXISTS server_ips_server_idx ON server_ips (server_id)`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS server_ips_address_unique ON server_ips (server_id, address)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS sites_account_idx ON sites (account_id)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS sites_server_idx ON sites (server_id)`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS sites_ext_id_unique ON sites (ext_id) WHERE ext_id IS NOT NULL`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS sites_account_slug_unique ON sites (account_id, slug)`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS repos_ext_id_unique ON repos (ext_id) WHERE ext_id IS NOT NULL`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS repos_gitlab_id_unique ON repos (gitlab_id) WHERE gitlab_id IS NOT NULL`) + DB().Exec(`CREATE INDEX IF NOT EXISTS deployments_account_idx ON deployments (account_id)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS deployments_server_idx ON deployments (server_id)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS deployments_site_idx ON deployments (site_id)`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS deployments_ext_id_unique ON deployments (ext_id) WHERE ext_id IS NOT NULL`) + DB().Exec(`CREATE INDEX IF NOT EXISTS domains_account_idx ON domains (account_id)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS domains_server_idx ON domains (server_id)`) + DB().Exec(`CREATE INDEX IF NOT EXISTS domains_site_idx ON domains (site_id)`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS domains_name_unique ON domains (name)`) + // Dedupe by (server_id, config_path) for nginx sites and by + // (server_id, site_id, service_name) for compose services — matches + // the upsert keys in app/controllers/api/inventory_deploymentd.go. + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS deployments_unique_nginx + ON deployments (server_id, config_path) WHERE mode = 'dedicated' AND config_path IS NOT NULL`) + DB().Exec(`DROP INDEX IF EXISTS deployments_unique_compose`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS deployments_unique_compose + ON deployments (server_id, site_id, service_name) WHERE mode = 'compose' AND site_id IS NOT NULL AND service_name IS NOT NULL`) + + if err := seedCanonicalPlansAndBackfill(false); err != nil { + panic(fmt.Sprintf("migrate: billing catalog: %v", err)) + } + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS plans_active_code_unique ON plans (code) WHERE archived = FALSE`) + DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_provider_external_unique ON subscriptions (provider, provider_subscription_id) WHERE provider_subscription_id IS NOT NULL`) + DB().Exec(`CREATE INDEX IF NOT EXISTS subscriptions_account_status_idx ON subscriptions (account_id, status)`) + DB().Exec(`DO $$ BEGIN + ALTER TABLE plans ADD CONSTRAINT plans_price_nonnegative CHECK (price_monthly_minor >= 0 AND price_annual_minor >= 0); + EXCEPTION WHEN duplicate_object THEN NULL; END $$`) + DB().Exec(`DO $$ BEGIN + ALTER TABLE plans ADD CONSTRAINT plans_limits_valid CHECK (monitor_cap >= 0 AND interval_min_seconds >= 30); + EXCEPTION WHEN duplicate_object THEN NULL; END $$`) + + // Seed default region. Use defaultRegionCode (declared in check_jobs.go) + // so the literal does not appear three times in the package. + localRegion := Region{} + DB().Where("code = ?", defaultRegionCode).First(&localRegion) + if localRegion.ID == 0 { + DB().Create(&Region{Code: defaultRegionCode, Name: "Local (default)", Enabled: true, Priority: 100}) + } + + DB().Exec(` +WITH t AS ( + select u.id as user_id, i.encrypted_password as encrypted_password + from users as u + join identities as i on u.id = i.user_id + where u.encrypted_password is NULL +) +UPDATE users +SET encrypted_password = t.encrypted_password +from t +where users.id = t.user_id + `) + + log.Println("migrated DB.") +} + +func prepareCanonicalPlansTable() error { + if !DB().Migrator().HasTable("plans") || DB().Migrator().HasColumn("plans", "code") { + return nil + } + return DB().Transaction(func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE plans RENAME TO plans_legacy`).Error + }) +} + +func planForeignKeyReferences(tx *gorm.DB, table, target string) (bool, error) { + var references bool + err := tx.Raw(`SELECT EXISTS ( + SELECT 1 FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) + WHERE c.contype = 'f' AND c.conrelid = to_regclass(?) + AND c.confrelid = to_regclass(?) AND a.attname = 'plan_id' + )`, table, target).Scan(&references).Error + return references, err +} + +func seedCanonicalPlansAndBackfill(remapAccounts bool) error { + plans := []Plan{ + {Code: "free", NameRU: "Бесплатный", NameEN: "Free", Currency: "RUB", MonitorCap: 50, IntervalMinSeconds: 300, StatusPagesCap: 1, MaintenanceCap: -1, LoginSeatsIncluded: 3, NotifySeatsIncluded: 0, Integrations: []string{"email", "telegram"}, CheckKinds: []string{"http", "ssl", "dns", "whois", "ping"}, DataRetentionMonths: 3, IsDefault: true}, + {Code: "solo", NameRU: "Соло", NameEN: "Solo", PriceMonthlyMinor: 74900, PriceAnnualMinor: 64900, Currency: "RUB", MonitorCap: 10, IntervalMinSeconds: 60, StatusPagesCap: 3, MaintenanceCap: 5, LoginSeatsIncluded: 5, NotifySeatsIncluded: 3, Integrations: []string{"email", "telegram", "sms", "voice", "mattermost", "webhook"}, CheckKinds: []string{"http", "ssl", "dns", "ssh", "ftp", "whois", "ping"}, DataRetentionMonths: 12, Confirmations: true, AllowHardAlerts: true, ConfirmTimeoutSec: 60}, + {Code: "team", NameRU: "Команда", NameEN: "Team", PriceMonthlyMinor: 299000, PriceAnnualMinor: 254900, Currency: "RUB", MonitorCap: 100, IntervalMinSeconds: 60, StatusPagesCap: 100, MaintenanceCap: 50, LoginSeatsIncluded: 5, NotifySeatsIncluded: 5, Integrations: []string{"email", "telegram", "sms", "voice", "mattermost", "webhook"}, CheckKinds: []string{"http", "ssl", "dns", "ssh", "ftp", "whois", "ping", "rkn_blocklist", "llm"}, DataRetentionMonths: 24, DistributedWorkers: true, Confirmations: true, AllowHardAlerts: true, ConfirmTimeoutSec: 45}, + {Code: "enterprise", NameRU: "Предприятие", NameEN: "Enterprise", PriceMonthlyMinor: 549000, PriceAnnualMinor: 464900, Currency: "RUB", MonitorCap: 200, IntervalMinSeconds: 30, StatusPagesCap: 0, MaintenanceCap: 0, LoginSeatsIncluded: 0, NotifySeatsIncluded: 0, UnlimitedSeats: true, Integrations: []string{"email", "telegram", "sms", "voice", "mattermost", "webhook", "sso_saml"}, CheckKinds: []string{"http", "ssl", "dns", "ssh", "ftp", "whois", "ping", "rkn_blocklist", "llm"}, DataRetentionMonths: 36, DistributedWorkers: true, Confirmations: true, AllowHardAlerts: true, ConfirmTimeoutSec: 30, SOC2: true, GDPRDPA: true}, + } + return DB().Transaction(func(tx *gorm.DB) error { + for i := range plans { + var existing Plan + err := tx.Where("code = ?", plans[i].Code).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(&plans[i]).Error; err != nil { + return err + } + } else if err != nil { + return err + } + } + // Catalog rows already existed before maintenance_cap was introduced. + // Update this new entitlement only; do not overwrite customer-adjusted + // prices or other existing plan attributes during a normal migration. + if err := tx.Exec(`UPDATE plans SET maintenance_cap = CASE code + WHEN 'free' THEN -1 WHEN 'solo' THEN 5 WHEN 'team' THEN 50 WHEN 'enterprise' THEN 0 ELSE maintenance_cap END + WHERE code IN ('free', 'solo', 'team', 'enterprise')`).Error; err != nil { + return err + } + hasLegacy := tx.Migrator().HasTable("plans_legacy") + hasMigratedLegacy := tx.Migrator().HasTable("plans_legacy_migrated") + if !hasLegacy && !hasMigratedLegacy { + return nil + } + hasAccounts := tx.Migrator().HasTable("accounts") + accountsNeedRemap := false + if remapAccounts && hasLegacy && hasAccounts { + referencesCanonical, err := planForeignKeyReferences(tx, "accounts", "plans") + if err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS accounts_plan_id_fkey`).Error; err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS fk_accounts_plan`).Error; err != nil { + return err + } + accountsNeedRemap = !referencesCanonical + if accountsNeedRemap { + if err := tx.Exec(`UPDATE accounts SET plan_id = CASE + WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = accounts.plan_id AND (l."default" = TRUE OR l.price = 0)) THEN (SELECT id FROM plans WHERE code = 'free') + WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = accounts.plan_id AND l.total_monitors > 100) THEN (SELECT id FROM plans WHERE code = 'team') + ELSE (SELECT id FROM plans WHERE code = 'solo') END + WHERE EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = accounts.plan_id)`).Error; err != nil { + return err + } + } + } + hasSubscriptions := tx.Migrator().HasTable("subscriptions") + subscriptionsNeedRemap := false + if remapAccounts && hasLegacy && hasSubscriptions { + referencesCanonical, err := planForeignKeyReferences(tx, "subscriptions", "plans") + if err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_plan_id_fkey`).Error; err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS fk_subscriptions_plan`).Error; err != nil { + return err + } + subscriptionsNeedRemap = !referencesCanonical + if subscriptionsNeedRemap { + if err := tx.Exec(`UPDATE subscriptions SET plan_id = CASE + WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = subscriptions.plan_id AND (l."default" = TRUE OR l.price = 0)) THEN (SELECT id FROM plans WHERE code = 'free') + WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = subscriptions.plan_id AND l.total_monitors > 100) THEN (SELECT id FROM plans WHERE code = 'team') + ELSE (SELECT id FROM plans WHERE code = 'solo') END + WHERE EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = subscriptions.plan_id)`).Error; err != nil { + return err + } + } + } + if remapAccounts && hasLegacy && (accountsNeedRemap || subscriptionsNeedRemap) && tx.Migrator().HasTable("subscription_events") { + if err := tx.Exec(`UPDATE subscription_events e SET from_plan_id = CASE + WHEN l."default" = TRUE OR l.price = 0 THEN (SELECT id FROM plans WHERE code = 'free') + WHEN l.total_monitors > 100 THEN (SELECT id FROM plans WHERE code = 'team') + ELSE (SELECT id FROM plans WHERE code = 'solo') END + FROM plans_legacy l WHERE e.from_plan_id = l.id`).Error; err != nil { + return err + } + if err := tx.Exec(`UPDATE subscription_events e SET to_plan_id = CASE + WHEN l."default" = TRUE OR l.price = 0 THEN (SELECT id FROM plans WHERE code = 'free') + WHEN l.total_monitors > 100 THEN (SELECT id FROM plans WHERE code = 'team') + ELSE (SELECT id FROM plans WHERE code = 'solo') END + FROM plans_legacy l WHERE e.to_plan_id = l.id`).Error; err != nil { + return err + } + } + if !remapAccounts && hasAccounts && hasSubscriptions { + now := time.Now().UTC() + if err := tx.Exec(`INSERT INTO subscriptions (account_id, plan_id, provider, status, billing_cycle, current_period_start, current_period_end, currency, amount_minor, metadata_json, created_at, updated_at) + SELECT a.id, a.plan_id, 'manual', 'active', 'monthly', ?, ?, p.currency, p.price_monthly_minor, '{}'::jsonb, ?, ? + FROM accounts a JOIN plans p ON p.id = a.plan_id + WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)`, now, now.AddDate(0, 0, 30), now, now).Error; err != nil { + return err + } + } + if !remapAccounts || !hasLegacy { + return nil + } + if hasAccounts { + // AutoMigrate names this association fk_accounts_plan, while an older + // migration used accounts_plan_id_fkey. Either name may still point at + // plans_legacy after the table rename, so replace both deterministically. + if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS accounts_plan_id_fkey`).Error; err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS fk_accounts_plan`).Error; err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE accounts ADD CONSTRAINT fk_accounts_plan FOREIGN KEY (plan_id) REFERENCES plans(id)`).Error; err != nil { + return err + } + } + if hasSubscriptions { + if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_plan_id_fkey`).Error; err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS fk_subscriptions_plan`).Error; err != nil { + return err + } + if err := tx.Exec(`ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_plan FOREIGN KEY (plan_id) REFERENCES plans(id)`).Error; err != nil { + return err + } + } + return tx.Exec("ALTER TABLE plans_legacy RENAME TO plans_legacy_migrated").Error + }) +} diff --git a/app/models/migrate_test.go b/app/models/migrate_test.go new file mode 100644 index 0000000..e313ff3 --- /dev/null +++ b/app/models/migrate_test.go @@ -0,0 +1,162 @@ +package models + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestMigrateUpgradesLegacyPlansWithoutClosingLockConnection(t *testing.T) { + var databaseName string + require.NoError(t, DB().Raw("SELECT current_database()").Scan(&databaseName).Error) + require.Contains(t, databaseName, "test") + + original := db + schema := "migrate_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + require.NoError(t, original.Exec("CREATE SCHEMA "+schema).Error) + var isolatedSQLDB interface{ Close() error } + t.Cleanup(func() { + SetDB(original) + if isolatedSQLDB != nil { + _ = isolatedSQLDB.Close() + } + original.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") + }) + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable search_path=%s", + testDatabaseEnv("DATABASE_HOST", "POSTGRES_HOST", "localhost"), + testDatabaseEnv("DATABASE_PORT", "POSTGRES_PORT", "5432"), + testDatabaseEnv("DATABASE_USER", "POSTGRES_USER", "rsmon"), + testDatabaseEnv("DATABASE_PASSWORD", "POSTGRES_PASSWORD", "rsmon"), + databaseName, + schema, + ) + isolated, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := isolated.DB() + require.NoError(t, err) + isolatedSQLDB = sqlDB + RegisterCallbacks(isolated) + SetDB(isolated.Set("gorm:association_autoupdate", false)) + + require.NoError(t, DB().Exec(`CREATE TABLE plans ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + price BIGINT NOT NULL DEFAULT 0, + total_monitors BIGINT NOT NULL DEFAULT 0, + "default" BOOLEAN NOT NULL DEFAULT FALSE + )`).Error) + require.NoError(t, DB().Exec(`CREATE TABLE accounts ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + plan_id BIGINT REFERENCES plans(id) + )`).Error) + require.NoError(t, DB().Exec(`CREATE TABLE subscriptions ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + plan_id BIGINT NOT NULL REFERENCES plans(id), + provider VARCHAR(16) NOT NULL DEFAULT 'manual', + status VARCHAR(24) NOT NULL DEFAULT 'active', + billing_cycle VARCHAR(8) NOT NULL DEFAULT 'monthly', + currency VARCHAR(3) NOT NULL DEFAULT 'RUB', + amount_minor BIGINT NOT NULL DEFAULT 0, + metadata_json JSONB NOT NULL DEFAULT '{}' + )`).Error) + require.NoError(t, DB().Exec(`CREATE TABLE subscription_events ( + id BIGSERIAL PRIMARY KEY, + subscription_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + provider VARCHAR(16) NOT NULL DEFAULT 'manual', + kind VARCHAR(32) NOT NULL, + from_plan_id BIGINT, + to_plan_id BIGINT, + payload_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`).Error) + require.NoError(t, DB().Exec(`INSERT INTO plans (id, name, price, total_monitors, "default") + VALUES (42, 'Legacy free', 0, 10, TRUE)`).Error) + require.NoError(t, DB().Exec("INSERT INTO accounts (id, name, plan_id) VALUES (7, 'Legacy account', 42)").Error) + require.NoError(t, DB().Exec("INSERT INTO subscriptions (id, account_id, plan_id) VALUES (9, 7, 42)").Error) + require.NoError(t, DB().Exec(`INSERT INTO subscription_events + (id, subscription_id, account_id, kind, from_plan_id, to_plan_id) + VALUES (11, 9, 7, 'legacy_change', 42, 42)`).Error) + + Migrate() + + require.True(t, DB().Migrator().HasColumn("plans", "code")) + require.False(t, DB().Migrator().HasTable("plans_legacy")) + require.True(t, DB().Migrator().HasTable("plans_legacy_migrated")) + var codes []string + require.NoError(t, DB().Table("plans").Order("code").Pluck("code", &codes).Error) + require.ElementsMatch(t, CanonicalPlanCodes(), codes) + var accountPlanCode string + require.NoError(t, DB().Table("accounts").Select("plans.code"). + Joins("JOIN plans ON plans.id = accounts.plan_id").Where("accounts.id = 7"). + Scan(&accountPlanCode).Error) + require.Equal(t, "free", accountPlanCode) + var subscriptionPlanCode string + require.NoError(t, DB().Table("subscriptions").Select("plans.code"). + Joins("JOIN plans ON plans.id = subscriptions.plan_id").Where("subscriptions.id = 9"). + Scan(&subscriptionPlanCode).Error) + require.Equal(t, "free", subscriptionPlanCode) + var eventPlanCodes struct { + FromCode string + ToCode string + } + require.NoError(t, DB().Table("subscription_events e"). + Select("fp.code AS from_code, tp.code AS to_code"). + Joins("JOIN plans fp ON fp.id = e.from_plan_id"). + Joins("JOIN plans tp ON tp.id = e.to_plan_id"). + Where("e.id = 11").Scan(&eventPlanCodes).Error) + require.Equal(t, "free", eventPlanCodes.FromCode) + require.Equal(t, "free", eventPlanCodes.ToCode) + require.NoError(t, DB().Exec("SELECT 1").Error) + + // The previous implementation retained this name after remapping. Its + // canonical FKs must prevent a retry from remapping the same rows again. + require.NoError(t, DB().Exec("ALTER TABLE plans_legacy_migrated RENAME TO plans_legacy").Error) + require.NotPanics(t, Migrate) + accountPlanCode = "" + require.NoError(t, DB().Table("accounts").Select("plans.code"). + Joins("JOIN plans ON plans.id = accounts.plan_id").Where("accounts.id = 7"). + Scan(&accountPlanCode).Error) + require.Equal(t, "free", accountPlanCode) + require.True(t, DB().Migrator().HasTable("plans_legacy_migrated")) +} + +func TestMigrationAdvisoryLockIsReleasedAfterPanic(t *testing.T) { + sqlDB, err := DB().DB() + require.NoError(t, err) + otherConn, err := sqlDB.Conn(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { _ = otherConn.Close() }) + + require.PanicsWithValue(t, "migration failed", func() { + withMigrationAdvisoryLock(func() { panic("migration failed") }) + }) + + const migrateAdvisoryLock = int64(1234567890) + var acquired bool + require.NoError(t, otherConn.QueryRowContext(context.Background(), + "SELECT pg_try_advisory_lock($1)", migrateAdvisoryLock).Scan(&acquired)) + require.True(t, acquired) + _, err = otherConn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", migrateAdvisoryLock) + require.NoError(t, err) +} + +func testDatabaseEnv(primary, fallback, defaultValue string) string { + if value := os.Getenv(primary); value != "" { + return value + } + if value := os.Getenv(fallback); value != "" { + return value + } + return defaultValue +} diff --git a/app/models/monitor.go b/app/models/monitor.go new file mode 100644 index 0000000..44b59e9 --- /dev/null +++ b/app/models/monitor.go @@ -0,0 +1,534 @@ +package models + +import ( + "log" + "sync" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/lib/pq" + "github.com/pkg/errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +const ( + stateOK = "OK" + stateERR = "ERR" + stateWARN = "WARN" + stateFail = "FAIL" + stateDegraded = "DEGRADED" + + // Event states. + stateEnded = "ended" + + // Check column names used in map[string]interface{} GORM updates. Defining + // them as constants keeps GORM column references in sync with model fields. + colLastStart = "last_start" + colLastEnd = "last_end" + colState = "state" + colWarnings = "warnings" + colInfos = "infos" + + // Check kinds. Used to avoid sprinkling magic strings across the codebase. + kindHTTP = "http" + kindSSL = "ssl" + kindSSH = "ssh" + kindFTP = "ftp" + kindDNS = "dns" + kindWhois = "whois" + kindRKN = "rkn" + kindBSSL = "bssl" + kindLLM = "llm" + kindLLMHTTP = "llm-http" + kindPing = "ping" + kindTCP = "tcp" + kindUDP = "udp" +) + +// Monitor monitor +type Monitor struct { + concerns.Model + // activity status + Enabled bool `gorm:"not null;default:true" json:"enabled"` + + // check state, OK - all green, ERR - some checks have failed, UNK - new or not run, FAIL - unable to check + State string `gorm:"not null;default:'UNK'" json:"state"` + ConfirmState string `gorm:"size:32;not null;default:'none';index" json:"confirm_state"` + ConfirmAt *time.Time `json:"confirm_at,omitempty"` + ConfirmedByWorkerID *int64 `gorm:"type:bigint REFERENCES worker_nodes(id) ON DELETE SET NULL" json:"confirmed_by_worker_id,omitempty"` + + // Group ID + GroupID int64 `gorm:"type:bigint REFERENCES groups(id)" json:"group_id,omitempty" validate:"required"` + Group *Group `json:"group,omitempty"` + + // Optional inventory Site join (see docs/plans/inventory-management.md §6.3). + // Lets the operator navigate monitor → site → deployments → server in one query. + SiteID *int64 `gorm:"type:bigint REFERENCES sites(id) ON DELETE SET NULL;index" json:"site_id,omitempty"` + Site *Site `json:"site,omitempty"` + + // Tags for monitor grouping/searching + Tags pq.StringArray `gorm:"type:varchar(255)[]" json:"tags"` + + // PreferredRegions is the optional whitelist of region codes a distributed + // worker must be in to receive this monitor's checks. Empty/NULL means + // "no preference" — any worker can pick it up. Used by Phase 2 of + // docs/plans/worker-notifier-mvp.md (regional job routing); see + // app/models/check_jobs.go applyRegionRouting. + PreferredRegions pq.StringArray `gorm:"type:varchar(64)[]" json:"preferred_regions,omitempty"` + + // RegionMode controls how PreferredRegions is interpreted by the + // distributed-worker job router. Defaults to "any" so monitors without + // explicit routing still match every worker — backwards-compatible with + // Phase 1 deployments. + // "any" — no region filter; legacy behavior (default) + // "specific" — only workers whose region_code is in PreferredRegions + // "all" — Phase 3 placeholder; today behaves like "any". The + // multi-region quorum aggregation is not implemented yet, + // see docs/todo.md Phase 3. + RegionMode string `gorm:"size:16;not null;default:'any'" json:"region_mode" validate:"omitempty,oneof=any specific all"` + + // Monitor name + Name *string `json:"name,omitempty"` + + // Host to monitor + Host string `json:"host" validate:"required"` + + // UserID specify user for this monitor (info field) + UserID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"user_id"` + User *User `json:"user,omitempty"` + + // Comment (info field) + Comment *string `json:"comment"` + + Checks []Check `json:"checks,omitempty"` + DNSRecords []DNSRecord `json:"-"` + + StatsData `gorm:"-:all" sql:"-" json:"stats"` + concerns.Timestamped + Audited +} + +// KINDS Check kinds +var KINDS = []string{kindHTTP, kindSSL, kindSSH, kindFTP, kindDNS, kindWhois, kindRKN, kindBSSL, kindLLM, kindLLMHTTP, kindPing, kindTCP, kindUDP} + +// ValidCheckKind is the single canonical allow-list for user supplied check +// kinds. Keep it beside the kind constants so every transport validates the +// same set before a check reaches a worker. +func ValidCheckKind(kind string) bool { + for _, candidate := range KINDS { + if kind == candidate { + return true + } + } + return false +} + +// Region routing mode constants used by Monitor.RegionMode and +// app/models/check_jobs.go applyRegionRouting. Centralized so the literal +// values are not sprinkled through the codebase. +const ( + // RegionModeAny keeps the legacy behavior: every worker is eligible, + // PreferredRegions is ignored. Default for newly-created monitors. + RegionModeAny = "any" + + // RegionModeSpecific limits eligible workers to those whose RegionCode + // is contained in PreferredRegions. Empty PreferredRegions falls back to + // RegionModeAny so the field is safe to leave blank in the UI. + RegionModeSpecific = "specific" + + // RegionModeAll is the Phase 3 placeholder: a monitor pinned to all of + // its preferred regions for quorum aggregation. Phase 2 treats it as + // RegionModeAny and logs a TODO marker so it is easy to grep for. + RegionModeAll = "all" +) + +// RegionCodesFromSlice is a convenience wrapper so callers (mainly tests +// and HTTP handlers) can pass a plain []string and get the pq.StringArray +// type the model expects. nil/empty input is preserved as a nil slice so +// the GORM column writes a SQL NULL instead of an empty array, matching +// the column default. +func RegionCodesFromSlice(in []string) pq.StringArray { + if len(in) == 0 { + return nil + } + out := make(pq.StringArray, len(in)) + copy(out, in) + return out +} + +// Int64ArrayFromSlice mirrors RegionCodesFromSlice for bigint[] columns +// such as status_pages.monitor_ids and status_page_maintenance.monitor_ids. +// The GORM pq.Int64Array driver expects a non-nil slice for ordered +// inserts; callers that always have a non-empty list (the dashboard list +// filter, the maintenance form) can rely on this to write a stable shape. +func Int64ArrayFromSlice(in []int64) pq.Int64Array { + if len(in) == 0 { + return pq.Int64Array{} + } + out := make(pq.Int64Array, len(in)) + copy(out, in) + return out +} + +// ValidateRegionMode returns an error when RegionMode is not one of the +// documented values ("any", "specific", "all"). Empty strings are treated as +// "any" for backwards compatibility with monitors persisted before the field +// existed; the DB column also defaults to "any". +func (m *Monitor) ValidateRegionMode() error { + switch m.RegionMode { + case "", RegionModeAny, RegionModeSpecific, RegionModeAll: + return nil + default: + return errors.Errorf("invalid region_mode %q (expected any|specific|all)", m.RegionMode) + } +} + +// WantsRegion returns true when the monitor should be routed to a worker +// operating in the given region code. Callers use this in +// app/models/check_jobs.go to filter the eligible worker pool per check. +// +// - RegionModeAny: always true (no preference). +// - RegionModeAll (Phase 3 placeholder): behaves like Any today; returns +// true unconditionally so every region sees the check. +// - RegionModeSpecific: true when code is contained in PreferredRegions, +// or when PreferredRegions is empty (fall-back to Any). +func (m *Monitor) WantsRegion(code string) bool { + switch m.RegionMode { + case RegionModeSpecific: + if len(m.PreferredRegions) == 0 { + return true + } + for _, r := range m.PreferredRegions { + if r == code { + return true + } + } + return false + case RegionModeAll: + // TODO(phase3): enumerate PreferredRegions and emit one assignment + // per region so the result aggregator can do quorum. Today we + // behave like Any so existing workers keep getting checks. + return true + default: + return true + } +} + +// GetLabel provides functionality. +func (m *Monitor) GetLabel() string { + if m.Name != nil { + return *m.Name + } + return m.Host +} + +// ProcessChecks provides functionality. +func (m *Monitor) ProcessChecks(tx *gorm.DB) error { + log.Println("process checks") + checks := make([]Check, 0) + for _, c := range m.Checks { //nolint:gocritic // range copy is acceptable here + log.Println("maybe delete check", c.ID, c.Deleted, c.IsNew) + if c.Deleted { + if !c.IsNew { + log.Println("delete check", c.ID) + err := tx.Exec("delete from event_checks where check_id = ?", c.ID).Error + if err != nil { + return err + } + + // First, find all message IDs for this check + var messageIDs []int64 + err = tx.Model(&Message{}).Where("check_id = ?", c.ID).Pluck("id", &messageIDs).Error + if err != nil { + return err + } + + // Delete event_messages (join table) first to avoid FK constraint violation + if len(messageIDs) > 0 { + err = tx.Exec("DELETE FROM event_messages WHERE message_id IN (?)", messageIDs).Error + if err != nil { + return err + } + } + + // Now delete the messages + err = tx.Where("check_id = ?", c.ID).Delete(Message{}).Error + if err != nil { + return err + } + + err = tx.Where("id = ? AND monitor_id = ?", c.ID, m.ID).Delete(Check{}).Error + if err != nil { + return err + } + } + continue + } + + if c.IsNew { + c.ID = 0 + } + err := c.ValidateSettings() + if err != nil { + return errors.Wrap(err, "check validation error") + } + checks = append(checks, c) + } + m.Checks = checks + return nil +} + +// ActiveEvent provides functionality. +func (m *Monitor) ActiveEvent() Event { + evt := Event{} + DB().Where("monitor_id = ? AND state != 'old'", m.ID).First(&evt) + if evt.ID != 0 { + evt.MonitorID = m.ID + t := time.Now() + evt.StartTime = &t + } + return evt +} + +var mutex sync.Mutex + +// checkSeverityRank assigns an ordinal to each check state so the monitor +// aggregator can pick the highest-severity child deterministically. +// Severity order is FAIL > ERR > DEGRADED > WARN > OK — see docs/todo.md +// Phase 3 for the rationale (DEGRADED = partial regional failure, sits +// between OK and ERR). Unknown states (UNK, empty, ...) rank 0 so any +// real check state takes precedence over them. +func checkSeverityRank(state string) int { + switch state { + case stateFail: + return 5 + case stateERR: + return 4 + case stateDegraded: + return 3 + case stateWARN: + return 2 + case stateOK: + return 1 + default: + return 0 + } +} + +// UpdateStatusFromChecks updates the monitor status based on its checks. +func (m *Monitor) UpdateStatusFromChecks() { + mutex.Lock() + checks := make([]Check, 0) + + tx := DB().Begin() + var locked Monitor + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, m.ID).Error; err != nil { + tx.Rollback() + mutex.Unlock() + log.Println("UpdateStatusFromChecks lock monitor", err) + return + } + m.State = locked.State + + _ = tx.Model(m).Association("Checks").Find(&checks) + prevState := m.State + m.State = stateOK + + // Pick the highest-severity enabled check. The previous implementation + // inlined three if-statements with non-obvious precedence (a WARN that + // appeared AFTER an ERR in the iteration would never downgrade back, + // but a FAIL after ERR would silently get clobbered). Using a single + // severity rank keeps the rule FAIL > ERR > DEGRADED > WARN > OK + // independent of slice ordering — the same rule Phase 3 introduces + // for DEGRADED, applied uniformly to the existing states too. + bestRank := checkSeverityRank(stateOK) + bestState := stateOK + hasChecks := false + for _, check := range checks { //nolint:gocritic // range copy is acceptable here + if check.Enabled == nil || !*check.Enabled { + continue + } + hasChecks = true + if r := checkSeverityRank(check.State); r > bestRank { + bestRank = r + bestState = check.State + } + } + m.State = bestState + + if !hasChecks { + m.State = stateWARN + } + + if m.State != prevState { + err := tx.Model(&m).UpdateColumn("state", m.State).Error + if err != nil { + tx.Rollback() + log.Println("UpdateStatusFromChecks fail update state", err) + mutex.Unlock() + return + } + } + + evt := Event{} + if err := tx.Where("monitor_id = ? AND state = ?", m.ID, "current").First(&evt).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + tx.Rollback() + mutex.Unlock() + log.Println("UpdateStatusFromChecks active event", err) + return + } + // log.Println("state", m.State, "active event:", evt.ID) + + for _, check := range checks { //nolint:gocritic // range copy is acceptable here + if check.State == stateERR || check.State == stateFail { + evt.ChecksDown = append(evt.ChecksDown, check.Kind) + evt.Checks = append(evt.Checks, check) + if check.Error != nil { + evt.Reason = *check.Error + } else { + evt.Reason = "unknown error" + } + } + } + + switch m.State { + case stateOK, stateWARN, stateDegraded: + // DEGRADED is treated like OK/WARN for the event lifecycle: we do + // NOT open a new "current" outage event for a partial regional + // failure. Operators see DEGRADED on the check detail page and + // the monitor list, but the existing notifier pipeline (down / + // restore events) only fires for full ERR/FAIL. A future + // improvement can add a separate "degraded" message kind. + if evt.ID != 0 { + upd := map[string]interface{}{ + "duration": time.Since(*evt.StartTime).Seconds(), + "state": stateEnded, + "oks": evt.Oks + 1, + } + if evt.EndTime == nil { + upd["end_time"] = time.Now() + } + err := tx.Model(&evt).UpdateColumns(upd).Error + if err != nil { + tx.Rollback() + log.Println("UpdateStatusFromChecks fail update to ended", err) + mutex.Unlock() + return + } + } + case stateERR, stateFail: + if evt.ID == 0 { + tn := time.Now() + evt.StartTime = &tn + evt.Duration = 0 + evt.State = "current" + evt.MonitorID = m.ID + err := tx.Save(&evt).Error + if err != nil { + tx.Rollback() + spew.Dump(evt) + log.Println("UpdateStatusFromChecks fail create", err) + mutex.Unlock() + return + } + } else { + upd := map[string]interface{}{ + "end_time": nil, + "state": "current", + "errors": evt.Errors + 1, + } + if evt.StartTime == nil { + upd["start_time"] = time.Now() + upd["duration"] = 0 + } else { + upd["duration"] = time.Since(*evt.StartTime).Seconds() + } + + err := tx.Model(&evt).UpdateColumns(upd).Error + if err != nil { + tx.Rollback() + spew.Dump(evt) + spew.Dump(upd) + log.Println("UpdateStatusFromChecks fail update to current", err) + mutex.Unlock() + return + } + } + } + + if err := m.syncStatusPageIncidentsTx(tx, &evt); err != nil { + tx.Rollback() + log.Println("UpdateStatusFromChecks status page incident", err) + mutex.Unlock() + return + } + err := tx.Commit().Error + + mutex.Unlock() + + if err != nil { + log.Println("UpdateStatusFromChecks commit fail", err) + return + } + if m.State != prevState { + m.invalidateStatusPages() + } +} + +func (m *Monitor) invalidateStatusPages() { + var ids []int64 + if err := DB().Model(&StatusPage{}).Where("? = ANY(monitor_ids)", m.ID).Pluck("id", &ids).Error; err != nil { + return + } + for _, id := range ids { + InvalidateStatusPageCache(id) + } +} + +func (m *Monitor) syncStatusPageIncidentsTx(tx *gorm.DB, event *Event) error { + if event == nil || event.ID == 0 { + return nil + } + var pages []StatusPage + if err := tx.Where("auto_open_incidents = TRUE AND ? = ANY(monitor_ids)", m.ID).Find(&pages).Error; err != nil { + return err + } + for i := range pages { + page := &pages[i] + var incident StatusPageIncident + err := tx.Where("status_page_id = ? AND event_id = ?", page.ID, event.ID).First(&incident).Error + if m.State == stateERR || m.State == "FAIL" { + if err != nil { + // The database uniqueness constraint makes concurrent state updates idempotent. + incident = StatusPageIncident{StatusPageID: page.ID, EventID: &event.ID, Title: m.GetLabel() + " is unavailable", BodyMD: event.Reason, Severity: StatusPageIncidentSeverityCrit, StartedAt: time.Now()} + if err := tx.Create(&incident).Error; err != nil { + return err + } + if err := EnqueueStatusPageIncidentDeliveriesTx(tx, page, &incident, "opened"); err != nil { + return err + } + } else if incident.BodyMD != event.Reason { + if err := tx.Model(&incident).Update("body_md", event.Reason).Error; err != nil { + return err + } + incident.BodyMD, incident.UpdatedAt = event.Reason, time.Now() + if err := EnqueueStatusPageIncidentDeliveriesTx(tx, page, &incident, "updated"); err != nil { + return err + } + } + } else if err == nil && incident.ResolvedAt == nil { + now := time.Now() + if err := tx.Model(&incident).Update("resolved_at", now).Error; err != nil { + return err + } + incident.ResolvedAt, incident.UpdatedAt = &now, now + if err := EnqueueStatusPageIncidentDeliveriesTx(tx, page, &incident, "resolved"); err != nil { + return err + } + } + } + return nil +} diff --git a/app/models/monitor_state_test.go b/app/models/monitor_state_test.go new file mode 100644 index 0000000..aabe6b5 --- /dev/null +++ b/app/models/monitor_state_test.go @@ -0,0 +1,215 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// monitorStateTestWorld bundles the account/group/monitor fixture the +// monitor-state tests need. It deliberately re-creates the row in each +// test rather than sharing, because Monitor.UpdateStatusFromChecks +// mutates the row in place and the per-test assertions need a clean +// baseline. +type monitorStateTestWorld struct { + plan models.Plan + account models.Account + group models.Group + monitor models.Monitor +} + +// seedMonitorStateWorld provisions one plan/account/group/monitor with +// the requested initial state. The monitor is enabled so +// UpdateStatusFromChecks treats its checks as live. +func seedMonitorStateWorld(t *testing.T, initialState string) monitorStateTestWorld { + t.Helper() + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "ms-plan", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + account := models.Account{Name: "ms-acc", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&account).Error) + + group := models.Group{AccountID: account.ID, Name: "ms"} + require.NoError(t, models.DB().Create(&group).Error) + + mon := models.Monitor{ + Name: stringPtrAgg("mon.test"), + Host: "mon.test", + GroupID: group.ID, + Enabled: true, + State: initialState, + } + require.NoError(t, models.DB().Create(&mon).Error) + + return monitorStateTestWorld{ + plan: plan, + account: account, + group: group, + monitor: mon, + } +} + +// attachCheck creates an enabled check on the monitor with the given +// state. Returns the persisted check so the test can re-load it. +func attachCheck(t *testing.T, monitorID int64, kind string, state string) models.Check { + t.Helper() + enTrue := true + c := models.Check{ + MonitorID: monitorID, + Kind: kind, + Interval: 60, + Enabled: &enTrue, + State: state, + Settings: datatypes.JSON([]byte(`{}`)), + } + require.NoError(t, models.DB().Create(&c).Error) + return c +} + +// reloadMonitor pulls the latest monitor state from the DB so the test +// can compare against the post-UpdateStatusFromChecks row. +func reloadMonitor(t *testing.T, id int64) models.Monitor { + t.Helper() + var m models.Monitor + require.NoError(t, models.DB().First(&m, id).Error) + return m +} + +// --------------------------------------------------------------------------- +// Phase 3 severity rules: FAIL > ERR > DEGRADED > WARN > OK. +// --------------------------------------------------------------------------- + +// TestMonitorStateFromChecks_Degraded: monitor with 1 OK + 1 DEGRADED +// child → monitor.State = DEGRADED. The new severity rank must +// correctly promote DEGRADED above OK. +func TestMonitorStateFromChecks_Degraded(t *testing.T) { + world := seedMonitorStateWorld(t, "UNK") + attachCheck(t, world.monitor.ID, "http", "OK") + attachCheck(t, world.monitor.ID, "http", "DEGRADED") + + world.monitor.UpdateStatusFromChecks() + + got := reloadMonitor(t, world.monitor.ID) + assert.Equal(t, "DEGRADED", got.State, + "DEGRADED child must promote monitor above OK") +} + +// TestMonitorStateFromChecks_DegradedWithError: monitor with 1 ERR + +// 1 DEGRADED → monitor.State = ERR. ERR beats DEGRADED in the +// severity order. +func TestMonitorStateFromChecks_DegradedWithError(t *testing.T) { + world := seedMonitorStateWorld(t, "UNK") + attachCheck(t, world.monitor.ID, "http", "ERR") + attachCheck(t, world.monitor.ID, "http", "DEGRADED") + + world.monitor.UpdateStatusFromChecks() + + got := reloadMonitor(t, world.monitor.ID) + assert.Equal(t, "ERR", got.State, + "ERR must beat DEGRADED — the order is FAIL > ERR > DEGRADED > WARN > OK") +} + +// TestMonitorStateFromChecks_DegradedOnlyOK covers the single-DEGRADED +// case explicitly so a regression that treats DEGRADED as "WARN-ish" +// would flip this assertion. +func TestMonitorStateFromChecks_DegradedOnlyOK(t *testing.T) { + world := seedMonitorStateWorld(t, "UNK") + attachCheck(t, world.monitor.ID, "http", "OK") + attachCheck(t, world.monitor.ID, "http", "DEGRADED") + + world.monitor.UpdateStatusFromChecks() + + got := reloadMonitor(t, world.monitor.ID) + assert.Equal(t, "DEGRADED", got.State, + "mixed OK+DEGRADED monitor must be DEGRADED, not OK") +} + +// TestMonitorStateFromChecks_SeverityOrderingTable is a table-driven +// sweep of the FAIL > ERR > DEGRADED > WARN > OK ladder. The case set +// is intentionally small — every pair that could reveal a wrong +// winner under the new severity rank. Keeping it table-driven makes +// it trivial to add more cases if a future state is introduced. +func TestMonitorStateFromChecks_SeverityOrderingTable(t *testing.T) { + cases := []struct { + name string + checks []string + wantMon string + }{ + {"all_ok", []string{"OK", "OK"}, "OK"}, + {"all_warn", []string{"WARN", "WARN"}, "WARN"}, + {"ok_with_warn", []string{"OK", "WARN"}, "WARN"}, + {"warn_with_ok", []string{"WARN", "OK"}, "WARN"}, // order independence + {"ok_with_degraded", []string{"OK", "DEGRADED"}, "DEGRADED"}, + {"warn_with_degraded", []string{"WARN", "DEGRADED"}, "DEGRADED"}, + {"degraded_with_warn", []string{"DEGRADED", "WARN"}, "DEGRADED"}, + {"err_with_degraded", []string{"ERR", "DEGRADED"}, "ERR"}, + {"degraded_with_err", []string{"DEGRADED", "ERR"}, "ERR"}, + {"fail_with_err", []string{"FAIL", "ERR"}, "FAIL"}, + {"err_with_fail", []string{"ERR", "FAIL"}, "FAIL"}, // order independence + {"fail_alone", []string{"FAIL"}, "FAIL"}, + {"all_degraded", []string{"DEGRADED", "DEGRADED"}, "DEGRADED"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + world := seedMonitorStateWorld(t, "UNK") + for i, st := range c.checks { + attachCheck(t, world.monitor.ID, + "http-"+string(rune('a'+i)), st) + } + world.monitor.UpdateStatusFromChecks() + got := reloadMonitor(t, world.monitor.ID) + assert.Equal(t, c.wantMon, got.State, + "severity winner for child states %v must be %s", c.checks, c.wantMon) + }) + } +} + +// TestMonitorStateFromChecks_NoChecksIsWarn pins the existing "no +// enabled checks → WARN" behavior — the new severity rank must not +// accidentally produce OK for an empty monitor. +func TestMonitorStateFromChecks_NoChecksIsWarn(t *testing.T) { + world := seedMonitorStateWorld(t, "UNK") + world.monitor.UpdateStatusFromChecks() + + got := reloadMonitor(t, world.monitor.ID) + assert.Equal(t, "WARN", got.State, + "an enabled monitor with zero checks must remain WARN") +} + +// TestMonitorStateFromChecks_DisabledCheckIgnored verifies that a +// disabled check is not folded into the severity decision. Otherwise +// a stuck-in-ERR check that has been disabled would keep tripping the +// monitor forever. +func TestMonitorStateFromChecks_DisabledCheckIgnored(t *testing.T) { + world := seedMonitorStateWorld(t, "UNK") + attachCheck(t, world.monitor.ID, "http", "OK") + + enFalse := false + disabled := models.Check{ + MonitorID: world.monitor.ID, + Kind: "http-disabled", + Interval: 60, + Enabled: &enFalse, + State: "ERR", + Settings: datatypes.JSON([]byte(`{}`)), + } + require.NoError(t, models.DB().Create(&disabled).Error) + + world.monitor.UpdateStatusFromChecks() + + got := reloadMonitor(t, world.monitor.ID) + assert.Equal(t, "OK", got.State, + "disabled check must be ignored — only the enabled OK check counts") +} + +// guard against time import being pruned by an editor when individual +// test bodies stop referencing it directly. +var _ = time.Second diff --git a/app/models/monitor_transfer_test.go b/app/models/monitor_transfer_test.go new file mode 100644 index 0000000..2618f63 --- /dev/null +++ b/app/models/monitor_transfer_test.go @@ -0,0 +1,94 @@ +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// TestMonitorTransferGroupSwap verifies that swapping a monitor's +// group_id between two accounts correctly re-homes the monitor without +// touching any other monitor data. This is the database primitive that +// POST /api/v1/monitors/:id/transfer relies on. +func TestMonitorTransferGroupSwap(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + // Two accounts, each with their own default group. + accA := models.Account{Name: "A", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&accA).Error) + accB := models.Account{Name: "B", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&accB).Error) + + groupA := models.Group{AccountID: accA.ID, Name: "A-default"} + require.NoError(t, models.DB().Create(&groupA).Error) + groupB := models.Group{AccountID: accB.ID, Name: "B-default"} + require.NoError(t, models.DB().Create(&groupB).Error) + + // Monitor lives in account A with one HTTP check. + monitor := models.Monitor{ + GroupID: groupA.ID, + Host: "example-a.test", + } + require.NoError(t, models.DB().Create(&monitor).Error) + + check := models.Check{ + MonitorID: monitor.ID, + Kind: "http", + URL: ptrString("https://example-a.test/"), + Interval: 300, + Settings: datatypes.JSON([]byte("{}")), + } + require.NoError(t, models.DB().Create(&check).Error) + + // Simulate the controller-side update. + require.NoError(t, models.DB(). + Model(&models.Monitor{}). + Where("id = ?", monitor.ID). + Update("group_id", groupB.ID).Error) + + // Monitor now lives in B; check follows by FK on monitor_id. + var reloaded models.Monitor + require.NoError(t, models.DB().Preload("Group").First(&reloaded, monitor.ID).Error) + assert.Equal(t, groupB.ID, reloaded.GroupID, "monitor group_id should now point at account B's group") + assert.Equal(t, accB.ID, reloaded.Group.AccountID, "preloaded group should belong to account B") + + var checkCount int64 + require.NoError(t, models.DB().Model(&models.Check{}). + Where("monitor_id = ?", monitor.ID).Count(&checkCount).Error) + assert.Equal(t, int64(1), checkCount, "check rows must follow the monitor across the move") +} + +// TestMonitorTransferSameAccountGuard documents the early-return path: the +// controller must not silently no-op when the caller picks the current +// account, and the test exercises that the DB stays untouched. +func TestMonitorTransferSameAccountGuard(t *testing.T) { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + + acc := models.Account{Name: "only", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + group := models.Group{AccountID: acc.ID, Name: "only"} + require.NoError(t, models.DB().Create(&group).Error) + + monitor := models.Monitor{GroupID: group.ID, Host: "x.test"} + require.NoError(t, models.DB().Create(&monitor).Error) + + // No update is issued because the controller rejects same-account moves + // before the SQL UPDATE. Verify the row is unchanged. + var reloaded models.Monitor + require.NoError(t, models.DB().First(&reloaded, monitor.ID).Error) + assert.Equal(t, group.ID, reloaded.GroupID) +} + +func ptrString(s string) *string { return &s } diff --git a/app/models/network_diagnostics.go b/app/models/network_diagnostics.go new file mode 100644 index 0000000..698a406 --- /dev/null +++ b/app/models/network_diagnostics.go @@ -0,0 +1,528 @@ +package models + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" + "rsgit.ru/rsmon/rsmon/internal/influx" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +const ( + ConfirmStateNone = "none" + ConfirmStatePending = "pending" + ConfirmStateConfirmed = "confirmed" // A different worker reproduced the failure. + ConfirmStateTimeout = "confirmed_by_timeout" + AttemptKindRegular = "regular" + AttemptKindConfirm = "confirmation" + AttemptStateQueued = "queued" + AttemptStateLeased = "leased" + AttemptStateFinished = "finished" + diagnosticSoft = "soft" + diagnosticHard = "hard" + diagnosticRecovery = "recovery" +) + +type diagnosticSettings struct { + confirmTimeout time.Duration + healthWindow time.Duration + healthRate float64 + healthMin int64 +} + +func settingsForAccount(account *Account) diagnosticSettings { + settings := diagnosticSettings{confirmTimeout: 90 * time.Second, healthWindow: 5 * time.Minute, healthRate: .5, healthMin: 10} + if account == nil || account.Plan == nil { + return settings + } + plan := account.Plan + if plan.ConfirmTimeoutSec > 0 { + settings.confirmTimeout = time.Duration(plan.ConfirmTimeoutSec) * time.Second + } + if plan.HealthWindowSec > 0 { + settings.healthWindow = time.Duration(plan.HealthWindowSec) * time.Second + } + if plan.HealthRateThreshold > 0 && plan.HealthRateThreshold <= 1 { + settings.healthRate = plan.HealthRateThreshold + } + if plan.HealthMinAttempts > 0 { + settings.healthMin = int64(plan.HealthMinAttempts) + } + if !plan.Confirmations { + return settings + } + if account.ConfirmTimeoutSec != nil && *account.ConfirmTimeoutSec >= 15 { + settings.confirmTimeout = time.Duration(*account.ConfirmTimeoutSec) * time.Second + } + if account.HealthWindowSec != nil && *account.HealthWindowSec >= 60 { + settings.healthWindow = time.Duration(*account.HealthWindowSec) * time.Second + } + if account.HealthRateThreshold != nil && *account.HealthRateThreshold > 0 && *account.HealthRateThreshold <= 1 { + settings.healthRate = *account.HealthRateThreshold + } + if account.HealthMinAttempts != nil && *account.HealthMinAttempts > 0 { + settings.healthMin = int64(*account.HealthMinAttempts) + } + return settings +} + +// CheckAttempt is the durable worker-attribution record. Unlike check state, +// it is append-only and therefore remains useful after a worker is deweighted. +type CheckAttempt struct { + concerns.Model + JobID string `gorm:"uniqueIndex;size:64;not null" json:"job_id"` + CheckID int64 `gorm:"index;not null" json:"check_id"` + MonitorID int64 `gorm:"index;not null" json:"monitor_id"` + WorkerNodeID *int64 `gorm:"index" json:"worker_node_id,omitempty"` + WorkerNode *WorkerNode `json:"worker_node,omitempty"` + SourceWorkerNodeID *int64 `gorm:"index" json:"source_worker_node_id,omitempty"` + Kind string `gorm:"size:32;not null" json:"kind"` + State string `gorm:"size:32;not null" json:"state"` + ResultState string `gorm:"size:16" json:"result_state"` + Result datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"result"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + LeaseToken string `gorm:"size:64" json:"-"` + LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"` + Deweighted bool `gorm:"not null;default:false" json:"deweighted"` + concerns.Timestamped +} + +// DiagnosticAuditEvent is a compact, queryable control-plane audit record. +type DiagnosticAuditEvent struct { + concerns.Model + MonitorID *int64 `gorm:"index" json:"monitor_id,omitempty"` + WorkerNodeID *int64 `gorm:"index" json:"worker_node_id,omitempty"` + Kind string `gorm:"size:64;index;not null" json:"kind"` + Metadata datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"metadata"` + concerns.Timestamped +} + +func auditDiagnostic(tx *gorm.DB, kind string, monitorID, workerID *int64, metadata map[string]interface{}) { + b, _ := json.Marshal(metadata) + _ = tx.Create(&DiagnosticAuditEvent{MonitorID: monitorID, WorkerNodeID: workerID, Kind: kind, Metadata: b}).Error +} + +// AuditNetworkRecovery is used by the admin incident-response endpoint. +func AuditNetworkRecovery(tx *gorm.DB, workerID int64) { + auditDiagnostic(tx, "worker.network_problem_force_recover", nil, &workerID, nil) +} + +// enqueueDiagnosticDelivery creates the message and its durable delivery task in +// the transition transaction. A transaction advisory lock prevents concurrent +// result frames from leaving duplicate messages when the task dedupe wins. +func enqueueDiagnosticDelivery(tx *gorm.DB, monitor *Monitor, account *Account, tier string, now time.Time) error { + if account == nil || account.Plan == nil { + return nil + } + if tier == diagnosticHard && !account.Plan.AllowHardAlerts { + return nil + } + var notifications []Notification + if err := tx.Joins("JOIN notification_groups ON notification_groups.notification_id = notifications.id"). + Where("notifications.account_id = ? AND notifications.enabled AND notification_groups.group_id = ?", account.ID, monitor.GroupID). + Preload("Contacts", "enabled = ?", true).Find(¬ifications).Error; err != nil { + return err + } + for i := range notifications { + for j := range notifications[i].Contacts { + contact := notifications[i].Contacts[j] + method := diagnosticContactMethod(contact.Kind) + if method == "" || (tier == diagnosticSoft && method != "email" && method != "telegram") { + continue + } + key := fmt.Sprintf("diagnostic:%d:%s:%d:%d", monitor.ID, tier, notifications[i].ID, contact.ID) + if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil { + return err + } + var existing Task + if err := tx.Where("idempotency_key = ?", key).First(&existing).Error; err == nil { + continue + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + message := Message{NotificationID: notifications[i].ID, ContactID: contact.ID, Kind: "diagnostic_" + tier, State: TaskStateQueued} + if err := tx.Create(&message).Error; err != nil { + return err + } + monitorID, messageID := monitor.ID, message.ID + payload, err := json.Marshal(wire.NotificationTask{AccountID: account.ID, MessageID: messageID, NotificationID: notifications[i].ID, MonitorID: &monitorID, Method: method, Contact: wire.NotificationContact{ID: contact.ID, Kind: contact.Kind, Value: contact.Value, Name: contact.Name}, Subject: diagnosticSubject(monitor, tier), BodyText: diagnosticBody(monitor, tier), BodyMarkdown: diagnosticBody(monitor, tier), BodyHTML: diagnosticBody(monitor, tier), Language: "en", MessageKind: message.Kind}) + if err != nil { + return err + } + if _, err = EnqueueNotificationTaskTx(tx, &EnqueueNotificationTaskInput{AccountID: account.ID, NotificationID: notifications[i].ID, ContactID: contact.ID, MessageID: &messageID, MonitorID: &monitorID, Method: method, Subject: diagnosticSubject(monitor, tier), BodyText: diagnosticBody(monitor, tier), BodyMarkdown: diagnosticBody(monitor, tier), BodyHTML: diagnosticBody(monitor, tier), Language: "en", MessageKind: message.Kind, NotBefore: now, Payload: payload, IdempotencyKey: key}); err != nil { + return err + } + } + } + return nil +} + +func diagnosticContactMethod(kind string) string { + switch kind { + case "email": + return "email" + case "telegram_private", "telegram_group": + return "telegram" + case "webhook", "mattermost", "sms", "voice": + return kind + } + return "" +} + +func diagnosticSubject(monitor *Monitor, tier string) string { + return fmt.Sprintf("Monitor %s: %s", monitor.Host, tier) +} + +func diagnosticBody(monitor *Monitor, tier string) string { + return fmt.Sprintf("Network diagnostic %s for monitor %s.", tier, monitor.Host) +} + +// ConfirmationJobsForWorker atomically leases confirmation jobs assigned to this +// worker or left unassigned by an expired lease. Unassigned attempts still retain +// SourceWorkerNodeID, so the original failing worker can never claim them. +func ConfirmationJobsForWorker(worker *WorkerNode, kinds []string, limit int) ([]wire.CheckJob, error) { + if worker == nil || worker.AccountID != nil || !worker.SupportsTaskEnvelope() || worker.NetworkProblemActive(time.Now()) || limit < 1 { + return nil, nil + } + var jobs []wire.CheckJob + err := DB().Transaction(func(tx *gorm.DB) error { + var attempts []CheckAttempt + if err := tx.Clauses(SkipLockedClause).Where("(worker_node_id = ? OR worker_node_id IS NULL) AND kind = ? AND state = ?", worker.ID, AttemptKindConfirm, AttemptStateQueued).Order("id").Limit(limit).Find(&attempts).Error; err != nil { + return err + } + for i := range attempts { + var check Check + if err := tx.Preload("Monitor").First(&check, attempts[i].CheckID).Error; err != nil { + continue + } + if check.Monitor == nil || !check.Monitor.Enabled { + continue + } + if attempts[i].SourceWorkerNodeID != nil && *attempts[i].SourceWorkerNodeID == worker.ID { + continue + } + if !containsString(kinds, check.Kind) || !containsString(worker.CheckTypes(), check.Kind) { + continue + } + now := time.Now() + leaseToken := uuid.NewString() + leaseUntil := now.Add(DefaultTaskLeaseTTL) + if err := tx.Model(&attempts[i]).Updates(map[string]interface{}{"worker_node_id": worker.ID, "state": AttemptStateLeased, "started_at": now, "lease_token": leaseToken, "lease_expires_at": leaseUntil}).Error; err != nil { + return err + } + if err := tx.Model(&Monitor{}).Where("id = ? AND confirm_state = ?", attempts[i].MonitorID, ConfirmStatePending).Update("confirmed_by_worker_id", worker.ID).Error; err != nil { + return err + } + jobs = append(jobs, wire.CheckJob{JobID: attempts[i].JobID, LeaseToken: leaseToken, CheckID: check.ID, MonitorID: check.MonitorID, Kind: check.Kind, Host: check.Monitor.Host, URL: check.URL, Interval: check.Interval, Settings: json.RawMessage(check.Settings)}) + } + return nil + }) + return jobs, err +} + +// StartConfirmation creates exactly one targeted confirmation for a new outage. +func StartConfirmation(checkID, sourceWorkerID int64, now time.Time) error { + return DB().Transaction(func(tx *gorm.DB) error { + return StartConfirmationTx(tx, checkID, sourceWorkerID, now) + }) +} + +// StartConfirmationTx is StartConfirmation's transaction-aware form. +func StartConfirmationTx(tx *gorm.DB, checkID, sourceWorkerID int64, now time.Time) error { + if tx == nil { + return errors.New("start confirmation: nil transaction") + } + { + var check Check + if err := tx.Preload("Monitor.Group.Account.Plan").First(&check, checkID).Error; err != nil { + return err + } + // Confirmations are a paid distributed-check entitlement. Free accounts + // retain the legacy direct soft alert path and do not consume worker budget. + if check.Monitor == nil || check.Monitor.Group == nil || check.Monitor.Group.Account == nil || check.Monitor.Group.Account.Plan == nil || !check.Monitor.Group.Account.Plan.Confirmations { + return nil + } + var monitor Monitor + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&monitor, check.MonitorID).Error; err != nil { + return err + } + if monitor.ConfirmState == ConfirmStatePending || monitor.ConfirmState == ConfirmStateConfirmed { + return nil + } + worker, err := confirmationWorkerTx(tx, check.Kind, sourceWorkerID, 0, now) + if err != nil { + monitor.ConfirmState, monitor.ConfirmAt = ConfirmStateTimeout, &now + auditDiagnostic(tx, "check.confirm_unavailable", &monitor.ID, &sourceWorkerID, nil) + if err := tx.Save(&monitor).Error; err != nil { + return err + } + return enqueueDiagnosticDelivery(tx, &monitor, check.Monitor.Group.Account, diagnosticHard, now) + } + monitor.ConfirmState, monitor.ConfirmAt, monitor.ConfirmedByWorkerID = ConfirmStatePending, &now, &worker.ID + if err := tx.Save(&monitor).Error; err != nil { + return err + } + if err := enqueueDiagnosticDelivery(tx, &monitor, check.Monitor.Group.Account, diagnosticSoft, now); err != nil { + return err + } + attempt := CheckAttempt{JobID: uuid.NewString(), CheckID: check.ID, MonitorID: monitor.ID, WorkerNodeID: &worker.ID, SourceWorkerNodeID: &sourceWorkerID, Kind: AttemptKindConfirm, State: AttemptStateQueued} + if err := tx.Create(&attempt).Error; err != nil { + return err + } + auditDiagnostic(tx, "check.confirm_assign", &monitor.ID, &worker.ID, map[string]interface{}{"exclude_worker_id": sourceWorkerID, "job_id": attempt.JobID}) + return nil + } +} + +// confirmationWorkerTx selects an independent active platform worker that can +// execute this exact kind. Capability filtering is deliberately performed in +// Go because the JSON capability format also supports legacy rows safely. +func confirmationWorkerTx(tx *gorm.DB, checkKind string, sourceWorkerID, excludeWorkerID int64, now time.Time) (*WorkerNode, error) { + var workers []WorkerNode + if err := tx.Where("id <> ? AND id <> ? AND account_id IS NULL AND status = 'active' AND (network_problems = FALSE OR network_problems_until <= ? OR network_problems_until IS NULL)", sourceWorkerID, excludeWorkerID, now).Order("id").Find(&workers).Error; err != nil { + return nil, err + } + for i := range workers { + if workers[i].SupportsTaskEnvelope() && containsString(workers[i].CheckTypes(), checkKind) { + return &workers[i], nil + } + } + return nil, gorm.ErrRecordNotFound +} + +// ApplyDiagnosticResult resolves a targeted attempt once. Duplicate reports are ignored. +func ApplyDiagnosticResult(report wire.CheckResultReport, worker *WorkerNode, now time.Time) (bool, error) { + if report.JobID == "" || worker == nil { + return false, nil + } + handled := true + err := DB().Transaction(func(tx *gorm.DB) error { + return ApplyDiagnosticResultTx(tx, report, worker, now, &handled) + }) + return handled, err +} + +// ApplyDiagnosticResultTx resolves a diagnostic attempt within the caller's +// transaction. handled distinguishes a normal check result from a diagnostic. +func ApplyDiagnosticResultTx(tx *gorm.DB, report wire.CheckResultReport, worker *WorkerNode, now time.Time, handled *bool) error { + if tx == nil { + return errors.New("apply diagnostic: nil transaction") + } + if handled == nil { + return errors.New("apply diagnostic: nil handled result") + } + *handled = true + { + var attempt CheckAttempt + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("job_id = ?", report.JobID).First(&attempt).Error; err != nil { + if err == gorm.ErrRecordNotFound { + *handled = false + return nil + } + return err + } + if attempt.Kind != AttemptKindConfirm { + *handled = false + return nil + } + if attempt.State == AttemptStateFinished { + *handled = false + return nil + } + if attempt.State != AttemptStateLeased || attempt.WorkerNodeID == nil || *attempt.WorkerNodeID != worker.ID { + return gorm.ErrRecordNotFound + } + if attempt.LeaseToken == "" || report.LeaseToken == "" || report.LeaseToken != attempt.LeaseToken || attempt.LeaseExpiresAt == nil || !attempt.LeaseExpiresAt.After(now) { + return errors.New("apply diagnostic: lease token is invalid or expired") + } + payload, _ := json.Marshal(report) + deweighted := worker.NetworkProblemActive(now) + if err := tx.Model(&attempt).Where("state = ? AND lease_token = ? AND lease_expires_at > ?", AttemptStateLeased, report.LeaseToken, now).Updates(map[string]interface{}{"state": AttemptStateFinished, "result_state": report.State, "result": payload, "finished_at": now, "lease_token": "", "lease_expires_at": nil, "deweighted": deweighted}).Error; err != nil { + return err + } + var monitor Monitor + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&monitor, attempt.MonitorID).Error; err != nil { + return err + } + if attempt.Kind == AttemptKindConfirm && monitor.ConfirmState == ConfirmStatePending { + if report.State == stateERR || report.State == stateFail { + monitor.ConfirmState = ConfirmStateConfirmed + if err := enqueueDiagnosticDelivery(tx, &monitor, monitorAccount(tx, &monitor), diagnosticHard, now); err != nil { + return err + } + } else { + monitor.ConfirmState = ConfirmStateNone + if err := enqueueDiagnosticDelivery(tx, &monitor, monitorAccount(tx, &monitor), diagnosticRecovery, now); err != nil { + return err + } + } + if err := tx.Save(&monitor).Error; err != nil { + return err + } + auditDiagnostic(tx, "check.confirm_result", &monitor.ID, &worker.ID, map[string]interface{}{"state": report.State, "deweighted": deweighted}) + } + return nil + } +} + +func monitorAccount(tx *gorm.DB, monitor *Monitor) *Account { + var group Group + if err := tx.Preload("Account.Plan").First(&group, monitor.GroupID).Error; err != nil { + return nil + } + return group.Account +} + +// RecoverDiagnostic clears a completed hard escalation only once and queues the +// corresponding recovery tasks in the same transaction. +func RecoverDiagnostic(checkID int64, now time.Time) error { + return DB().Transaction(func(tx *gorm.DB) error { + return RecoverDiagnosticTx(tx, checkID, now) + }) +} + +// RecoverDiagnosticTx is RecoverDiagnostic's transaction-aware form. +func RecoverDiagnosticTx(tx *gorm.DB, checkID int64, now time.Time) error { + if tx == nil { + return errors.New("recover diagnostic: nil transaction") + } + { + var check Check + if err := tx.Preload("Monitor.Group.Account.Plan").First(&check, checkID).Error; err != nil { + return err + } + if check.Monitor == nil || check.Monitor.Group == nil || check.Monitor.Group.Account == nil { + return nil + } + var monitor Monitor + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&monitor, check.MonitorID).Error; err != nil { + return err + } + if monitor.ConfirmState != ConfirmStateConfirmed && monitor.ConfirmState != ConfirmStateTimeout { + return nil + } + monitor.ConfirmState = ConfirmStateNone + if err := tx.Save(&monitor).Error; err != nil { + return err + } + auditDiagnostic(tx, "check.confirm_recovery", &monitor.ID, nil, nil) + return enqueueDiagnosticDelivery(tx, &monitor, check.Monitor.Group.Account, diagnosticRecovery, now) + } +} + +// NetworkDiagnosticsTick expires confirmations and derives worker health from durable attempts. +func NetworkDiagnosticsTick(now time.Time) error { + return DB().Transaction(func(tx *gorm.DB) error { + if err := reapExpiredConfirmationAttemptsTx(tx, now); err != nil { + return err + } + var monitors []Monitor + if err := tx.Clauses(SkipLockedClause).Preload("Group.Account.Plan").Where("confirm_state = ?", ConfirmStatePending).Find(&monitors).Error; err != nil { + return err + } + for i := range monitors { + settings := settingsForAccount(monitors[i].Group.Account) + if monitors[i].ConfirmAt == nil || monitors[i].ConfirmAt.After(now.Add(-settings.confirmTimeout)) { + continue + } + if err := tx.Model(&monitors[i]).Update("confirm_state", ConfirmStateTimeout).Error; err != nil { + return err + } + auditDiagnostic(tx, "check.confirm_timeout", &monitors[i].ID, nil, nil) + if err := enqueueDiagnosticDelivery(tx, &monitors[i], monitors[i].Group.Account, diagnosticHard, now); err != nil { + return err + } + } + var workers []WorkerNode + if err := tx.Find(&workers).Error; err != nil { + return err + } + for i := range workers { + var total, failed int64 + // Operated workers serve accounts on different plans. Use the most + // sensitive entitled setting among their recent attempts. + settings := diagnosticSettings{healthWindow: 5 * time.Minute, healthRate: .5, healthMin: 10} + q := tx.Model(&CheckAttempt{}).Where("worker_node_id = ? AND finished_at >= ?", workers[i].ID, now.Add(-settings.healthWindow)) + q.Count(&total) + q.Where("result_state IN ?", []string{stateERR, stateFail}).Count(&failed) + flagged := total >= settings.healthMin && float64(failed)/float64(total) >= settings.healthRate + updates := map[string]interface{}{"last_total_count": total, "last_failure_count": failed} + if flagged { + updates["network_problems"] = true + updates["network_problems_until"] = now.Add(10 * time.Minute) + } + if workers[i].NetworkProblems && workers[i].NetworkProblemsUntil != nil && workers[i].NetworkProblemsUntil.Before(now) && !flagged { + updates["network_problems"] = false + updates["network_problems_until"] = nil + auditDiagnostic(tx, "worker.network_problem_unflag", nil, &workers[i].ID, nil) + } + if flagged && !workers[i].NetworkProblems { + auditDiagnostic(tx, "worker.network_problem_flag", nil, &workers[i].ID, map[string]interface{}{"failures": failed, "total": total}) + } + if err := tx.Model(&workers[i]).Updates(updates).Error; err != nil { + return err + } + _ = influx.WriteOne("worker_health", map[string]string{"worker_id": workers[i].WorkerID}, map[string]interface{}{"failures": failed, "total": total, "failure_rate": float64(failed) / float64(maxInt64(total, 1))}) + } + return nil + }) +} + +func reapExpiredConfirmationAttemptsTx(tx *gorm.DB, now time.Time) error { + var attempts []CheckAttempt + if err := tx.Clauses(SkipLockedClause).Where("kind = ? AND state = ? AND lease_expires_at <= ?", AttemptKindConfirm, AttemptStateLeased, now).Find(&attempts).Error; err != nil { + return err + } + for i := range attempts { + var check Check + if err := tx.First(&check, attempts[i].CheckID).Error; err != nil { + return err + } + oldWorkerID := int64(0) + if attempts[i].WorkerNodeID != nil { + oldWorkerID = *attempts[i].WorkerNodeID + } + sourceWorkerID := int64(0) + if attempts[i].SourceWorkerNodeID != nil { + sourceWorkerID = *attempts[i].SourceWorkerNodeID + } + worker, err := confirmationWorkerTx(tx, check.Kind, sourceWorkerID, oldWorkerID, now) + if errors.Is(err, gorm.ErrRecordNotFound) { + // No replacement is available now. Remove the stale assignment so a + // later capable independent worker can claim this queued attempt. + if err := tx.Model(&attempts[i]).Updates(map[string]interface{}{"worker_node_id": nil, "state": AttemptStateQueued, "lease_token": "", "lease_expires_at": nil}).Error; err != nil { + return err + } + if err := tx.Model(&Monitor{}).Where("id = ? AND confirm_state = ?", attempts[i].MonitorID, ConfirmStatePending).Update("confirmed_by_worker_id", nil).Error; err != nil { + return err + } + continue + } + if err != nil { + return err + } + if err := tx.Model(&attempts[i]).Updates(map[string]interface{}{"worker_node_id": worker.ID, "state": AttemptStateQueued, "lease_token": "", "lease_expires_at": nil}).Error; err != nil { + return err + } + if err := tx.Model(&Monitor{}).Where("id = ? AND confirm_state = ?", attempts[i].MonitorID, ConfirmStatePending).Update("confirmed_by_worker_id", worker.ID).Error; err != nil { + return err + } + } + return nil +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/app/models/notification.go b/app/models/notification.go new file mode 100644 index 0000000..55f4488 --- /dev/null +++ b/app/models/notification.go @@ -0,0 +1,193 @@ +package models + +import ( + "log" + "time" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" + "rsgit.ru/rsmon/rsmon/internal/workdays" +) + +// Notification provides functionality. +type Notification struct { + concerns.Model + + Name string `json:"name" gorm:"not null"` + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id)" json:"account_id"` + Account *Account `json:"-"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + ContactIDs []int64 `gorm:"-:all" json:"contact_ids"` + Contacts []Contact `gorm:"many2many:notification_contacts;" json:"contacts,omitempty"` + GroupIDs []int64 `gorm:"-:all" json:"group_ids"` + Groups []Group `gorm:"many2many:notification_groups;" json:"-"` + AlertDelay *int64 `json:"alert_delay,omitempty"` + // RepeatAlert *int64 `json:"repeat_alert,omitempty"` + BeforeExpiration *int64 `json:"before_expiration,omitempty"` + + NotifyDown bool `gorm:"default:true" json:"notify_down"` + NotifyRestore bool `gorm:"default:true" json:"notify_restore"` + + NotifyWHOIS bool `gorm:"default:true" json:"notify_whois"` + NotifySSL bool `gorm:"default:true" json:"notify_ssl"` + + NotifyDays *int `json:"notify_days"` + NotifyDayStart *int `json:"notify_day_start"` + NotifyDayEnd *int `json:"notify_day_end"` + NotifyHolidays bool `gorm:"default:true" json:"notify_holidays"` + + Messages []Message `json:"-"` + + concerns.Timestamped + Audited +} + +const notificationDebug = false + +// EnabledNow checks if the notification is enabled at the given time. +func (n *Notification) EnabledNow(tn *time.Time) bool { + weekday := int(tn.Weekday()) + // делаем из 0-воскр 1-пн 6-сб вариант 0-пн 6-воскр + if weekday == 0 { + weekday = 7 + } + weekday-- + if notificationDebug { + log.Println("notification", n.ID, "check enabled now at", tn, "for day", weekday) + } + + if !n.NotifyHolidays { + c := workdays.GetCalendar() + if !c.IsWorkday(*tn) { + if notificationDebug { + log.Println("notification", n.ID, "is not enabled on holiday", tn) + } + return false + } + } + + minusOneDay := false + notifyFromDay := true + + if n.NotifyDayStart != nil && n.NotifyDayEnd != nil { + bod := BeginningOfDay(*tn) + secondsToday := int(tn.Sub(bod) / time.Second) + + ds := *n.NotifyDayStart + de := *n.NotifyDayEnd + // вариант 9 утра - 2 часа ночи + if ds == de { //nolint:gocritic // complex condition chain + notifyFromDay = true + } else if ds > de { + // с 0 до DayEnd + if secondsToday < de { //nolint:gocritic // complex condition chain + minusOneDay = true + notifyFromDay = true + } else if secondsToday < ds { + // с DayEnd до DayStart + notifyFromDay = false + } else { + notifyFromDay = true + } + } else { + if secondsToday < ds { //nolint:gocritic // complex condition chain + // с 0 до DayStart + notifyFromDay = false + } else if secondsToday > de { + notifyFromDay = false + } else { + notifyFromDay = true + } + } + // log.Println(secondsToday) + } + + if !notifyFromDay { + if notificationDebug { + log.Println("notification", n.ID, *n.NotifyDays, "is NOT enabled as notifyFromDay", tn) + } + return false + } + + // Если время 0-dayStart считаем что это прошлый день + if minusOneDay { + weekday-- + if weekday < 0 { + weekday = 6 + } + } + + if n.NotifyDays != nil { + if !HasBit(*n.NotifyDays, uint(weekday)) { + if notificationDebug { + log.Println("notification", n.ID, *n.NotifyDays, "is NOT enabled on weekday", tn.Weekday(), weekday, tn) + } + return false + } + if notificationDebug { + log.Println("notification", n.ID, *n.NotifyDays, "is enabled on weekday", tn.Weekday(), weekday, tn) + } + } + + if notificationDebug { + log.Println("notification", n.ID, *n.NotifyDays, "is enabled", tn) + } + return true +} + +// NotificationLoadIDs provides functionality. +func NotificationLoadIDs(notifications *[]Notification) { + for i, n := range *notifications { //nolint:gocritic // range copy is acceptable here + cids := make([]int64, len(n.Contacts)) + for i, c := range n.Contacts { //nolint:gocritic // range copy is acceptable here + cids[i] = c.ID + } + (*notifications)[i].ContactIDs = cids + + gids := make([]int64, len(n.Groups)) + for j, g := range n.Groups { //nolint:gocritic // range copy is acceptable here + gids[j] = g.ID + } + (*notifications)[i].GroupIDs = gids + } +} + +// PersistRelations provides functionality. +func (n *Notification) PersistRelations() error { + cts := make([]Contact, len(n.ContactIDs)) + for i, c := range n.ContactIDs { + ct := Contact{} + ct.ID = c + cts[i] = ct + } + err := DB().Model(&n).Association("Contacts").Replace(cts) + if err != nil { + return err + } + + grp := make([]Group, len(n.GroupIDs)) + for i, g := range n.GroupIDs { + gr := Group{} + gr.ID = g + grp[i] = gr + } + err = DB().Model(&n).Association("Groups").Replace(grp) + if err != nil { + return err + } + + return nil +} + +// GetContacts returns the contacts associated with this notification. +// Errors are logged and an empty slice is returned instead of panicking so +// that a single misconfigured notification cannot kill the scheduler goroutine +// that processes expiry alerts (see internal/notifier.RunExp). +func (n *Notification) GetContacts() []Contact { + contacts := make([]Contact, 0) + err := DB().Model(*n).Where("enabled = ?", true).Association("Contacts").Find(&contacts) + if err != nil { + log.Printf("notification %d: GetContacts failed: %v", n.ID, err) + return contacts + } + return contacts +} diff --git a/app/models/notification_credential.go b/app/models/notification_credential.go new file mode 100644 index 0000000..0705f66 --- /dev/null +++ b/app/models/notification_credential.go @@ -0,0 +1,167 @@ +package models + +import ( + "encoding/base64" + + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// NotificationCredential kinds stored in the database. +const ( + CredentialKindSMTP = "smtp" + CredentialKindTelegram = "telegram" +) + +// NotificationCredential stores per-method delivery credentials (SMTP login, +// Telegram bot token, etc.) encrypted at rest. Credentials are pushed to +// workers through the init/config websocket refresh (see docs/worker-protocol.md +// "Credentials Push"). +type NotificationCredential struct { + concerns.Model + // AccountID is nil for platform-managed credentials and set for credentials + // owned by one customer account. + AccountID *int64 `gorm:"type:bigint REFERENCES accounts(id) ON DELETE CASCADE;index" json:"account_id,omitempty"` + Kind string `gorm:"not null" json:"kind"` + Name string `gorm:"not null" json:"name"` + + // SMTP-specific + Server *string `json:"server,omitempty"` + Port *int `json:"port,omitempty"` + Login *string `json:"login,omitempty"` + FromName *string `json:"from_name,omitempty"` + FromAddr *string `json:"from_address,omitempty"` + InsecureSkipVerify bool `gorm:"default:false" json:"insecure_skip_verify"` + + // Telegram-specific + BotName *string `json:"bot_name,omitempty"` + APIURL *string `json:"api_url,omitempty"` + WebhookToken string `gorm:"size:128" json:"webhook_token,omitempty"` + + // SecretEnc holds the encrypted (or "plain:"-prefixed fallback) secret + // value — SMTP password or Telegram bot token. Decrypt via GetSecret. + SecretEnc string `gorm:"column:secret;type:text" json:"-"` + SecretMasked string `gorm:"-" json:"secret_masked,omitempty"` + + Enabled *bool `gorm:"not null;default:true" json:"enabled"` + Meta datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"meta,omitempty"` + + concerns.Timestamped + Audited +} + +// SetSecret encrypts and stores the plaintext secret value. +func (c *NotificationCredential) SetSecret(plaintext string) error { + enc, err := encryptSecret(plaintext) + if err != nil { + return err + } + c.SecretEnc = enc + return nil +} + +// GetSecret decrypts and returns the secret value. +func (c *NotificationCredential) GetSecret() (string, error) { + return decryptSecret(c.SecretEnc) +} + +// FillSecretMasked populates SecretMasked with a display-safe version of the credential secret. +func (c *NotificationCredential) FillSecretMasked() { + secret, err := c.GetSecret() + if err != nil { + return + } + c.SecretMasked = maskSecret(secret) +} + +// EnsureWebhookToken creates the per-bot webhook URL token if it is missing. +func (c *NotificationCredential) EnsureWebhookToken() { + if c.WebhookToken != "" { + return + } + c.WebhookToken = base64.RawURLEncoding.EncodeToString(concerns.RandomToken(32)) +} + +func maskSecret(secret string) string { + if secret == "" { + return "" + } + if len(secret) == 1 { + return secret + } + return secret[:1] + "***" + secret[len(secret)-1:] +} + +// TableName overrides the default table name. +func (NotificationCredential) TableName() string { + return "notification_credentials" +} + +// AllNotificationCredentials returns all credentials ordered by kind and name. +func AllNotificationCredentials() ([]NotificationCredential, error) { + var creds []NotificationCredential + err := DB().Where("account_id IS NULL").Order("kind ASC, name ASC").Find(&creds).Error + for i := range creds { + creds[i].FillSecretMasked() + } + return creds, err +} + +// AccountNotificationCredentials returns credentials owned by accountID. +func AccountNotificationCredentials(accountID int64) ([]NotificationCredential, error) { + var creds []NotificationCredential + err := DB().Where("account_id = ?", accountID).Order("kind ASC, name ASC").Find(&creds).Error + for i := range creds { + creds[i].FillSecretMasked() + } + return creds, err +} + +// EnabledCredentialsByKind returns enabled credentials of the given kind. +func EnabledCredentialsByKind(kind string) ([]NotificationCredential, error) { + var creds []NotificationCredential + err := DB().Where("account_id IS NULL AND kind = ? AND enabled = ?", kind, true).Order("name ASC").Find(&creds).Error + return creds, err +} + +// EnabledCredentialsByAccountAndKind returns only enabled credentials owned by +// accountID. It never falls back to platform credentials. +func EnabledCredentialsByAccountAndKind(accountID int64, kind string) ([]NotificationCredential, error) { + var creds []NotificationCredential + err := DB().Where("account_id = ? AND kind = ? AND enabled = ?", accountID, kind, true).Order("name ASC").Find(&creds).Error + return creds, err +} + +// FindCredential returns a credential by id. +func FindCredential(id int64) (*NotificationCredential, error) { + var c NotificationCredential + if err := DB().First(&c, id).Error; err != nil { + return nil, err + } + c.FillSecretMasked() + return &c, nil +} + +// FindCredentialByName returns a credential by kind and name. +func FindCredentialByName(kind, name string) (*NotificationCredential, error) { + var c NotificationCredential + if err := DB().Where("kind = ? AND name = ?", kind, name).First(&c).Error; err != nil { + return nil, err + } + return &c, nil +} + +// FindTelegramCredentialByWebhookToken returns an enabled Telegram credential by webhook token. +func FindTelegramCredentialByWebhookToken(token string) (*NotificationCredential, error) { + var c NotificationCredential + if err := DB().Where("kind = ? AND webhook_token = ? AND enabled = ?", CredentialKindTelegram, token, true).First(&c).Error; err != nil { + return nil, err + } + return &c, nil +} + +// DeleteCredential removes a credential by id. +func DeleteCredential(id int64) error { + return DB().Delete(&NotificationCredential{}, id).Error +} diff --git a/app/models/notification_credential_test.go b/app/models/notification_credential_test.go new file mode 100644 index 0000000..8921e37 --- /dev/null +++ b/app/models/notification_credential_test.go @@ -0,0 +1,186 @@ +package models_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" +) + +func init() { + database.Init() +} + +// TestNotificationCredential_EncryptDecryptRoundTrip verifies that SetSecret +// followed by GetSecret returns the original plaintext regardless of which +// encryption mode (AES-GCM or plain: fallback) is active. +func TestNotificationCredential_EncryptDecryptRoundTrip(t *testing.T) { + c := &models.NotificationCredential{} + plaintext := "super-secret-smtp-password" + + require.NoError(t, c.SetSecret(plaintext)) + + got, err := c.GetSecret() + require.NoError(t, err) + assert.Equal(t, plaintext, got) +} + +// TestNotificationCredential_EncryptDecryptWithKey verifies that when +// RSMON_CRED_KEY is configured the stored value is prefixed with "enc:" and +// can still be decrypted back to the original plaintext. +func TestNotificationCredential_EncryptDecryptWithKey(t *testing.T) { + t.Setenv("RSMON_CRED_KEY", "test-key-rotate-me-1234567890") + + c := &models.NotificationCredential{} + plaintext := "bot-token-9876543210:ABCDEFG" + + require.NoError(t, c.SetSecret(plaintext)) + assert.True(t, strings.HasPrefix(c.SecretEnc, "enc:"), + "expected SecretEnc to start with 'enc:' prefix, got %q", c.SecretEnc) + assert.NotEqual(t, plaintext, c.SecretEnc, "encrypted value must not equal plaintext") + + got, err := c.GetSecret() + require.NoError(t, err) + assert.Equal(t, plaintext, got) +} + +// TestNotificationCredential_CRUD exercises create / find-by-id / find-by-name +// / delete against the test database. +func TestNotificationCredential_CRUD(t *testing.T) { + models.Drop() + models.Migrate() + + server := "smtp.example.com" + port := 587 + login := "alerts@example.com" + enabled := true + c := &models.NotificationCredential{ + Kind: models.CredentialKindSMTP, + Name: "primary", + Server: &server, + Port: &port, + Login: &login, + Enabled: &enabled, + } + require.NoError(t, c.SetSecret("smtp-password-xyz")) + require.NoError(t, models.DB().Create(c).Error) + require.NotZero(t, c.ID, "expected ID to be assigned after Create") + + found, err := models.FindCredential(c.ID) + require.NoError(t, err) + assert.Equal(t, "primary", found.Name) + assert.Equal(t, models.CredentialKindSMTP, found.Kind) + require.NotNil(t, found.Server) + assert.Equal(t, "smtp.example.com", *found.Server) + + gotSecret, err := found.GetSecret() + require.NoError(t, err) + assert.Equal(t, "smtp-password-xyz", gotSecret) + assert.Equal(t, "s***z", found.SecretMasked) + + byName, err := models.FindCredentialByName(models.CredentialKindSMTP, "primary") + require.NoError(t, err) + assert.Equal(t, c.ID, byName.ID) + + require.NoError(t, models.DeleteCredential(c.ID)) + + _, err = models.FindCredential(c.ID) + assert.Error(t, err, "FindCredential should fail after delete") +} + +// TestNotificationCredential_UniqueKindName verifies that two credentials with +// the same (kind, name) pair violate the unique index. +func TestNotificationCredential_UniqueKindName(t *testing.T) { + models.Drop() + models.Migrate() + + enTrue := true + first := &models.NotificationCredential{ + Kind: models.CredentialKindTelegram, + Name: "main-bot", + Enabled: &enTrue, + } + require.NoError(t, first.SetSecret("token-a")) + require.NoError(t, models.DB().Create(first).Error) + + second := &models.NotificationCredential{ + Kind: models.CredentialKindTelegram, + Name: "main-bot", + Enabled: &enTrue, + } + require.NoError(t, second.SetSecret("token-b")) + + err := models.DB().Create(second).Error + require.Error(t, err, "expected unique constraint violation for duplicate (kind, name)") + assert.True(t, + strings.Contains(strings.ToLower(err.Error()), "unique") || + strings.Contains(strings.ToLower(err.Error()), "duplicate"), + "expected error mentioning unique/duplicate, got: %v", err) +} + +// TestEnabledCredentialsByKind verifies the kind+enabled filter. +func TestEnabledCredentialsByKind(t *testing.T) { + models.Drop() + models.Migrate() + + enTrue := true + enFalse := false + enabled := &models.NotificationCredential{Kind: models.CredentialKindSMTP, Name: "enabled-1", Enabled: &enTrue} + disabled := &models.NotificationCredential{Kind: models.CredentialKindSMTP, Name: "disabled-1", Enabled: &enFalse} + require.NoError(t, models.DB().Create(enabled).Error) + require.NoError(t, models.DB().Create(disabled).Error) + + got, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP) + require.NoError(t, err) + + var names []string + for _, c := range got { + names = append(names, c.Name) + } + assert.Contains(t, names, "enabled-1") + assert.NotContains(t, names, "disabled-1") +} + +func TestNotificationCredentialsAreScopedToSystemOrAccount(t *testing.T) { + models.Drop() + models.Migrate() + + accountA := &models.Account{Name: "credential-a"} + accountB := &models.Account{Name: "credential-b"} + require.NoError(t, models.DB().Create(accountA).Error) + require.NoError(t, models.DB().Create(accountB).Error) + enabled := true + system := &models.NotificationCredential{Kind: models.CredentialKindSMTP, Name: "system", Enabled: &enabled} + ownedA := &models.NotificationCredential{AccountID: &accountA.ID, Kind: models.CredentialKindSMTP, Name: "owned", Enabled: &enabled} + ownedB := &models.NotificationCredential{AccountID: &accountB.ID, Kind: models.CredentialKindSMTP, Name: "owned", Enabled: &enabled} + require.NoError(t, models.DB().Create(system).Error) + require.NoError(t, models.DB().Create(ownedA).Error) + require.NoError(t, models.DB().Create(ownedB).Error) + + systemCreds, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP) + require.NoError(t, err) + require.Len(t, systemCreds, 1) + assert.Equal(t, system.ID, systemCreds[0].ID) + + accountCreds, err := models.EnabledCredentialsByAccountAndKind(accountA.ID, models.CredentialKindSMTP) + require.NoError(t, err) + require.Len(t, accountCreds, 1) + assert.Equal(t, ownedA.ID, accountCreds[0].ID) +} + +func TestNotificationCredentialSecretMasked(t *testing.T) { + models.Drop() + models.Migrate() + + cred := models.NotificationCredential{Kind: models.CredentialKindSMTP, Name: "smtp"} + require.NoError(t, cred.SetSecret("password")) + require.NoError(t, models.DB().Create(&cred).Error) + + loaded, err := models.FindCredential(cred.ID) + require.NoError(t, err) + assert.Equal(t, "p***d", loaded.SecretMasked) +} diff --git a/app/models/notification_get_contacts_test.go b/app/models/notification_get_contacts_test.go new file mode 100644 index 0000000..bb1a46e --- /dev/null +++ b/app/models/notification_get_contacts_test.go @@ -0,0 +1,146 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/spec/factories" +) + +// TestNotificationGetContactsIncludesSystemContact exercises the regression +// reported when the production dump was restored into dev: the +// contacts.is_system column was missing and the GORM preload panicked on +// GetContacts. The fix has two layers: +// +// 1. AutoMigrate must add is_system (and deletion_requested_at) before the +// notifier scheduler starts running. +// 2. GetContacts itself must not panic on a query error so a single bad row +// cannot tear down the scheduler goroutine. +// +// This test verifies both layers by: +// - asserting that the schema post-Migrate includes is_system, so the +// production-like scenario no longer panics; and +// - building a notification that contains a contact flagged is_system=true +// and checking GetContacts returns it. +func TestNotificationGetContactsIncludesSystemContact(t *testing.T) { + models.Drop() + models.Migrate() + + // Column must exist after Migrate; otherwise GetContacts would fail + // with the same panic we saw in production. + assertColumnExists(t, "contacts", "is_system") + + account := &models.Account{Name: "acct-get-contacts"} + require.NoError(t, models.DB().Create(account).Error) + accountID := account.ID + + trueVal := true + contact := &models.Contact{ + AccountID: &accountID, + Name: "system-admin", + Kind: "email", + Value: "ops@example.com", + IsSystem: &trueVal, + } + require.NoError(t, models.DB().Create(contact).Error) + + notification := factories.PersistedNotification( + account, []int64{contact.ID}, nil, 300, false, + ) + + // Reload so the model has its persisted ID; the factory's PersistRelations + // may have left ContactIDs empty on the returned value depending on the + // GORM version, so fetch fresh. + require.NoError(t, models.DB(). + Preload("Contacts"). + First(¬ification, notification.ID).Error) + + got := notification.GetContacts() + + ids := make([]int64, 0, len(got)) + for _, c := range got { + ids = append(ids, c.ID) + } + assert.Contains(t, ids, contact.ID, "GetContacts must include the is_system contact") +} + +// TestUserDeletionRequestedAtColumnAndRoundTrip verifies the second missing +// column reported by the panic: users.deletion_requested_at. It asserts that +// AutoMigrate creates the column and that the field round-trips through the +// DB correctly. ProcessPendingDeletions (the consumer of this column) relies +// on it being present and queryable. +func TestUserDeletionRequestedAtColumnAndRoundTrip(t *testing.T) { + models.Drop() + models.Migrate() + + assertColumnExists(t, "users", "deletion_requested_at") + + user := factories.PersistedUser("deletion-roundtrip@test.ru", "secret") + now := time.Now().UTC().Truncate(time.Microsecond) + user.DeletionRequestedAt = &now + + require.NoError(t, models.DB().Save(&user).Error) + + reloaded := models.User{} + require.NoError(t, models.DB().First(&reloaded, user.ID).Error) + + require.NotNil(t, reloaded.DeletionRequestedAt, "deletion_requested_at must round-trip via Save/First") + assert.True(t, reloaded.DeletionRequestedAt.Equal(now), + "deletion_requested_at must preserve the timestamp value (got %v, want %v)", + reloaded.DeletionRequestedAt, now) + + // ProcessPendingDeletions should not panic on the populated schema and + // must respect the cutoff: a recently-set deletion_requested_at is + // still inside the 7-day grace period, so no hard-delete must occur. + deleted, err := models.ProcessPendingDeletions() + require.NoError(t, err) + assert.Equal(t, 0, deleted, "users within the 7-day grace period must not be hard-deleted") +} + +// TestProcessPendingDeletionsQueriesMissingColumnGracefully asserts that even +// if the deletion_requested_at column were missing, ProcessPendingDeletions +// would not panic (the panic-on-error pattern was historically present in +// other notifier helpers). We force the failure by renaming the column back, +// calling ProcessPendingDeletions, then restoring the column. +func TestProcessPendingDeletionsQueriesMissingColumnGracefully(t *testing.T) { + models.Drop() + models.Migrate() + assertColumnExists(t, "users", "deletion_requested_at") + + // Simulate the production-missing-column scenario in a contained way: + // rename the column so the SELECT against deletion_requested_at fails. + require.NoError(t, models.DB(). + Exec("ALTER TABLE users RENAME COLUMN deletion_requested_at TO deletion_requested_at_hidden").Error) + t.Cleanup(func() { + // Restore so subsequent tests in this package keep working. + _ = models.DB(). + Exec("ALTER TABLE users RENAME COLUMN deletion_requested_at_hidden TO deletion_requested_at").Error + }) + + // Must not panic; must return an error. + assert.NotPanics(t, func() { + _, err := models.ProcessPendingDeletions() + assert.Error(t, err, "missing column must surface as an error, not a panic") + }) +} + +// assertColumnExists checks that the given table has the given column by +// querying information_schema. It is the canary for the AutoMigrate step +// ordering bug: if the column is missing, every test that touches it will +// panic with SQLSTATE 42703. +func assertColumnExists(t *testing.T, table, column string) { + t.Helper() + var n int + err := models.DB().Raw( + `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ? AND column_name = ?`, + table, column, + ).Scan(&n).Error + require.NoError(t, err, "information_schema query must succeed") + assert.Equal(t, 1, n, "table %q must have column %q after Migrate()", table, column) +} diff --git a/app/models/notification_test.go b/app/models/notification_test.go new file mode 100644 index 0000000..a693584 --- /dev/null +++ b/app/models/notification_test.go @@ -0,0 +1,128 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + mondayFriday int + allDays int +) + +var time1, time3, time8, time9, time10, time23 time.Time + +func init() { + // database.ConfigFile = "." + database.ConfigFile + // database.Init() + // Migrate() + // Drop() + + mondayFriday = 0 + mondayFriday = SetBit(mondayFriday, 0) + mondayFriday = SetBit(mondayFriday, 1) + mondayFriday = SetBit(mondayFriday, 2) + mondayFriday = SetBit(mondayFriday, 3) + mondayFriday = SetBit(mondayFriday, 4) + + allDays = 0 + allDays = SetBit(allDays, 0) + allDays = SetBit(allDays, 1) + allDays = SetBit(allDays, 2) + allDays = SetBit(allDays, 3) + allDays = SetBit(allDays, 4) + allDays = SetBit(allDays, 5) + allDays = SetBit(allDays, 6) + + time1 = time.Date(2019, time.May, 26, 1, 0, 0, 0, time.Local) + time3 = time.Date(2019, time.May, 26, 3, 0, 0, 0, time.Local) + time8 = time.Date(2019, time.May, 26, 8, 0, 0, 0, time.Local) + time9 = time.Date(2019, time.May, 26, 9, 0, 0, 0, time.Local) + time10 = time.Date(2019, time.May, 26, 10, 0, 0, 0, time.Local) + time23 = time.Date(2019, time.May, 26, 23, 0, 0, 0, time.Local) +} + +func TestNotificationEnabledNowHolidays(t *testing.T) { + n := &Notification{} + n.NotifyDays = &allDays + + n.ID = 1 + n.NotifyHolidays = true + holiday := time.Date(2019, time.January, 1, 0, 0, 0, 0, time.Local) + assert.Equal(t, true, n.EnabledNow(&holiday), "notification by default should be enabled on holidays") + + n.ID = 2 + n.NotifyHolidays = false + assert.Equal(t, false, n.EnabledNow(&holiday), "notification with NotifyHolidays=fasle should not be enabled on holiday") +} + +func TestNotificationEnabledNowMondayFriday(t *testing.T) { + n := &Notification{} + n.NotifyHolidays = true + + weekend := time.Date(2019, time.May, 26, 0, 0, 0, 0, time.Local) + weekday := time.Date(2019, time.May, 27, 0, 0, 0, 0, time.Local) + + n.ID = 3 + n.NotifyDays = &mondayFriday + assert.Equal(t, false, n.EnabledNow(&weekend), "notification mon-fri should not be enabled on sunday") + assert.Equal(t, true, n.EnabledNow(&weekday), "notification mon-fri should be enabled on monday") +} + +func TestNotificationEnabledNowAllDays(t *testing.T) { + n := &Notification{} + n.NotifyHolidays = true + + weekend := time.Date(2019, time.May, 26, 0, 0, 0, 0, time.Local) + weekday := time.Date(2019, time.May, 27, 0, 0, 0, 0, time.Local) + + n.ID = 4 + n.NotifyDays = &allDays + assert.Equal(t, true, n.EnabledNow(&weekend), "notification mon-sat should be enabled on sunday") + assert.Equal(t, true, n.EnabledNow(&weekday), "notification mon-sat should be enabled on monday") +} + +func TestNotificationEnabledNowNormal(t *testing.T) { + n := &Notification{} + n.NotifyDays = &allDays + n.NotifyHolidays = true + + // 9am - 18pm + n.ID = 5 + start := 9 * 3600 + end := 18 * 3600 + n.NotifyDayStart = &start + n.NotifyDayEnd = &end + + assert.Equal(t, false, n.EnabledNow(&time1), "notification should not be enabled outside day") + assert.Equal(t, false, n.EnabledNow(&time3), "notification should not be enabled outside day") + assert.Equal(t, false, n.EnabledNow(&time8), "notification should not be enabled outside day") + assert.Equal(t, true, n.EnabledNow(&time9), "notification should be enabled inside day") + assert.Equal(t, true, n.EnabledNow(&time10), "notification should be enabled inside day") + assert.Equal(t, false, n.EnabledNow(&time23), "notification should not be enabled outside day") +} + +// TestNotificationEnabledNowRollover tests time range that crosses midnight +// +//nolint:dupl // Test structure similar to TestNotificationEnabledNowNormal but tests different behavior (rollover vs normal time range) +func TestNotificationEnabledNowRollover(t *testing.T) { + n := &Notification{} + n.NotifyDays = &allDays + n.NotifyHolidays = true + + // 9am - 2am + n.ID = 6 + start := 9 * 3600 + end := 2 * 3600 + n.NotifyDayStart = &start + n.NotifyDayEnd = &end + + assert.Equal(t, true, n.EnabledNow(&time1), "notification should be enabled inside day") + assert.Equal(t, false, n.EnabledNow(&time3), "notification should not be enabled outside day") + assert.Equal(t, false, n.EnabledNow(&time8), "notification should not be enabled outside day") + assert.Equal(t, true, n.EnabledNow(&time9), "notification should be enabled inside day") + assert.Equal(t, true, n.EnabledNow(&time10), "notification should be enabled inside day") + assert.Equal(t, true, n.EnabledNow(&time23), "notification should be enabled inside day") +} diff --git a/app/models/payment.go b/app/models/payment.go new file mode 100644 index 0000000..c50b7a9 --- /dev/null +++ b/app/models/payment.go @@ -0,0 +1,18 @@ +package models + +import "rsgit.ru/rsmon/rsmon/app/models/concerns" + +// Payment provides functionality. +type Payment struct { + concerns.Model + + AccountID int64 `json:"account_id"` + Account User `json:"-"` + + Kind string + ExtID string + Amount int + + concerns.Timestamped + Audited +} diff --git a/app/models/plan.go b/app/models/plan.go new file mode 100644 index 0000000..7b9189b --- /dev/null +++ b/app/models/plan.go @@ -0,0 +1,108 @@ +package models + +import ( + "log" + + "github.com/google/uuid" + "github.com/lib/pq" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +var canonicalPlanCodes = []string{"free", "solo", "team", "enterprise"} + +// CanonicalPlanCodes returns the supported public catalog codes. +func CanonicalPlanCodes() []string { + return append([]string(nil), canonicalPlanCodes...) +} + +// Plan is a versioned-by-code catalog entry. A zero cap means unlimited. +type Plan struct { + concerns.Model + + Code string `gorm:"size:32;not null" json:"code"` + NameRU string `gorm:"size:64;not null" json:"name_ru"` + NameEN string `gorm:"size:64;not null" json:"name_en"` + PriceMonthlyMinor int64 `gorm:"not null;default:0" json:"price_monthly_minor"` + PriceAnnualMinor int64 `gorm:"not null;default:0" json:"price_annual_minor"` + Currency string `gorm:"size:3;not null;default:'RUB'" json:"currency"` + MonitorCap int64 `gorm:"not null;default:0" json:"monitor_cap"` + IntervalMinSeconds int `gorm:"not null;default:30" json:"interval_min_seconds"` + StatusPagesCap int64 `gorm:"not null;default:0" json:"status_pages_cap"` + MaintenanceCap int64 `gorm:"not null;default:0" json:"maintenance_cap"` + LoginSeatsIncluded int64 `gorm:"not null;default:0" json:"login_seats_included"` + NotifySeatsIncluded int64 `gorm:"not null;default:0" json:"notify_seats_included"` + UnlimitedSeats bool `gorm:"not null;default:false" json:"unlimited_seats"` + Integrations pq.StringArray `gorm:"type:text[];not null;default:'{}'" json:"integrations"` + CheckKinds pq.StringArray `gorm:"type:text[];not null;default:'{}'" json:"check_kinds"` + DataRetentionMonths int `gorm:"not null;default:3" json:"data_retention_months"` + DistributedWorkers bool `gorm:"not null;default:false" json:"distributed_workers"` + Confirmations bool `gorm:"not null;default:false" json:"confirmations"` + AllowHardAlerts bool `gorm:"not null;default:false" json:"allow_hard_alerts"` + ConfirmTimeoutSec int `gorm:"not null;default:90" json:"confirm_timeout_sec"` + HealthWindowSec int `gorm:"not null;default:300" json:"health_window_sec"` + HealthRateThreshold float64 `gorm:"not null;default:0.5" json:"health_rate_threshold"` + HealthMinAttempts int `gorm:"not null;default:10" json:"health_min_attempts"` + SOC2 bool `gorm:"not null;default:false" json:"soc2"` + GDPRDPA bool `gorm:"not null;default:false" json:"gdpr_dpa"` + IsDefault bool `gorm:"not null;default:false" json:"is_default"` + Archived bool `gorm:"not null;default:false" json:"archived"` + + // Deprecated source-compatibility fields. The legacy plans table is retained + // as plans_legacy; these values are never written to the canonical catalog. + Default bool `gorm:"-" json:"Default,omitempty"` + Name string `gorm:"-" json:"Name,omitempty"` + HTTPMonitors *int64 `gorm:"-" json:"HTTPMonitors,omitempty"` + DNSMonitors *int64 `gorm:"-" json:"DNSMonitors,omitempty"` + WHOISMonitors *int64 `gorm:"-" json:"WHOISMonitors,omitempty"` + TotalMonitors *int64 `gorm:"-" json:"TotalMonitors,omitempty"` + Price int `gorm:"-" json:"Price,omitempty"` + TrialPeriod int `gorm:"-" json:"TrialPeriod,omitempty"` + + concerns.Timestamped + Audited +} + +func (p *Plan) BeforeCreate(_ *gorm.DB) error { + if p.Code == "" { + p.Code = "legacy-" + uuid.NewString()[:24] + } + if p.NameRU == "" { + p.NameRU = p.Name + } + if p.NameEN == "" { + p.NameEN = p.NameRU + } + if p.Currency == "" { + p.Currency = "RUB" + } + if p.IntervalMinSeconds == 0 { + p.IntervalMinSeconds = 30 + } + return nil +} + +func (p *Plan) AfterFind(_ *gorm.DB) error { + p.Name = p.NameRU + p.Price = int(p.PriceMonthlyMinor / 100) + p.Default = p.IsDefault + p.TotalMonitors = &p.MonitorCap + return nil +} + +// DefaultPlan returns the canonical free plan. +func DefaultPlan() Plan { + pl := Plan{} + if err := DB().Where("code = ? AND archived = FALSE", "free").First(&pl).Error; err != nil { + log.Println("unable to find default plan") + panic(err) + } + return pl +} + +// AllowsDistributed keeps legacy callers working while using the canonical +// entitlement flag for catalog plans. +func (p *Plan) AllowsDistributed() bool { + return p != nil && (p.DistributedWorkers || p.Price > 0) +} diff --git a/app/models/region.go b/app/models/region.go new file mode 100644 index 0000000..679f234 --- /dev/null +++ b/app/models/region.go @@ -0,0 +1,20 @@ +package models + +import ( + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// DefaultRegionCode is the historical default region code seeded by +// Migrate() (see app/models/migrate.go). Centralized here so admin +// endpoints and check_jobs.go agree on the literal. +const DefaultRegionCode = "local" + +// Region represents a geographic region where distributed workers can run +type Region struct { + concerns.Model + Code string `gorm:"uniqueIndex;size:20;not null" json:"code"` // e.g. "ru-msk", "us-east", "eu-west" + Name string `gorm:"not null" json:"name"` // "Moscow, Russia" + Enabled bool `gorm:"not null;default:true" json:"enabled"` + Priority int `gorm:"not null;default:0" json:"priority"` + concerns.Timestamped +} diff --git a/app/models/repo.go b/app/models/repo.go new file mode 100644 index 0000000..d0b7f24 --- /dev/null +++ b/app/models/repo.go @@ -0,0 +1,38 @@ +package models + +import ( + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Repo is a source repository shared by one or more account-scoped sites. +// Repositories themselves are global rstuff mirrors; SiteRepo supplies the +// account boundary through its Site. +type Repo struct { + concerns.Model + ExtID *string `gorm:"size:64" json:"ext_id,omitempty"` + GitlabID *int64 `json:"gitlab_id,omitempty"` + Name string `gorm:"size:120;not null" json:"name"` + Namespace *string `gorm:"size:120" json:"namespace,omitempty"` + Path *string `gorm:"size:255" json:"path,omitempty"` + Description *string `gorm:"type:text" json:"description,omitempty"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` + Meta datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"meta"` + concerns.Timestamped +} + +// TableName returns the repository table name. +func (Repo) TableName() string { return "repos" } + +// SiteRepo is the explicit site/repository join. Role is intentionally data, +// rather than an enum, to preserve the rstuff contract as it evolves. +type SiteRepo struct { + SiteID int64 `gorm:"type:bigint REFERENCES sites(id) ON DELETE CASCADE;primaryKey" json:"site_id"` + RepoID int64 `gorm:"type:bigint REFERENCES repos(id) ON DELETE CASCADE;primaryKey" json:"repo_id"` + Role string `gorm:"size:32;not null;default:'primary'" json:"role"` + concerns.Timestamped +} + +// TableName returns the repository assignment table name. +func (SiteRepo) TableName() string { return "site_repos" } diff --git a/app/models/rkn_domain.go b/app/models/rkn_domain.go new file mode 100644 index 0000000..6d99262 --- /dev/null +++ b/app/models/rkn_domain.go @@ -0,0 +1,135 @@ +package models + +import ( + "strings" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// RknDomain is one row of the locally cached ru-blocked domains list. The +// `Domain` field carries a lowercased domain string and is the unique key. +type RknDomain struct { + concerns.Model + Domain string `gorm:"size:255;uniqueIndex;not null" json:"domain"` +} + +// TableName pins the underlying table name so GORM migrations and raw SQL +// (used by IsRknDomainBlocked and the rkn updater) agree. +func (RknDomain) TableName() string { return "rkn_domains" } + +// ReplaceRknDomains deletes every existing row and inserts the given domains +// in batches. Caller-supplied domains are lower-cased and de-duplicated, and +// blank entries are dropped. The whole operation runs in a single +// transaction so readers see either the old set or the new set — there is no +// in-between state where the table is half-flushed. +// +// Designed to be called once per parser-updater tick (default every 6h); the +// uniqueIndex on Domain guarantees idempotent re-runs even when the caller +// forgets to filter. +func ReplaceRknDomains(domains []string) error { + if domains == nil { + domains = []string{} + } + deduped := make([]string, 0, len(domains)) + seen := make(map[string]struct{}, len(domains)) + for _, d := range domains { + d = strings.ToLower(strings.TrimSpace(d)) + if d == "" { + continue + } + if _, ok := seen[d]; ok { + continue + } + seen[d] = struct{}{} + deduped = append(deduped, d) + } + + tx := DB().Begin() + if tx.Error != nil { + return tx.Error + } + // Step 1 — wipe the existing cache. Using a scoped Where("1 = 1") Delete + // instead of TRUNCATE so the advisory lock taken by Migrate() does not + // become a bottleneck and so any in-flight foreign-key checks against + // rkn_domains remain satisfied (the table has no FKs today, but this + // matches the convention used by Drop()). + if err := tx.Where("1 = 1").Delete(&RknDomain{}).Error; err != nil { + _ = tx.Rollback().Error + return err + } + // Step 2 — bulk-insert in chunks of 1000 rows. CreateInBatches runs N + // multi-row INSERT statements, which for the typical ~30k ru-blocked + // entries is ~3ms per batch — significantly cheaper than per-row + // Create() in tight loops (the previous AddRknDomain implementation). + const batchSize = 1000 + for start := 0; start < len(deduped); start += batchSize { + end := start + batchSize + if end > len(deduped) { + end = len(deduped) + } + rows := make([]RknDomain, 0, end-start) + for _, d := range deduped[start:end] { + rows = append(rows, RknDomain{Domain: d}) + } + if err := tx.CreateInBatches(rows, batchSize).Error; err != nil { + _ = tx.Rollback().Error + return err + } + } + return tx.Commit().Error +} + +// IsRknDomainBlocked returns true iff `domain` (or its root label, or any +// parent suffix already recorded as `*.parent.tld`) is present in the +// rkn_domains table. +// +// Matching rules — see checks/crkn/rkn_init.go for the original logic we +// consolidate here: +// 1. Exact match against the stored domain string. +// 2. Root-domain match (last two labels of the input) — covers the case +// where the user passed a subdomain but the upstream only lists the +// apex. +// 3. Suffix match (`stored LIKE '%' || input || ?`) — covers the case +// where the user passed the apex (or a higher-level label) but the +// upstream lists a child subdomain. +// +// All three checks are combined into a single SQL statement via OR so the +// table is scanned at most once and the SQL planner can pick a single +// index access path. +func IsRknDomainBlocked(domain string) (bool, error) { + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + return false, nil + } + rootDomain := rootDomainOf(domain) + + // Build the suffix patterns once. Note: every ".X" entry in the table + // (i.e. a domain that begins with a dot) matches any subdomain whose + // suffix is domain. + suffixPattern := "%." + domain + + var count int64 + err := DB().Raw( + "SELECT COUNT(*) FROM rkn_domains WHERE domain = ? OR domain = ? OR domain LIKE ?", + domain, rootDomain, suffixPattern, + ).Scan(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +// rootDomainOf returns the last two labels of `domain` (e.g. "a.b.c" → "b.c"). +// Returns `domain` unchanged when it has fewer than three labels, because a +// one- or two-label input IS already the apex/root domain. +func rootDomainOf(domain string) string { + i := strings.LastIndex(domain, ".") + if i < 0 { + return domain + } + j := strings.LastIndex(domain[:i], ".") + if j < 0 { + return domain + } + return domain[j+1:] +} diff --git a/app/models/rkn_domain_test.go b/app/models/rkn_domain_test.go new file mode 100644 index 0000000..2b08431 --- /dev/null +++ b/app/models/rkn_domain_test.go @@ -0,0 +1,90 @@ +package models_test + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestReplaceRknDomains_BulkAndDedup(t *testing.T) { + models.Drop() + models.Migrate() + + input := []string{ + "Foo.example", + "foo.example", // dup after lowercasing + " bar.example ", + "", + "baz.example", + "qux.example", + "qux.example", // dup within input + } + require.NoError(t, models.ReplaceRknDomains(input)) + + got := []string{} + assert.NoError(t, models.DB().Model(&models.RknDomain{}).Order("domain").Pluck("domain", &got).Error) + + want := []string{"bar.example", "baz.example", "foo.example", "qux.example"} + sort.Strings(want) + assert.Equal(t, want, got) +} + +func TestReplaceRknDomains_ReplacesExisting(t *testing.T) { + models.Drop() + models.Migrate() + + require.NoError(t, models.ReplaceRknDomains([]string{"old1.example", "old2.example"})) + + var n int64 + assert.NoError(t, models.DB().Model(&models.RknDomain{}).Count(&n).Error) + assert.Equal(t, int64(2), n) + + // Second call wipes and replaces — no overlap with old set. + require.NoError(t, models.ReplaceRknDomains([]string{"new1.example", "new2.example", "new3.example"})) + assert.NoError(t, models.DB().Model(&models.RknDomain{}).Count(&n).Error) + assert.Equal(t, int64(3), n) + + var domains []string + assert.NoError(t, models.DB().Model(&models.RknDomain{}).Order("domain").Pluck("domain", &domains).Error) + assert.Equal(t, []string{"new1.example", "new2.example", "new3.example"}, domains) +} + +func TestIsRknDomainBlocked(t *testing.T) { + models.Drop() + models.Migrate() + + input := []string{ + "example.com", + "foo.bar", + "sub.test", + } + require.NoError(t, models.ReplaceRknDomains(input)) + + cases := []struct { + host string + want bool + }{ + {"example.com", true}, // exact + {"EXAMPLE.com", true}, // case insensitive (caller lowercases) + {"www.example.com", true}, // stored has apex; query apex → root-domain match + {"deep.nested.example.com", true}, // suffix match via LIKE '%.X' + {"foo.bar", true}, + {"sub.test", true}, + {"a.sub.test", true}, + {"unrelated.org", false}, + {"two.labels", false}, // 2-label input not in list → must not collapse to "labels" + {"", false}, + } + + for _, c := range cases { + t.Run(c.host, func(t *testing.T) { + got, err := models.IsRknDomainBlocked(c.host) + require.NoError(t, err) + assert.Equal(t, c.want, got) + }) + } +} diff --git a/app/models/rkn_ip.go b/app/models/rkn_ip.go new file mode 100644 index 0000000..9f7e58d --- /dev/null +++ b/app/models/rkn_ip.go @@ -0,0 +1,193 @@ +package models + +import ( + "fmt" + "log" + "net" + "strings" + + "github.com/davecgh/go-spew/spew" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" + "rsgit.ru/rsmon/rsmon/internal/netaddr" +) + +// RknIP stores a single CIDR from the ru-blocked IP list. The cidr column +// uses Postgres's native cidr type so the (>>) containment operator works +// directly inside IsRknIPBlocked queries — GiST index recommended for any +// table large enough to amortize the planner cost; see EnsureRknIndexes. +type RknIP struct { + concerns.Model + Network *netaddr.Cidr `json:"network" gorm:"type:cidr;"` +} + +// TableName pins the underlying table name. +func (RknIP) TableName() string { return "rkn_ips" } + +// PanicOnErr panics with a spew-formatted error dump if err is non-nil. +// Kept for callers that still use the old add-by-row InsertInBatches path +// (none after this commit, but kept in case external scripts reference it). +func PanicOnErr(err error) { + if err != nil { + spew.Dump(err) + panic(err) + } +} + +// FindRknIP returns true iff the IP inside `ip` (treated as a /32 or /128 +// host) falls inside any CIDR already stored in rkn_ips. Kept for callers +// that already construct an internal/netaddr.Inet. +func FindRknIP(ip netaddr.Inet) (bool, error) { + ipstr := ip.Inet.String() + if !strings.Contains(ipstr, "/") { + if ip.Inet.To4() != nil { + ipstr += "/32" + } else { + ipstr += "/128" + } + } + _, network, err := net.ParseCIDR(ipstr) + if err != nil { + return false, err + } + cidr := netaddr.Cidr{Cidr: *network, Valid: true} + var matched []RknIP + if err := DB().Raw("SELECT id FROM rkn_ips WHERE network >>= ?", &cidr).Scan(&matched).Error; err != nil { + return false, err + } + return len(matched) > 0, nil +} + +// AddRknIP is the legacy per-row insert path. Deprecated: callers should +// invoke ReplaceRknIPs from the rkn updater. Kept around so existing cron +// scripts that import the symbol keep compiling. +func AddRknIP(data []string, count int) { + tx := DB().Begin() + for k, dataIP := range data { + if count > 0 && k > count-1 { + break + } + if !strings.Contains(dataIP, "/") { + dataIP += "/32" + } + _, network, err := net.ParseCIDR(dataIP) + PanicOnErr(err) + + cidr := netaddr.Cidr{Cidr: *network, Valid: true} + var rknIPs []RknIP + err = tx.Raw("SELECT id FROM rkn_ips where network >>= ?", &cidr).Scan(&rknIPs).Error + PanicOnErr(err) + if len(rknIPs) == 0 { + rknIPs = []RknIP{} + err = tx.Raw("SELECT id FROM rkn_ips where network << ?", &cidr).Scan(&rknIPs).Error + PanicOnErr(err) + if len(rknIPs) > 0 { + for _, r := range rknIPs { + PanicOnErr(tx.Delete(&r).Error) + } + } + PanicOnErr(tx.Create(&RknIP{Network: &cidr}).Error) + } + log.Println("created:", dataIP) + } + PanicOnErr(tx.Commit().Error) +} + +// ReplaceRknIPs deletes every existing row and bulk-inserts the given CIDRs +// in batches of 1000. Whole operation runs in a single transaction so a +// partially-applied update can never leave the table in a hybrid state. +// +// CIDR.parseCIDR-loop uses net.ParseCIDR to canonicalise the address — +// upstream .dat files occasionally contain range/mask pairs that aren't +// already reduced (e.g. 192.168.0.0/16 spelled as 192.168.5.0/16); the +// Postgres cidr type normalises on insert. +func ReplaceRknIPs(nets []*net.IPNet) error { + tx := DB().Begin() + if tx.Error != nil { + return tx.Error + } + + rows := make([]RknIP, 0, len(nets)) + seen := make(map[string]struct{}, len(nets)) + for _, n := range nets { + if n == nil || n.IP == nil { + continue + } + // Canonicalise by routing through net.ParseCIDR. This drops the + // host bits (a common bug in upstream dumps where a /24 range + // is written with the .5 host bit set) and stamps the right + // address family flag for Postgres. + canonical := n.String() + if _, parsed, err := net.ParseCIDR(canonical); err == nil { + n = parsed + canonical = parsed.String() + } + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + cidr := netaddr.Cidr{Cidr: *n, Valid: true} + rows = append(rows, RknIP{Network: &cidr}) + } + + // Wipe + re-insert in one transaction. + if err := tx.Where("1 = 1").Delete(&RknIP{}).Error; err != nil { + _ = tx.Rollback().Error + return err + } + const batchSize = 1000 + for start := 0; start < len(rows); start += batchSize { + end := start + batchSize + if end > len(rows) { + end = len(rows) + } + if err := tx.CreateInBatches(rows[start:end], batchSize).Error; err != nil { + _ = tx.Rollback().Error + return err + } + } + return tx.Commit().Error +} + +// IsRknIPBlocked returns true iff `ip` (any textual form ParseCIDR accepts) +// falls inside any CIDR stored in the rkn_ips table. The query uses the +// cidr >>= inet containment operator — see EnsureRknIndexes for the GiST +// index that makes this fast at scale. +func IsRknIPBlocked(ip string) (bool, error) { + ip = strings.TrimSpace(ip) + if ip == "" { + return false, nil + } + var count int64 + if err := DB().Raw("SELECT COUNT(*) FROM rkn_ips WHERE network >>= ?::inet", ip).Scan(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// EnsureRknIndexes creates the indexes that AutoMigrate cannot express — +// the GiST index on rkn_ips.network uses cidr >>= cidr containment (the +// expression index `network` already covers equality and prefix ranges, +// but the planner benefits from a GiST for `network >>= ` +// queries against ~30k rows). The unique index on rkn_domains.domain is +// also declared in the GORM tag, this function only adds what GORM can't +// (GiST) and is idempotent so it's safe to call repeatedly during boot +// or migration. +func EnsureRknIndexes() error { + // GiST on cidr requires the btree_gist contrib — its `cidr_ops` + // opclass exposes cidr to GiST. CREATE EXTENSION IF NOT EXISTS is + // idempotent. + if err := DB().Exec("CREATE EXTENSION IF NOT EXISTS btree_gist").Error; err != nil { + return fmt.Errorf("ensure btree_gist: %w", err) + } + // rkn_ips GiST index on the cidr column supports the >>= containment + // operator that IsRknIPBlocked uses. Without it a 30k-row table makes + // every IP check a sequential scan; with it each check is an index + // probe. + if err := DB().Exec( + "CREATE INDEX IF NOT EXISTS idx_rkn_ips_network ON rkn_ips USING gist (network)", + ).Error; err != nil { + return fmt.Errorf("ensure idx_rkn_ips_network: %w", err) + } + return nil +} diff --git a/app/models/rkn_ip_test.go b/app/models/rkn_ip_test.go new file mode 100644 index 0000000..435df93 --- /dev/null +++ b/app/models/rkn_ip_test.go @@ -0,0 +1,114 @@ +package models_test + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func mustIPNet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(cidr) + require.NoError(t, err) + return n +} + +func TestReplaceRknIPs_BulkAndDedup(t *testing.T) { + models.Drop() + models.Migrate() + + input := []*net.IPNet{ + mustIPNet(t, "10.0.0.0/8"), + mustIPNet(t, "10.5.0.0/8"), // same canonical /8 — dedup + mustIPNet(t, "192.168.1.0/24"), + mustIPNet(t, "2001:db8::/32"), + } + require.NoError(t, models.ReplaceRknIPs(input)) + + // The uniqueIndex on rkn_ips.network ensures the dedup actually drops + // duplicates; ReplaceRknIPs does an in-memory dedup, but the DB-level + // constraint is the guarantee. + var count int64 + assert.NoError(t, models.DB().Model(&models.RknIP{}).Count(&count).Error) + assert.Equal(t, int64(3), count, "expected dedup to 3 unique CIDRs") +} + +func TestReplaceRknIPs_ReplacesExisting(t *testing.T) { + models.Drop() + models.Migrate() + + require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{ + mustIPNet(t, "8.8.8.0/24"), + })) + + var n int64 + assert.NoError(t, models.DB().Model(&models.RknIP{}).Count(&n).Error) + assert.Equal(t, int64(1), n) + + require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{ + mustIPNet(t, "1.0.0.0/8"), + mustIPNet(t, "2.0.0.0/8"), + })) + + assert.NoError(t, models.DB().Model(&models.RknIP{}).Count(&n).Error) + assert.Equal(t, int64(2), n) +} + +func TestIsRknIPBlocked(t *testing.T) { + models.Drop() + models.Migrate() + + require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{ + mustIPNet(t, "10.0.0.0/8"), + mustIPNet(t, "192.168.1.0/24"), + mustIPNet(t, "2001:db8::/32"), + })) + + cases := []struct { + ip string + want bool + }{ + {"10.5.7.9", true}, + {"10.255.255.255", true}, + {"192.168.1.42", true}, + {"11.0.0.1", false}, + {"8.8.8.8", false}, + {"2001:db8::1", true}, + {"2001:db9::1", false}, + {"", false}, + } + + for _, c := range cases { + t.Run(c.ip, func(t *testing.T) { + got, err := models.IsRknIPBlocked(c.ip) + require.NoError(t, err) + assert.Equal(t, c.want, got, "ip=%s", c.ip) + }) + } +} + +func TestEnsureRknIndexes_Idempotent(t *testing.T) { + // Calling EnsureRknIndexes twice must not error — it's used both by + // Migrate() and could be called from boot scripts. + models.Drop() + models.Migrate() + + require.NoError(t, models.EnsureRknIndexes()) + require.NoError(t, models.EnsureRknIndexes()) + + // GiST index must exist on rkn_ips.network. + var exists bool + err := models.DB().Raw(` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname='public' AND tablename='rkn_ips' + AND indexname='idx_rkn_ips_network' + ) + `).Scan(&exists).Error + require.NoError(t, err) + assert.True(t, exists, "idx_rkn_ips_network must exist") +} diff --git a/app/models/selfcheck.go b/app/models/selfcheck.go new file mode 100644 index 0000000..53264e5 --- /dev/null +++ b/app/models/selfcheck.go @@ -0,0 +1,55 @@ +package models + +import ( + "time" + + "github.com/pkg/errors" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// SelfCheck provides functionality. +type SelfCheck struct { + concerns.Model + Kind string `gorm:"not null;uniqueIndex:selfchecks" json:"kind"` + Server *string `gorm:"uniqueIndex:selfchecks" json:"server"` + Info string `json:"info"` + LastCheck time.Time `json:"created_at"` +} + +// LogCheck provides functionality. +func LogCheck(kind string) error { + m := SelfCheck{ + Kind: kind, + Server: nil, + } + DB().FirstOrInit(&m, m) + + m.LastCheck = time.Now() + + return DB().Save(&m).Error +} + +// IsOk checks if the selfcheck for the given kind ran recently. +func IsOk(kind string) (bool, string, error) { + m := SelfCheck{ + Kind: kind, + Server: nil, + } + DB().First(&m, m) + + if m.ID == 0 { + return false, "", errors.New("not run") + } + + var ago time.Time + if kind == "exp" { + ago = time.Now().Add(-3 * time.Hour) + } else { + ago = time.Now().Add(-15 * time.Minute) + } + + isOk := m.LastCheck.After(ago) + + return isOk, m.LastCheck.Format(time.RFC3339), nil +} diff --git a/app/models/server.go b/app/models/server.go new file mode 100644 index 0000000..35e5a7c --- /dev/null +++ b/app/models/server.go @@ -0,0 +1,388 @@ +package models + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql/driver" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/lib/pq" + "gorm.io/datatypes" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// AccountMCPToken stores a one-way verifier. MCP tokens are bearer credentials +// and must remain valid even when the deployment has no encryption key. +type AccountMCPToken struct { + concerns.Model + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id) ON DELETE CASCADE;not null;index" json:"account_id"` + Name string `gorm:"size:120;not null" json:"name"` + TokenEnc string `gorm:"column:token;type:char(64);not null;index" json:"-"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + concerns.Timestamped +} + +func (AccountMCPToken) TableName() string { return "account_mcp_tokens" } + +func (t *AccountMCPToken) SetToken(token string) error { + sum := sha256.Sum256([]byte(token)) + t.TokenEnc = hex.EncodeToString(sum[:]) + return nil +} + +func (t *AccountMCPToken) TokenMatches(token string) bool { + sum := sha256.Sum256([]byte(token)) + encoded := hex.EncodeToString(sum[:]) + return subtle.ConstantTimeCompare([]byte(t.TokenEnc), []byte(encoded)) == 1 +} + +func GenerateMCPToken() string { + return "mcp_" + base64.RawURLEncoding.EncodeToString(concerns.RandomToken(32)) +} + +// Server health states — worst-of-monitor-states rollup; see +// docs/plans/servers-and-hardware-metrics.md §5.1 for the data model. +const ( + ServerHealthDown = "down" + ServerHealthWarn = "warn" + ServerHealthUp = "up" + ServerHealthPaused = "paused" + ServerHealthUnknown = "unknown" +) + +// ServerEnvironments is the allow-list for Server.Environment. +// New environments require an explicit edit so they are visible in +// tests. +var ServerEnvironments = []string{"production", "staging", "dev", "test"} + +// ServerKind is the rstuff-mirrored lifecycle label for a Server +// (production / staging / old). See +// docs/parity/rstuff-inventory.md §6.1 for the byte-stable mapping. +type ServerKind string + +// ServerKind values match rstuff's Server.kind enum exactly. +// New values require adding a Postgres enum value via +// app/models/migrate.go. +const ( + ServerKindProduction ServerKind = "production" + ServerKindStaging ServerKind = "staging" + ServerKindOld ServerKind = "old" +) + +// Scan implements sql.Scanner for ServerKind. +func (k *ServerKind) Scan(src any) error { + if src == nil { + *k = "" + return nil + } + switch v := src.(type) { + case string: + *k = ServerKind(v) + case []byte: + *k = ServerKind(string(v)) + default: + return fmt.Errorf("server_kind: cannot scan %T", src) + } + return nil +} + +// Value implements driver.Valuer for ServerKind. +func (k ServerKind) Value() (driver.Value, error) { + if k == "" { + return nil, nil + } + return string(k), nil +} + +// Server represents a customer-facing logical host (e.g. "prod-web-01"). +// +// One Server can host many WorkerNodes (HA after a VM migration). Each +// WorkerNode carries a nullable ServerID so legacy "no server assigned" +// rows keep working. The 1:N relation is stored as a nullable FK on +// worker_nodes.server_id. The N:M relation to Monitor is the +// monitor_servers join table. See +// docs/plans/servers-and-hardware-metrics.md §3 for the layer model +// and §5.1 for the schema. +// +// The inventory fields (ExtID, Kind, Token, PriceCents, Comment, +// Meta) are added per docs/plans/inventory-management.md §6.2 so +// rstuff can push the same row in via Valkey Streams and +// deploymentd can authenticate via Token. +type Server struct { + concerns.Model + + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` + Account *Account `json:"-"` + Name string `gorm:"size:120;not null" json:"name"` + Slug string `gorm:"size:120;not null;index" json:"slug"` + Description *string `gorm:"type:text" json:"description"` + Region string `gorm:"size:64;not null;index" json:"region"` + Environment string `gorm:"size:32;not null;default:'production'" json:"environment"` + Tags pq.StringArray `gorm:"type:varchar(255)[]" json:"tags"` + Icon *string `gorm:"size:16" json:"icon"` + Color *string `gorm:"size:16" json:"color"` + Paused bool `gorm:"not null;default:false" json:"paused"` + + // Inventory fields (rstuff mirror; see inventory-management.md §6.2). + ExtID *string `gorm:"size:64" json:"ext_id,omitempty"` + Kind ServerKind `gorm:"type:server_kind;not null;default:'production'" json:"kind"` + // Token is omitted from JSON because it is a bearer credential. + // Read it back only via /api/v1/servers/:id/token (operator-only) + // and never echoed in list/show responses. + Token *string `gorm:"size:64" json:"-"` + PriceCents int `gorm:"not null;default:0" json:"price_cents"` + Comment *string `gorm:"type:text" json:"comment,omitempty"` + // Meta is rstuff-style free-form jsonb; serialized via JSON + // encoding (gin renders it as a nested object). + Meta datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"meta"` + + HealthState string `gorm:"size:16;not null;default:'unknown';index" json:"health_state"` + LastCheckAt *time.Time `json:"last_check_at"` + Uptime24h float64 `gorm:"not null;default:1.0" json:"uptime_24h"` + Uptime30d float64 `gorm:"not null;default:1.0" json:"uptime_30d"` + + Monitors []Monitor `gorm:"many2many:monitor_servers;joinForeignKey:server_id;joinReferences:monitor_id;" json:"monitors,omitempty"` + Workers []WorkerNode `gorm:"foreignKey:ServerID" json:"workers,omitempty"` + + concerns.Timestamped + Audited +} + +// TableName provides functionality. +func (Server) TableName() string { return "servers" } + +// MonitorServer is the join row for the N:M relation between Monitor and +// Server. One Monitor can be hosted on many Servers (multi-region +// failover); one Server can host many Monitors. +type MonitorServer struct { + MonitorID int64 `gorm:"type:bigint REFERENCES monitors(id) ON DELETE CASCADE;primaryKey" json:"monitor_id"` + ServerID int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE CASCADE;primaryKey" json:"server_id"` + Position int `gorm:"not null;default:0" json:"position"` + concerns.Timestamped +} + +// TableName provides functionality. +func (MonitorServer) TableName() string { return "monitor_servers" } + +// ServerMetric is the last-N-point cache written alongside VictoriaMetrics. +// The full time-series lives in TSDB; Postgres only keeps the most recent +// row per (server, source) for fast health badges and "last seen" cells. +// See docs/plans/servers-and-hardware-metrics.md §5.3. +type ServerMetric struct { + concerns.Model + + ServerID int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE CASCADE;not null;index" json:"server_id"` + WorkerID *int64 `gorm:"type:bigint REFERENCES worker_nodes(id) ON DELETE SET NULL" json:"worker_id"` + Source string `gorm:"size:32;not null;default:'worker'" json:"source"` + CPUPercent *float64 `json:"cpu_percent"` + MemUsed *int64 `json:"mem_used"` + MemTotal *int64 `json:"mem_total"` + DiskUsed *int64 `json:"disk_used"` + DiskTotal *int64 `json:"disk_total"` + NetRx *int64 `json:"net_rx"` + NetTx *int64 `json:"net_tx"` + HostUptimeSec *int64 `json:"host_uptime_sec"` + Load1 *float64 `json:"load1"` + Load5 *float64 `json:"load5"` + Load15 *float64 `json:"load15"` + ProcessCount *int `json:"process_count"` + Processes datatypes.JSON `gorm:"type:jsonb;not null;default:'[]'::jsonb" json:"processes"` + Networks datatypes.JSON `gorm:"type:jsonb;not null;default:'[]'::jsonb" json:"networks"` + + concerns.Timestamped +} + +// ServerAlertRule defines one account-owned threshold for a server metric. +// ClearThreshold implements hysteresis: a firing rule only recovers after the +// value drops below it, avoiding alert flapping around Threshold. +type ServerAlertRule struct { + concerns.Model + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id) ON DELETE CASCADE;not null;index" json:"account_id"` + ServerID int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE CASCADE;not null;index" json:"server_id"` + Metric string `gorm:"size:32;not null" json:"metric"` + Threshold float64 `gorm:"not null" json:"threshold"` + ClearThreshold float64 `gorm:"not null" json:"clear_threshold"` + DurationSec int `gorm:"not null;default:300" json:"duration_sec"` + NotificationID int64 `gorm:"type:bigint REFERENCES notifications(id) ON DELETE CASCADE;not null" json:"notification_id"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + State string `gorm:"size:16;not null;default:'ok'" json:"state"` + BreachSince *time.Time `json:"breach_since"` + LastValue *float64 `json:"last_value"` + LastMetricID *int64 `json:"last_metric_id"` + LastFiredAt *time.Time `json:"last_fired_at"` + concerns.Timestamped +} + +func (ServerAlertRule) TableName() string { return "server_alert_rules" } + +// ServerAlertEvent is the durable dedupe/audit record for threshold changes. +type ServerAlertEvent struct { + concerns.Model + RuleID int64 `gorm:"type:bigint REFERENCES server_alert_rules(id) ON DELETE CASCADE;not null;index" json:"rule_id"` + State string `gorm:"size:16;not null" json:"state"` + Value float64 `gorm:"not null" json:"value"` + concerns.Timestamped +} + +func (ServerAlertEvent) TableName() string { return "server_alert_events" } + +// TableName provides functionality. +func (ServerMetric) TableName() string { return "server_metrics" } + +// ValidateEnvironment returns nil iff env is in the allow list. +func ValidateEnvironment(env string) error { + for _, e := range ServerEnvironments { + if env == e { + return nil + } + } + return errors.New("invalid environment") +} + +// Slugify turns a server name into a URL-safe slug. +func Slugify(name string) string { + slug := strings.ToLower(strings.TrimSpace(name)) + var b strings.Builder + for _, r := range slug { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == ' ', r == '_', r == '-', r == '.': + b.WriteByte('-') + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + out = "server" + } + return out +} + +// AssignMonitors replaces the full set of monitors for a server. Saves +// the join table explicitly because gorm:association_autoupdate is +// disabled globally (CLAUDE.md GORM Conventions). +func (s *Server) AssignMonitors(tx *gorm.DB, monitorIDs []uint) error { + if tx == nil { + tx = DB() + } + if err := tx.Exec("DELETE FROM monitor_servers WHERE server_id = ?", s.ID).Error; err != nil { + return err + } + if len(monitorIDs) == 0 { + return nil + } + seen := make(map[uint]struct{}, len(monitorIDs)) + for i, mid := range monitorIDs { + if _, ok := seen[mid]; ok { + continue + } + seen[mid] = struct{}{} + row := MonitorServer{ServerID: s.ID, MonitorID: int64(mid), Position: i} + if err := tx.Create(&row).Error; err != nil { + return err + } + } + return nil +} + +// RollupHealthState computes the worst-of-monitor-states. Returns one of +// ServerHealth{Down,Warn,Up,Paused,Unknown}. +func (s *Server) RollupHealthState(monitors []Monitor) string { + if s.Paused { + return ServerHealthPaused + } + if len(monitors) == 0 { + return ServerHealthUnknown + } + allPaused := true + worst := ServerHealthUp + for i := range monitors { + m := &monitors[i] + if m.Enabled { + allPaused = false + } + switch m.State { + case stateERR, stateFail: + return ServerHealthDown + case stateWARN: + worst = ServerHealthWarn + } + } + if allPaused { + return ServerHealthPaused + } + return worst +} + +// SortedTagList returns tags sorted ascending; helper for stable JSON. +func (s *Server) SortedTagList() []string { + out := make([]string, len(s.Tags)) + copy(out, s.Tags) + sort.Strings(out) + return out +} + +// HealthForServer recomputes and persists health_state + last_check_at +// for one server. Called by the distworker health ticker. +func HealthForServer(serverID int64) error { + server := Server{} + if err := DB().First(&server, serverID).Error; err != nil { + return err + } + var monitors []Monitor + if err := DB().Joins("JOIN monitor_servers ms ON ms.monitor_id = monitors.id"). + Where("ms.server_id = ?", serverID).Find(&monitors).Error; err != nil { + return err + } + state := server.RollupHealthState(monitors) + now := time.Now() + updates := map[string]interface{}{"health_state": state} + if len(monitors) > 0 { + updates["last_check_at"] = &now + } + return DB().Model(&server).Updates(updates).Error +} + +// LatestServerMetric returns the newest accepted worker snapshot for a server. +func LatestServerMetric(serverID int64) (*ServerMetric, error) { + metric := ServerMetric{} + err := DB().Where("server_id = ?", serverID).Order("id DESC").First(&metric).Error + if err != nil { + return nil, err + } + return &metric, nil +} + +// FindServerByToken returns the Server whose token column equals +// the given hex value, or nil with gorm.ErrRecordNotFound when no +// row matches. Used by the deploymentd receiver middleware. +func FindServerByToken(token string) (*Server, error) { + var s Server + err := DB().Where("token = ?", token).First(&s).Error + if err != nil { + return nil, err + } + return &s, nil +} + +// GenerateServerToken returns a 32-byte random hex string. Caller +// stores the plaintext exactly once (via /servers/:id/rotate-token) +// and updates Server.Token; the old value is no longer recoverable. +func GenerateServerToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // crypto/rand should not fail on Linux; panic keeps the + // contract simple for callers in the rare fatal case. + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/app/models/server_health_ticker.go b/app/models/server_health_ticker.go new file mode 100644 index 0000000..093a421 --- /dev/null +++ b/app/models/server_health_ticker.go @@ -0,0 +1,43 @@ +package models + +import ( + "context" + "log" + "sync" + "time" +) + +// HealthTickInterval is exported so focused tests can exercise the same +// lifecycle with a short interval. +var HealthTickInterval = time.Minute + +var serverHealthTickerOnce sync.Once + +// StartServerHealthTicker periodically refreshes derived server health. The +// first production tick is delayed to keep CLI migration paths side-effect +// free; assignment/pause paths recompute synchronously. +func StartServerHealthTicker(parent context.Context) { + serverHealthTickerOnce.Do(func() { + go func() { + ticker := time.NewTicker(HealthTickInterval) + defer ticker.Stop() + for { + select { + case <-parent.Done(): + return + case <-ticker.C: + var ids []int64 + if err := DB().Model(&Server{}).Pluck("id", &ids).Error; err != nil { + log.Printf("server health: list: %v", err) + continue + } + for _, id := range ids { + if err := HealthForServer(id); err != nil { + log.Printf("server health %d: %v", id, err) + } + } + } + } + }() + }) +} diff --git a/app/models/server_ip.go b/app/models/server_ip.go new file mode 100644 index 0000000..323b351 --- /dev/null +++ b/app/models/server_ip.go @@ -0,0 +1,36 @@ +package models + +import ( + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// ServerIp is a single IP (v4 or v6) bound to a Server. The +// canonical source for these rows is the deploymentd server-inventory +// receiver (M1) and the network-diagnostics partial plan; today the +// only writer is operator-entered via /api/v1/servers/:id/ips. +// +// `address` is Postgres `inet` so range queries (`<<` / `>>`) work +// without parsing text. One row per (server_id, address). The +// `is_primary` flag is set when more than one IP exists and the +// deploymentd payload signals a primary; otherwise the first row wins. +// +//nolint:revive // ServerIP rename deferred to M3; rstuff schema uses ServerIp verbatim and parity test depends on it. +type ServerIp struct { + concerns.Model + + ServerID int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE CASCADE;not null;index" json:"server_id"` + Server *Server `json:"-"` + // Address is mapped to Postgres inet via raw SQL in Migrate(); the + // GORM `type:` tag is not enough because gorm.io/driver/postgres + // does not register `inet` in its type map. Field is stored as a + // string and validated by the API layer (see + // app/controllers/api/server.go ServerIPsAdd). + Address string `gorm:"type:inet;not null" json:"address"` + IsPrimary bool `gorm:"not null;default:false" json:"is_primary"` + RelatedSitesCount int `gorm:"not null;default:0" json:"related_sites_count"` + + concerns.Timestamped +} + +// TableName provides functionality. +func (ServerIp) TableName() string { return "server_ips" } diff --git a/app/models/site.go b/app/models/site.go new file mode 100644 index 0000000..603a6df --- /dev/null +++ b/app/models/site.go @@ -0,0 +1,98 @@ +package models + +import ( + "strings" + + "gorm.io/datatypes" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Site represents a customer-facing website or app. One Site can be +// hosted on zero or one Server (server_id nullable) and exposes one +// or more Deployments (compose services or nginx vhosts) plus zero +// or more Repos (via site_repos). The RSMon slice mirrors rstuff's +// `sites` table verbatim — see docs/parity/rstuff-inventory.md §2 +// and docs/plans/inventory-management.md §4. +type Site struct { + concerns.Model + + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` + Account *Account `json:"-"` + ServerID *int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE SET NULL;index" json:"server_id,omitempty"` + Server *Server `json:"-"` + ExtID *string `gorm:"size:64" json:"ext_id,omitempty"` + Name string `gorm:"size:120;not null" json:"name"` + Slug string `gorm:"size:120;not null;index" json:"slug"` + URL *string `gorm:"type:text" json:"url,omitempty"` + Description *string `gorm:"type:text" json:"description,omitempty"` + // Kind is a free-text label (not the PG enum) so we can absorb + // rstuff additions without a migration. Default "production". + Kind string `gorm:"size:32;not null;default:'production'" json:"kind"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` + Meta datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"meta"` + + Deployments []Deployment `gorm:"foreignKey:SiteID" json:"deployments,omitempty"` + Repos []Repo `gorm:"many2many:site_repos;" json:"repos,omitempty"` + + concerns.Timestamped + Audited +} + +// TableName returns the table name used for Site. Matches rstuff's +// `sites` plural exactly so the parity test stays trivial. +func (Site) TableName() string { return "sites" } + +// SiteSlugify turns a name into a URL-safe slug. Mirrors the rules +// in app/models/server.go:Slugify so /sites/:slug looks the same as +// /servers/:slug. Consecutive separators collapse to a single dash; +// non-ASCII letters are stripped (the same rule as Slugify — we +// don't transliterate in v1, see docs/plans/inventory-management.md +// §11 for transliteration as a future hardening item). +func SiteSlugify(name string) string { + slug := strings.ToLower(strings.TrimSpace(name)) + var b strings.Builder + prevDash := false + for _, r := range slug { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + case r == ' ', r == '_', r == '-', r == '.': + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + out = "site" + } + return out +} + +// FindOrCreateSiteBySlug returns the site with the given slug for the +// given account, creating an empty row (Name=slug, Kind=production, +// IsActive=true) when no match exists. The caller's tx wraps the +// operation so docker payload ingestion stays atomic. +// See docs/plans/inventory-management.md §7.2 — Docker receiver. +func FindOrCreateSiteBySlug(tx *gorm.DB, accountID int64, slug string) (*Site, error) { + if tx == nil { + tx = DB() + } + var site Site + err := tx.Where("account_id = ? AND slug = ?", accountID, slug).First(&site).Error + if err == nil { + return &site, nil + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + site = Site{AccountID: accountID, Slug: slug, Name: slug, Kind: "production", IsActive: true} + if err := tx.Create(&site).Error; err != nil { + return nil, err + } + return &site, nil +} diff --git a/app/models/stats_data.go b/app/models/stats_data.go new file mode 100644 index 0000000..34d17f5 --- /dev/null +++ b/app/models/stats_data.go @@ -0,0 +1,120 @@ +package models + +// StatsData provides functionality. +type StatsData struct { + MonitorID *int64 `json:"monitor_id,omitempty"` + Up30d *float64 `json:"up_30d"` + Up7d *float64 `json:"up_7d"` + Up1d *float64 `json:"up_1d"` +} + +// Process provides functionality. +func (data *StatsData) Process() { + if data.Up1d == nil { + dv := 100.0 + data.Up1d = &dv + } + if data.Up7d == nil { + dv := 100.0 + data.Up7d = &dv + } + if data.Up30d == nil { + dv := 100.0 + data.Up30d = &dv + } +} + +// 86400 seconds / 1d +// 604800 seconds / 7d +// 2592000 seconds / 30d + +// UptimeSQL provides functionality. +const UptimeSQL = ` +SELECT + monitors.id AS monitor_id, + round(up1.up::numeric, 3) AS up1d, + round(up7.up::numeric, 3) AS up7d, + round(up30.up::numeric, 3) AS up30d +FROM monitors +LEFT JOIN ( + select monitor_id, CAST(10000 - (10000 * sum(duration) / min(lt.least)) as float) / 100 as up from events + join ( + select id, LEAST(extract(epoch from (NOW() - created_at))::int, 86400) from monitors + ) lt ON lt.id = events.monitor_id + where start_time > NOW() - interval '1' day + group by monitor_id +) up1 ON monitors.id = up1.monitor_id +LEFT JOIN ( + select monitor_id, CAST(10000 - (10000 * sum(duration) / min(lt.least)) as float) / 100 as up from events + join ( + select id, LEAST(extract(epoch from (NOW() - created_at))::int, 604800) from monitors + ) lt ON lt.id = events.monitor_id + where start_time > NOW() - interval '7' day + group by monitor_id +) up7 ON monitors.id = up7.monitor_id +LEFT JOIN ( + select monitor_id, CAST(10000 - (10000 * sum(duration) / min(lt.least)) as float) / 100 as up from events + join ( + select id, LEAST(extract(epoch from (NOW() - created_at))::int, 2592000) from monitors + ) lt ON lt.id = events.monitor_id + where start_time > NOW() - interval '30' day + group by monitor_id +) up30 ON monitors.id = up30.monitor_id +` + +// UptimeAllSQL provides functionality. +const UptimeAllSQL = ` +SELECT + round(up1.up::numeric, 3) AS up1d, + round(up7.up::numeric, 3) AS up7d, + round(up30.up::numeric, 3) AS up30d +FROM ( + select CAST(10000 - (10000 * sum(duration) / sum(lt.least)) as float) / 100 as up from events + join ( + select id, LEAST(extract(epoch from (NOW() - created_at))::int, 86400) from monitors + ) lt ON lt.id = events.monitor_id + where start_time > NOW() - interval '1' day AND events.monitor_id IN (?) +) up1, ( + select CAST(10000 - (10000 * sum(duration) / sum(lt.least)) as float) / 100 as up from events + join ( + select id, LEAST(extract(epoch from (NOW() - created_at))::int, 604800) from monitors + ) lt ON lt.id = events.monitor_id + where start_time > NOW() - interval '7' day AND events.monitor_id IN (?) +) up7, ( + select CAST(10000 - (10000 * sum(duration) / sum(lt.least)) as float) / 100 as up from events + join ( + select id, LEAST(extract(epoch from (NOW() - created_at))::int, 2592000) from monitors + ) lt ON lt.id = events.monitor_id + where start_time > NOW() - interval '30' day AND events.monitor_id IN (?) +) up30 +` + +// MonitorStats provides functionality. +func MonitorStats(monitors *[]Monitor) error { + ids := make([]int64, len(*monitors)) + for i, m := range *monitors { //nolint:gocritic // range copy is acceptable here + ids[i] = m.ID + } + + rows, err := DB().Raw(UptimeSQL+"WHERE monitors.id IN (?)", ids).Rows() + if err != nil { + return err + } + + stats := make(map[int64]StatsData, 0) + + defer rows.Close() //nolint:errcheck + for rows.Next() { + data := StatsData{} + _ = DB().ScanRows(rows, &data) + data.Process() + + stats[*data.MonitorID] = data + } + + for i, m := range *monitors { //nolint:gocritic // range copy is acceptable here + (*monitors)[i].StatsData = stats[m.ID] + } + + return nil +} diff --git a/app/models/status_page.go b/app/models/status_page.go new file mode 100644 index 0000000..c5b8ed0 --- /dev/null +++ b/app/models/status_page.go @@ -0,0 +1,461 @@ +// Package models — status page subsystem (docs/plans/status-pages.md). +// +// M0 ships the schema for status_pages and its five related tables +// (subscribers, incidents, maintenance, domains). The M0 milestone is +// read-only at the dashboard level — no editor and no public render yet — +// but landing the schema now lets downstream milestones wire public +// routes, editor flows, and the notifier→subscriber bridge without +// further ALTER TABLE churn. M5 (custom domain) only fills in +// status_page_domains rows; the table itself is reserved here so the +// M5 migration is just data, not DDL. +// +// All tables follow the existing RSMon conventions: concerns.Model + +// concerns.Timestamped + Audited mixins, gorm.DeletedAt for soft delete +// on the top-level status_pages row, pq.Int64Array for the monitor_ids +// bigint[] join columns (same shape as sites and the Check.Warnings +// slice). Partial-unique indexes (slug, subscriber email) are added via +// raw SQL in app/models/migrate.go because the GORM tag language cannot +// express a WHERE deleted_at IS NULL predicate. +package models + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/lib/pq" + "golang.org/x/net/idna" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" + "rsgit.ru/rsmon/rsmon/config/credis" +) + +// Status page color defaults. Matches the existing landing-page primary +// green and the accent blue used in the /settings UI, so a freshly +// created page already blends in with the rest of the app. +const ( + statusPageDefaultPrimaryColor = "#62c600" + statusPageDefaultAccentColor = "#1a73e8" + statusPageDefaultHistoryDays = 90 + statusPageMaxSlugLen = 64 + statusPageMaxNameLen = 120 +) + +// Hex color regex — accepts #RGB and #RRGGBB. Centralized so the +// controller/model validation agree on the same shape. +var hexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// StatusPageSlugRegex mirrors the slug format enforced in +// StatusPage.NormalizeSlug / ValidateSlug. Lowercase alphanumerics and +// dashes, must start and end with an alphanumeric. Length is checked +// separately so the regex stays readable. +var statusPageSlugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`) + +// StatusPage represents a public status page owned by an account. One +// account may own many pages (gated by plan in M2+); slugs are globally +// unique because public URLs do not contain the account ID. Each page exposes +// a curated subset of the account's monitors and a recent-incidents +// feed. Soft-deleted rows remain in the table so the partial-unique +// index on (account_id, slug) WHERE deleted_at IS NULL still rejects +// duplicate slugs against historical records — see the comment on +// StatusPagesAccountSlugUnique in migrate.go. +type StatusPage struct { + concerns.Model + + AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` + Account *Account `json:"-"` + + Slug string `gorm:"size:64;not null;index" json:"slug"` + Name string `gorm:"size:120;not null" json:"name"` + + Description *string `gorm:"type:text" json:"description,omitempty"` + LogoURL *string `gorm:"size:255" json:"logo_url,omitempty"` + + PrimaryColor string `gorm:"size:7;not null;default:'#62c600'" json:"primary_color"` + AccentColor string `gorm:"size:7;not null;default:'#1a73e8'" json:"accent_color"` + + // MonitorIDs is the curated subset of account monitors the page + // exposes. Order is preserved so the dashboard list and the public + // render show the same ordering. Stored as bigint[] to keep + // monitor-to-page mapping lookup-free on the read path; M2 will + // add a UI to maintain this set. + MonitorIDs pq.Int64Array `gorm:"type:bigint[];not null;default:'{}'" json:"monitor_ids"` + + ShowUptimeBars bool `gorm:"not null;default:true" json:"show_uptime_bars"` + ShowResponseTimes bool `gorm:"not null;default:true" json:"show_response_times"` + ShowHistoryDays int `gorm:"not null;default:90" json:"show_history_days"` + + // PasswordHash is populated in M4 only. Stored at length 255 so + // a future bcrypt cost bump does not need a column resize. + PasswordHash *string `gorm:"size:255" json:"-"` + // GATrackingID — Google Analytics 4 measurement ID; emitted by + // the public renderer in M4. + GATrackingID *string `gorm:"size:32" json:"ga_tracking_id,omitempty"` + + // NoIndex emits so the + // page can be staged without polluting search indexes. + NoIndex bool `gorm:"not null;default:false" json:"no_index"` + + // IsPublished gates the public /status/:slug render. Until M2 + // ships the editor the default value keeps M0 pages invisible. + IsPublished bool `gorm:"not null;default:false" json:"is_published"` + // AutoOpenIncidents is opt-in so publishing a page does not change + // existing alert behavior until an owner explicitly enables it. + AutoOpenIncidents bool `gorm:"not null;default:false" json:"auto_open_incidents"` + + concerns.Timestamped + Audited + + // DeletedAt is the GORM soft-delete marker. Using gorm.DeletedAt + // rather than concerns.SoftDelete because the latter adds a + // DeleterID users(id) FK that we do not yet need on status_pages. + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName returns the explicit table name so GORM does not try to +// pluralize to "status_pages" via inflection. The plural is already +// correct; we declare it anyway for clarity. +func (StatusPage) TableName() string { return "status_pages" } + +// StatusPageSubscriberKind — values stored in status_page_subscribers.kind. +// "alert" subscribes to incident-driven notifications; "digest_daily" +// receives the morning summary (M3). New kinds should be appended so +// the JSON serializations stay stable. +const ( + StatusPageSubscriberKindAlert = "alert" + StatusPageSubscriberKindDigestDaily = "digest_daily" +) + +// StatusPageSubscriber is a row in status_page_subscribers. Email is +// stored verbatim (no citext) because the codebase already persists +// contact emails as-is; case-insensitive uniqueness is enforced via +// the partial unique index in migrate.go using lower(email). +type StatusPageSubscriber struct { + concerns.Model + + StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` + StatusPage *StatusPage `json:"-"` + // ContactID is an internal delivery endpoint. It is never returned by public + // subscription APIs; tasks use it to preserve the normal worker email path. + ContactID *int64 `gorm:"type:bigint REFERENCES contacts(id) ON DELETE SET NULL;index" json:"-"` + + Email string `gorm:"size:255;not null" json:"email"` + Kind string `gorm:"size:16;not null;default:'alert'" json:"kind"` + ConfirmTokenHash string `gorm:"size:64" json:"-"` + // LegacyConfirmToken is retained only for confirmation links issued before + // token hashing shipped. It is cleared on first use or resend. + LegacyConfirmToken *string `gorm:"column:confirm_token;size:255" json:"-"` + TokenExpiresAt time.Time `json:"-"` + UnsubscribeTokenHash string `gorm:"size:64;default:''" json:"-"` + ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` + UnsubscribedAt *time.Time `json:"unsubscribed_at,omitempty"` + + concerns.Timestamped +} + +// TableName returns the explicit status_page_subscribers table name. +func (StatusPageSubscriber) TableName() string { return "status_page_subscribers" } + +// StatusPageIncident severity values. info = heads-up notices, warn = +// degradation, crit = full outage. Used for color-coding in the public +// render (M1) and for filtering in the dashboard list (M0). +const ( + StatusPageIncidentSeverityInfo = "info" + StatusPageIncidentSeverityWarn = "warn" + StatusPageIncidentSeverityCrit = "crit" +) + +// StatusPageIncident represents a single incident entry on a status +// page. event_id is a soft link back to the existing Event model so +// "auto-open on monitor error" can be wired later without a second +// migration. posted_by_user_id is nullable so external integrations +// can write incidents anonymously. +type StatusPageIncident struct { + concerns.Model + + StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` + StatusPage *StatusPage `json:"-"` + + EventID *int64 `gorm:"type:bigint REFERENCES events(id) ON DELETE SET NULL;index" json:"event_id,omitempty"` + + Title string `gorm:"size:200;not null" json:"title"` + BodyMD string `gorm:"type:text" json:"body_md,omitempty"` + Severity string `gorm:"size:16;not null;default:'info'" json:"severity"` + + StartedAt time.Time `gorm:"not null;index" json:"started_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + + PostedByUserID *int64 `gorm:"type:bigint REFERENCES users(id)" json:"posted_by_user_id,omitempty"` + + concerns.Timestamped +} + +// TableName returns the explicit status_page_incidents table name. +func (StatusPageIncident) TableName() string { return "status_page_incidents" } + +// StatusPageMaintenance is a scheduled maintenance window. The +// monitor_ids column is the set of monitors the window covers; empty +// means "all monitors on the page". +type StatusPageMaintenance struct { + concerns.Model + + StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` + StatusPage *StatusPage `json:"-"` + + Title string `gorm:"size:200;not null" json:"title"` + Description string `gorm:"type:text" json:"description,omitempty"` + + StartsAt time.Time `gorm:"not null;index" json:"starts_at"` + EndsAt time.Time `gorm:"not null" json:"ends_at"` + + MonitorIDs pq.Int64Array `gorm:"type:bigint[];not null;default:'{}'" json:"monitor_ids"` + + NotifySubscribers bool `gorm:"not null;default:true" json:"notify_subscribers"` + + concerns.Timestamped +} + +// TableName returns the explicit status_page_maintenance table name. +func (StatusPageMaintenance) TableName() string { return "status_page_maintenance" } + +// StatusPageDomain is the M5 custom-domain mapping. Reserved in M0 so +// the table does not need to be created at M5 — only rows are written +// then. domain is unique globally (CNAMEs are hostnames, they cannot +// be reused across pages), txt_token is the value the user adds as a +// DNS TXT record to prove ownership. +type StatusPageDomain struct { + concerns.Model + + StatusPageID int64 `gorm:"type:bigint REFERENCES status_pages(id) ON DELETE CASCADE;not null;index" json:"status_page_id"` + StatusPage *StatusPage `json:"-"` + + Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + TXTToken string `gorm:"size:64;not null" json:"txt_token,omitempty"` + VerifyError string `gorm:"type:text" json:"verify_error,omitempty"` + + concerns.Timestamped +} + +// TableName returns the explicit status_page_domains table name. +func (StatusPageDomain) TableName() string { return "status_page_domains" } + +// NormalizeStatusPageDomain accepts a hostname only. URLs, ports, IP literals, +// wildcard names, and invalid IDNA are deliberately rejected before DNS work. +func NormalizeStatusPageDomain(in string) (string, error) { + domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(in)), ".") + if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "/:@") || net.ParseIP(domain) != nil { + return "", errStatusPage("domain must be a hostname") + } + ascii, err := idna.Lookup.ToASCII(domain) + if err != nil || ascii == "" || len(ascii) > 253 || !strings.Contains(ascii, ".") { + return "", errStatusPage("domain must be a valid hostname") + } + for _, label := range strings.Split(ascii, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return "", errStatusPage("domain must be a valid hostname") + } + for _, r := range label { + if r != '-' && (r < 'a' || r > 'z') && (r < '0' || r > '9') { + return "", errStatusPage("domain must be a valid hostname") + } + } + } + return ascii, nil +} + +// HashStatusPageToken keeps bearer-style subscription URLs out of the database. +func HashStatusPageToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return fmt.Sprintf("%x", sum[:]) +} + +func (s *StatusPageSubscriber) TokenMatches(token string) bool { + return s != nil && s.TokenExpiresAt.After(time.Now()) && s.ConfirmTokenHash == HashStatusPageToken(token) +} + +func (s *StatusPageSubscriber) UnsubscribeTokenMatches(token string) bool { + return s != nil && s.UnsubscribeTokenHash != "" && s.UnsubscribeTokenHash == HashStatusPageToken(token) +} + +// StatusPagePlatformDomain and StatusPagePublicIPs are deployment-owned DNS +// targets. Customer domains must point here before they can become routable. +func StatusPagePlatformDomain() string { + return strings.TrimSuffix(strings.ToLower(os.Getenv("STATUS_PAGE_PLATFORM_DOMAIN")), ".") +} + +func StatusPagePublicIPs() []string { + return strings.FieldsFunc(os.Getenv("STATUS_PAGE_PUBLIC_IPS"), func(r rune) bool { return r == ',' || r == ' ' }) +} + +var ( + statusPageLookupCNAME = net.LookupCNAME + statusPageLookupHost = net.LookupHost + statusPageLookupTXT = net.LookupTXT +) + +// VerifyStatusPageDomain performs the BYO-DNS preflight. A customer must keep +// the ownership TXT record and point either a CNAME at the platform hostname or +// an A/AAAA record at one of the explicitly configured public addresses. +func VerifyStatusPageDomain(domain *StatusPageDomain) error { + if domain == nil { + return errStatusPage("domain is required") + } + want, err := NormalizeStatusPageDomain(domain.Domain) + if err != nil { + return err + } + platform := StatusPagePlatformDomain() + publicIPs := StatusPagePublicIPs() + if platform == "" && len(publicIPs) == 0 { + return errStatusPage("custom domain verification is not configured") + } + matchedTarget := false + if platform != "" { + if cname, lookupErr := statusPageLookupCNAME(want); lookupErr == nil { + matchedTarget = strings.TrimSuffix(strings.ToLower(cname), ".") == platform + } + } + if !matchedTarget && len(publicIPs) > 0 { + if hosts, lookupErr := statusPageLookupHost(want); lookupErr == nil { + for _, host := range hosts { + for _, allowed := range publicIPs { + if host == allowed { + matchedTarget = true + } + } + } + } + } + if !matchedTarget { + return errStatusPage("DNS must contain the configured CNAME or public A/AAAA address") + } + txt, lookupErr := statusPageLookupTXT(want) + if lookupErr != nil { + return errStatusPage("ownership TXT record was not found") + } + for _, value := range txt { + if value == "rsmon-verify="+domain.TXTToken { + return nil + } + } + return errStatusPage("ownership TXT record does not match") +} + +// NormalizeStatusPageSlug lowercases and trims a candidate slug so the +// global partial unique index on slug is satisfied +// regardless of how the caller capitalizes the input. Returns the +// empty string when the result would be unusable as a URL path; +// callers should fall back to a name-derived slug in that case. +func NormalizeStatusPageSlug(in string) string { + slug := strings.ToLower(strings.TrimSpace(in)) + return slug +} + +// ValidateStatusPageSlug enforces the slug format we expose to users: +// lowercase alphanumeric plus dash, must start and end with an +// alphanumeric, max length 64. Used by the editor before save (M2). +// Returns nil when the slug is acceptable. +func ValidateStatusPageSlug(slug string) error { + if slug == "" { + return errStatusPage("slug is required") + } + if len(slug) > statusPageMaxSlugLen { + return errStatusPage("slug is too long") + } + if !statusPageSlugRegex.MatchString(slug) { + return errStatusPage("slug must be lowercase alphanumeric with dashes") + } + return nil +} + +// ValidateStatusPageColors returns an error if either color is set but +// not a valid CSS hex string. Empty strings fall back to the model +// defaults when written via BeforeSave hooks. +func ValidateStatusPageColors(primary, accent string) error { + if primary != "" && !hexColorRegex.MatchString(primary) { + return errStatusPage("primary_color must be #RGB or #RRGGBB") + } + if accent != "" && !hexColorRegex.MatchString(accent) { + return errStatusPage("accent_color must be #RGB or #RRGGBB") + } + return nil +} + +// BeforeSave is the GORM hook that fills in the canonical defaults +// (colors, history days) so callers can pass an empty struct and still +// get a usable page. Hook is also the single place where slugs are +// normalized, so the unique index never has to chase trailing spaces. +// The gorm.DB parameter is required by the hook signature but unused — +// the validation here is purely local to the model. +func (p *StatusPage) BeforeSave(_ *gorm.DB) error { + if p == nil { + return nil + } + p.Slug = NormalizeStatusPageSlug(p.Slug) + if err := ValidateStatusPageSlug(p.Slug); err != nil { + return err + } + if p.PrimaryColor == "" { + p.PrimaryColor = statusPageDefaultPrimaryColor + } + if p.AccentColor == "" { + p.AccentColor = statusPageDefaultAccentColor + } + if p.ShowHistoryDays == 0 { + p.ShowHistoryDays = statusPageDefaultHistoryDays + } + return ValidateStatusPageColors(p.PrimaryColor, p.AccentColor) +} + +// IsPublishedNow reports whether the page is publicly visible. M0 +// always returns false because the editor (M2) is the only thing that +// flips IsPublished to true; this helper centralizes that contract. +func (p *StatusPage) IsPublishedNow() bool { + return p != nil && p.IsPublished && !p.DeletedAt.Valid +} + +// errStatusPage builds a validation error carrying the message. The +// returned error is a plain error; controllers translate it into a +// 422 response. +func errStatusPage(msg string) error { + if msg == "" { + return errors.New("status_page: invalid") + } + return fmt.Errorf("status_page: %s", msg) +} + +// IsActiveSubscriber returns true when the subscriber has confirmed and +// has not unsubscribed. Used by the M3 incident-notification loop. +func (s *StatusPageSubscriber) IsActiveSubscriber() bool { + if s == nil { + return false + } + return s.ConfirmedAt != nil && s.UnsubscribedAt == nil +} + +func (s *StatusPageSubscriber) BeforeCreate(_ *gorm.DB) error { + if s.TokenExpiresAt.IsZero() { + s.TokenExpiresAt = time.Now().Add(24 * time.Hour) + } + if s.ConfirmTokenHash == "" { + s.ConfirmTokenHash = HashStatusPageToken(fmt.Sprintf("legacy-%d-%s", time.Now().UnixNano(), s.Email)) + } + return nil +} + +// InvalidateStatusPageCache removes the public HTML cache without making +// Redis availability part of the monitor or management write path. +func InvalidateStatusPageCache(pageID int64) { + if credis.Redis != nil { + _ = credis.Redis.Del(context.Background(), "statuspage:html:"+strconv.FormatInt(pageID, 10)).Err() + } +} diff --git a/app/models/status_page_delivery.go b/app/models/status_page_delivery.go new file mode 100644 index 0000000..d07127e --- /dev/null +++ b/app/models/status_page_delivery.go @@ -0,0 +1,408 @@ +package models + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "html" + "log" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +const ( + statusPageDigestHour = 9 + statusPageOutboxPending = "pending" + statusPageOutboxDispatched = "dispatched" + statusPageOutboxCanceled = "canceled" +) + +// StatusPageDelivery is an outbox row. It does not retain email addresses; +// confirmation links are the only transient body data and are redacted on +// cancellation. The subscriber/contact data is read while dispatching. +type StatusPageDelivery struct { + ID int64 `gorm:"primarykey"` + StatusPageID int64 `gorm:"not null;index"` + SubscriberID int64 `gorm:"not null;index"` + IncidentID *int64 `gorm:"index"` + Kind string `gorm:"size:32;not null"` + Version string `gorm:"size:64;not null"` + LocalDate string `gorm:"size:10"` + State string `gorm:"size:16;not null;index"` + IdempotencyKey string `gorm:"uniqueIndex;size:255;not null"` + MessageID *int64 `gorm:"index"` + TaskID *int64 `gorm:"index"` + LastError string `gorm:"type:text"` + Subject string `gorm:"type:text"` + BodyText string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (StatusPageDelivery) TableName() string { return "status_page_deliveries" } + +type StatusPageDigestSchedule struct { + ID int64 `gorm:"primarykey"` + SubscriberID int64 `gorm:"uniqueIndex:status_page_digest_due;not null"` + LocalDate string `gorm:"uniqueIndex:status_page_digest_due;size:10;not null"` + DueAt time.Time `gorm:"not null;index"` + CreatedAt time.Time +} + +func (StatusPageDigestSchedule) TableName() string { return "status_page_digest_schedules" } + +func EnsureStatusPageSubscriberContactTx(tx *gorm.DB, page *StatusPage, subscriber *StatusPageSubscriber) error { + if subscriber.ContactID != nil { + return nil + } + accountID := page.AccountID + contact := &Contact{AccountID: &accountID, Name: "Status page subscriber", Kind: "email", Value: subscriber.Email, Enabled: true} + if err := tx.Create(contact).Error; err != nil { + return err + } + subscriber.ContactID = &contact.ID + return tx.Model(subscriber).Update("contact_id", contact.ID).Error +} + +func enqueueStatusPageDeliveryTx(tx *gorm.DB, pageID, subscriberID int64, incidentID *int64, kind, version, localDate string) error { + key := fmt.Sprintf("status-page:%s:subscriber:%d:incident:%d:version:%s:date:%s", kind, subscriberID, valueOrZero(incidentID), version, localDate) + return tx.Where(StatusPageDelivery{IdempotencyKey: key}).FirstOrCreate(&StatusPageDelivery{StatusPageID: pageID, SubscriberID: subscriberID, IncidentID: incidentID, Kind: kind, Version: version, LocalDate: localDate, State: statusPageOutboxPending, IdempotencyKey: key}).Error +} + +func EnqueueStatusPageConfirmationTx(tx *gorm.DB, page *StatusPage, subscriber *StatusPageSubscriber, confirmationURL string) error { + key := fmt.Sprintf("status-page:confirm:subscriber:%d:token:%s", subscriber.ID, subscriber.ConfirmTokenHash) + return tx.Where(StatusPageDelivery{IdempotencyKey: key}).FirstOrCreate(&StatusPageDelivery{StatusPageID: page.ID, SubscriberID: subscriber.ID, Kind: "confirm", Version: subscriber.ConfirmTokenHash, State: statusPageOutboxPending, IdempotencyKey: key, Subject: "Confirm status page subscription", BodyText: "Confirm: " + confirmationURL}).Error +} + +func EnqueueStatusPageWelcomeTx(tx *gorm.DB, page *StatusPage, subscriber *StatusPageSubscriber, unsubscribeURL string) error { + key := fmt.Sprintf("status-page:welcome:subscriber:%d:token:%s", subscriber.ID, subscriber.UnsubscribeTokenHash) + return tx.Where(StatusPageDelivery{IdempotencyKey: key}).FirstOrCreate(&StatusPageDelivery{StatusPageID: page.ID, SubscriberID: subscriber.ID, Kind: "welcome", Version: subscriber.UnsubscribeTokenHash, State: statusPageOutboxPending, IdempotencyKey: key, Subject: "Status page subscription confirmed", BodyText: "Manage subscription: " + unsubscribeURL}).Error +} + +func valueOrZero(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +// EnqueueStatusPageIncidentDeliveriesTx is called in the incident transition +// transaction, so an incident can never become visible without its delivery +// intent being recoverable by the dispatcher. +func EnqueueStatusPageIncidentDeliveriesTx(tx *gorm.DB, page *StatusPage, incident *StatusPageIncident, kind string) error { + var subscribers []StatusPageSubscriber + if err := tx.Where("status_page_id = ? AND kind = ? AND confirmed_at IS NOT NULL AND unsubscribed_at IS NULL", page.ID, StatusPageSubscriberKindAlert).Find(&subscribers).Error; err != nil { + return err + } + version := incident.UpdatedAt.UTC().Format(time.RFC3339Nano) + for _, subscriber := range subscribers { + if err := enqueueStatusPageDeliveryTx(tx, page.ID, subscriber.ID, &incident.ID, kind, version, ""); err != nil { + return err + } + } + return nil +} + +func statusPageNotificationTx(tx *gorm.DB, accountID int64) (*Notification, error) { + var notification Notification + err := tx.Where("account_id = ? AND name = ?", accountID, "Status page delivery").First(¬ification).Error + if err == nil { + return ¬ification, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + notification = Notification{Name: "Status page delivery", AccountID: accountID, Enabled: true} + return ¬ification, tx.Create(¬ification).Error +} + +// DispatchStatusPageDeliveries retries pending outbox rows. It builds ordinary +// Message and Task rows in the same transaction, and preserves a pending row on +// transient worker/capability failure for the next tick. +func DispatchStatusPageDeliveries(now time.Time) error { + var rows []StatusPageDelivery + if err := DB().Where("state = ?", statusPageOutboxPending).Order("id ASC").Limit(200).Find(&rows).Error; err != nil { + return err + } + var errs []error + for _, row := range rows { + if err := dispatchStatusPageDelivery(row.ID, now); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func dispatchStatusPageDelivery(id int64, now time.Time) error { + err := DB().Transaction(func(tx *gorm.DB) error { + var outbox StatusPageDelivery + if err := tx.First(&outbox, id).Error; err != nil { + return err + } + var subscriber StatusPageSubscriber + var page StatusPage + // Subscriber then outbox is the global lock order shared with + // unsubscribe/delete, preventing dispatch-vs-cancel deadlocks. + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&subscriber, outbox.SubscriberID).Error; err != nil { + return cancelStatusPageDeliveryTx(tx, &outbox, "subscriber deleted") + } + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&outbox, id).Error; err != nil { + return err + } + if outbox.State != statusPageOutboxPending { + return nil + } + if subscriber.UnsubscribedAt != nil || (subscriber.ConfirmedAt == nil && outbox.Kind != "confirm") { + return cancelStatusPageDeliveryTx(tx, &outbox, "subscriber inactive") + } + if err := tx.First(&page, outbox.StatusPageID).Error; err != nil { + return err + } + if err := EnsureStatusPageSubscriberContactTx(tx, &page, &subscriber); err != nil { + return err + } + notification, err := statusPageNotificationTx(tx, page.AccountID) + if err != nil { + return err + } + subject, text := outbox.Subject, outbox.BodyText + if subject == "" { + subject, text = "["+page.Name+"] status update", "Status update" + } + if outbox.IncidentID != nil { + var incident StatusPageIncident + if err := tx.First(&incident, *outbox.IncidentID).Error; err != nil { + return err + } + subject = "[" + page.Name + "] " + incident.Title + text = strings.ToUpper(outbox.Kind[:1]) + outbox.Kind[1:] + ": " + incident.Title + "\n\n" + incident.BodyMD + } else if outbox.BodyText == "" { + text = "Open incidents daily digest" + } + message := &Message{NotificationID: notification.ID, ContactID: *subscriber.ContactID, Kind: "status_page", State: TaskStateQueued} + if err := tx.Create(message).Error; err != nil { + return err + } + payload, err := json.Marshal(wire.NotificationTask{AccountID: page.AccountID, MessageID: message.ID, NotificationID: notification.ID, Method: "email", Contact: wire.NotificationContact{ID: *subscriber.ContactID, Kind: "email", Value: subscriber.Email, Name: "Status page subscriber"}, Subject: subject, BodyText: text, BodyMarkdown: text, BodyHTML: "

" + html.EscapeString(text) + "

", Language: "en", MessageKind: "status_page"}) + if err != nil { + return err + } + task, err := EnqueueNotificationTaskTx(tx, &EnqueueNotificationTaskInput{AccountID: page.AccountID, NotificationID: notification.ID, ContactID: *subscriber.ContactID, MessageID: &message.ID, Method: "email", Subject: subject, BodyText: text, BodyMarkdown: text, BodyHTML: "

" + html.EscapeString(text) + "

", Language: "en", MessageKind: "status_page", NotBefore: now, Payload: payload, IdempotencyKey: outbox.IdempotencyKey}) + if err != nil { + return err + } + return tx.Model(&outbox).Updates(map[string]any{"state": statusPageOutboxDispatched, "message_id": message.ID, "task_id": task.ID, "last_error": ""}).Error + }) + if err != nil { + // This update intentionally runs after rollback: diagnostic state must not + // disappear with the failed message/task transaction. + _ = DB().Model(&StatusPageDelivery{}).Where("id = ? AND state = ?", id, statusPageOutboxPending).Update("last_error", err.Error()).Error + } + return err +} + +func cancelStatusPageDeliveryTx(tx *gorm.DB, outbox *StatusPageDelivery, reason string) error { + updates := map[string]any{"state": statusPageOutboxCanceled, "last_error": reason, "subject": "", "body_text": ""} + if err := tx.Model(outbox).Updates(updates).Error; err != nil { + return err + } + if outbox.TaskID != nil { + // Redact every task, including terminal audit rows. State is preserved for + // terminal rows, while the payload can no longer disclose the address. + if err := tx.Model(&Task{}).Where("id = ?", *outbox.TaskID).Update("payload", []byte(`{}`)).Error; err != nil { + return err + } + if err := tx.Model(&Task{}).Where("id = ? AND state NOT IN ?", *outbox.TaskID, []string{TaskStateSucceeded, TaskStateFailedPerm, TaskStateDead}).Updates(map[string]any{"state": TaskStateDead, "last_error": "canceled: " + reason, "payload": []byte(`{}`), "lease_owner": "", "lease_token": "", "lease_expires_at": nil}).Error; err != nil { + return err + } + } + if outbox.MessageID != nil { + return tx.Model(&Message{}).Where("id = ? AND state NOT IN ?", *outbox.MessageID, []string{"sent", "error"}).Updates(map[string]any{"state": "error", "error": "canceled", "response": nil}).Error + } + return nil +} + +func CancelStatusPageSubscriberDeliveriesTx(tx *gorm.DB, subscriberID int64, reason string) error { + var subscriber StatusPageSubscriber + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&subscriber, subscriberID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var rows []StatusPageDelivery + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("subscriber_id = ?", subscriberID).Order("id ASC").Find(&rows).Error; err != nil { + return err + } + for i := range rows { + if err := cancelStatusPageDeliveryTx(tx, &rows[i], reason); err != nil { + return err + } + } + return nil +} + +// RedactStatusPageSubscriberDeliveryTx removes bearer links and addresses from +// retained status-page lineage without touching unrelated account messages. +func RedactStatusPageSubscriberDeliveryTx(tx *gorm.DB, subscriberID int64) error { + var rows []StatusPageDelivery + if err := tx.Where("subscriber_id = ?", subscriberID).Find(&rows).Error; err != nil { + return err + } + for i := range rows { + row := &rows[i] + if err := tx.Model(row).Updates(map[string]any{"subject": "", "body_text": ""}).Error; err != nil { + return err + } + if row.TaskID != nil { + if err := tx.Model(&Task{}).Where("id = ?", *row.TaskID).Updates(map[string]any{"payload": []byte(`{}`), "result": []byte(`{}`), "last_error": ""}).Error; err != nil { + return err + } + if err := tx.Model(&NotificationDelivery{}).Where("task_id = ?", *row.TaskID).Updates(map[string]any{"provider_response": "", "error": ""}).Error; err != nil { + return err + } + } + if row.MessageID != nil { + if err := tx.Model(&Message{}).Where("id = ?", *row.MessageID).Updates(map[string]any{"response": nil, "error": nil}).Error; err != nil { + return err + } + } + } + return nil +} + +func RedactStatusPageConfirmationTx(tx *gorm.DB, subscriberID int64) error { + var rows []StatusPageDelivery + if err := tx.Where("subscriber_id = ? AND kind = ?", subscriberID, "confirm").Find(&rows).Error; err != nil { + return err + } + for i := range rows { + if err := tx.Model(&rows[i]).Updates(map[string]any{"subject": "", "body_text": ""}).Error; err != nil { + return err + } + if rows[i].TaskID != nil { + if err := tx.Model(&Task{}).Where("id = ?", *rows[i].TaskID).Updates(map[string]any{"payload": []byte(`{}`), "result": []byte(`{}`), "last_error": ""}).Error; err != nil { + return err + } + if err := tx.Model(&NotificationDelivery{}).Where("task_id = ?", *rows[i].TaskID).Updates(map[string]any{"provider_response": "", "error": ""}).Error; err != nil { + return err + } + } + if rows[i].MessageID != nil { + if err := tx.Model(&Message{}).Where("id = ?", *rows[i].MessageID).Updates(map[string]any{"response": nil, "error": nil}).Error; err != nil { + return err + } + } + } + return nil +} + +// DeleteStatusPageTx preserves FK-safe audit rows while making every delivery +// endpoint inert and irreversibly removing subscriber PII. +func DeleteStatusPageTx(tx *gorm.DB, page *StatusPage) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND deleted_at IS NULL", page.ID).First(page).Error; err != nil { + return err + } + var subscribers []StatusPageSubscriber + if err := tx.Where("status_page_id = ?", page.ID).Find(&subscribers).Error; err != nil { + return err + } + now := time.Now() + for i := range subscribers { + s := &subscribers[i] + if err := CancelStatusPageSubscriberDeliveriesTx(tx, s.ID, "status page deleted"); err != nil { + return err + } + if err := RedactStatusPageSubscriberDeliveryTx(tx, s.ID); err != nil { + return err + } + if err := tx.Model(s).Updates(map[string]any{"email": "redacted", "confirm_token_hash": "", "confirm_token": nil, "unsubscribe_token_hash": "", "unsubscribed_at": now}).Error; err != nil { + return err + } + if s.ContactID != nil { + if err := tx.Model(&Contact{}).Where("id = ?", *s.ContactID).Updates(map[string]any{"enabled": false, "value": "redacted", "name": "Deleted status page subscriber"}).Error; err != nil { + return err + } + } + } + return tx.Delete(page).Error +} + +// EnqueueStatusPageDailyDigests records missed due dates first, then creates +// durable outbox rows. Dates remain retryable until their outbox is dispatched. +func EnqueueStatusPageDailyDigests(now time.Time) error { + var subscribers []StatusPageSubscriber + if err := DB().Preload("StatusPage.Account").Where("kind = ? AND confirmed_at IS NOT NULL AND unsubscribed_at IS NULL", StatusPageSubscriberKindDigestDaily).Find(&subscribers).Error; err != nil { + return err + } + var errs []error + for i := range subscribers { + s := &subscribers[i] + if s.StatusPage == nil || s.StatusPage.Account == nil { + continue + } + loc, err := time.LoadLocation(s.StatusPage.Account.Timezone) + if err != nil { + loc = time.UTC + } + localNow := now.In(loc) + day := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc) + due := time.Date(day.Year(), day.Month(), day.Day(), statusPageDigestHour, 0, 0, 0, loc) + // Only persist today's actual due date. Older pending outbox rows are + // retried by DispatchStatusPageDeliveries; never invent history from a + // current incident snapshot after downtime. + if now.Before(due) || s.ConfirmedAt.After(due) { + continue + } + var incidents []StatusPageIncident + if err := DB().Where("status_page_id = ? AND resolved_at IS NULL", s.StatusPageID).Order("started_at ASC").Find(&incidents).Error; err != nil { + errs = append(errs, err) + continue + } + if len(incidents) == 0 { + continue + } // No digest is better than a misleading outage summary. + lines := make([]string, 0, len(incidents)) + for _, incident := range incidents { + lines = append(lines, "- "+incident.Title) + } + err = DB().Transaction(func(tx *gorm.DB) error { + schedule := StatusPageDigestSchedule{SubscriberID: s.ID, LocalDate: day.Format("2006-01-02"), DueAt: due.UTC()} + if err := tx.Where("subscriber_id = ? AND local_date = ?", s.ID, schedule.LocalDate).FirstOrCreate(&schedule).Error; err != nil { + return err + } + key := fmt.Sprintf("status-page:digest:subscriber:%d:date:%s", s.ID, schedule.LocalDate) + return tx.Where(StatusPageDelivery{IdempotencyKey: key}).FirstOrCreate(&StatusPageDelivery{StatusPageID: s.StatusPageID, SubscriberID: s.ID, Kind: "digest", Version: schedule.LocalDate, LocalDate: schedule.LocalDate, State: statusPageOutboxPending, IdempotencyKey: key, Subject: "[" + s.StatusPage.Name + "] daily status digest", BodyText: "Open incidents:\n" + strings.Join(lines, "\n")}).Error + }) + if err != nil { + errs = append(errs, err) + } + } + if err := DispatchStatusPageDeliveries(now); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +func StartStatusPageDigestScheduler(ctx context.Context) { + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + if err := DispatchStatusPageDeliveries(time.Now().UTC()); err != nil { + log.Printf("status-page delivery dispatch: %v", err) + } + if err := EnqueueStatusPageDailyDigests(time.Now().UTC()); err != nil { + log.Printf("status-page digest: %v", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} diff --git a/app/models/status_page_test.go b/app/models/status_page_test.go new file mode 100644 index 0000000..6ad1766 --- /dev/null +++ b/app/models/status_page_test.go @@ -0,0 +1,401 @@ +package models_test + +import ( + "strings" + "testing" + "time" + + "gorm.io/gorm" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// statusPageSeedAcc returns a fresh account for status-page tests. +func statusPageSeedAcc(t *testing.T) *models.Account { + t.Helper() + acc := &models.Account{Name: "status-page-" + strings.ReplaceAll(t.Name(), "/", "_")} + require.NoError(t, models.DB().Create(acc).Error) + return acc +} + +// TestStatusPage_Defaults asserts that creating a StatusPage with the +// minimum required fields fills in the documented defaults (colors, +// history days, soft-delete marker) and leaves IsPublished false. +func TestStatusPage_Defaults(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + + page := &models.StatusPage{ + AccountID: acc.ID, + Slug: "acme-status", + Name: "Acme Status", + } + require.NoError(t, models.DB().Create(page).Error) + + got := models.StatusPage{} + require.NoError(t, models.DB().First(&got, page.ID).Error) + + assert.Equal(t, "#62c600", got.PrimaryColor, "default primary color") + assert.Equal(t, "#1a73e8", got.AccentColor, "default accent color") + assert.Equal(t, 90, got.ShowHistoryDays, "default history days") + assert.True(t, got.ShowUptimeBars, "show_uptime_bars defaults true") + assert.True(t, got.ShowResponseTimes, "show_response_times defaults true") + assert.False(t, got.IsPublished, "is_published defaults false") + assert.False(t, got.IsPublishedNow(), "IsPublishedNow() returns false until publish") + assert.True(t, got.DeletedAt.Valid == false, "no soft-delete timestamp on fresh row") +} + +// TestStatusPage_SlugUniquenessSoftDelete verifies the partial unique +// index allows recreating a slug after soft-deleting the previous row. +func TestStatusPage_SlugUniquenessSoftDelete(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + + first := &models.StatusPage{ + AccountID: acc.ID, + Slug: "rollout", + Name: "Rollout", + } + require.NoError(t, models.DB().Create(first).Error) + + // Second live row with the same slug must fail. + dup := &models.StatusPage{ + AccountID: acc.ID, + Slug: "rollout", + Name: "Rollout copy", + } + err := models.DB().Create(dup).Error + require.Error(t, err, "duplicate slug on live rows must fail") + assert.True(t, + strings.Contains(strings.ToLower(err.Error()), "unique") || + strings.Contains(strings.ToLower(err.Error()), "duplicate"), + "unexpected error: %v", err, + ) + + // Soft-delete the first row. After that the slug is reusable. + require.NoError(t, models.DB().Delete(first).Error) + + again := &models.StatusPage{ + AccountID: acc.ID, + Slug: "rollout", + Name: "Rollout v2", + } + require.NoError(t, models.DB().Create(again).Error, + "recreating a slug after a soft-delete must succeed (partial index)") + assert.NotEqual(t, first.ID, again.ID) +} + +// TestStatusPage_SlugIsGlobal verifies public URLs are unambiguous: the +// account is not part of /status/:slug, so another account cannot claim it. +func TestStatusPage_SlugIsGlobal(t *testing.T) { + models.Drop() + models.Migrate() + accA := statusPageSeedAcc(t) + accB := &models.Account{Name: "status-page-other"} + require.NoError(t, models.DB().Create(accB).Error) + + a := &models.StatusPage{AccountID: accA.ID, Slug: "shared", Name: "A"} + require.NoError(t, models.DB().Create(a).Error) + + b := &models.StatusPage{AccountID: accB.ID, Slug: "shared", Name: "B"} + require.Error(t, models.DB().Create(b).Error, + "different accounts must not be able to reuse a public slug") +} + +func TestStatusPageIncidentDeliveryIsIdempotentAndAccountScoped(t *testing.T) { + models.Drop() + models.Migrate() + account := statusPageSeedAcc(t) + page := &models.StatusPage{AccountID: account.ID, Slug: "delivery-page", Name: "Delivery"} + require.NoError(t, models.DB().Create(page).Error) + now := time.Now() + subscriber := &models.StatusPageSubscriber{StatusPageID: page.ID, Email: "subscriber@example.test", Kind: models.StatusPageSubscriberKindAlert, ConfirmedAt: &now} + require.NoError(t, models.DB().Create(subscriber).Error) + incident := &models.StatusPageIncident{StatusPageID: page.ID, Title: "API unavailable", Severity: models.StatusPageIncidentSeverityCrit, StartedAt: now} + require.NoError(t, models.DB().Create(incident).Error) + + require.NoError(t, models.DB().Transaction(func(tx *gorm.DB) error { + return models.EnqueueStatusPageIncidentDeliveriesTx(tx, page, incident, "opened") + })) + require.NoError(t, models.DB().Transaction(func(tx *gorm.DB) error { + return models.EnqueueStatusPageIncidentDeliveriesTx(tx, page, incident, "opened") + })) + var deliveries []models.StatusPageDelivery + require.NoError(t, models.DB().Where("idempotency_key LIKE ?", "status-page:opened:%").Find(&deliveries).Error) + require.Len(t, deliveries, 1) + assert.Equal(t, page.ID, deliveries[0].StatusPageID) +} + +func TestStatusPageDailyDigestIsTimezoneDateIdempotent(t *testing.T) { + models.Drop() + models.Migrate() + account := statusPageSeedAcc(t) + account.Timezone = "UTC" + require.NoError(t, models.DB().Save(account).Error) + page := &models.StatusPage{AccountID: account.ID, Slug: "digest-page", Name: "Digest"} + require.NoError(t, models.DB().Create(page).Error) + now := time.Date(2026, time.July, 13, 9, 15, 0, 0, time.UTC) + confirmedAt := now.Add(-time.Hour) + subscriber := &models.StatusPageSubscriber{StatusPageID: page.ID, Email: "digest@example.test", Kind: models.StatusPageSubscriberKindDigestDaily, ConfirmedAt: &confirmedAt} + require.NoError(t, models.DB().Create(subscriber).Error) + require.NoError(t, models.DB().Create(&models.StatusPageIncident{StatusPageID: page.ID, Title: "Still open", Severity: models.StatusPageIncidentSeverityWarn, StartedAt: now}).Error) + + _ = models.EnqueueStatusPageDailyDigests(now) + _ = models.EnqueueStatusPageDailyDigests(now.Add(30 * time.Second)) + var count int64 + require.NoError(t, models.DB().Model(&models.StatusPageDelivery{}).Where("idempotency_key LIKE ?", "status-page:digest:%").Count(&count).Error) + assert.GreaterOrEqual(t, count, int64(1), "catch-up may include earlier local due dates") +} + +// TestStatusPage_NormalizeSlugAndValidate exercises the slug normalizer +// and validator — uppercase input becomes lowercase, and an invalid +// slug (leading dash) is rejected on save via BeforeSave. +func TestStatusPage_NormalizeSlugAndValidate(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + + assert.Equal(t, "lower-case", models.NormalizeStatusPageSlug(" Lower-Case ")) + assert.NoError(t, models.ValidateStatusPageSlug("acme-status")) + + bad := []string{"", "-leading-dash", "trailing-dash-", "UPPER", "with spaces", "with_underscore"} + for _, slug := range bad { + err := models.ValidateStatusPageSlug(slug) + assert.Error(t, err, "expected error for slug %q", slug) + } + + // Save enforces the same rules — uppercase gets normalized, invalid + // characters reject. + good := &models.StatusPage{AccountID: acc.ID, Slug: "ACME-Status", Name: "Acme"} + require.NoError(t, models.DB().Create(good).Error) + assert.Equal(t, "acme-status", good.Slug) + + bad2 := &models.StatusPage{AccountID: acc.ID, Slug: "-bad", Name: "Bad"} + err := models.DB().Create(bad2).Error + require.Error(t, err, "invalid slug should be rejected by BeforeSave") +} + +// TestStatusPage_ColorValidation rejects non-hex colors at save time. +func TestStatusPage_ColorValidation(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + + bad := &models.StatusPage{ + AccountID: acc.ID, + Slug: "badcolor", + Name: "BadColor", + PrimaryColor: "not-a-color", + } + err := models.DB().Create(bad).Error + require.Error(t, err, "non-hex color must be rejected") + + good := &models.StatusPage{ + AccountID: acc.ID, + Slug: "goodcolor", + Name: "GoodColor", + PrimaryColor: "#0a1b2c", + AccentColor: "#abc", + } + require.NoError(t, models.DB().Create(good).Error) + + reloaded := models.StatusPage{} + require.NoError(t, models.DB().First(&reloaded, good.ID).Error) + assert.Equal(t, "#0a1b2c", reloaded.PrimaryColor) + assert.Equal(t, "#abc", reloaded.AccentColor) +} + +// TestStatusPageSubscriber_UniqueActive verifies the partial unique +// index on (status_page_id, lower(email)) only fires while a +// subscriber is not unsubscribed. +func TestStatusPageSubscriber_UniqueActive(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + page := &models.StatusPage{ + AccountID: acc.ID, + Slug: "sub", + Name: "Sub", + } + require.NoError(t, models.DB().Create(page).Error) + + mk := func(email string, unsub *time.Time) *models.StatusPageSubscriber { + s := &models.StatusPageSubscriber{ + StatusPageID: page.ID, + Email: email, + Kind: models.StatusPageSubscriberKindAlert, + ConfirmTokenHash: models.HashStatusPageToken("tok-" + email), + } + s.UnsubscribedAt = unsub + return s + } + + first := mk("Alice@Example.com", nil) + require.NoError(t, models.DB().Create(first).Error) + + // Same email with mixed case must conflict (lower(email) is the + // unique key), so a true case-insensitive uniqueness is in place. + dup := mk("alice@example.com", nil) + err := models.DB().Create(dup).Error + require.Error(t, err, "active subscriber with same lowercased email must conflict") + + // Soft-unsubscribe the original then re-add a fresh row with the + // same email — this must succeed because the partial index + // excludes unsubscribed rows. + now := time.Now() + first.UnsubscribedAt = &now + require.NoError(t, models.DB().Save(first).Error) + + resurrected := mk("alice@example.com", nil) + require.NoError(t, models.DB().Create(resurrected).Error, + "creating a fresh subscriber after the previous one unsubscribed must succeed") + assert.NotEqual(t, first.ID, resurrected.ID) + + // ActiveSubscriber reflects both confirmed and unsubscribed flags. + confirmed := time.Now() + active := &models.StatusPageSubscriber{ + StatusPageID: page.ID, + Email: "bob@example.com", + Kind: models.StatusPageSubscriberKindAlert, + ConfirmTokenHash: models.HashStatusPageToken("tok-bob"), + ConfirmedAt: &confirmed, + } + require.NoError(t, models.DB().Create(active).Error) + assert.True(t, active.IsActiveSubscriber(), "confirmed and not unsubscribed → active") + + pending := &models.StatusPageSubscriber{ + StatusPageID: page.ID, + Email: "carol@example.com", + Kind: models.StatusPageSubscriberKindAlert, + ConfirmTokenHash: models.HashStatusPageToken("tok-carol"), + } + require.NoError(t, models.DB().Create(pending).Error) + assert.False(t, pending.IsActiveSubscriber(), "unconfirmed → not active") +} + +// TestStatusPageIncident_SeverityAndEventFK checks that the FK to +// events is wired correctly and that severity defaults to "info". +func TestStatusPageIncident_SeverityAndEventFK(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + page := &models.StatusPage{AccountID: acc.ID, Slug: "inc", Name: "Inc"} + require.NoError(t, models.DB().Create(page).Error) + + now := time.Now() + inc := &models.StatusPageIncident{ + StatusPageID: page.ID, + Title: "API slowdown", + BodyMD: "Investigating.", + Severity: models.StatusPageIncidentSeverityWarn, + StartedAt: now, + } + require.NoError(t, models.DB().Create(inc).Error) + + got := models.StatusPageIncident{} + require.NoError(t, models.DB().First(&got, inc.ID).Error) + assert.Equal(t, "warn", got.Severity) + + // Default severity is "info" when omitted. + auto := &models.StatusPageIncident{ + StatusPageID: page.ID, + Title: "heads up", + StartedAt: now, + } + require.NoError(t, models.DB().Create(auto).Error) + gotAuto := models.StatusPageIncident{} + require.NoError(t, models.DB().First(&gotAuto, auto.ID).Error) + assert.Equal(t, models.StatusPageIncidentSeverityInfo, gotAuto.Severity) + + // EventID stays nullable and the FK tolerates a NULL event. + var nilEvt *int64 + noEvt := &models.StatusPageIncident{ + StatusPageID: page.ID, + Title: "no event", + Severity: models.StatusPageIncidentSeverityCrit, + StartedAt: now, + EventID: nilEvt, + } + require.NoError(t, models.DB().Create(noEvt).Error) +} + +// TestStatusPageMaintenance_BigintArray ensures the monitor_ids bigint[] +// column round-trips through the GORM pq.Int64Array driver correctly. +func TestStatusPageMaintenance_BigintArray(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + page := &models.StatusPage{AccountID: acc.ID, Slug: "mt", Name: "Maintenance"} + require.NoError(t, models.DB().Create(page).Error) + + now := time.Now() + maint := &models.StatusPageMaintenance{ + StatusPageID: page.ID, + Title: "DB upgrade", + Description: "Rolling upgrade.", + StartsAt: now, + EndsAt: now.Add(2 * time.Hour), + MonitorIDs: models.Int64ArrayFromSlice([]int64{1, 7, 42}), + NotifySubscribers: true, + } + require.NoError(t, models.DB().Create(maint).Error) + + got := models.StatusPageMaintenance{} + require.NoError(t, models.DB().First(&got, maint.ID).Error) + assert.Equal(t, []int64{1, 7, 42}, []int64(got.MonitorIDs)) + assert.True(t, got.NotifySubscribers) +} + +// TestStatusPageDomain_TableReservation verifies the M5 table is +// created with the right unique constraints even though M0 does not +// yet populate it. +func TestStatusPageDomain_TableReservation(t *testing.T) { + models.Drop() + models.Migrate() + acc := statusPageSeedAcc(t) + page := &models.StatusPage{AccountID: acc.ID, Slug: "dom", Name: "Domain"} + require.NoError(t, models.DB().Create(page).Error) + + d := &models.StatusPageDomain{ + StatusPageID: page.ID, + Domain: "status.example.com", + TXTToken: "rsmon-verify=abc123", + } + require.NoError(t, models.DB().Create(d).Error) + require.NoError(t, models.DB().Create(&models.StatusPageDomain{ + StatusPageID: page.ID, + Domain: "www.status.example.com", + TXTToken: "rsmon-verify=second", + }).Error, "one status page may own multiple domains") + + // Second page claiming the same domain must fail because domain + // is globally unique. + other := &models.StatusPage{AccountID: acc.ID, Slug: "dom2", Name: "Domain2"} + require.NoError(t, models.DB().Create(other).Error) + dup := &models.StatusPageDomain{ + StatusPageID: other.ID, + Domain: "status.example.com", + TXTToken: "rsmon-verify=xyz789", + } + err := models.DB().Create(dup).Error + require.Error(t, err) + assert.True(t, + strings.Contains(strings.ToLower(err.Error()), "unique") || + strings.Contains(strings.ToLower(err.Error()), "duplicate"), + "expected unique-constraint error, got %v", err) +} + +func TestNormalizeStatusPageDomain(t *testing.T) { + got, err := models.NormalizeStatusPageDomain(" Status.Example.COM. ") + require.NoError(t, err) + assert.Equal(t, "status.example.com", got) + for _, input := range []string{"https://example.com", "example.com:443", "127.0.0.1", "*.example.com", "-bad.example.com"} { + _, err := models.NormalizeStatusPageDomain(input) + assert.Error(t, err, input) + } +} diff --git a/app/models/subscription.go b/app/models/subscription.go new file mode 100644 index 0000000..2011954 --- /dev/null +++ b/app/models/subscription.go @@ -0,0 +1,61 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +const ( + SubscriptionStatusTrialing = "trialing" + SubscriptionStatusActive = "active" + SubscriptionStatusPastDue = "past_due" + SubscriptionStatusPaused = "paused" + SubscriptionStatusCanceled = "canceled" + SubscriptionStatusExpired = "expired" +) + +// Subscription is the current billable entitlement for one account. M0 only +// writes manual subscriptions; provider flows are deliberately deferred. +type Subscription struct { + concerns.Model + AccountID int64 `gorm:"not null;index" json:"account_id"` + Account *Account `json:"-"` + PlanID int64 `gorm:"not null;index" json:"plan_id"` + Plan *Plan `json:"plan,omitempty"` + Provider string `gorm:"size:16;not null;default:'manual'" json:"provider"` + ProviderSubscriptionID *string `gorm:"size:128" json:"provider_subscription_id,omitempty"` + ProviderCustomerID *string `gorm:"size:128" json:"provider_customer_id,omitempty"` + Status string `gorm:"size:24;not null;default:'active';index" json:"status"` + BillingCycle string `gorm:"size:8;not null;default:'monthly'" json:"billing_cycle"` + CurrentPeriodStart *time.Time `json:"current_period_start,omitempty"` + CurrentPeriodEnd *time.Time `gorm:"index" json:"current_period_end,omitempty"` + TrialEndsAt *time.Time `json:"trial_ends_at,omitempty"` + CancelAtPeriodEnd bool `gorm:"not null;default:false" json:"cancel_at_period_end"` + CanceledAt *time.Time `json:"canceled_at,omitempty"` + Currency string `gorm:"size:3;not null" json:"currency"` + AmountMinor int64 `gorm:"not null;default:0" json:"amount_minor"` + MetadataJSON datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'" json:"metadata_json"` + concerns.Timestamped +} + +// SubscriptionEvent is an append-only audit record for entitlement changes +// and provider deliveries. ProviderEventID makes future webhook processing +// idempotent without coupling the ledger to a specific PSP. +type SubscriptionEvent struct { + concerns.Model + SubscriptionID int64 `gorm:"not null;index" json:"subscription_id"` + AccountID int64 `gorm:"not null;index" json:"account_id"` + Provider string `gorm:"size:16;not null;default:'manual';uniqueIndex:idx_subscription_events_provider_event,priority:1" json:"provider"` + Kind string `gorm:"size:32;not null" json:"kind"` + FromPlanID *int64 `json:"from_plan_id,omitempty"` + ToPlanID *int64 `json:"to_plan_id,omitempty"` + AmountMinor *int64 `json:"amount_minor,omitempty"` + Currency string `gorm:"size:3" json:"currency,omitempty"` + ActorUserID *int64 `json:"actor_user_id,omitempty"` + ProviderEventID *string `gorm:"size:128;uniqueIndex:idx_subscription_events_provider_event,priority:2" json:"provider_event_id,omitempty"` + PayloadJSON datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'" json:"payload_json"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/app/models/tag.go b/app/models/tag.go new file mode 100644 index 0000000..e8e0dcc --- /dev/null +++ b/app/models/tag.go @@ -0,0 +1,47 @@ +package models + +import ( + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Tag stores metadata for a single tag name within an account: the +// user-chosen color (hex string like "#FF5733") and icon (FontAwesome +// icon name like "faTag"). The (account_id, name) pair is unique. +// +// Tags are still attached to monitors via the monitors.tags text[] +// column (see Monitor.Tags). This table only holds the per-tag display +// metadata so the same tag renders consistently across the SPA — it +// does not affect monitor scoping or filtering. +// +// Rows are created lazily: the SPA POSTs a Tag the first time a user +// customizes its color/icon, and GET /tags?with_counts=1 LEFT JOINs +// the unnested monitors.tags array against this table to enrich the +// (name, count) pairs with display metadata. A Tag row may exist with +// zero monitors using it (count=0) — that happens when a user creates +// a tag from /settings/tags/new but has not yet applied it. +type Tag struct { + concerns.Model + AccountID int64 `gorm:"index;not null" json:"account_id"` + Account *Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Color string `gorm:"type:varchar(32);not null;default:'#6c757d'" json:"color"` + Icon string `gorm:"type:varchar(64);not null;default:'faTag'" json:"icon"` + Audited + concerns.Timestamped +} + +// TagInfo is a tag with the number of monitors using it plus optional +// display metadata (color, icon) coming from the Tag table. +type TagInfo struct { + Name string `json:"name"` + Count int64 `json:"count"` + Color string `json:"color"` + Icon string `json:"icon"` +} + +// DefaultTagColor is the hex color used when a Tag has no metadata row. +const DefaultTagColor = "#6c757d" + +// DefaultTagIcon is the FontAwesome icon name used when a Tag has no +// metadata row. Matches the icon the SPA renders by default. +const DefaultTagIcon = "faTag" diff --git a/app/models/tags_test.go b/app/models/tags_test.go new file mode 100644 index 0000000..dec8b08 --- /dev/null +++ b/app/models/tags_test.go @@ -0,0 +1,135 @@ +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// seedTagsMonitors creates one group with four monitors: +// +// a.test [prod, web] +// b.test [prod, api] +// c.test [staging] +// d.test [] (NULL tags) +// +// and returns the group id. It mirrors the exact SQL the /tags rename, delete +// and with_counts endpoints run, pinning the contract. +func seedTagsMonitors(t *testing.T) int64 { + models.Drop() + models.Migrate() + + plan := models.Plan{Name: "test", Default: true} + require.NoError(t, models.DB().Create(&plan).Error) + acc := models.Account{Name: "A", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&acc).Error) + group := models.Group{AccountID: acc.ID, Name: "A-default"} + require.NoError(t, models.DB().Create(&group).Error) + + monitors := []models.Monitor{ + {GroupID: group.ID, Host: "a.test", Tags: []string{"prod", "web"}}, + {GroupID: group.ID, Host: "b.test", Tags: []string{"prod", "api"}}, + {GroupID: group.ID, Host: "c.test", Tags: []string{"staging"}}, + {GroupID: group.ID, Host: "d.test"}, + } + for i := range monitors { + require.NoError(t, models.DB().Create(&monitors[i]).Error) + } + return group.ID +} + +func TestTagsRenameSQL(t *testing.T) { + groupID := seedTagsMonitors(t) + + res := models.DB().Exec( + `UPDATE monitors SET tags = ( + SELECT array_agg(DISTINCT x) FROM unnest(array_replace(tags, ?, ?)) AS t(x) + ) WHERE tags IS NOT NULL AND group_id IN (?) AND ? = ANY(tags)`, + "prod", "production", []int64{groupID}, "prod", + ) + require.NoError(t, res.Error) + assert.Equal(t, int64(2), res.RowsAffected, "only a.test and b.test contain prod") + + var a models.Monitor + require.NoError(t, models.DB().Where("host = ?", "a.test").First(&a).Error) + assert.Equal(t, []string{"production", "web"}, []string(a.Tags)) + + var b models.Monitor + require.NoError(t, models.DB().Where("host = ?", "b.test").First(&b).Error) + assert.ElementsMatch(t, []string{"production", "api"}, []string(b.Tags)) + + var c models.Monitor + require.NoError(t, models.DB().Where("host = ?", "c.test").First(&c).Error) + assert.Equal(t, []string{"staging"}, []string(c.Tags), "staging-only monitor untouched") +} + +func TestTagsRenameSQL_Dedup(t *testing.T) { + groupID := seedTagsMonitors(t) + + // Rename prod -> web. a.test has both prod and web: must collapse to a + // single "web" entry (array_agg DISTINCT), not [web, web]. + res := models.DB().Exec( + `UPDATE monitors SET tags = ( + SELECT array_agg(DISTINCT x) FROM unnest(array_replace(tags, ?, ?)) AS t(x) + ) WHERE tags IS NOT NULL AND group_id IN (?) AND ? = ANY(tags)`, + "prod", "web", []int64{groupID}, "prod", + ) + require.NoError(t, res.Error) + assert.Equal(t, int64(2), res.RowsAffected) + + var a models.Monitor + require.NoError(t, models.DB().Where("host = ?", "a.test").First(&a).Error) + assert.Equal(t, []string{"web"}, []string(a.Tags), "duplicate must be deduped") + + var b models.Monitor + require.NoError(t, models.DB().Where("host = ?", "b.test").First(&b).Error) + assert.ElementsMatch(t, []string{"web", "api"}, []string(b.Tags)) +} + +func TestTagsDeleteSQL(t *testing.T) { + groupID := seedTagsMonitors(t) + + res := models.DB().Exec( + `UPDATE monitors SET tags = array_remove(tags, ?) + WHERE tags IS NOT NULL AND group_id IN (?) AND ? = ANY(tags)`, + "prod", []int64{groupID}, "prod", + ) + require.NoError(t, res.Error) + assert.Equal(t, int64(2), res.RowsAffected) + + var a models.Monitor + require.NoError(t, models.DB().Where("host = ?", "a.test").First(&a).Error) + assert.Equal(t, []string{"web"}, []string(a.Tags)) + + var b models.Monitor + require.NoError(t, models.DB().Where("host = ?", "b.test").First(&b).Error) + assert.Equal(t, []string{"api"}, []string(b.Tags)) +} + +func TestTagsCountSQL(t *testing.T) { + groupID := seedTagsMonitors(t) + + type tagInfo struct { + Name string + Count int64 + } + var tags []tagInfo + err := models.DB().Raw( + `SELECT tag AS name, COUNT(*) AS count + FROM (SELECT unnest(tags) AS tag FROM monitors + WHERE tags IS NOT NULL AND group_id IN (?)) sub + GROUP BY tag + ORDER BY tag`, + []int64{groupID}, + ).Scan(&tags).Error + require.NoError(t, err) + + got := map[string]int64{} + for _, tg := range tags { + got[tg.Name] = tg.Count + } + assert.Equal(t, map[string]int64{"prod": 2, "web": 1, "api": 1, "staging": 1}, got) +} diff --git a/app/models/task.go b/app/models/task.go new file mode 100644 index 0000000..e60fbed --- /dev/null +++ b/app/models/task.go @@ -0,0 +1,145 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm/clause" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Task kinds stored in the tasks.kind column. The plan (docs/plans/worker-notifier-mvp.md +// section 4.1) reserves the same enum for checks and notifications; this phase only +// emits notification rows but the enum is shared so the selector can stay a single +// function. +const ( + TaskKindNotification = "notification" + TaskKindCheck = "check" + TaskKindServerMetric = "server_metric" +) + +// Task states for the durable task envelope. +const ( + TaskStateQueued = "queued" + TaskStateLeased = "leased" + TaskStateSucceeded = "succeeded" + TaskStateFailedRetry = "failed_retry" + TaskStateFailedPerm = "failed_perm" + TaskStateDead = "dead" +) + +// Notification result statuses reported by the worker (mirrors the wire enum so the +// result handler can decode without re-typing the constants). +const ( + NotificationResultDelivered = "delivered" + NotificationResultRetryable = "retryable" + NotificationResultPermanent = "permanent" + NotificationResultPartial = "partial" +) + +// SkipLockedClause is the SELECT ... FOR UPDATE SKIP LOCKED clause used by +// every worker-pool selector (checks in check_jobs.ChecksForWorker and +// tasks in task_selector.TasksForWorker / TasksForWorkerNotification). +// Sharing the value keeps the SQL identical across selectors so goconst +// does not flag the literal, and a future change (e.g. NOWAIT) only has +// to touch one place. +var SkipLockedClause = clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"} + +// Task is the durable envelope for both check and notification work executed by the +// distributed worker pool. Selection uses FOR UPDATE SKIP LOCKED per worker poll so +// a single primary key or sequence never becomes the bottleneck. +// +// Phase 1 (this commit) only emits notification tasks. The `kind` discriminator and +// capability filters are designed to accept checks in phase 2 without a schema change. +type Task struct { + concerns.Model + + JobID string `gorm:"uniqueIndex;size:64" json:"job_id"` + Kind string `gorm:"size:32;index" json:"kind"` + State string `gorm:"size:32;index" json:"state"` + LastError string `gorm:"type:text" json:"last_error"` + + // Tenancy + audit anchor. AccountID is required for the capability match in + // TasksForWorker; monitor_id / message_id / contact_id are denormalized for + // fast admin queries. + AccountID int64 `gorm:"index" json:"account_id"` + MonitorID *int64 `gorm:"index" json:"monitor_id,omitempty"` + CheckID *int64 `json:"check_id,omitempty"` + MessageID *int64 `json:"message_id,omitempty"` + ContactID *int64 `json:"contact_id,omitempty"` + + // Payload is the kind-specific blob the worker needs to execute. For + // notifications the producer pre-renders subject/body so the worker does not + // need templating context (see RenderNotificationContent in internal/notifier). + Payload datatypes.JSON `gorm:"type:jsonb" json:"payload"` + + // Scheduling + retry envelope. NotBefore is set to NOW() by the producer and + // bumped by the result handler on retryable failures. Deadline is a soft cap + // the selector can use to skip stale tasks. + NotBefore time.Time `json:"not_before"` + Deadline *time.Time `json:"deadline,omitempty"` + + // LeaseOwner + LeaseExpiresAt are owned by the selector while the task is + // in state=leased. The reaper clears them when the lease expires. + LeaseOwner string `gorm:"size:128" json:"lease_owner"` + LeaseToken string `gorm:"size:64" json:"-"` + LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"` + + Attempts int `json:"attempts"` + MaxAttempts int `json:"max_attempts"` + + // IdempotencyKey is unique per logical event so a retry of the producer's + // enqueue never produces a second Task row. See EnqueueNotificationTask. + IdempotencyKey string `gorm:"uniqueIndex;size:255" json:"idempotency_key"` + + // Result holds the most recent worker result body (NotificationResultReport or + // CheckResultReport shape, depending on Kind). It is JSONB so the admin UI can + // pretty-print without a separate result table for transient lookups. + Result datatypes.JSON `gorm:"type:jsonb" json:"result"` + SucceededAt *time.Time `json:"succeeded_at,omitempty"` + + concerns.Timestamped +} + +// TaskReplay records the single operator-initiated replay of a dead task. +// OriginalTaskID is unique, making repeated clicks/API retries idempotent. +type TaskReplay struct { + concerns.Model + OriginalTaskID int64 `gorm:"uniqueIndex" json:"original_task_id"` + RequeuedTaskID int64 `gorm:"uniqueIndex" json:"requeued_task_id"` + OperatorUserID int64 `gorm:"index" json:"operator_user_id"` + concerns.Timestamped +} + +// TableName overrides the default table name so pluralization stays consistent +// with the rest of the schema (tasks, not "task" or "taskses"). +func (Task) TableName() string { + return "tasks" +} + +// NotificationDelivery is the per-attempt audit row required by section 7.5 of the +// plan ("Audit rows: each successful or failed delivery writes a row in a new +// notification_deliveries table"). The result handler appends one row per result +// frame, which lets support answer "did the customer ever get this alert" without +// scanning application logs. +type NotificationDelivery struct { + concerns.Model + + MessageID int64 `gorm:"index" json:"message_id"` + WorkerID string `gorm:"size:128;index" json:"worker_id"` + TaskID int64 `gorm:"index" json:"task_id"` + + Status string `gorm:"size:32" json:"status"` + Error string `gorm:"type:text" json:"error"` + + DurationMs int `json:"duration_ms"` + ProviderResponse string `gorm:"type:text" json:"provider_response"` + + concerns.Timestamped +} + +// TableName mirrors the plan's preferred lowercase plural. +func (NotificationDelivery) TableName() string { + return "notification_deliveries" +} diff --git a/app/models/task_reaper.go b/app/models/task_reaper.go new file mode 100644 index 0000000..d6b1edf --- /dev/null +++ b/app/models/task_reaper.go @@ -0,0 +1,136 @@ +package models + +import ( + "context" + "log" + "time" + + "gorm.io/gorm" +) + +// ReapExpiredTasks is the periodic cleanup function described in +// docs/plans/worker-notifier-mvp.md section 8.5: +// +// - tasks in state='leased' whose lease_expires_at is past are returned to +// state='queued' and have their lease_owner cleared, so the next selector +// poll can pick them up. +// - tasks in state='failed_retry' whose not_before is past AND +// attempts >= max_attempts are moved to state='dead' so they show up on +// the admin dead-letter page and stop consuming selector bandwidth. +// +// It returns the number of rows it touched so the caller can log a metric. +// Cheap enough to run from the web process every 30s. +func ReapExpiredTasks() (reaped int, deaded int, err error) { + now := time.Now() + err = DB().Transaction(func(tx *gorm.DB) error { + if err := expireQueuedNotificationTasksTx(tx, now); err != nil { + return err + } + var expired []Task + if err := tx.Where("state = ? AND lease_expires_at IS NOT NULL AND lease_expires_at < ?", TaskStateLeased, now).Find(&expired).Error; err != nil { + return err + } + for i := range expired { + if expired[i].Attempts >= expired[i].MaxAttempts { + result := tx.Model(&Task{}).Where("id = ? AND state = ?", expired[i].ID, TaskStateLeased).Updates(map[string]interface{}{"state": TaskStateDead, "lease_owner": "", "lease_token": "", "lease_expires_at": nil, "last_error": "lease expired after max attempts", "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 1 { + deaded++ + if err := FinalizeNotificationTaskTx(tx, &expired[i], "dead", "lease expired after max attempts"); err != nil { + return err + } + } + continue + } + result := tx.Model(&Task{}).Where("id = ? AND state = ?", expired[i].ID, TaskStateLeased).Updates(map[string]interface{}{"state": TaskStateQueued, "lease_owner": "", "lease_token": "", "lease_expires_at": nil, "updated_at": now}) + if result.Error != nil { + return result.Error + } + reaped += int(result.RowsAffected) + } + var exhausted []Task + if err := tx.Where("state = ? AND not_before <= ? AND attempts >= max_attempts", TaskStateFailedRetry, now).Find(&exhausted).Error; err != nil { + return err + } + for i := range exhausted { + result := tx.Model(&Task{}).Where("id = ? AND state = ?", exhausted[i].ID, TaskStateFailedRetry).Updates(map[string]interface{}{"state": TaskStateDead, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 1 { + deaded++ + if err := FinalizeNotificationTaskTx(tx, &exhausted[i], "dead", exhausted[i].LastError); err != nil { + return err + } + } + } + return nil + }) + return reaped, deaded, err +} + +// FinalizeNotificationTaskTx makes a terminal notification task customer-visible +// and auditable. The caller owns the task state transition in this transaction. +func FinalizeNotificationTaskTx(tx *gorm.DB, task *Task, status, reason string) error { + if task == nil || task.Kind != TaskKindNotification || task.MessageID == nil { + return nil + } + if err := tx.Model(&Message{}).Where("id = ? AND state NOT IN ?", *task.MessageID, []string{"sent", "error"}).Updates(map[string]interface{}{"state": "error", "error": reason}).Error; err != nil { + return err + } + return tx.Create(&NotificationDelivery{MessageID: *task.MessageID, TaskID: task.ID, Status: status, Error: reason}).Error +} + +func expireQueuedNotificationTasksTx(tx *gorm.DB, now time.Time) error { + var tasks []Task + if err := tx.Where("state = ? AND kind = ? AND deadline IS NOT NULL AND deadline <= ?", TaskStateQueued, TaskKindNotification, now).Find(&tasks).Error; err != nil { + return err + } + for i := range tasks { + result := tx.Model(&Task{}).Where("id = ? AND state = ?", tasks[i].ID, TaskStateQueued).Updates(map[string]interface{}{ + "state": TaskStateDead, "last_error": "notification deadline expired", "updated_at": now, + }) + if result.Error != nil || result.RowsAffected == 0 { + if result.Error != nil { + return result.Error + } + continue + } + if err := FinalizeNotificationTaskTx(tx, &tasks[i], "expired", "notification deadline expired"); err != nil { + return err + } + } + return nil +} + +// StartTaskReaper launches a goroutine that runs ReapExpiredTasks on the given +// interval. It honors ctx.Done() so the caller can wind it down without +// leaking. The function is safe to call once per process; the control plane +// runs the reaper from main.init() so only one ticker ever exists in a single +// web process. +func StartTaskReaper(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = 30 * time.Second + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + reaped, deaded, err := ReapExpiredTasks() + if err != nil { + log.Printf("task_reaper: error: %v", err) + continue + } + if reaped > 0 || deaded > 0 { + log.Printf("task_reaper: reaped=%d dead=%d", reaped, deaded) + } + } + } + }() +} diff --git a/app/models/task_selector.go b/app/models/task_selector.go new file mode 100644 index 0000000..26b52c7 --- /dev/null +++ b/app/models/task_selector.go @@ -0,0 +1,391 @@ +package models + +import ( + "errors" + "fmt" + "log" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// DefaultCheckTaskLeaseTTL is how long a leased check remains owned before the reaper +// returns it to the queue. It is deliberately larger than the worker's heartbeat +// (10s) so a healthy worker can finish a 30s check without the reaper stealing +// the lease, and deliberately smaller than the heartbeat timeout (2m) so a +// crashed worker sees its tasks reaped within one watchdog cycle. +const DefaultCheckTaskLeaseTTL = 60 * time.Second + +// DefaultTaskLeaseTTL remains the check-task default for existing confirmation +// callers. Generic task selection must use TaskLeaseTTL so notification work is +// not reclaimed during its longer execution window. +const DefaultTaskLeaseTTL = DefaultCheckTaskLeaseTTL + +// Notification execution is bounded by the worker runner at five minutes. The +// additional minute covers result serialization, websocket/HTTP transport, and +// a control-plane scheduling delay before the persisted lease may be reaped. +const ( + DefaultNotificationExecutionTimeout = 5 * time.Minute + NotificationTaskReportMargin = time.Minute + DefaultNotificationTaskLeaseTTL = DefaultNotificationExecutionTimeout + NotificationTaskReportMargin +) + +// TaskLeaseTTL returns the persisted lease lifetime for a task kind. +func TaskLeaseTTL(kind string) time.Duration { + if kind == TaskKindNotification { + return DefaultNotificationTaskLeaseTTL + } + return DefaultCheckTaskLeaseTTL +} + +// DefaultTaskMaxAttempts is the retry budget for a task before it moves to dead. +const DefaultTaskMaxAttempts = 5 + +// DefaultNotificationTaskDeadline is assigned to manually replayed notification +// dead letters. Normal producer tasks may be deadline-free, but a replay must +// never inherit an already-expired deadline. +const DefaultNotificationTaskDeadline = 15 * time.Minute + +// ErrNotificationMethodNotAuthorized is returned by EnqueueNotificationTask +// when the producer can prove no worker in the pool is authorized for the +// (method, account) pair. The caller may skip the enqueue or log + continue. +var ErrNotificationMethodNotAuthorized = errors.New("no worker authorized for method/account") + +// EnqueueNotificationTaskInput is the pre-rendered envelope produced by the +// notifier producer. All slices are required; the selector never reads them. +type EnqueueNotificationTaskInput struct { + AccountID int64 + NotificationID int64 + ContactID int64 + MessageID *int64 + MonitorID *int64 + CheckID *int64 + EventIDs []int64 + Method string // "email", "telegram", "webhook", "mattermost", "sms", "voice" + Subject string + BodyText string + BodyHTML string + BodyMarkdown string + Language string + MessageKind string // "down", "up", "exp", "test" + NotBefore time.Time + Deadline *time.Time + MaxAttempts int + Payload []byte // marshaled task-specific data + IdempotencyKey string // optional; manual test tasks use a unique key and do not have event IDs +} + +// EnqueueNotificationTask writes one Task row keyed by a stable idempotency key. +// A second call with the same key (same notification/contact/event triple) is a +// no-op so the producer is safe to call more than once per pass. +// +// The capability precheck uses the same NotificationMethods + NotificationAccounts +// rule the selector does, so the producer can skip enqueueing work that no +// operated worker could ever pick up (sms/voice until phase 4). +func EnqueueNotificationTask(input *EnqueueNotificationTaskInput) (*Task, error) { + return EnqueueNotificationTaskTx(DB(), input) +} + +// EnqueueNotificationTaskTx is the transactional form used by state machines +// that must commit their transition, audit event, message, and task together. +func EnqueueNotificationTaskTx(tx *gorm.DB, input *EnqueueNotificationTaskInput) (*Task, error) { + if tx == nil { + return nil, errors.New("enqueue: nil transaction") + } + if input.AccountID == 0 || input.ContactID == 0 { + return nil, errors.New("enqueue: account_id and contact_id are required") + } + + idempotencyKey := input.IdempotencyKey + if idempotencyKey == "" { + if input.NotificationID == 0 || len(input.EventIDs) == 0 { + return nil, errors.New("enqueue: notification_id and event_ids are required without explicit idempotency_key") + } + idempotencyKey = notificationIdempotencyKey(input.NotificationID, input.ContactID, input.EventIDs[0]) + } + + // Fast path: row already exists from a previous producer tick. Returning + // the existing row is the idempotency guarantee — second calls return the + // same id, no second INSERT. + var existing Task + if err := tx.Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; err == nil { + return &existing, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + maxAttempts := input.MaxAttempts + if maxAttempts <= 0 { + maxAttempts = DefaultTaskMaxAttempts + } + if !anyWorkerCanDeliver(input.Method, input.AccountID) { + return nil, fmt.Errorf("%w: method=%s account=%d", ErrNotificationMethodNotAuthorized, input.Method, input.AccountID) + } + + notBefore := input.NotBefore + if notBefore.IsZero() { + notBefore = time.Now() + } + + now := time.Now() + task := &Task{ + JobID: uuid.New().String(), + Kind: TaskKindNotification, + State: TaskStateQueued, + AccountID: input.AccountID, + MessageID: input.MessageID, + ContactID: &input.ContactID, + MonitorID: input.MonitorID, + CheckID: input.CheckID, + NotBefore: notBefore, + Deadline: input.Deadline, + Attempts: 0, + MaxAttempts: maxAttempts, + IdempotencyKey: idempotencyKey, + } + if len(input.Payload) > 0 { + task.Payload = input.Payload + } + task.CreatedAt = now + task.UpdatedAt = now + + // ON CONFLICT DO NOTHING so a concurrent producer tick racing with us on + // the same idempotency_key loses the race but does not duplicate the row. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(task).Error; err != nil { + return nil, err + } + if task.ID == 0 { + // Lost the race. Re-read and return the winner. + if err := tx.Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; err != nil { + return nil, err + } + return &existing, nil + } + return task, nil +} + +// NotificationIdempotencyKey exposes the producer's idempotency key so the +// result handler and admin tooling can match a Task row back to the logical +// (notification, contact, event) tuple without re-deriving the format. +func NotificationIdempotencyKey(notificationID, contactID, eventID int64) string { + return notificationIdempotencyKey(notificationID, contactID, eventID) +} + +func notificationIdempotencyKey(notificationID, contactID, eventID int64) string { + return fmt.Sprintf("notif:%d:contact:%d:event:%d", notificationID, contactID, eventID) +} + +// anyWorkerCanDeliver returns true if at least one active worker in the pool is +// authorized to deliver the given (method, account) pair. Used by the producer +// to skip enqueues no worker could ever pick up. +func anyWorkerCanDeliver(method string, accountID int64) bool { + var nodes []WorkerNode + if err := DB().Where("status = ? AND last_seen > ?", "active", time.Now().Add(-WorkerHeartbeatFreshness)).Find(&nodes).Error; err != nil { + log.Printf("task_selector: cannot list workers: %v", err) + // Be permissive on lookup failure: the selector's own filter would still + // hold the lease back, so the worst case is a queued task nobody picks + // up — which the reaper eventually dead-letters. + return true + } + for i := range nodes { + if nodes[i].SupportsTaskEnvelope() && nodes[i].CanDeliverNotification(method, accountID) { + return true + } + } + return false +} + +// TasksForWorker leases up to `limit` due tasks for the worker. The selection +// is one transaction so the FOR UPDATE SKIP LOCKED + UPDATE that flips state +// from queued to leased is atomic. Notification tasks are filtered by the worker's +// notification_methods + notification_accounts capability set; check tasks are +// filtered by check_types in their payload. +// +// The function is safe to call from multiple goroutines for different workers. +// Two workers that hit the DB at the same time will see disjoint task sets. +func TasksForWorker(worker *WorkerNode, limit int) ([]Task, error) { + if worker == nil { + return nil, errors.New("TasksForWorker: worker is nil") + } + if !worker.SupportsTaskEnvelope() { + return nil, nil + } + if limit <= 0 { + limit = 1 + } + + notifMethods := worker.NotificationMethods() + notifAccounts := worker.AccessibleAccountIDs() + hasNotif := len(notifMethods) > 0 + + tx := DB().Begin() + if tx.Error != nil { + return nil, tx.Error + } + defer func() { + if r := recover(); r != nil { + _ = tx.Rollback().Error + panic(r) + } + }() + + now := time.Now() + var out []Task + + // First pass: notification tasks the worker is authorized to deliver. We + // also bump attempts and flip state to leased in the same row so the + // outer selector+lease is atomic. The method filter is a JSONB extract on + // payload->>'method' so a single worker query can target one method list. + if hasNotif { + notifQuery := tx.Clauses(SkipLockedClause). + Where("state = ? AND kind = ?", TaskStateQueued, TaskKindNotification). + Where("not_before <= ?", now). + Where("(deadline IS NULL OR deadline > ?)", now). + Where("payload->>'method' IN (?)", notifMethods). + Where("payload->>'method' <> ''") + if len(notifAccounts) > 0 { + notifQuery = notifQuery.Where("account_id IN (?)", notifAccounts) + } + + var picked []Task + if err := notifQuery.Limit(limit).Find(&picked).Error; err != nil { + _ = tx.Rollback().Error + return nil, err + } + + for i := range picked { + row := picked[i] + newAttempts := row.Attempts + 1 + leaseToken := uuid.NewString() + leaseUntil := now.Add(TaskLeaseTTL(row.Kind)) + if err := tx.Model(&row).Updates(map[string]interface{}{ + colState: TaskStateLeased, + "lease_owner": worker.WorkerID, + "lease_expires_at": leaseUntil, + "attempts": newAttempts, + "lease_token": leaseToken, + "updated_at": now, + }).Error; err != nil { + _ = tx.Rollback().Error + return nil, err + } + row.State = TaskStateLeased + row.LeaseOwner = worker.WorkerID + row.LeaseExpiresAt = &leaseUntil + row.Attempts = newAttempts + row.LeaseToken = leaseToken + out = append(out, row) + } + } + + remaining := limit - len(out) + if checkTypes := worker.CheckTypes(); remaining > 0 && len(checkTypes) > 0 { + checkQuery := tx.Clauses(SkipLockedClause). + Where("state = ? AND kind = ?", TaskStateQueued, TaskKindCheck). + Where("not_before <= ?", now). + Where("(deadline IS NULL OR deadline > ?)", now). + Where("payload->>'kind' IN (?)", checkTypes) + if accounts := worker.AccessibleAccountIDs(); len(accounts) > 0 { + checkQuery = checkQuery.Where("account_id IN (?)", accounts) + } + var picked []Task + if err := checkQuery.Limit(remaining).Find(&picked).Error; err != nil { + _ = tx.Rollback().Error + return nil, err + } + for i := range picked { + row := picked[i] + newAttempts := row.Attempts + 1 + leaseToken := uuid.NewString() + leaseUntil := now.Add(TaskLeaseTTL(row.Kind)) + if err := tx.Model(&row).Updates(map[string]interface{}{ + colState: TaskStateLeased, "lease_owner": worker.WorkerID, + "lease_expires_at": leaseUntil, "attempts": newAttempts, "lease_token": leaseToken, "updated_at": now, + }).Error; err != nil { + _ = tx.Rollback().Error + return nil, err + } + row.State, row.LeaseOwner, row.LeaseExpiresAt, row.Attempts, row.LeaseToken = TaskStateLeased, worker.WorkerID, &leaseUntil, newAttempts, leaseToken + out = append(out, row) + } + } + + if err := tx.Commit().Error; err != nil { + return nil, err + } + return out, nil +} + +// AvailableWorkerTaskCapacity returns unoccupied local worker slots. Durable +// unexpired leases and the worker's heartbeat-reported active/queued workload +// describe the same work from different sides, so the larger value is used to +// avoid both over-dispatching and double-counting a healthy worker. +func AvailableWorkerTaskCapacity(worker *WorkerNode) (int, error) { + if worker == nil { + return 0, errors.New("worker capacity: worker is nil") + } + concurrency := worker.Concurrency + if concurrency < 1 { + concurrency = 1 + } + var leased int64 + if err := DB().Model(&Task{}).Where("state = ? AND lease_owner = ? AND lease_expires_at > ?", TaskStateLeased, worker.WorkerID, time.Now()).Count(&leased).Error; err != nil { + return 0, err + } + var confirmationLeases int64 + if err := DB().Model(&CheckAttempt{}).Where("worker_node_id = ? AND kind = ? AND state = ? AND lease_expires_at > ?", worker.ID, AttemptKindConfirm, AttemptStateLeased, time.Now()).Count(&confirmationLeases).Error; err != nil { + return 0, err + } + used := int(leased + confirmationLeases) + if reported := worker.ReportedWorkload(); reported > used { + used = reported + } + if used >= concurrency { + return 0, nil + } + return concurrency - used, nil +} + +// GetTaskByJobID returns one task row keyed by its unique job_id. The result +// handler uses this to validate that the incoming JobID exists and matches the +// calling worker before it mutates state. +func GetTaskByJobID(jobID string) (*Task, error) { + if jobID == "" { + return nil, errors.New("GetTaskByJobID: empty job_id") + } + var task Task + if err := DB().Where("job_id = ?", jobID).First(&task).Error; err != nil { + return nil, err + } + return &task, nil +} + +// TasksForWorkerTx is the variant exposed for tests so a single SELECT inside a +// caller-provided transaction can be inspected without the auto-commit wrapper. +// Production code should use TasksForWorker. +func TasksForWorkerTx(tx *gorm.DB, worker *WorkerNode, limit int) ([]Task, error) { + if tx == nil { + return nil, errors.New("TasksForWorkerTx: nil tx") + } + notifMethods := worker.NotificationMethods() + if len(notifMethods) == 0 { + return nil, nil + } + now := time.Now() + var out []Task + // Use SKIP LOCKED to avoid contention between workers (mirror of ChecksForWorker). + q := tx.Clauses(SkipLockedClause). + Where("state = ? AND kind = ?", TaskStateQueued, TaskKindNotification). + Where("not_before <= ?", now). + Where("(deadline IS NULL OR deadline > ?)", now). + Where("payload->>'method' IN (?)", notifMethods) + if accounts := worker.AccessibleAccountIDs(); len(accounts) > 0 { + q = q.Where("account_id IN (?)", accounts) + } + if err := q.Limit(limit).Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} diff --git a/app/models/task_test.go b/app/models/task_test.go new file mode 100644 index 0000000..55437b5 --- /dev/null +++ b/app/models/task_test.go @@ -0,0 +1,681 @@ +package models_test + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" +) + +func init() { + database.Init() +} + +// seedRegion creates a Region row before a worker fixture inserts, so the FK +// from worker_nodes -> regions holds. Idempotent: Drop() cleans up. +func seedRegion(t *testing.T, code string) models.Region { + t.Helper() + r := models.Region{} + err := models.DB().Where("code = ?", code).First(&r).Error + if err == nil { + return r + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("seed region lookup: %v", err) + } + r = models.Region{Code: code, Name: code, Enabled: true} + require.NoError(t, models.DB().Create(&r).Error) + return r +} + +// seedAccountUserPlan returns an account with a default plan and the first user +// for the FK chain that contacts/notifications walk. Cleanup is the caller's +// responsibility (Drop() at end of test). +func seedAccountUserPlan(t *testing.T) (models.Account, models.User) { + t.Helper() + plan := models.Plan{Name: "test-plan", Default: false} + if err := models.DB().Create(&plan).Error; err != nil { + t.Fatalf("seed plan: %v", err) + } + + user := models.User{Name: "test-user", Email: taskStringPtr("test-" + uuid.NewString() + "@example.com"), Timezone: "UTC"} + if err := models.DB().Create(&user).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + + account := models.Account{Name: "test-account", Timezone: "UTC", Language: "en", PlanID: &plan.ID} + if err := models.DB().Create(&account).Error; err != nil { + t.Fatalf("seed account: %v", err) + } + return account, user +} + +func seedNotification(t *testing.T, accountID int64) models.Notification { + t.Helper() + n := models.Notification{Name: "default", AccountID: accountID, Enabled: true, NotifyDown: true, NotifyRestore: true} + require.NoError(t, models.DB().Create(&n).Error) + return n +} + +func seedEmailContact(t *testing.T, accountID int64) models.Contact { + t.Helper() + c := models.Contact{Name: "ops", Kind: "email", Value: "ops@example.com", AccountID: &accountID} + require.NoError(t, models.DB().Create(&c).Error) + return c +} + +func taskStringPtr(s string) *string { return &s } + +func TestTaskSchemaMigration(t *testing.T) { + models.Drop() + models.Migrate() + + require.True(t, models.DB().Migrator().HasTable(&models.Task{}), "tasks table must exist after Migrate()") + require.True(t, models.DB().Migrator().HasTable(&models.NotificationDelivery{}), "notification_deliveries table must exist after Migrate()") +} + +func TestEnqueueNotificationTask_Idempotency(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + + // Producer precheck requires at least one worker authorized for the + // (method, account) pair. Add an operated-style email worker. + now := time.Now() + w := &models.WorkerNode{ + WorkerID: "worker-idempotency-" + uuid.NewString(), + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + Concurrency: 4, + LastSeen: &now, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, + "task_envelope": true, + "notification_methods": []string{"email"}, + "notification_accounts": []int64{}, + })), + } + require.NoError(t, models.DB().Create(w).Error) + + payload := []byte(`{"method":"email","subject":"[rsmon] x is down","body_text":"down","body_html":"

down

","body_markdown":"**down**","language":"en","message_kind":"down"}`) + + input := models.EnqueueNotificationTaskInput{ + AccountID: account.ID, + NotificationID: notification.ID, + ContactID: contact.ID, + Method: "email", + Subject: "[rsmon] x is down", + BodyText: "down", + BodyHTML: "

down

", + Language: "en", + MessageKind: "down", + EventIDs: []int64{42}, + Payload: payload, + } + + first, err := models.EnqueueNotificationTask(&input) + require.NoError(t, err) + require.NotZero(t, first.ID) + + // Second call with the same logical event must not create a duplicate row. + second, err := models.EnqueueNotificationTask(&input) + require.NoError(t, err) + assert.Equal(t, first.ID, second.ID, "idempotency: second enqueue should return the same row") + + var count int64 + require.NoError(t, models.DB().Model(&models.Task{}).Where("idempotency_key = ?", first.IdempotencyKey).Count(&count).Error) + assert.EqualValues(t, 1, count, "exactly one Task row per idempotency key") +} + +// TestEnqueueNotificationTask_AuthorizationSkip makes sure the producer can +// observe ErrNotificationMethodNotAuthorized when no worker is eligible for +// the (method, account) pair. The producer uses this to avoid enqueueing work +// no operated worker could ever pick up. +func TestEnqueueNotificationTask_AuthorizationSkip(t *testing.T) { + models.Drop() + models.Migrate() + + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + seedRegion(t, "test") + + // Register a worker that only knows telegram. An email task must fail the + // precheck. + w := &models.WorkerNode{ + WorkerID: "worker-tg-only-" + uuid.NewString(), + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + Concurrency: 4, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, + "notification_methods": []string{"telegram"}, + "notification_accounts": []int64{}, + })), + } + require.NoError(t, models.DB().Create(w).Error) + + _, err := models.EnqueueNotificationTask(&models.EnqueueNotificationTaskInput{ + AccountID: account.ID, + NotificationID: notification.ID, + ContactID: contact.ID, + Method: "email", + EventIDs: []int64{1}, + Payload: []byte(`{"method":"email"}`), + }) + require.ErrorIs(t, err, models.ErrNotificationMethodNotAuthorized) +} + +func TestEnqueueNotificationTask_RequiresTaskEnvelopeWorker(t *testing.T) { + models.Drop() + models.Migrate() + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + seedRegion(t, "test") + now := time.Now() + worker := &models.WorkerNode{WorkerID: "legacy-notify-" + uuid.NewString(), RegionCode: "test", Status: "active", LastSeen: &now, AuthToken: uuid.NewString(), Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, + }))} + require.NoError(t, models.DB().Create(worker).Error) + require.NoError(t, models.DB().Model(&models.WorkerNode{}).Where("id <> ?", worker.ID).Update("status", "dead").Error) + input := &models.EnqueueNotificationTaskInput{AccountID: account.ID, NotificationID: notification.ID, ContactID: contact.ID, Method: "email", EventIDs: []int64{77}, Payload: []byte(`{"method":"email"}`)} + _, err := models.EnqueueNotificationTask(input) + require.ErrorIs(t, err, models.ErrNotificationMethodNotAuthorized) + var count int64 + require.NoError(t, models.DB().Model(&models.Task{}).Count(&count).Error) + assert.Zero(t, count) + worker.Capabilities = datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, "task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, + })) + require.NoError(t, models.DB().Save(worker).Error) + task, err := models.EnqueueNotificationTask(input) + require.NoError(t, err) + assert.Equal(t, models.TaskStateQueued, task.State) +} + +func TestTasksForWorker_SkipsLockedAndLeases(t *testing.T) { + models.Drop() + models.Migrate() + + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + seedRegion(t, "test") + + now := time.Now() + w := &models.WorkerNode{ + WorkerID: "worker-email-only-" + uuid.NewString(), + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + Concurrency: 4, + LastSeen: &now, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, + "task_envelope": true, + "notification_methods": []string{"email"}, + "notification_accounts": []int64{}, + })), + } + require.NoError(t, models.DB().Create(w).Error) + + // Enqueue 3 tasks of different methods; only the email ones should be + // picked up by the worker. We use the raw helper because the producer's + // precheck would refuse the telegram row when no worker handles telegram — + // the selector test must exercise the SELECT-side filter, not the + // producer-side authorization. + mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{1}) + mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "telegram", []int64{2}) + mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{3}) + + picked, err := models.TasksForWorker(w, 10) + require.NoError(t, err) + require.Len(t, picked, 2, "only email tasks should be leased") + + for _, p := range picked { + assert.Equal(t, models.TaskStateLeased, p.State) + assert.Equal(t, w.WorkerID, p.LeaseOwner) + assert.NotNil(t, p.LeaseExpiresAt) + assert.Equal(t, 1, p.Attempts) + } + + // A second call must not return the same rows. + picked2, err := models.TasksForWorker(w, 10) + require.NoError(t, err) + assert.Empty(t, picked2, "second selector poll should see an empty queue while leased") +} + +func TestEnqueueDueCheckTasks_UsesGenericEnvelope(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + account, _ := seedAccountUserPlan(t) + group := models.Group{Name: "checks", AccountID: account.ID} + require.NoError(t, models.DB().Create(&group).Error) + monitor := models.Monitor{GroupID: group.ID, Host: "example.com", Enabled: true} + require.NoError(t, models.DB().Create(&monitor).Error) + enabled := true + check := models.Check{MonitorID: monitor.ID, Enabled: &enabled, Kind: "http", Interval: 60, Settings: datatypes.JSON([]byte(`{}`))} + require.NoError(t, models.DB().Create(&check).Error) + worker := &models.WorkerNode{ + WorkerID: "generic-check-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 1, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"check_types": []string{"http"}, "task_envelope": true})), + } + require.NoError(t, models.DB().Create(worker).Error) + + require.NoError(t, models.EnqueueDueCheckTasks(worker, []string{"http"}, 1)) + picked, err := models.TasksForWorker(worker, 1) + require.NoError(t, err) + require.Len(t, picked, 1) + assert.Equal(t, models.TaskKindCheck, picked[0].Kind) + assert.Equal(t, models.TaskStateLeased, picked[0].State) + assert.Equal(t, check.ID, *picked[0].CheckID) +} + +func TestTasksForWorker_ChecksRespectPrivateAccountScope(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + accountA, _ := seedAccountUserPlan(t) + accountB, _ := seedAccountUserPlan(t) + accountID := accountA.ID + private := &models.WorkerNode{ + WorkerID: "private-check-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), AccountID: &accountID, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"check_types": []string{"http"}, "task_envelope": true})), + } + platform := &models.WorkerNode{ + WorkerID: "platform-check-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"check_types": []string{"http"}, "task_envelope": true})), + } + require.NoError(t, models.DB().Create(private).Error) + require.NoError(t, models.DB().Create(platform).Error) + task := models.Task{JobID: uuid.NewString(), Kind: models.TaskKindCheck, State: models.TaskStateQueued, AccountID: accountB.ID, Payload: datatypes.JSON([]byte(`{"kind":"http"}`)), NotBefore: time.Now().Add(-time.Second), MaxAttempts: 5, IdempotencyKey: "cross-account-" + uuid.NewString()} + require.NoError(t, models.DB().Create(&task).Error) + picked, err := models.TasksForWorker(private, 1) + require.NoError(t, err) + assert.Empty(t, picked) + picked, err = models.TasksForWorker(platform, 1) + require.NoError(t, err) + require.Len(t, picked, 1) + assert.Equal(t, task.ID, picked[0].ID) +} + +func TestChecksForWorker_RespectsPrivateAccountScope(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + accountA, _ := seedAccountUserPlan(t) + accountB, _ := seedAccountUserPlan(t) + makeCheck := func(accountID int64, host string) models.Check { + group := models.Group{Name: host, AccountID: accountID} + require.NoError(t, models.DB().Create(&group).Error) + monitor := models.Monitor{GroupID: group.ID, Host: host, Enabled: true} + require.NoError(t, models.DB().Create(&monitor).Error) + enabled := true + check := models.Check{MonitorID: monitor.ID, Enabled: &enabled, Kind: "http", Interval: 60, Settings: datatypes.JSON([]byte(`{}`))} + require.NoError(t, models.DB().Create(&check).Error) + return check + } + owned := makeCheck(accountA.ID, "owned.example") + _ = makeCheck(accountB.ID, "other.example") + accountID := accountA.ID + private := &models.WorkerNode{ + WorkerID: "private-legacy-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), AccountID: &accountID, + Capabilities: datatypes.JSON([]byte(`{"check_types":["http"]}`)), + } + require.NoError(t, models.DB().Create(private).Error) + checks := models.ChecksForWorker(private, []string{"http"}, 10) + require.Len(t, checks, 1) + assert.Equal(t, owned.ID, checks[0].ID) +} + +func TestTasksForWorker_SkipsExpiredDeadline(t *testing.T) { + models.Drop() + models.Migrate() + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + seedRegion(t, "test") + now := time.Now() + worker := &models.WorkerNode{ + WorkerID: "worker-deadline-" + uuid.NewString(), RegionCode: "test", Status: "active", LastSeen: &now, AuthToken: uuid.NewString(), Concurrency: 1, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}})), + } + require.NoError(t, models.DB().Create(worker).Error) + + expired := time.Now().Add(-time.Second) + task := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{91}) + require.NoError(t, models.DB().Model(&task).Update("deadline", expired).Error) + picked, err := models.TasksForWorker(worker, 1) + require.NoError(t, err) + assert.Empty(t, picked) + + var stored models.Task + require.NoError(t, models.DB().First(&stored, task.ID).Error) + assert.Equal(t, models.TaskStateQueued, stored.State) + assert.Equal(t, 0, stored.Attempts) +} + +func TestReapExpiredTasksTerminatesExpiredQueuedNotification(t *testing.T) { + models.Drop() + models.Migrate() + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + message := models.Message{NotificationID: notification.ID, ContactID: contact.ID, Kind: "down", State: models.TaskStateQueued} + require.NoError(t, models.DB().Create(&message).Error) + deadline := time.Now().Add(-time.Minute) + // This reaper test intentionally has no eligible worker; insert directly + // so it tests deadline handling rather than producer capability validation. + task := mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{92}) + require.NoError(t, models.DB().Model(&task).Updates(map[string]interface{}{"message_id": message.ID, "deadline": deadline}).Error) + + _, _, err := models.ReapExpiredTasks() + require.NoError(t, err) + var stored models.Task + require.NoError(t, models.DB().First(&stored, task.ID).Error) + assert.Equal(t, models.TaskStateDead, stored.State) + assert.Equal(t, "notification deadline expired", stored.LastError) + var storedMessage models.Message + require.NoError(t, models.DB().First(&storedMessage, message.ID).Error) + assert.Equal(t, "error", storedMessage.State) + var auditCount int64 + require.NoError(t, models.DB().Model(&models.NotificationDelivery{}).Where("task_id = ? AND status = ?", task.ID, "expired").Count(&auditCount).Error) + assert.EqualValues(t, 1, auditCount) +} + +func TestReapExpiredTasks_RecyclesLeasesAndDeadsExhaustedRetries(t *testing.T) { + models.Drop() + models.Migrate() + + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + seedRegion(t, "test") + + now := time.Now() + w := &models.WorkerNode{ + WorkerID: "worker-reap-" + uuid.NewString(), + RegionCode: "test", + Status: "active", + LastSeen: &now, + AuthToken: uuid.NewString(), + Concurrency: 4, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, + "task_envelope": true, + "notification_methods": []string{"email"}, + "notification_accounts": []int64{}, + })), + } + require.NoError(t, models.DB().Create(w).Error) + + // 1) A leased task whose lease expired — should go back to queued. + expiredLease := time.Now().Add(-time.Minute) + leased := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{10}) + require.NoError(t, models.DB().Model(&leased).Updates(map[string]interface{}{ + "state": models.TaskStateLeased, + "lease_owner": w.WorkerID, + "lease_expires_at": expiredLease, + "attempts": 1, + }).Error) + + // 2) A failed_retry task past its not_before and at max_attempts — should move to dead. + failedRetry := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{11}) + require.NoError(t, models.DB().Model(&failedRetry).Updates(map[string]interface{}{ + "state": models.TaskStateFailedRetry, + "attempts": 5, + "max_attempts": 5, + "not_before": time.Now().Add(-time.Minute), + }).Error) + + // 3) A failed_retry task past not_before but attempts < max_attempts — must stay failed_retry. + pendingRetry := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{12}) + require.NoError(t, models.DB().Model(&pendingRetry).Updates(map[string]interface{}{ + "state": models.TaskStateFailedRetry, + "attempts": 2, + "max_attempts": 5, + "not_before": time.Now().Add(-time.Minute), + }).Error) + + reaped, deaded, err := models.ReapExpiredTasks() + require.NoError(t, err) + assert.Equal(t, 1, reaped, "one expired lease should be returned to queue") + assert.Equal(t, 1, deaded, "one exhausted retry should move to dead") + + var leasedRow models.Task + require.NoError(t, models.DB().First(&leasedRow, leased.ID).Error) + assert.Equal(t, models.TaskStateQueued, leasedRow.State) + assert.Empty(t, leasedRow.LeaseOwner) + assert.Nil(t, leasedRow.LeaseExpiresAt) + + var deadRow models.Task + require.NoError(t, models.DB().First(&deadRow, failedRetry.ID).Error) + assert.Equal(t, models.TaskStateDead, deadRow.State) + + var pendingRow models.Task + require.NoError(t, models.DB().First(&pendingRow, pendingRetry.ID).Error) + assert.Equal(t, models.TaskStateFailedRetry, pendingRow.State) +} + +func TestReapExpiredTasks_ExpiredNotificationLeaseExhaustionFinalizesMessage(t *testing.T) { + models.Drop() + models.Migrate() + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + message := models.Message{NotificationID: notification.ID, ContactID: contact.ID, Kind: "down", State: models.TaskStateQueued} + require.NoError(t, models.DB().Create(&message).Error) + task := mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{99}) + require.NoError(t, models.DB().Model(&task).Updates(map[string]interface{}{ + "message_id": message.ID, "state": models.TaskStateLeased, "attempts": 5, "max_attempts": 5, + "lease_expires_at": time.Now().Add(-time.Minute), + }).Error) + _, deaded, err := models.ReapExpiredTasks() + require.NoError(t, err) + assert.Equal(t, 1, deaded) + var storedTask models.Task + require.NoError(t, models.DB().First(&storedTask, task.ID).Error) + assert.Equal(t, models.TaskStateDead, storedTask.State) + assert.Equal(t, "lease expired after max attempts", storedTask.LastError) + var storedMessage models.Message + require.NoError(t, models.DB().First(&storedMessage, message.ID).Error) + assert.Equal(t, "error", storedMessage.State) + require.NotNil(t, storedMessage.Error) + assert.Equal(t, "lease expired after max attempts", *storedMessage.Error) + var delivery models.NotificationDelivery + require.NoError(t, models.DB().Where("task_id = ?", task.ID).First(&delivery).Error) + assert.Equal(t, "dead", delivery.Status) + assert.Equal(t, "lease expired after max attempts", delivery.Error) +} + +func TestNotificationTaskLeaseOutlivesExecutionTimeoutAndReapsAfterExpiry(t *testing.T) { + models.Drop() + models.Migrate() + account, _ := seedAccountUserPlan(t) + notification := seedNotification(t, account.ID) + contact := seedEmailContact(t, account.ID) + seedRegion(t, "test") + worker := &models.WorkerNode{ + WorkerID: "notification-lease-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 1, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}})), + } + require.NoError(t, models.DB().Create(worker).Error) + task := mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{101}) + leased, err := models.TasksForWorker(worker, 1) + require.NoError(t, err) + require.Len(t, leased, 1) + require.NotNil(t, leased[0].LeaseExpiresAt) + assert.GreaterOrEqual(t, leased[0].LeaseExpiresAt.Sub(time.Now()), models.DefaultNotificationTaskLeaseTTL-time.Second) + assert.Greater(t, leased[0].LeaseExpiresAt.Sub(time.Now()), models.DefaultNotificationExecutionTimeout) + + withinExecution := time.Now().Add(models.DefaultNotificationExecutionTimeout) + require.NoError(t, models.DB().Model(&task).Update("lease_expires_at", withinExecution).Error) + reaped, _, err := models.ReapExpiredTasks() + require.NoError(t, err) + assert.Zero(t, reaped) + var stored models.Task + require.NoError(t, models.DB().First(&stored, task.ID).Error) + assert.Equal(t, models.TaskStateLeased, stored.State) + + require.NoError(t, models.DB().Model(&task).Update("lease_expires_at", time.Now().Add(-time.Second)).Error) + reaped, _, err = models.ReapExpiredTasks() + require.NoError(t, err) + assert.Equal(t, 1, reaped) + require.NoError(t, models.DB().First(&stored, task.ID).Error) + assert.Equal(t, models.TaskStateQueued, stored.State) +} + +func mustEnqueue(t *testing.T, accountID, notificationID, contactID int64, method string, eventIDs []int64) models.Task { + t.Helper() + payload := []byte(`{"method":"` + method + `"}`) + task, err := models.EnqueueNotificationTask(&models.EnqueueNotificationTaskInput{ + AccountID: accountID, + NotificationID: notificationID, + ContactID: contactID, + Method: method, + EventIDs: eventIDs, + Payload: payload, + NotBefore: time.Now().Add(-time.Second), + }) + require.NoError(t, err) + require.NotZero(t, task.ID) + return *task +} + +// mustEnqueueRaw inserts a Task row directly without going through the producer +// precheck. The selector test deliberately mixes methods (email + telegram) on a +// worker that only handles email; the producer would refuse the telegram row, +// which is the wrong thing to assert about in a selector test. +func mustEnqueueRaw(t *testing.T, accountID, notificationID, contactID int64, method string, eventIDs []int64) models.Task { + t.Helper() + payload := datatypes.JSON([]byte(`{"method":"` + method + `"}`)) + contact := contactID + task := models.Task{ + JobID: uuid.New().String(), + Kind: models.TaskKindNotification, + State: models.TaskStateQueued, + AccountID: accountID, + ContactID: &contact, + Payload: payload, + NotBefore: time.Now().Add(-time.Second), + Attempts: 0, + MaxAttempts: 5, + IdempotencyKey: models.NotificationIdempotencyKey(notificationID, contactID, eventIDs[0]), + } + require.NoError(t, models.DB().Create(&task).Error) + require.NotZero(t, task.ID) + return task +} + +func mustJSON(t *testing.T, v interface{}) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} + +// TestWorkerNodeCapabilities_NotificationFlags confirms that the JSON-backed +// capabilities blob correctly exposes the notification_methods and +// notification_accounts arrays that the selector and credential push depend on. +func TestWorkerNodeCapabilities_NotificationFlags(t *testing.T) { + models.Drop() + models.Migrate() + seedRegion(t, "test") + + w := &models.WorkerNode{ + WorkerID: "caps-worker-" + uuid.NewString(), + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + Concurrency: 4, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, + "notification_methods": []string{"email", "telegram"}, + "notification_accounts": []int64{7, 8}, + })), + } + require.NoError(t, models.DB().Create(w).Error) + + got := models.WorkerNode{} + require.NoError(t, models.DB().First(&got, w.ID).Error) + assert.Equal(t, []string{"email", "telegram"}, got.NotificationMethods()) + assert.Equal(t, []int64{7, 8}, got.NotificationAccounts()) + assert.True(t, got.CanDeliverNotification("email", 7)) + assert.False(t, got.CanDeliverNotification("email", 9), "account 9 is not in the allowed list") + assert.False(t, got.CanDeliverNotification("mattermost", 7), "method not authorized") + + // Operated-style worker: empty accounts list means "all accounts". + w2 := &models.WorkerNode{ + WorkerID: "ops-worker-" + uuid.NewString(), + RegionCode: "test", + Status: "active", + AuthToken: uuid.NewString(), + Concurrency: 4, + Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ + "check_types": []string{"http"}, + "notification_methods": []string{"email"}, + "notification_accounts": []int64{}, + })), + } + require.NoError(t, models.DB().Create(w2).Error) + + got2 := models.WorkerNode{} + require.NoError(t, models.DB().First(&got2, w2.ID).Error) + assert.True(t, got2.CanDeliverNotification("email", 9999), "empty accounts list means all accounts") +} + +func TestWorkerNodeReportedWorkloadUsesDisjointHeartbeatFields(t *testing.T) { + w := &models.WorkerNode{Capabilities: datatypes.JSON([]byte(`{"active_checks":2,"queue_depth":3,"active_notifications":5,"notification_queue_depth":7}`))} + assert.Equal(t, 17, w.ReportedWorkload()) +} + +// TestEnsureConfiguredWorkerNode_NotificationCapabilitiesDefaults verifies that +// EnsureConfiguredWorkerNode (the in-cluster worker provisioner) populates +// notification_methods / notification_accounts on the JSON blob so the new +// selector and the new credential push work out of the box for the bundled +// worker. +func TestEnsureConfiguredWorkerNode_NotificationCapabilitiesDefaults(t *testing.T) { + models.Drop() + models.Migrate() + + t.Setenv("WORKER_AUTH_TOKEN", "secret-token-for-test-xyz") + t.Setenv("DEPLOY_ENV", "test-env") + t.Setenv("RSMON_WORKER_ID", "worker-test") + t.Setenv("NOTIFICATION_METHODS", "email,telegram") + t.Setenv("NOTIFICATION_ACCOUNTS", "11,22") + + models.EnsureConfiguredWorkerNode() + + var got models.WorkerNode + require.NoError(t, models.DB().Where("worker_id = ?", "worker-test").First(&got).Error) + assert.Equal(t, []string{"email", "telegram"}, got.NotificationMethods()) + assert.Equal(t, []int64{11, 22}, got.NotificationAccounts()) +} + +// TestNotificationIdempotencyKeyFormat pins the producer-side key shape so the +// result handler can re-derive it for matching without depending on internal +// package state. +func TestNotificationIdempotencyKeyFormat(t *testing.T) { + key := models.NotificationIdempotencyKey(7, 9, 13) + assert.Equal(t, "notif:7:contact:9:event:13", key) +} + +// guard against uuid being accidentally dropped from the imports. +var _ = uuid.New diff --git a/app/models/telegram_bot.go b/app/models/telegram_bot.go new file mode 100644 index 0000000..8e6c442 --- /dev/null +++ b/app/models/telegram_bot.go @@ -0,0 +1,77 @@ +package models + +import ( + "time" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +const ( + // TelegramBotMessageReceived marks inbound bot messages. + TelegramBotMessageReceived = "received" + // TelegramBotMessageSent marks outbound bot replies. + TelegramBotMessageSent = "sent" + // TelegramBotStatusMain is the singleton status row name for the bot. + TelegramBotStatusMain = "main" +) + +// TelegramBotMessage stores a Telegram bot chat message for admin history. +type TelegramBotMessage struct { + concerns.Model + + Direction string `gorm:"size:16;index" json:"direction"` + ChatID int64 `gorm:"index" json:"chat_id"` + ChatType string `gorm:"size:32" json:"chat_type"` + Username string `gorm:"size:255" json:"username"` + Text string `gorm:"type:text" json:"text"` + Command string `gorm:"size:64" json:"command"` + ContactID *int64 `gorm:"index" json:"contact_id,omitempty"` + Error string `gorm:"type:text" json:"error,omitempty"` + + CreatedAt time.Time `json:"created_at"` +} + +// TableName overrides the default table name. +func (TelegramBotMessage) TableName() string { + return "telegram_bot_messages" +} + +// TelegramBotStatus stores the current Telegram bot heartbeat/status. +type TelegramBotStatus struct { + concerns.Model + + Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` + Username string `gorm:"size:255" json:"username"` + Online bool `gorm:"not null;default:false" json:"online"` + LastSeen *time.Time `json:"last_seen,omitempty"` + LastError string `gorm:"type:text" json:"last_error,omitempty"` + + concerns.Timestamped +} + +// TableName overrides the default table name. +func (TelegramBotStatus) TableName() string { + return "telegram_bot_statuses" +} + +// RecentTelegramBotMessages returns the latest Telegram bot messages, capped to a safe limit. +func RecentTelegramBotMessages(limit int) ([]TelegramBotMessage, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + messages := []TelegramBotMessage{} + err := DB().Order("id DESC").Limit(limit).Find(&messages).Error + return messages, err +} + +// TelegramBotCurrentStatus returns the singleton Telegram bot status row. +func TelegramBotCurrentStatus() (*TelegramBotStatus, error) { + status := TelegramBotStatus{} + if err := DB().Where("name = ?", TelegramBotStatusMain).First(&status).Error; err != nil { + return nil, err + } + if status.LastSeen == nil || time.Since(*status.LastSeen) > 2*time.Minute { + status.Online = false + } + return &status, nil +} diff --git a/app/models/telegram_bot_test.go b/app/models/telegram_bot_test.go new file mode 100644 index 0000000..35600f4 --- /dev/null +++ b/app/models/telegram_bot_test.go @@ -0,0 +1,24 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestTelegramBotCurrentStatusMarksStaleOffline(t *testing.T) { + models.Drop() + models.Migrate() + + seen := time.Now().Add(-3 * time.Minute) + status := models.TelegramBotStatus{Name: models.TelegramBotStatusMain, Online: true, LastSeen: &seen} + require.NoError(t, models.DB().Create(&status).Error) + + got, err := models.TelegramBotCurrentStatus() + require.NoError(t, err) + assert.False(t, got.Online) +} diff --git a/app/models/user.go b/app/models/user.go new file mode 100644 index 0000000..e664294 --- /dev/null +++ b/app/models/user.go @@ -0,0 +1,233 @@ +package models + +import ( + "crypto/md5" + "fmt" + "log" + "time" + + "github.com/lib/pq" + "github.com/pkg/errors" + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models/authidentity" + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// User represents a platform user. +// +// A User is a global identity that may belong to many tenants via the +// Access join table (see Access). Contacts created by or assigned to a +// User carry that UserID; admins reach them via the cross-tenant +// /admin/users page, while per-account management is via +// /settings/users (which shows Accesses preloaded with User + Invite). +// +// Authentication state (password, confirmation, lock, recover) lives +// here, not on Access, because those attributes are account-independent. +type User struct { + // concerns.Model + ID int64 `gorm:"primarykey" json:"id"` + + Email *string `gorm:"uniqueIndex;size:255" json:"email" validate:"required"` + Name string `json:"name"` + Enabled bool `gorm:"not null;default:true" json:"-"` + // Operator grants platform-wide operational access. Account ownership alone + // must never grant cross-tenant task inspection or replay. + Operator bool `gorm:"not null;default:false" json:"operator"` + Timezone string `json:"timezone"` + Language string `gorm:"default:ru" json:"language"` + // Settings holds small per-user UI preferences. It intentionally stays + // separate from account settings because the sidebar is a personal view. + Settings datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"settings"` + Phone string `gorm:"index,size:255" json:"phone"` + TelegramID *int64 `gorm:"uniqueIndex" json:"telegram_id"` + TelegramUsername string `gorm:"size:255" json:"telegram_username"` + + Accesses []Access `json:"-"` + Contacts []Contact `json:"-"` + + LastActiveAt *time.Time `json:"last_active_at"` + LastActiveIP *string `json:"last_active_ip"` + + EncryptedPassword *string `json:"-"` + PasswordSetAt *time.Time `json:"-"` + + // Confirm + ConfirmationToken *string `json:"-"` + Confirmed bool `json:"confirmed"` + + // Lock + AttemptCount int `json:"-"` + LastAttempt *time.Time `json:"-"` + LockedAt *time.Time `json:"-"` + + // Recover + RecoverToken *string `json:"-"` + RecoverTokenAt *time.Time `json:"-"` + + RememberTokens pq.StringArray `gorm:"index;type:varchar(100)[]" json:"-"` + + // DeletionRequestedAt is set when the user requests account deletion. + // During the 7-day grace period the user can cancel the deletion + // (clearing this field). After 7 days the user and all related data + // are hard-deleted by a scheduled job. + DeletionRequestedAt *time.Time `json:"deletion_requested_at"` + + concerns.Timestamped `json:"-"` +} + +// DeletionPending returns true if the user has requested deletion and is +// still within the 7-day grace period. +func (u *User) DeletionPending() bool { + return u.DeletionRequestedAt != nil +} + +// GetLabel returns info label for user. +func (u *User) GetLabel() string { + return u.DisplayName() +} + +// DisplayName implements qor.CurrentUser for admin. +func (u *User) DisplayName() string { + if u.Email != nil { + return u.Name + " " + *u.Email + } + return u.Name +} + +// AfterSocialLogin is a callback after social login. +func (u *User) AfterSocialLogin(inviteID int64) (*User, error) { + oldUser := User{} + DB().Where("email = ?", u.Email).Where("id != ?", u.ID).First(&oldUser) + if oldUser.ID > 0 { + log.Println("new user", u.ID, "has same email", u.Email, "as old user", oldUser.ID, "so replacing") + err := DB().Model(&authidentity.AuthIdentity{}).Where("user_id = ?", u.ID).Updates( + authidentity.Basic{ + UserID: &oldUser.ID, + }, + ).Error + + return &oldUser, err + } + err := u.AfterRegister(inviteID) + return u, err +} + +// AfterInvite is a callback after invite acceptance. +func (u *User) AfterInvite(invite *Invite) error { + invite.InviteeID = &u.ID + + err := DB().Table("accesses").Where("invite_id = ?", invite.ID).Updates(map[string]interface{}{"user_id": u.ID}).Error + if err != nil { + return errors.Wrap(err, "invite: failed to add accesses to invited user") + } + + err = DB().Model(&authidentity.AuthIdentity{}).Where("provider = ? AND user_id = ?", "password", u.ID).Updates(map[string]interface{}{ + "confirmed_at": time.Now(), + }).Error + if err != nil { + return errors.Wrap(err, "invite: failed set user as confirmed") + } + + eml := invite.Email + u.Email = &eml + if invite.Name != "" { + u.Name = invite.Name + } + err = DB().Save(&u).Error + if err != nil { + return errors.Wrap(err, "invite: failed to save user") + } + + if invite.Name == "" { + log.Println("set name", u.Name) + invite.Name = u.Name + } + + invite.State = stateOK + + err = DB().Save(&invite).Error + if err != nil { + return errors.Wrap(err, "invite: failed to save invite") + } + + invite.Invitee = u + + return nil +} + +// AfterRegister is a callback after registration. +func (u *User) AfterRegister(inviteID int64) error { + var err error + + if inviteID > 0 { + invite := Invite{} + err := DB().First(&invite, inviteID).Error + if err != nil { + return errors.Wrap(err, "invite: failed to find invite") + } + + err = u.AfterInvite(&invite) + if err != nil { + return err + } + } + + _, err = CreateAccountForUser("", u) + if err != nil { + return err + } + + return nil +} + +// AfterLogin is a callback after login. +func (u *User) AfterLogin(inviteID int64) error { + var err error + if inviteID > 0 { + invite := Invite{} + err = DB().First(&invite, inviteID).Error + if err == nil { + invite.InviteeID = &u.ID + invite.State = stateOK + err = DB().Save(&invite).Error + if err != nil { + return errors.Wrap(err, "failed to save invite") + } + err = DB().Table("accesses").Where("invite_id = ?", invite.ID).Updates(map[string]interface{}{"user_id": u.ID}).Error + if err != nil { + return errors.Wrap(err, "failed to add accesses to invited user") + } + + err = DB().Model(&authidentity.AuthIdentity{}).Where("provider = ? AND user_id = ?", "password", u.ID).Updates(map[string]interface{}{ //nolint:lll + "confirmed_at": time.Now(), + }).Error + if err != nil { + return errors.Wrap(err, "failed set user as confirmed") + } + } + } + return nil +} + +// Gravatar returns the Gravatar URL for the user. +func (u *User) Gravatar(size int) string { + if u.Email == nil { + return "" + } + + hash := md5.Sum([]byte(*u.Email)) + return fmt.Sprintf("https://www.gravatar.com/avatar/%x?s=%d&d=blank", hash, size) +} + +// AsJSON returns a JSON representation of user. +func (u User) AsJSON() map[string]interface{} { //nolint:gocritic // hugeParam: accepted for interface compatibility + r := map[string]interface{}{ + "id": u.ID, + "email": u.Email, + "avatar": u.Gravatar(32), + "deletion_requested_at": u.DeletionRequestedAt, + } + + return r +} diff --git a/app/models/whois.go b/app/models/whois.go new file mode 100644 index 0000000..a0ea834 --- /dev/null +++ b/app/models/whois.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "github.com/lib/pq" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// Whois provides functionality. +type Whois struct { + concerns.Model + + MonitorID *int64 + Monitor *Monitor + + Tld string + Nameservers pq.StringArray `gorm:"type:varchar(255)[]"` + Expires *time.Time + UpdatedAt *time.Time + Data string +} diff --git a/app/models/worker_log_event.go b/app/models/worker_log_event.go new file mode 100644 index 0000000..bded6fc --- /dev/null +++ b/app/models/worker_log_event.go @@ -0,0 +1,31 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// WorkerLogEvent stores critical log events sent by worker nodes. The same +// payload may also be forwarded to VictoriaLogs; Postgres keeps a compact audit +// copy so the control plane can show recent critical events even if external log +// storage is temporarily unavailable. +type WorkerLogEvent struct { + concerns.Model + + WorkerID int64 `gorm:"type:bigint REFERENCES worker_nodes(id) ON DELETE SET NULL;index" json:"worker_id"` + WorkerNodeID string `gorm:"size:100;not null;index" json:"worker_node_id"` + ServerID *int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE SET NULL;index" json:"server_id,omitempty"` + Level string `gorm:"size:16;not null;index" json:"level"` + Message string `gorm:"type:text;not null" json:"message"` + Source string `gorm:"size:64" json:"source"` + Payload datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"payload"` + OccurredAt time.Time `gorm:"not null;index" json:"occurred_at"` + + concerns.Timestamped +} + +// TableName provides functionality. +func (WorkerLogEvent) TableName() string { return "worker_log_events" } diff --git a/app/models/worker_node.go b/app/models/worker_node.go new file mode 100644 index 0000000..40168ba --- /dev/null +++ b/app/models/worker_node.go @@ -0,0 +1,452 @@ +package models + +import ( + "encoding/json" + "log" + "os" + "strconv" + "strings" + "time" + + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models/concerns" +) + +// WorkerNode represents a distributed monitoring worker +type WorkerNode struct { + concerns.Model + WorkerID string `gorm:"uniqueIndex;size:100;not null" json:"worker_id"` // UUID or configured ID + RegionCode string `gorm:"size:20;not null;index" json:"region_code"` + Region *Region `gorm:"foreignKey:RegionCode;references:Code" json:"region,omitempty"` + Status string `gorm:"not null;default:'registered'" json:"status"` // registered, active, inactive, dead + AuthToken string `gorm:"uniqueIndex;size:64;not null" json:"-"` + LastSeen *time.Time `json:"last_seen"` + Version string `gorm:"size:50" json:"version"` + URL string `gorm:"size:500" json:"url"` //nolint:lll // publicly-advertised URL; may differ from bind host:port when behind Traefik + Capabilities datatypes.JSON `gorm:"not null;default:'{}'" json:"capabilities"` // {"check_types": ["http","ssl","dns",...]} + Concurrency int `gorm:"not null;default:20" json:"concurrency"` + NetworkProblems bool `gorm:"not null;default:false;index" json:"network_problems"` + NetworkProblemsUntil *time.Time `json:"network_problems_until,omitempty"` + LastFailureCount int `gorm:"not null;default:0" json:"last_failure_count"` + LastTotalCount int `gorm:"not null;default:0" json:"last_total_count"` + // Capability flags (see docs/plans/inventory-management.md §3). + // All default to true so an existing worker row that pre-dates + // this migration keeps running checks. Toggle from the admin UI + // or POST a boolean to /api/v1/workers to disable any one of + // them; the distworker client re-reads these flags on every + // task poll. + // + // Pointer types so GORM can distinguish "client didn't supply + // the key, fall back to the DB default" from "client explicitly + // set false". A plain `bool` would be silently re-overwritten + // by the column default on Save (default:true kicks in when + // GORM sees the zero value, regardless of whether the handler + // asked for false). See the controller tests for the + // partial-update case. + RunChecks *bool `gorm:"not null;default:true" json:"run_checks"` + CollectMetrics *bool `gorm:"not null;default:true" json:"collect_metrics"` + DetectProjects *bool `gorm:"not null;default:true" json:"detect_projects"` + // ServerID is the optional inventory Server this worker daemon + // is running on (see docs/plans/servers-and-hardware-metrics.md + // §3 and docs/plans/inventory-management.md §1). Nullable so + // legacy "no server assigned" rows keep working. Indexed because + // the distworker health ticker joins servers→workers frequently. + ServerID *int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE SET NULL;index" json:"server_id,omitempty"` + // AccountID scopes a worker to a single customer account (private + // worker per docs/distributed/private-workers.md). NULL means a + // platform-operated worker eligible to serve any account; non-NULL + // is a customer-operated worker pinned to one account. ON DELETE + // SET NULL keeps an operated worker valid if its account row is + // ever removed without an explicit private-worker cleanup. + AccountID *int64 `gorm:"type:bigint REFERENCES accounts(id) ON DELETE SET NULL;index" json:"account_id,omitempty"` + LLMs []LLM `json:"llms,omitempty" gorm:"many2many:worker_llms;"` + concerns.Timestamped +} + +const WorkerHeartbeatFreshness = 2 * time.Minute + +func (w *WorkerNode) NetworkProblemActive(now time.Time) bool { + return w != nil && w.NetworkProblems && (w.NetworkProblemsUntil == nil || w.NetworkProblemsUntil.After(now)) +} + +// WorkerStatuses provides functionality. +// WorkerStatus represents the possible worker statuses +var WorkerStatuses = []string{"registered", "active", "inactive", "dead"} + +// AllWorkerCheckKinds is the complete distributed-worker capability set. +func AllWorkerCheckKinds() []string { + return []string{kindHTTP, kindSSL, kindDNS, kindSSH, kindFTP, kindWhois, kindBSSL, kindLLM, kindLLMHTTP, kindPing, kindTCP, kindUDP} +} + +// NormalizeWorkerCapabilities expands aliases and removes unknown or duplicate capabilities. +func NormalizeWorkerCapabilities(capabilities []string) []string { + allowed := make(map[string]bool) + for _, kind := range AllWorkerCheckKinds() { + allowed[kind] = true + } + + seen := make(map[string]bool) + normalized := make([]string, 0, len(capabilities)) + for _, capability := range capabilities { + capability = strings.TrimSpace(strings.ToLower(capability)) + if capability == "all" || capability == "*" { + return AllWorkerCheckKinds() + } + if !allowed[capability] || seen[capability] { + continue + } + seen[capability] = true + normalized = append(normalized, capability) + } + if len(normalized) == 0 { + return AllWorkerCheckKinds() + } + return normalized +} + +// IsAlive returns true if the worker is considered alive based on last_seen +func (w *WorkerNode) IsAlive() bool { + if w.LastSeen == nil { + return false + } + // Worker is considered dead if no heartbeat for 2 minutes + return w.LastSeen.After(time.Now().Add(-WorkerHeartbeatFreshness)) +} + +// NotificationMethods returns the notification methods the worker is authorized +// to deliver (e.g. ["email", "telegram"]). Empty slice means "no notification +// delivery authorized". See docs/plans/worker-notifier-mvp.md section 4.3. +func (w *WorkerNode) NotificationMethods() []string { + caps := w.capabilitiesMap() + if caps == nil { + return nil + } + raw, ok := caps["notification_methods"] + if !ok { + return nil + } + return parseStringList(raw) +} + +// NotificationAccounts returns the account IDs the worker is authorized to +// serve for notifications. Empty slice means "owned by RSMon, all accounts". +// Customer-hosted workers (phase 4) ship a non-empty slice to scope credentials. +func (w *WorkerNode) NotificationAccounts() []int64 { + caps := w.capabilitiesMap() + if caps == nil { + return nil + } + raw, ok := caps["notification_accounts"] + if !ok { + return nil + } + return parseInt64List(raw) +} + +// AccessibleAccountIDs returns the accounts this worker may access. Empty means +// RSMon-operated/global worker. It combines the legacy single AccountID field +// with the newer notification_accounts capability list. +func (w *WorkerNode) AccessibleAccountIDs() []int64 { + if w == nil { + return nil + } + if w.AccountID != nil && *w.AccountID > 0 { + // A private worker cannot widen its account scope through a mutable + // capability JSON blob. + return []int64{*w.AccountID} + } + seen := map[int64]bool{} + out := []int64{} + for _, id := range w.NotificationAccounts() { + if id > 0 && !seen[id] { + seen[id] = true + out = append(out, id) + } + } + return out +} + +// CanDeliverNotification returns true when the worker is allowed to deliver +// the given method for the given account. An empty NotificationAccounts slice +// means the worker is RSMon-operated and may serve any account. +func (w *WorkerNode) CanDeliverNotification(method string, accountID int64) bool { + methods := w.NotificationMethods() + if len(methods) == 0 { + return false + } + if !containsString(methods, method) { + return false + } + accounts := w.AccessibleAccountIDs() + if len(accounts) == 0 { + return true + } + return containsInt64(accounts, accountID) +} + +// CheckTypes returns the check kinds this worker may execute. +func (w *WorkerNode) CheckTypes() []string { + capabilities := w.capabilitiesMap() + if capabilities == nil { + return nil + } + return parseStringList(capabilities["check_types"]) +} + +// SupportsTaskEnvelope is an explicit protocol capability. Version labels are +// build metadata (and may be "latest" or a commit SHA), not a wire contract. +// Rows created before this capability existed intentionally remain v1. +func (w *WorkerNode) SupportsTaskEnvelope() bool { + capabilities := w.capabilitiesMap() + if capabilities == nil { + return false + } + supported, _ := capabilities["task_envelope"].(bool) + return supported +} + +// ReportedWorkload is the worker's local active and queued work across both +// checks and notifications. It is advisory; durable leases remain authoritative. +func (w *WorkerNode) ReportedWorkload() int { + capabilities := w.capabilitiesMap() + if capabilities == nil { + return 0 + } + keys := []string{"active_checks", "queue_depth", "active_notifications", "notification_queue_depth"} + total := 0 + for _, key := range keys { + switch value := capabilities[key].(type) { + case float64: + if value > 0 { + total += int(value) + } + case int: + if value > 0 { + total += value + } + } + } + return total +} + +func (w *WorkerNode) capabilitiesMap() map[string]interface{} { + if w == nil || len(w.Capabilities) == 0 { + return nil + } + var out map[string]interface{} + if err := json.Unmarshal(w.Capabilities, &out); err != nil { + return nil + } + return out +} + +func parseStringList(raw interface{}) []string { + switch v := raw.(type) { + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + case []string: + out := make([]string, 0, len(v)) + for _, s := range v { + if s != "" { + out = append(out, s) + } + } + return out + } + return nil +} + +func parseInt64List(raw interface{}) []int64 { + switch v := raw.(type) { + case []interface{}: + out := make([]int64, 0, len(v)) + for _, item := range v { + switch n := item.(type) { + case float64: + out = append(out, int64(n)) + case int64: + out = append(out, n) + } + } + return out + case []int64: + return v + } + return nil +} + +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func containsInt64(haystack []int64, needle int64) bool { + for _, n := range haystack { + if n == needle { + return true + } + } + return false +} + +// EnsureConfiguredWorkerNode creates or updates the bundled Docker Compose worker from environment variables. +func EnsureConfiguredWorkerNode() { + token := envFirst("WORKER_AUTH_TOKEN", "RSMON_AUTH_TOKEN") + if token == "" { + return + } + + deployEnv := envDefault("DEPLOY_ENV", "production") + workerID := envDefault("RSMON_WORKER_ID", "worker-"+deployEnv+"-01") + regionCode := envDefault("RSMON_REGION_CODE", deployEnv) + version := envDefault("RSMON_WORKER_VERSION", envDefault("IMAGE_TAG", "latest")) + concurrency := envInt("WORKER_CONCURRENCY", 20) + workerURL := strings.TrimSpace(os.Getenv("WORKER_URL")) + capabilities := NormalizeWorkerCapabilities(splitEnvList(envDefault( + "RSMON_CAPABILITIES", + "http,ssl,dns,ssh,ftp,whois,bssl,llm,llm-http,ping,tcp,udp", + ))) + + region := Region{} + if err := DB().Where("code = ?", regionCode).First(®ion).Error; err != nil { + region = Region{Code: regionCode, Name: regionCode, Enabled: true} + if err := DB().Create(®ion).Error; err != nil { + log.Printf("worker: failed to create configured worker region %s: %v", regionCode, err) + return + } + } + + capJSON, err := json.Marshal(map[string]interface{}{ + "check_types": capabilities, + "task_envelope": true, + "notification_methods": parseStringList(notificationMethodsFromEnv()), + "notification_accounts": parseInt64List(notificationAccountsFromEnv()), + }) + if err != nil { + log.Printf("worker: failed to marshal configured worker capabilities: %v", err) + return + } + + worker := WorkerNode{} + DB().Where("worker_id = ? OR auth_token = ?", workerID, token).First(&worker) + created := worker.ID == 0 + worker.WorkerID = workerID + worker.RegionCode = regionCode + worker.Status = "registered" + worker.AuthToken = token + worker.Version = version + worker.URL = workerURL + worker.Capabilities = datatypes.JSON(capJSON) + worker.Concurrency = concurrency + + if err := DB().Save(&worker).Error; err != nil { + log.Printf("worker: failed to provision configured worker %s: %v", workerID, err) + return + } + if created { + log.Printf("worker: provisioned configured worker %s in region %s", workerID, regionCode) + } else { + log.Printf("worker: updated configured worker %s in region %s", workerID, regionCode) + } +} + +func splitEnvList(value string) []string { + parts := strings.Split(value, ",") + items := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + items = append(items, part) + } + } + return items +} + +func envFirst(keys ...string) string { + for _, key := range keys { + if value := os.Getenv(key); value != "" { + return value + } + } + return "" +} + +func envDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func envInt(key string, defaultValue int) int { + value, err := strconv.Atoi(os.Getenv(key)) + if err != nil || value <= 0 { + return defaultValue + } + return value +} + +// Notification method constants used across the package. Centralized here so +// the literal does not appear three or more times (goconst). +const ( + methodEmail = "email" + methodTelegram = "telegram" + methodWebhook = "webhook" + methodMattermost = "mattermost" +) + +// defaultNotificationMethods is the operated-worker default notification method +// list. Lives at package scope so goconst does not flag the literal across +// the package (account.go and user.go already reference "email"). +var defaultNotificationMethods = []string{methodEmail, methodTelegram, methodWebhook, methodMattermost} + +// notificationMethodsFromEnv reads the optional NOTIFICATION_METHODS env var. +// Empty result yields the default "all four" list so the operated worker can +// deliver email / telegram / webhook / mattermost out of the box. +func notificationMethodsFromEnv() []string { + raw := strings.TrimSpace(os.Getenv("NOTIFICATION_METHODS")) + if raw == "" { + return append([]string{}, defaultNotificationMethods...) + } + out := make([]string, 0, 4) + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +// notificationAccountsFromEnv reads the optional NOTIFICATION_ACCOUNTS env var. +// Empty result means "all accounts allowed" (the RSMon-operated default). +func notificationAccountsFromEnv() []int64 { + raw := strings.TrimSpace(os.Getenv("NOTIFICATION_ACCOUNTS")) + if raw == "" { + return nil + } + out := make([]int64, 0, 4) + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil || n <= 0 { + continue + } + out = append(out, n) + } + return out +} diff --git a/checks/calls/init.go b/checks/calls/init.go new file mode 100644 index 0000000..e051b6e --- /dev/null +++ b/checks/calls/init.go @@ -0,0 +1,687 @@ +// Package calls provides functionality. +package calls + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "strings" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" + "rsgit.ru/rsmon/rsmon/storage" +) + +const ( + verdictError = "error" + verdictWarning = "warning" + verdictNormal = "normal" +) + +// Result is a result of an AI health check +type Result struct { + checkresult.CheckResult + + // Captured data + Screenshot string `json:"screenshot"` // base64 encoded screenshot + DOM string `json:"dom"` // HTML content + NetworkLogs string `json:"network_logs"` // JSON array of network logs + ConsoleLogs string `json:"console_logs"` // JSON array of console logs + URL string `json:"url"` // Final URL after redirects + StatusCode int `json:"status_code"` // HTTP status code + Title string `json:"title"` // Page title + + // LLM analysis + LLMResponse string `json:"llm_response"` // Full LLM response + LLMVerdict string `json:"llm_verdict"` // "normal", "error", "warning" + LLMReasoning string `json:"llm_reasoning"` // LLM's explanation + HasChanges bool `json:"has_changes"` // Whether changes were detected from previous + + // Previous screenshot comparison + PreviousURL string `json:"previous_url"` // URL of previous screenshot (if any) + PreviousTime string `json:"previous_time"` // Timestamp of previous screenshot + + // Metadata + CheckID uint `json:"check_id"` // For database reference + ScreenshotID uint `json:"screenshot_id"` // ID of stored screenshot +} + +// NetworkLog represents a network request log +type NetworkLog struct { + URL string `json:"url"` + Method string `json:"method"` + StatusCode int `json:"status_code"` + Type string `json:"type"` + Size int64 `json:"size"` + Time float64 `json:"time"` +} + +// ConsoleLog represents a console log entry +type ConsoleLog struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// ScreenshotRecord represents a stored screenshot in the database +type ScreenshotRecord struct { + ID uint `gorm:"primarykey"` + CreatedAt time.Time `json:"created_at"` + CheckID uint `json:"check_id"` + MonitorID uint `json:"monitor_id"` + URL string `json:"url"` + // ScreenshotPath stores the S3 object path (e.g., "screenshots/monitor_123/2025-01-15_123456.jpg") + + ScreenshotPath string `json:"screenshot_path" gorm:"type:varchar(500)"` + DOM string `gorm:"type:longtext" json:"dom"` + NetworkLogs string `gorm:"type:longtext" json:"network_logs"` + ConsoleLogs string `gorm:"type:longtext" json:"console_logs"` + StatusCode int `json:"status_code"` + Title string `json:"title"` + LLMVerdict string `json:"llm_verdict"` + LLMReasoning string `json:"llm_reasoning"` +} + +// Perform executes the AI health check +func Perform(c *models.Check) *Result { + result := &Result{ + CheckID: uint(c.ID), + } + result.Warnings = []string{} + result.Infos = []string{} + + start := time.Now() + + // Get the target URL + targetURL := getTargetURL(c) + result.URL = targetURL + + // Step 1: Capture screenshot, DOM, and logs + screenshotData, domData, networkLogs, consoleLogs, statusCode, title, err := captureWebData(targetURL) + if err != nil { + result.Error = fmt.Errorf("failed to capture web data: %w", err) + result.Duration = time.Since(start) + result.State = "FAIL" + return result + } + + result.Screenshot = screenshotData + result.DOM = domData + result.NetworkLogs = networkLogs + result.ConsoleLogs = consoleLogs + result.StatusCode = statusCode + result.Title = title + + // Step 2: Store current screenshot in database (if available) + var screenshotID uint + if isDBAvailable() { + screenshotID, err = storeScreenshot(uint(c.MonitorID), uint(c.ID), result) + if err != nil { + result.Warnings = append(result.Warnings, "Failed to store screenshot: "+err.Error()) + log.Printf("[ai-check] Failed to store screenshot: %v", err) + } else { + result.ScreenshotID = screenshotID + result.Infos = append(result.Infos, "Screenshot stored for reference") + } + } else { + result.Infos = append(result.Infos, "Database not available - running in standalone mode") + } + + // Step 3: Get previous screenshots for comparison (if DB available) + var previousScreenshots []ScreenshotRecord + if isDBAvailable() { + previousScreenshots, err = getPreviousScreenshots(uint(c.MonitorID), 3) + if err != nil { + result.Warnings = append(result.Warnings, "Could not retrieve previous screenshots: "+err.Error()) + } + } + + if len(previousScreenshots) > 0 { + result.PreviousURL = previousScreenshots[0].URL + result.PreviousTime = previousScreenshots[0].CreatedAt.Format(time.RFC3339) + result.Infos = append(result.Infos, fmt.Sprintf("Comparing with %d previous screenshot(s)", len(previousScreenshots))) + } + + // Step 4: Analyze with LLM + llmVerdict, llmReasoning, hasChanges, err := analyzeWithLLM(targetURL, result, previousScreenshots) + if err != nil { + result.Error = fmt.Errorf("LLM analysis failed: %w", err) + result.LLMResponse = err.Error() + result.Duration = time.Since(start) + result.State = "FAIL" + return result + } + + result.LLMVerdict = llmVerdict + result.LLMReasoning = llmReasoning + result.HasChanges = hasChanges + + // Update the screenshot record with LLM analysis + if screenshotID > 0 { + _ = updateScreenshotWithLLM(screenshotID, llmVerdict, llmReasoning) + } + + // Determine final state based on LLM verdict + switch llmVerdict { + case verdictError: + result.State = "ERR" + result.Error = errors.New(llmReasoning) + case verdictWarning: + result.State = "WARN" + result.Warnings = append(result.Warnings, llmReasoning) + default: + result.State = "OK" + if hasChanges { + result.Infos = append(result.Infos, "Visual changes detected from previous screenshot") + } + } + + result.Duration = time.Since(start) + return result +} + +// getTargetURL constructs the target URL from the check +func getTargetURL(c *models.Check) string { + if c.URL != nil && *c.URL != "" { + return *c.URL + } + + // Construct from monitor host + host := c.Monitor.Host + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + return "https://" + host + } + return host +} + +// captureWebData captures screenshot, DOM, network and console logs using chromedp +func captureWebData(targetURL string) (screenshot, dom, networkLogsJSON, consoleLogsJSON string, statusCode int, title string, err error) { + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Create chromedp context with options for headless chrome + allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, + chromedp.NoDefaultBrowserCheck, + chromedp.NoFirstRun, + chromedp.DisableGPU, + chromedp.IgnoreCertErrors, + chromedp.Flag("headless", "new"), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.Flag("hide-scrollbars", true), + chromedp.Flag("mute-audio", true), + chromedp.WindowSize(1920, 1080), + ) + defer allocCancel() + + taskCtx, taskCancel := chromedp.NewContext(allocCtx) + defer taskCancel() + + // Collect network logs + networkLogs := []NetworkLog{} + consoleLogs := []ConsoleLog{} + + var finalStatusCode int + + // Listen for network events + chromedp.ListenTarget(taskCtx, func(ev interface{}) { + switch e := ev.(type) { + case *network.EventRequestWillBeSent: + // Track the main request + if e.Type == network.ResourceTypeDocument { + networkLogs = append(networkLogs, NetworkLog{ + URL: e.Request.URL, + Method: e.Request.Method, + Type: string(e.Type), + }) + } + case *network.EventResponseReceived: + // Update the main request with status code + for i, log := range networkLogs { + if log.URL == e.Response.URL && log.Method == "" { // Not yet updated + networkLogs[i].StatusCode = int(e.Response.Status) + networkLogs[i].Size = int64(e.Response.EncodedDataLength) + + // Capture final status code for main document + if e.Type == network.ResourceTypeDocument && finalStatusCode == 0 { + finalStatusCode = int(e.Response.Status) + } + break + } + } + case *runtime.EventConsoleAPICalled: + // Capture console logs + value := "" + for _, arg := range e.Args { + // Get the string representation of the argument + value += arg.Value.String() + } + if value != "" && value != "undefined" { + consoleLogs = append(consoleLogs, ConsoleLog{ + Type: "console." + string(e.Type), + Value: strings.TrimSpace(value), + }) + } + } + }) + + // Run the browser tasks + var buf []byte + var html string + var pageTitle string + + tasks := chromedp.Tasks{ + network.Enable(), + chromedp.Navigate(targetURL), + chromedp.WaitReady("body", chromedp.ByQuery), + chromedp.Sleep(2 * time.Second), // Wait for page to fully render + chromedp.Title(&pageTitle), + chromedp.OuterHTML(":root", &html, chromedp.ByQueryAll), + chromedp.FullScreenshot(&buf, 90), // 90% quality JPEG + } + + if err := chromedp.Run(taskCtx, tasks); err != nil { + // Check if it's a timeout error + if errors.Is(err, context.DeadlineExceeded) { + return "", "", "", "", 0, "", fmt.Errorf("page load timeout after 30s") + } + return "", "", "", "", 0, "", fmt.Errorf("chromedp error: %w", err) + } + + // Encode screenshot as base64 + screenshot = base64.StdEncoding.EncodeToString(buf) + dom = html + + // Convert logs to JSON + networkBytes, _ := json.Marshal(networkLogs) + networkLogsJSON = string(networkBytes) + + consoleBytes, _ := json.Marshal(consoleLogs) + consoleLogsJSON = string(consoleBytes) + + // Set status code if we got it from network events + if finalStatusCode == 0 { + finalStatusCode = 200 // Assume OK if we got this far + } + + return screenshot, dom, networkLogsJSON, consoleLogsJSON, finalStatusCode, pageTitle, nil +} + +// storeScreenshot stores the screenshot data in S3 and metadata in the database +func storeScreenshot(monitorID, checkID uint, result *Result) (uint, error) { + // Initialize storage if not already initialized + if !storage.IsAvailable() { + if err := storage.Init(); err != nil { + return 0, fmt.Errorf("failed to initialize storage: %w", err) + } + } + + // Generate a unique filename for the screenshot + // Format: screenshots/monitor_/YYYY-MM-DD/.jpg + timestamp := time.Now().Format("2006-01-02") + screenshotID := uuid.New().String() + objectPath := fmt.Sprintf("screenshots/monitor_%d/%s/%s.jpg", monitorID, timestamp, screenshotID) + + // Decode base64 screenshot data + screenshotBytes, err := base64.StdEncoding.DecodeString(result.Screenshot) + if err != nil { + return 0, fmt.Errorf("failed to decode screenshot: %w", err) + } + + // Upload screenshot to S3 + ctx := context.Background() + reader := bytes.NewReader(screenshotBytes) + objectSize := int64(len(screenshotBytes)) + + _, err = storage.PutObject(ctx, objectPath, reader, objectSize, minio.PutObjectOptions{ + ContentType: "image/jpeg", + }) + if err != nil { + return 0, fmt.Errorf("failed to upload screenshot to S3: %w", err) + } + + log.Printf("[ai-check] Stored screenshot at S3 path: %s (size: %d bytes)", objectPath, objectSize) + + // Store metadata in database + record := &ScreenshotRecord{ + MonitorID: monitorID, + CheckID: checkID, + URL: result.URL, + ScreenshotPath: objectPath, + DOM: truncateString(result.DOM, 50000), // Limit DOM size + NetworkLogs: result.NetworkLogs, + ConsoleLogs: result.ConsoleLogs, + StatusCode: result.StatusCode, + Title: result.Title, + } + + // Auto migrate the table + if err := models.DB().AutoMigrate(&ScreenshotRecord{}); err != nil { + return 0, fmt.Errorf("failed to auto-migrate: %w", err) + } + + if err := models.DB().Create(record).Error; err != nil { + return 0, fmt.Errorf("failed to insert screenshot metadata: %w", err) + } + + return record.ID, nil +} + +// loadScreenshotData loads screenshot data from S3 for a given record +func loadScreenshotData(record *ScreenshotRecord) (string, error) { + if record.ScreenshotPath == "" { + return "", errors.New("no screenshot path in record") + } + + if !storage.IsAvailable() { + if err := storage.Init(); err != nil { + return "", fmt.Errorf("failed to initialize storage: %w", err) + } + } + + ctx := context.Background() + object, err := storage.GetObject(ctx, record.ScreenshotPath, minio.GetObjectOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get screenshot from S3: %w", err) + } + defer object.Close() //nolint:errcheck // accepted lint exception + + // Read the object data + var buf bytes.Buffer + _, err = io.Copy(&buf, object) + if err != nil { + return "", fmt.Errorf("failed to read screenshot data: %w", err) + } + + // Encode as base64 for LLM analysis + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// updateScreenshotWithLLM updates a screenshot record with LLM analysis +func updateScreenshotWithLLM(screenshotID uint, verdict, reasoning string) error { + return models.DB().Model(&ScreenshotRecord{}). + Where("id = ?", screenshotID). + Updates(map[string]interface{}{ + "llm_verdict": verdict, + "llm_reasoning": reasoning, + }).Error +} + +// isDBAvailable checks if the database is available +func isDBAvailable() bool { + return models.IsDBAvailable() +} + +// getPreviousScreenshots retrieves previous screenshots for comparison +func getPreviousScreenshots(monitorID uint, limit int) ([]ScreenshotRecord, error) { + var records []ScreenshotRecord + + err := models.DB().Model(&ScreenshotRecord{}). + Where("monitor_id = ? AND llm_verdict != ''", monitorID). + Order("created_at DESC"). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + + return records, nil +} + +// truncateString truncates a string to a maximum length +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "... (truncated)" +} + +// analyzeWithLLM sends the screenshot and data to the LLM for analysis +func analyzeWithLLM(targetURL string, current *Result, previous []ScreenshotRecord) (verdict, reasoning string, hasChanges bool, err error) { //nolint:lll + // Get LLM configuration from environment + llmKey := firstEnv("LLM_APIKEY", "LLAMA_KEY") + llmURL := firstEnv("LLM_URL", "LLAMA_URL") + llmModel := firstEnv("LLM_MODEL", "LLAMA_MODEL") + + if llmKey == "" || llmURL == "" { + return "", "", false, errors.New("LLM credentials not configured (LLAMA_KEY, LLAMA_URL)") + } + + if llmModel == "" { + llmModel = "llama3.2-vision" // Default model + } + + // Create OpenAI client with custom base URL + client := openai.NewClient( + option.WithBaseURL(llmURL), + option.WithAPIKey(llmKey), + ) + + // Build the system prompt + systemPrompt := buildSystemPrompt(len(previous) > 0) + + // Build user message with current screenshot + userContent := buildUserMessage(targetURL, current, previous) + + // Prepare messages + messages := []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(systemPrompt), + openai.UserMessage(userContent), + } + + // Call LLM with timeout + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + params := openai.ChatCompletionNewParams{ + Messages: messages, + Model: llmModel, + MaxTokens: openai.Int(2000), + Temperature: openai.Float(0.3), // Lower temperature for more consistent analysis + } + + completion, err := client.Chat.Completions.New(ctx, params) + if err != nil { + return "", "", false, fmt.Errorf("LLM request failed: %w", err) + } + + if len(completion.Choices) == 0 { + return "", "", false, errors.New("LLM returned no choices") + } + + response := completion.Choices[0].Message.Content + + // Parse the LLM response to extract verdict and reasoning + return parseLLMResponse(response) +} + +func firstEnv(keys ...string) string { + for _, key := range keys { + if value := os.Getenv(key); value != "" { + return value + } + } + return "" +} + +// buildSystemPrompt creates the system prompt for the LLM +func buildSystemPrompt(hasPrevious bool) string { + basePrompt := ` +You are a web application health monitoring assistant. Your task is to analyze website screenshots +and determine if the website appears to be functioning normally or if there are issues. + + +Consider the following aspects: +1. Visual layout - Is the page rendered correctly? Are elements properly aligned? +2. Error messages - Are there any visible error messages, 404s, 500s, or similar? +3. Loading states - Is the page stuck loading or showing spinners? +4. Broken elements - Are there broken images, missing styles, or layout shifts? +5. Content availability - Is the expected content visible? + +Respond in the following JSON format: +{ + "verdict": "normal" | "warning" | "error", + "reasoning": "Brief explanation of your assessment", + "has_changes": true | false +} + +Verdict guidelines: +- "error": Page is clearly broken (error messages, completely failed to load, blank page) +- "warning": Page loads but has issues (partial content, broken elements, degraded appearance) +- "normal": Page appears to be functioning correctly` + + if hasPrevious { + basePrompt += ` + +You will also be shown previous screenshots of the same website. Compare the current screenshot with previous ones and report any significant visual changes that might indicate problems (layout shifts, missing elements, color changes, etc.).` //nolint:lll + } + + return basePrompt +} + +// buildUserMessage creates the user message with screenshot data +func buildUserMessage(targetURL string, current *Result, previous []ScreenshotRecord) string { + var sb strings.Builder + + _, _ = fmt.Fprintf(&sb, "Analyze the screenshot of: %s\n\n", targetURL) + _, _ = fmt.Fprintf(&sb, "HTTP Status Code: %d\n", current.StatusCode) + _, _ = fmt.Fprintf(&sb, "Page Title: %s\n\n", current.Title) + + // Add console errors if any + var consoleErrors []ConsoleLog + json.Unmarshal([]byte(current.ConsoleLogs), &consoleErrors) //nolint:errcheck // accepted lint exception + errorCount := 0 + for _, log := range consoleErrors { + if strings.Contains(log.Type, "error") || strings.Contains(log.Type, "warn") { + errorCount++ + } + } + if errorCount > 0 { + _, _ = fmt.Fprintf(&sb, "Console Errors/Warnings: %d\n\n", errorCount) + } + + // Add network errors + var networkLogs []NetworkLog + json.Unmarshal([]byte(current.NetworkLogs), &networkLogs) //nolint:errcheck // accepted lint exception + failedRequests := 0 + for _, req := range networkLogs { + if req.StatusCode >= 400 { + failedRequests++ + } + } + if failedRequests > 0 { + _, _ = fmt.Fprintf(&sb, "Failed Network Requests: %d\n\n", failedRequests) + } + + sb.WriteString("Current screenshot (base64):\n") + sb.WriteString(current.Screenshot) + sb.WriteString("\n\n") + + // Add previous screenshots if available (load from S3) + if len(previous) > 0 { + _, _ = fmt.Fprintf(&sb, "For comparison, here are %d previous screenshot(s):\n\n", len(previous)) + for i, prev := range previous { //nolint:gocritic // range copy is acceptable here + // Load screenshot data from S3 + screenshotData, err := loadScreenshotData(&prev) + if err != nil { + log.Printf("[ai-check] Failed to load previous screenshot %d: %v", i+1, err) + _, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s): [error loading screenshot]\n\n", + i+1, prev.CreatedAt.Format("2006-01-02 15:04:05")) + continue + } + + _, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s):\n", i+1, prev.CreatedAt.Format("2006-01-02 15:04:05")) + sb.WriteString(screenshotData) + sb.WriteString("\n\n") + } + } + + return sb.String() +} + +// parseLLMResponse parses the LLM response to extract structured data +func parseLLMResponse(response string) (verdict, reasoning string, hasChanges bool, err error) { + // Try to extract JSON from the response + response = strings.TrimSpace(response) + + // Look for JSON block + jsonStart := strings.Index(response, "{") + jsonEnd := strings.LastIndex(response, "}") + + if jsonStart == -1 || jsonEnd == -1 { + // No JSON found, try to parse text response + return parseTextResponse(response) + } + + jsonStr := response[jsonStart : jsonEnd+1] + + var parsed struct { + Verdict string `json:"verdict"` + Reasoning string `json:"reasoning"` + HasChanges bool `json:"has_changes"` + } + + if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { + // JSON parse failed, fall back to text parsing + return parseTextResponse(response) + } + + // Validate verdict + switch parsed.Verdict { + case "normal", "ok", "healthy": + return verdictNormal, parsed.Reasoning, parsed.HasChanges, nil + case "warning", "warn": + return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil + case "error", "fail", "unhealthy": + return verdictError, parsed.Reasoning, parsed.HasChanges, nil + default: + // Unknown verdict, default to normal with warning + if parsed.Verdict != "" { + return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil + } + return verdictNormal, "Unable to determine specific issues from analysis", false, nil + } +} + +// parseTextResponse parses a non-JSON response +func parseTextResponse(response string) (verdict, reasoning string, hasChanges bool, err error) { + responseLower := strings.ToLower(response) + + // Look for keywords + hasError := strings.Contains(responseLower, "error") || + strings.Contains(responseLower, "broken") || + strings.Contains(responseLower, "failed") || + strings.Contains(responseLower, "not working") + + hasWarning := strings.Contains(responseLower, "warning") || + strings.Contains(responseLower, "issue") || + strings.Contains(responseLower, "problem") || + strings.Contains(responseLower, "degraded") + + hasChange := strings.Contains(responseLower, "change") || + strings.Contains(responseLower, "different") || + strings.Contains(responseLower, "modified") + + if hasError { + return verdictError, response, hasChange, nil + } else if hasWarning { + return verdictWarning, response, hasChange, nil + } + + return verdictNormal, response, hasChange, nil +} diff --git a/checks/calls/init_test.go b/checks/calls/init_test.go new file mode 100644 index 0000000..e803931 --- /dev/null +++ b/checks/calls/init_test.go @@ -0,0 +1,11 @@ +package calls + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsDBAvailableWithoutInitializedDatabase(t *testing.T) { + assert.False(t, isDBAvailable()) +} diff --git a/checks/calls/result.go b/checks/calls/result.go new file mode 100644 index 0000000..b4a3ed8 --- /dev/null +++ b/checks/calls/result.go @@ -0,0 +1,4 @@ +package calls + +// Result is a result of an AI health check +// The full Result struct is defined in init.go diff --git a/checks/cbssl/README.md b/checks/cbssl/README.md new file mode 100644 index 0000000..84ce975 --- /dev/null +++ b/checks/cbssl/README.md @@ -0,0 +1,158 @@ +# CBSSL - Browser-Based SSL Certificate Chain Checker + +## Overview + +The `cbssl` (Browser SSL) check validates SSL/TLS certificates against actual browser CA root stores. Unlike the standard `cssl` check which uses Go's system certificate pool, this check validates certificates against the same CA roots used by Chrome and Firefox on Linux. + +## Features + +- **Dual Browser Validation**: Validates certificates against both Chrome and Firefox CA roots +- **Full Chain Information**: Returns complete certificate chain details for each browser +- **Expiration Tracking**: Monitors certificate expiration dates and warns before expiry +- **Detailed Metrics**: Provides InfluxDB-compatible metrics with detailed validation results + +## How It Works + +### Browser CA Roots + +On Linux, both Chrome and Firefox use the system's CA certificate store: + +- **Chrome/Chromium**: Uses `/etc/ssl/certs/ca-certificates.crt` (on Alpine/Debian) +- **Firefox**: Uses NSS library or falls back to system certificates + +The check loads these CA certificates and validates the target site's certificate chain against each browser's root store independently. + +### Certificate Sources + +The checker looks for CA certificates in the following locations (in order): + +1. `/etc/ssl/certs/ca-certificates.crt` - Alpine/Debian system certificates +2. `/etc/ssl/cert.pem` - macOS system certificates +3. `/etc/pki/tls/certs/ca-bundle.crt` - RHEL/CentOS system certificates +4. `/usr/local/share/ca-certificates/` - Custom certificate directory +5. `/data/rsmon/docker/cert-bundles/output/` - Project-specific certificate bundles + +### Mozilla CA Bundle + +The project includes the Mozilla CA certificate bundle which contains the same CA certificates used by Firefox: + +```bash +# Downloaded from: https://curl.se/ca/cacert.pem +# Location: docker/cert-bundles/output/mozilla-ca-bundle.crt +# Certificate count: 144 CA certificates +``` + +## Result Format + +```go +type Result struct { + // Standard check result + cr.CheckResult + + // Chrome-specific results + ChromeValid bool // true if certificate validates against Chrome roots + ChromeError string // error message if Chrome validation fails + ChromeChain []CertInfo // certificate chain as validated by Chrome + + // Firefox-specific results + FirefoxValid bool // true if certificate validates against Firefox roots + FirefoxError string // error message if Firefox validation fails + FirefoxChain []CertInfo // certificate chain as validated by Firefox + + // Certificate details + Expires *time.Time // certificate expiration date + Subject string // certificate subject (CN) + Issuer string // certificate issuer (CN) + DNSNames []string // certificate SANs +} +``` + +## Check Parameters + +Currently, the check validates against both browsers. Future versions may support: + +- `browser` - Specify which browser to validate against: "chrome", "firefox", or "all" (default) + +## InfluxDB Metrics + +The check provides the following metrics: + +**Fields:** +- `took` - Request duration in milliseconds +- `chrome_valid` - Whether certificate validated against Chrome roots (1/0) +- `firefox_valid` - Whether certificate validated against Firefox roots (1/0) +- `expires_at` - Unix timestamp of certificate expiration +- `days_until_expiry` - Days until certificate expires + +**Tags:** +- `check` - Check ID +- `state` - Check state (OK, WARN, ERR, FAIL) +- `subject` - Certificate subject CN +- `issuer` - Certificate issuer CN +- `chrome_error` - Chrome validation error (if any) +- `firefox_error` - Firefox validation error (if any) +- `dns_names` - Comma-separated list of DNS names in certificate + +## Example Usage + +```go +import "rsgit.ru/rsmon/rsmon/checks/cbssl" + +// Perform the check +result := cbssl.Perform(check) + +// Check results +if result.ChromeValid && result.FirefoxValid { + // Certificate is valid for both browsers +} else if !result.ChromeValid { + // Certificate fails Chrome validation + fmt.Printf("Chrome error: %s\n", result.ChromeError) +} +``` + +## Differences from cssl + +| Feature | cssl | cbssl | +|---------|------|-------| +| CA Root Source | Go's system pool | Browser-specific CA roots | +| Browser Validation | Single (system) | Dual (Chrome + Firefox) | +| Chain Information | Basic leaf cert | Full chain per browser | +| Use Case | General SSL validation | Browser compatibility verification | + +## Certificate Bundle Management + +To update the CA certificate bundles: + +```bash +# Download latest Mozilla CA bundle +cd /data/rsmon +curl -fsSL -o docker/cert-bundles/output/mozilla-ca-bundle.crt \ + https://curl.se/ca/cacert.pem + +# Verify +grep -c "BEGIN CERTIFICATE" docker/cert-bundles/output/mozilla-ca-bundle.crt +``` + +## Troubleshooting + +### "failed to load any CA certificates" + +This error occurs when no CA certificates can be found. Solutions: + +1. Ensure the system has `ca-certificates` package installed +2. Place custom CA certificates in `/usr/local/share/ca-certificates/` +3. Add certificates to the project bundle at `docker/cert-bundles/output/` + +### Certificate validation failures + +If validation fails for a site that works in browsers: + +1. Check if the site uses a custom CA not in the Mozilla bundle +2. Verify the site's intermediate certificates are properly configured +3. Check for expired or malformed certificate chains + +## References + +- [Mozilla Included CA Certificate List](https://wiki.mozilla.org/CA/Included_Certificates) +- [Chrome Root Certificate Policy](https://www.chromium.org/Home/chromium-security/root-ca-policy) +- [curl CA Bundle](https://curl.se/docs/caextract.html) diff --git a/checks/cbssl/cbssl.go b/checks/cbssl/cbssl.go new file mode 100644 index 0000000..21b61df --- /dev/null +++ b/checks/cbssl/cbssl.go @@ -0,0 +1,429 @@ +// Package cbssl provides browser-based SSL certificate chain validation. +// It validates certificates against actual browser CA root stores (Chrome, Firefox). +package cbssl + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/pkg/errors" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const stateOK = "OK" + +// BrowserType represents the type of browser to use for CA roots +type BrowserType string + +// Browser type constants for CA root selection. +const ( + BrowserChrome BrowserType = "chrome" + BrowserFirefox BrowserType = "firefox" + BrowserAll BrowserType = "all" +) + +var ( + // Certificate pools for different browsers + chromeRoots *x509.CertPool + firefoxRoots *x509.CertPool + once sync.Once +) + +// BrowserCertPools holds the certificate pools for different browsers +type BrowserCertPools struct { + Chrome *x509.CertPool + Firefox *x509.CertPool +} + +func init() { + // Initialize certificate pools + once.Do(func() { + var err error + chromeRoots, firefoxRoots, err = LoadBrowserCARoots() + if err != nil { + log.Printf("Warning: failed to load browser CA roots: %v", err) + // Fall back to system roots + chromeRoots = x509.NewCertPool() + firefoxRoots = x509.NewCertPool() + systemRoots, err := x509.SystemCertPool() + if err == nil { + chromeRoots = systemRoots + firefoxRoots = systemRoots + } + } + }) +} + +// Perform executes the browser-based SSL certificate check +func Perform(c *models.Check) *Result { + result := &Result{} + result.State = stateOK + + // Get URL to check + checkURL, err := getCheckURL(c) + if err != nil { + result.State = "FAIL" + result.Error = errors.Wrap(err, "invalid url") + return result + } + log.Println("CBSSL URL:", checkURL) + + // Parse hostname from URL + parsedURL, err := url.Parse(checkURL) + if err != nil { + result.State = "FAIL" + result.Error = errors.Wrap(err, "failed to parse url") + return result + } + hostname := parsedURL.Hostname() + + // Get browser type - default to checking all browsers + // TODO: Add Browser field to CheckSettings to allow per-check configuration + browserType := BrowserAll + + // Create custom TLS client that validates against browser roots + client := &http.Client{ + Timeout: time.Second * 30, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + ServerName: hostname, + MinVersion: tls.VersionTLS12, + // We'll set RootCAs per request to test against different browsers + }, + }, + } + + // Test against Chrome roots + if browserType == BrowserChrome || browserType == BrowserAll { + chromeResult := testWithBrowser(client, checkURL, chromeRoots, "Chrome") + result.ChromeValid = chromeResult.Valid + result.ChromeError = chromeResult.ErrorMsg + result.ChromeChain = chromeResult.ChainInfo + if !chromeResult.Valid { + result.State = "ERR" + result.Error = errors.New("certificate validation failed against Chrome roots") + if chromeResult.ErrorMsg != "" { + result.Warnings = append(result.Warnings, fmt.Sprintf("Chrome: %s", chromeResult.ErrorMsg)) + } + } + } + + // Test against Firefox roots + if browserType == BrowserFirefox || browserType == BrowserAll { + firefoxResult := testWithBrowser(client, checkURL, firefoxRoots, "Firefox") + result.FirefoxValid = firefoxResult.Valid + result.FirefoxError = firefoxResult.ErrorMsg + result.FirefoxChain = firefoxResult.ChainInfo + if !firefoxResult.Valid && result.State == stateOK { + result.State = "ERR" + result.Error = errors.New("certificate validation failed against Firefox roots") + } + if firefoxResult.ErrorMsg != "" { + result.Warnings = append(result.Warnings, fmt.Sprintf("Firefox: %s", firefoxResult.ErrorMsg)) + } + } + + // Get certificate info for expiration check + certInfo, err := getCertificateInfo(checkURL) + if err != nil { + if result.State == stateOK { + result.State = "WARN" + } + result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to get certificate info: %v", err)) + } else { + result.Expires = &certInfo.NotAfter + result.Subject = certInfo.Subject.CommonName + result.Issuer = certInfo.Issuer.CommonName + result.DNSNames = certInfo.DNSNames + + // Check expiration + exp := time.Until(certInfo.NotAfter).Hours() / 24 + if exp < 2 { + if result.State == stateOK { + result.State = "WARN" + } + result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.1f days", exp)) + } + } + + return result +} + +// testWithBrowser tests the certificate against a specific browser's CA roots +func testWithBrowser(client *http.Client, checkURL string, roots *x509.CertPool, browserName string) BrowserTestResult { + result := BrowserTestResult{Valid: false} + + // Clone transport and set custom root CAs + transport, ok := client.Transport.(*http.Transport) + if !ok { + result.ErrorMsg = fmt.Sprintf("failed to get transport for %s", browserName) + return result + } + + customTransport := transport.Clone() + customTransport.TLSClientConfig.RootCAs = roots + + // Create new client with custom transport + customClient := &http.Client{ + Timeout: client.Timeout, + CheckRedirect: client.CheckRedirect, + Transport: customTransport, + } + + req, err := http.NewRequest("GET", checkURL, http.NoBody) + if err != nil { + result.ErrorMsg = fmt.Sprintf("failed to create request for %s: %v", browserName, err) + return result + } + + req.Header.Set("Cache-Control", "max-age=0") + req.Header.Set("Connection", "close") + req.Header.Set("User-Agent", getUserAgent(browserName)) + + resp, err := customClient.Do(req) + if err != nil { + // Check if it's a certificate verification error + if strings.Contains(err.Error(), "certificate") || strings.Contains(err.Error(), "x509") { + result.ErrorMsg = fmt.Sprintf("certificate verification failed: %v", err) + } else { + result.ErrorMsg = fmt.Sprintf("connection failed: %v", err) + } + return result + } + defer resp.Body.Close() //nolint:errcheck // accepted lint exception + + result.Valid = true + + // Get chain info + if resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0 { + chain := resp.TLS.VerifiedChains[0] + result.ChainInfo = buildChainInfo(chain) + } + + return result +} + +// BrowserTestResult represents the result of testing against a specific browser +type BrowserTestResult struct { + Valid bool + ErrorMsg string + ChainInfo []CertInfo +} + +func buildChainInfo(chain []*x509.Certificate) []CertInfo { + info := make([]CertInfo, 0, len(chain)) + for _, cert := range chain { + info = append(info, CertInfo{ + Subject: cert.Subject.CommonName, + Issuer: cert.Issuer.CommonName, + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + IsCA: cert.IsCA, + PublicKey: cert.PublicKeyAlgorithm.String(), + Signature: cert.SignatureAlgorithm.String(), + }) + } + return info +} + +func getUserAgent(browserName string) string { + switch browserName { + case "Chrome": + return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + case "Firefox": + return "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0" + default: + return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + } +} + +// getCertificateInfo retrieves certificate information without validation +func getCertificateInfo(checkURL string) (*x509.Certificate, error) { + parsedURL, err := url.Parse(checkURL) + if err != nil { + return nil, err + } + hostname := parsedURL.Hostname() + + client := &http.Client{ + Timeout: time.Second * 30, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + ServerName: hostname, + InsecureSkipVerify: true, + }, + }, + } + + resp, err := client.Get(checkURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck // accepted lint exception + + if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { + return nil, fmt.Errorf("no TLS certificates found") + } + + return resp.TLS.PeerCertificates[0], nil +} + +// getCheckURL constructs the URL to check from the check configuration +func getCheckURL(c *models.Check) (string, error) { + if c.URL != nil && *c.URL != "" { + ur := *c.URL + u, err := url.Parse(ur) + if err != nil { + return "", errors.Wrap(err, "bad url") + } + if u.Scheme == "" { + u.Scheme = "https" + } + return u.String(), nil + } + return "https://" + c.Monitor.Host, nil +} + +// LoadBrowserCARoots loads CA root certificates from browser installations +func LoadBrowserCARoots() (chrome, firefox *x509.CertPool, err error) { + chrome = x509.NewCertPool() + firefox = x509.NewCertPool() + + // Load Chrome/Chromium CA roots (uses system certs on Linux) + // On Alpine Linux, this is typically /etc/ssl/certs/ca-certificates.crt + chromeLoaded := false + + // Try common certificate bundle locations + certPaths := []string{ + "/etc/ssl/certs/ca-certificates.crt", // Alpine/Debian + "/etc/ssl/cert.pem", // macOS + "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS + "/usr/local/share/ca-certificates/", // Custom certs + "/data/rsmon/docker/cert-bundles/output/", // Our custom bundled certs + } + + for _, certPath := range certPaths { + if loadCertsFromPath(chrome, certPath) { + chromeLoaded = true + break + } + } + + // Load Firefox CA roots (NSS database) + // Firefox uses its own NSS database at ~/.pki/nssdb/ or uses system roots + firefoxLoaded := loadFirefoxCARoots(firefox) + + // If Firefox failed to load, use Chrome/system roots as fallback + if !firefoxLoaded && chromeLoaded { + firefox = chrome + } + + // If both failed, return error + if !chromeLoaded { + return nil, nil, fmt.Errorf("failed to load any CA certificates") + } + + return chrome, firefox, nil +} + +// loadCertsFromPath loads certificates from a file or directory +func loadCertsFromPath(pool *x509.CertPool, path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + + if info.IsDir() { + // Load all .crt and .pem files from directory + files, err := os.ReadDir(path) + if err != nil { + return false + } + loaded := false + for _, file := range files { + if file.IsDir() { + continue + } + ext := filepath.Ext(file.Name()) + if ext == ".crt" || ext == ".pem" { + fullPath := filepath.Join(path, file.Name()) + if pool.AppendCertsFromPEM(readFile(fullPath)) { + loaded = true + } + } + } + return loaded + } + + // Load single file + return pool.AppendCertsFromPEM(readFile(path)) +} + +func readFile(path string) []byte { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + return data +} + +// loadFirefoxCARoots loads Firefox CA roots from NSS database +// On Linux, Firefox typically uses the system's CA certificates via libnssckbi.so +func loadFirefoxCARoots(pool *x509.CertPool) bool { + // Firefox on Linux usually uses the system certificate database + // Try to load from Mozilla's built-in certificate bundle if available + paths := []string{ + "/usr/lib/x86_64-linux-gnu/libnssckbi.so", // Debian/Ubuntu NSS module + "/usr/lib/libnssckbi.so", // Generic path + "/data/rsmon/docker/cert-bundles/output/mozilla/", // Our bundled Mozilla certs + } + + for _, path := range paths { + if loadCertsFromPath(pool, path) { + return true + } + } + + return false +} + +// ParsePEMCerts parses PEM-encoded certificates from data +func ParsePEMCerts(data []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + var block *pem.Block + + rest := data + for { + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + certs = append(certs, cert) + } + } + + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found") + } + + return certs, nil +} diff --git a/checks/cbssl/cbssl_test.go b/checks/cbssl/cbssl_test.go new file mode 100644 index 0000000..a337466 --- /dev/null +++ b/checks/cbssl/cbssl_test.go @@ -0,0 +1,135 @@ +package cbssl + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +func TestParsePEMCerts(t *testing.T) { + // Generate a valid self-signed certificate for testing + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "testca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("failed to create certificate: %v", err) + } + + pemData := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + certs, err := ParsePEMCerts(pemData) + if err != nil { + t.Fatalf("ParsePEMCerts failed: %v", err) + } + + if len(certs) != 1 { + t.Fatalf("expected 1 certificate, got %d", len(certs)) + } + + if certs[0] == nil { + t.Fatal("expected non-nil certificate") + } +} + +func TestParsePEMCertsInvalid(t *testing.T) { + // Test invalid PEM data + pemData := []byte(`not a valid PEM`) + + _, err := ParsePEMCerts(pemData) + if err == nil { + t.Fatal("expected error for invalid PEM data") + } +} + +func TestGetUserAgent(t *testing.T) { + tests := []struct { + browser string + wantStart string + }{ + {"Chrome", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"}, + {"Firefox", "Mozilla/5.0 (X11; Linux x86_64"}, + {"Unknown", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"}, + } + + for _, tt := range tests { + t.Run(tt.browser, func(t *testing.T) { + ua := getUserAgent(tt.browser) + if len(ua) < 20 { + t.Errorf("getUserAgent() returned too short string: %s", ua) + } + }) + } +} + +func TestBrowserTypeString(t *testing.T) { + if BrowserChrome != "chrome" { + t.Errorf("expected 'chrome', got %s", BrowserChrome) + } + if BrowserFirefox != "firefox" { + t.Errorf("expected 'firefox', got %s", BrowserFirefox) + } + if BrowserAll != "all" { + t.Errorf("expected 'all', got %s", BrowserAll) + } +} + +func TestCertInfo(t *testing.T) { + info := CertInfo{ + Subject: "example.com", + Issuer: "CA Root", + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: false, + PublicKey: "RSA", + Signature: "SHA256-RSA", + } + + if info.Subject != "example.com" { + t.Errorf("expected subject 'example.com', got %s", info.Subject) + } + if info.IsCA { + t.Error("expected IsCA to be false") + } +} + +func TestReadFile(t *testing.T) { + // Test reading non-existent file + data := readFile("/nonexistent/file.txt") + if data != nil { + t.Error("expected nil for non-existent file") + } +} + +func TestLoadBrowserCARoots(t *testing.T) { + chrome, firefox, err := LoadBrowserCARoots() + if err != nil { + t.Logf("LoadBrowserCARoots failed (expected in some environments): %v", err) + // This is expected in minimal test environments without CA certificates + return + } + + if chrome == nil { + t.Error("expected non-nil chrome pool") + } + if firefox == nil { + t.Error("expected non-nil firefox pool") + } +} diff --git a/checks/cbssl/result.go b/checks/cbssl/result.go new file mode 100644 index 0000000..8b7b28c --- /dev/null +++ b/checks/cbssl/result.go @@ -0,0 +1,76 @@ +package cbssl + +import ( + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is a result of a browser-based SSL certificate check +type Result struct { + checkresult.CheckResult + + // Chrome-specific results + ChromeValid bool `json:"chrome_valid"` + ChromeError string `json:"chrome_error,omitempty"` + ChromeChain []CertInfo `json:"chrome_chain,omitempty"` + + // Firefox-specific results + FirefoxValid bool `json:"firefox_valid"` + FirefoxError string `json:"firefox_error,omitempty"` + FirefoxChain []CertInfo `json:"firefox_chain,omitempty"` + + // Certificate details + Expires *time.Time `json:"expires,omitempty"` + Subject string `json:"subject,omitempty"` + Issuer string `json:"issuer,omitempty"` + DNSNames []string `json:"dns_names,omitempty"` +} + +// CertInfo represents information about a certificate in the chain +type CertInfo struct { + Subject string `json:"subject"` + Issuer string `json:"issuer"` + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + IsCA bool `json:"is_ca"` + PublicKey string `json:"public_key_algorithm"` + Signature string `json:"signature_algorithm"` +} + +// InfluxFields returns the fields for InfluxDB metrics +func (r *Result) InfluxFields() map[string]interface{} { + ret := make(map[string]interface{}, 0) + ret["took"] = int64(r.Duration / time.Millisecond) + ret["chrome_valid"] = r.ChromeValid + ret["firefox_valid"] = r.FirefoxValid + if r.Expires != nil { + ret["expires_at"] = r.Expires.Unix() + ret["days_until_expiry"] = int64(time.Until(*r.Expires).Hours() / 24) + } + return ret +} + +// InfluxTags returns the tags for InfluxDB metrics +func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility + ret := make(map[string]string, 0) + ret["check"] = strconv.FormatInt(c.ID, 10) + ret["state"] = r.State + ret["subject"] = r.Subject + ret["issuer"] = r.Issuer + if r.Error != nil { + ret["error"] = r.Error.Error() + } + if r.ChromeError != "" { + ret["chrome_error"] = r.ChromeError + } + if r.FirefoxError != "" { + ret["firefox_error"] = r.FirefoxError + } + ret["warnings"] = strings.Join(r.Warnings, ",") + ret["dns_names"] = strings.Join(r.DNSNames, ",") + return ret +} diff --git a/checks/cdns/a_records.go b/checks/cdns/a_records.go new file mode 100644 index 0000000..56cf852 --- /dev/null +++ b/checks/cdns/a_records.go @@ -0,0 +1,39 @@ +// Package cdns provides functionality. +package cdns + +import ( + "errors" + "strconv" + + "github.com/miekg/dns" + + "rsgit.ru/rsmon/rsmon/internal/netaddr" +) + +// FetchARecords provides functionality. +func FetchARecords(zone, ns string) ([]NSRecord, error) { + config := dns.ClientConfig{Servers: []string{ns}} + c := new(dns.Client) + m := new(dns.Msg) + m.SetQuestion(zone, dns.TypeA) + m.RecursionDesired = true + r, _, err := c.Exchange(m, config.Servers[0]+":53") + if err != nil { + return nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, errors.New("bad rcode: " + strconv.Itoa(r.Rcode)) + } + + var result []NSRecord + for _, a := range r.Answer { + if ar, ok := a.(*dns.A); ok { + // spew.Dump(ar) + val := netaddr.Inet{Inet: ar.A} + result = append(result, NSRecord{Name: a.Header().Name, Kind: "A", Value: val}) + // fmt.Printf("%s\n", mx.String()) + } + } + + return result, nil +} diff --git a/checks/cdns/config.go b/checks/cdns/config.go new file mode 100644 index 0000000..8f5a987 --- /dev/null +++ b/checks/cdns/config.go @@ -0,0 +1,39 @@ +package cdns + +import ( + "time" + + "github.com/miekg/dns" +) + +// DNS check configuration constants. +const ( + // Timeout is the DNS query timeout in seconds. + Timeout float64 = float64(1.5) + MaxTrials uint = 3 + MaxNameservers uint = 20 + MaxAddresses uint = 10 + EdnsbufferSize uint16 = 4096 +) + +var ( + conf *dns.ClientConfig + debug = false + maxTrials = 3 + v4only = true + v6only = false + bufsize = EdnsbufferSize + timeout = time.Duration(float64(Timeout) * float64(time.Second)) + noedns = false + recursion = false + tcp = false + noauthrequired = false + nodnssec = false +) + +func init() { + conf = &dns.ClientConfig{ + Servers: []string{"8.8.8.8", "1.1.1.1", "77.88.8.8"}, + Port: "53", + } +} diff --git a/checks/cdns/dns.go b/checks/cdns/dns.go new file mode 100644 index 0000000..a98a92f --- /dev/null +++ b/checks/cdns/dns.go @@ -0,0 +1,234 @@ +// Source: https://github.com/bortzmeyer/check-soa/blob/master/check-soa.go +// 2-Clause BSD License: Copyright (c) 2012, Stephane Bortzmeyer All rights reserved. +// A simple program to have rapidly an idea of the health of a DNS +// zone. It queries each name server of the zone for the SOA record and +// displays the value of the serial number for each server. +// +// Stephane Bortzmeyer +// Heavily modified for RSMon + +package cdns + +import ( + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" + "github.com/weppos/publicsuffix-go/publicsuffix" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + stateOK = "OK" + stateERR = "ERR" + stateWARN = "WARN" +) + +var localhost net.IP + +func init() { + localhost = net.ParseIP("127.0.0.1") +} + +// Perform checks if domain is resolvable via it's DNS servers +func Perform(c *models.Check) *Result { + result := &Result{} + result.State = "FAIL" + host := c.Monitor.Host + + if host == "" { + result.State = "FAIL" + result.Error = errors.New("empty host name") + return result + } + + start := time.Now() + // log.Println("run dns:", host) + if host == "localhost" || strings.HasPrefix(host, "localhost:") { + result.Warnings = append(result.Warnings, "DNS check not possible for localhost, please disable") + return result + } + addr := net.ParseIP(host) + if addr != nil { + result.Warnings = append(result.Warnings, "DNS check not possible for ip address, please disable") + return result + } + + zname, err := publicsuffix.Domain(host) + if err != nil { + result.Warnings = append(result.Warnings, "Failed to get public suffix: "+err.Error()) + zname = host + } + // if zname != host { + // result.Infos = append(result.Infos, "not top level domain, running NS check for "+zname) + // } + + zone := dns.Fqdn(zname) + nsChan := make(chan DNSreply) + // log.Println(zone) + go localQuery(nsChan, zone, dns.TypeNS) + nsResult := <-nsChan + if nsResult.r == nil { + result.State = stateERR + result.Error = fmt.Errorf("cannot retrieve the list of name servers for %s: %s", zone, nsResult.err) + return result + } + if nsResult.r.Rcode == dns.RcodeNameError { + result.State = stateERR + result.Error = fmt.Errorf("no such domain %s", zone) + return result + } + // spew.Dump(nsResult) + + nslist := make(map[string]nameServer, 0) + for i := range nsResult.r.Answer { + ans := nsResult.r.Answer[i] + if ns, ok := ans.(*dns.NS); ok { + name := ns.Ns + nslist[name] = nameServer{name: name, ips: make([]string, MaxAddresses)} + } + } + + // spew.Dump(nslist) + + numNS, numNSaddr, success, results := masterTask(zone, nslist) + if success { + result.State = stateOK + } else { + result.State = stateERR + } + + if numNS == 0 { + result.State = stateERR + result.Error = fmt.Errorf("no NS records for zone \"%s\"", zone) + return result + } + if numNSaddr == 0 { + result.State = stateERR + result.Error = fmt.Errorf("no IP addresses for name servers of %s", zone) + return result + } + + gallOK := true + ganyOK := false + failedNS := []string{} + + lzone := dns.Fqdn(host) + + for _, rzt := range results { //nolint:gocritic // range copy is acceptable here + // spew.Dump(rzt) + + allOK := true + anyOK := false + ns := NSServer{Name: rzt.name} + for i := 0; i < len(rzt.ips); i++ { + ip := NSIP{ + ResponseTime: rzt.rtts[i], + IP: rzt.ips[i], + } + if rzt.success[i] { + anyOK = true + ganyOK = true + ip.State = stateOK + ip.Serial = rzt.serial[i] + } else { + allOK = false + gallOK = false + ip.State = stateERR + ip.Error = errors.New(rzt.errMsg[i]) + failedNS = append(failedNS, rzt.name) + // spew.Dump(rzt) + } + ns.NSIPs = append(ns.NSIPs, ip) + + if result.State == stateOK { + // spew.Dump(ns) + // log.Println("fetching records for", lzone, "from", ns.NSIPs[0].IP) + ns.Response, err = FetchARecords(lzone, ns.NSIPs[0].IP) + if err != nil { + ns.State = stateERR + ns.Error = err + gallOK = false + failedNS = append(failedNS, rzt.name+"/"+ns.NSIPs[0].IP) + // log.Println("failed:", err) + } + } + } + + if len(rzt.ips) == 0 { + ns.State = stateERR + ns.Error = errors.New(rzt.globalErrMsg) + failedNS = append(failedNS, rzt.name+"/no ip for dns server") + gallOK = false + } else { + if allOK { + ns.State = stateOK + } else { + if anyOK { + ns.State = "WARN" + // log.Println("failed NS") + // spew.Dump(ns) + if ns.Error == nil { + ns.Error = errors.New("some servers failed") + } + } else { + ns.State = stateERR + if ns.Error == nil { + ns.Error = errors.New("all servers failed") + } + } + } + } + + result.NSServers = append(result.NSServers, ns) + } + + if gallOK { + result.State = stateOK + } else { + if ganyOK { + result.State = stateWARN + result.Warnings = append(result.Warnings, "some servers failed: "+strings.Join(failedNS, ",")) + } else { + result.State = stateERR + if result.Error == nil { + result.Error = errors.New("all servers failed") + } + } + } + + for _, ni := range result.NSServers { + // log.Println(ni.Name) + for _, r := range ni.Response { + ip := r.Value.Inet + if ip.Equal(localhost) { + result.State = stateERR + err := "resolves to localhost/127.0.0.1" + if result.Error == nil { + result.Error = errors.New(err) + } else if result.Error.Error() != err { + result.Warnings = append(result.Warnings, err) + } + } + // log.Println(r.Name, r.Kind, r.Value) + } + } + + _, maxt := result.Times() + if maxt > 2*time.Second { + if result.State == stateOK { + result.State = stateWARN + } + result.Warnings = append(result.Warnings, "slow") + } + + result.Duration = time.Since(start) + + // spew.Dump(result) + + return result +} diff --git a/checks/cdns/local_query.go b/checks/cdns/local_query.go new file mode 100644 index 0000000..f8220cb --- /dev/null +++ b/checks/cdns/local_query.go @@ -0,0 +1,67 @@ +package cdns + +import ( + "errors" + "fmt" + "log" + "strings" + + "github.com/miekg/dns" +) + +func localQuery(mychan chan DNSreply, qname string, qtype uint16) { + if debug { + fmt.Printf("DEBUG: start of DNS request \"%s\" / %d\n", qname, qtype) + } + var result DNSreply + var trials uint + result.qname = qname + result.qtype = qtype + result.r = nil + result.err = errors.New("no name server to answer the question") + localm := new(dns.Msg) + localm.Id = dns.Id() + localm.RecursionDesired = true + localm.Question = make([]dns.Question, 1) + localm.SetEdns0(bufsize, false) // Even if no EDNS requested, see #9 May be we should retry without it if timeout? + localc := new(dns.Client) + localc.ReadTimeout = timeout + localm.Question[0] = dns.Question{Name: qname, Qtype: qtype, Qclass: dns.ClassINET} +Tests: + for trials = 0; trials < uint(maxTrials); trials++ { + for serverIndex := range conf.Servers { + server := conf.Servers[serverIndex] + result.nameserver = server + // Brackets around the server address are necessary for IPv6 name servers + // Brackets required for IPv6; do not use net.JoinHostPort (see check-soa commit 3e4edb1) + r, rtt, err := localc.Exchange(localm, "["+server+"]:"+conf.Port) + if r == nil { + result.r = nil + result.err = err + log.Println(err.Error()) + if strings.Contains(err.Error(), "timeout") { + // Try another resolver + continue + } + // We give in + break Tests + } + result.rtt = rtt + if r.Rcode == dns.RcodeSuccess { + // TODO: NODATA (NOERROR/ANSWER=0) are silently ignored (e.g. name exists but no IP address) + // TODO: for rcodes like SERVFAIL, trying another resolver could make sense + result.r = r + result.err = nil + break Tests + } + // All the other codes are errors + result.r = r + result.err = errors.New(dns.RcodeToString[r.Rcode]) + break Tests + } + } + if debug { + fmt.Printf("DEBUG: end of DNS request \"%s\" / %d\n", qname, qtype) + } + mychan <- result +} diff --git a/checks/cdns/master_task.go b/checks/cdns/master_task.go new file mode 100644 index 0000000..208e873 --- /dev/null +++ b/checks/cdns/master_task.go @@ -0,0 +1,123 @@ +package cdns + +import ( + "fmt" + "time" + + "github.com/miekg/dns" +) + +// Results provides functionality. +type Results map[string]nameServer + +func masterTask(zone string, nameservers map[string]nameServer) (uint, uint, bool, Results) { + var numRequests uint + success := true + addressChannel := make(chan DNSreply) + soaChannel := make(chan SOAreply) + numNS := uint(0) + numAddrNS := uint(0) + results := make(Results) + for name := range nameservers { + if !v6only { + go localQuery(addressChannel, name, dns.TypeA) + } + if !v4only { + go localQuery(addressChannel, name, dns.TypeAAAA) + } + numNS++ + } + if v6only || v4only { + numRequests = numNS + } else { + numRequests = numNS * 2 + } + for i := uint(0); i < numRequests; i++ { + addrResult := <-addressChannel + addrFamily := "IPv6" + if addrResult.qtype == dns.TypeA { + addrFamily = "IPv4" + } + if addrResult.r == nil { + // TODO We may have different globalErrMsg is it + // works with IPv4 but not IPv6 (it should not happen but it does) + nameservers[addrResult.qname] = nameServer{ + name: addrResult.qname, + ips: nil, + globalErrMsg: fmt.Sprintf("Cannot get the %s address: %s", addrFamily, addrResult.err), + } + success = false + } else { + if addrResult.r.Rcode != dns.RcodeSuccess { + nameservers[addrResult.qname] = nameServer{ + name: addrResult.qname, + ips: nil, + globalErrMsg: fmt.Sprintf("Cannot get the %s address: %s", addrFamily, dns.RcodeToString[addrResult.r.Rcode]), + } + success = false + } else { + for j := range addrResult.r.Answer { + ansa := addrResult.r.Answer[j] + var ns string + switch a := ansa.(type) { + case *dns.A: + ns = a.A.String() + existing := nameservers[addrResult.qname] + nameservers[addrResult.qname] = nameServer{name: addrResult.qname, ips: append(existing.ips, ns)} + numAddrNS++ + go soaQuery(soaChannel, zone, addrResult.qname, ns) + case *dns.AAAA: + ns = a.AAAA.String() + existing2 := nameservers[addrResult.qname] + nameservers[addrResult.qname] = nameServer{name: addrResult.qname, ips: append(existing2.ips, ns)} + numAddrNS++ + go soaQuery(soaChannel, zone, addrResult.qname, ns) + } + } + } + } + } + for i := uint(0); i < numAddrNS; i++ { + if debug { + fmt.Printf("DEBUG Getting result for ns #%d/%d\n", i+1, numAddrNS) + } + soaResult := <-soaChannel + _, present := results[soaResult.name] + if !present { + results[soaResult.name] = nameServer{ + name: soaResult.name, + ips: make([]string, 0), + success: make([]bool, 0), + errMsg: make([]string, 0), + serial: make([]uint32, 0), + rtts: make([]time.Duration, 0), + } + } + if !soaResult.retrieved { + results[soaResult.name] = nameServer{ + name: soaResult.name, + ips: append(results[soaResult.name].ips, soaResult.address), + success: append(results[soaResult.name].success, false), + errMsg: append(results[soaResult.name].errMsg, soaResult.msg), + serial: append(results[soaResult.name].serial, 0), + rtts: append(results[soaResult.name].rtts, soaResult.rtt), + } + success = false + } else { + results[soaResult.name] = nameServer{ + name: soaResult.name, + ips: append(results[soaResult.name].ips, soaResult.address), + success: append(results[soaResult.name].success, true), + errMsg: append(results[soaResult.name].errMsg, ""), + serial: append(results[soaResult.name].serial, soaResult.serial), + rtts: append(results[soaResult.name].rtts, soaResult.rtt), + } + } + } + for name := range nameservers { + if nameservers[name].ips == nil { + results[name] = nameservers[name] + } + } + return numNS, numAddrNS, success, results +} diff --git a/checks/cdns/result.go b/checks/cdns/result.go new file mode 100644 index 0000000..f9e377e --- /dev/null +++ b/checks/cdns/result.go @@ -0,0 +1,109 @@ +package cdns + +import ( + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" + "rsgit.ru/rsmon/rsmon/internal/netaddr" +) + +// NSIP holds the result of querying a single nameserver IP. +type NSIP struct { + State string + IP string + ResponseTime time.Duration + Serial uint32 + Error error +} + +// NSRecord holds a single DNS record from a nameserver response. +type NSRecord struct { + Name string + Kind string + Value netaddr.Inet +} + +// NSServer holds the result of querying a single nameserver. +type NSServer struct { + State string + Name string + Error error + NSIPs []NSIP + Response []NSRecord +} + +// Result holds the full DNS check result. +type Result struct { + checkresult.CheckResult + NSServers []NSServer +} + +// Servers provides functionality. +func (r *Result) Servers() []string { + ret := make([]string, 0, len(r.NSServers)) + for _, s := range r.NSServers { + ret = append(ret, s.Name+"-"+s.State) + } + return ret +} + +// ServersOK provides functionality. +func (r *Result) ServersOK() int { + ok := 0 + for _, s := range r.NSServers { + if s.State == "OK" { + ok++ + } + } + return ok +} + +// Times provides functionality. +func (r *Result) Times() (time.Duration, time.Duration) { + minTime := time.Hour + maxTime := time.Duration(0) + for _, s := range r.NSServers { + for _, i := range s.NSIPs { + if i.ResponseTime > maxTime { + maxTime = i.ResponseTime + } + if i.ResponseTime < minTime { + minTime = i.ResponseTime + } + } + } + return minTime, maxTime +} + +// InfluxFields provides functionality. +func (r *Result) InfluxFields() map[string]interface{} { + ret := make(map[string]interface{}, 0) + + _, maxTime := r.Times() + ret["took"] = int64(maxTime / time.Millisecond) + + return ret +} + +// InfluxTags provides functionality. +func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility + ret := make(map[string]string, 0) + ret["check"] = strconv.FormatInt(c.ID, 10) + ret["state"] = c.State + minTime, _ := r.Times() + ret["min_took"] = strconv.FormatInt(int64(minTime/time.Millisecond), 10) + + ret["state"] = r.State + total := len(r.NSServers) + oks := r.ServersOK() + ret["warnings"] = strings.Join(r.Warnings, ",") + + ret["nservers"] = strconv.Itoa(total) + ret["servers"] = strings.Join(r.Servers(), ",") + ret["servers_ok"] = strconv.Itoa(oks) + ret["servers_failed"] = strconv.Itoa(total - oks) + return ret +} diff --git a/checks/cdns/soa_query.go b/checks/cdns/soa_query.go new file mode 100644 index 0000000..d748a65 --- /dev/null +++ b/checks/cdns/soa_query.go @@ -0,0 +1,82 @@ +package cdns + +import ( + "fmt" + "net" + + "github.com/miekg/dns" +) + +func soaQuery(mychan chan SOAreply, zone string, name string, server string) { + var result SOAreply + var trials uint + result.retrieved = false + result.name = name + result.address = server + result.msg = "UNKNOWN" + m := new(dns.Msg) + if !noedns { + m.SetEdns0(bufsize, !nodnssec) + } + m.Id = dns.Id() + if recursion { + m.RecursionDesired = true + } else { + m.RecursionDesired = false + } + m.Question = make([]dns.Question, 1) + c := new(dns.Client) + c.ReadTimeout = timeout // Seems ignored for TCP? + if tcp { + c.Net = "tcp" + } + m.Question[0] = dns.Question{Name: zone, Qtype: dns.TypeSOA, Qclass: dns.ClassINET} + nsAddressPort := net.JoinHostPort(server, "53") + if debug { + fmt.Printf("DEBUG Querying SOA from %s\n", nsAddressPort) + } + for trials = 0; trials < uint(maxTrials); trials++ { + soa, rtt, err := c.Exchange(m, nsAddressPort) + if soa == nil { + result.rtt = 0 + result.msg = err.Error() + } else { + result.rtt = rtt + if soa.Rcode != dns.RcodeSuccess { + result.msg = dns.RcodeToString[soa.Rcode] + break + } + if len(soa.Answer) == 0 { /* May happen if the server is a recursor, not authoritative, since we query with RD=0 */ + result.msg = "0 answer" + break + } else { //nolint:revive // complex nested structure + gotSoa := false + for _, rsoa := range soa.Answer { + switch r := rsoa.(type) { + case *dns.SOA: + if noauthrequired || soa.Authoritative { + result.retrieved = true + result.serial = r.Serial + result.msg = "OK" + } else { + result.msg = "Not authoritative" + } + gotSoa = true + case *dns.CNAME: /* Bad practice but common */ + result.msg = "Apparently not a zone but an alias" + case *dns.RRSIG: + /* Ignore them. See bug #8 */ + default: + // TODO: a name server can send us other RR types. + result.msg = fmt.Sprintf("Internal error when processing %s, unexpected record type\n", rsoa) + } + } + if !gotSoa { + result.msg = "No SOA record in reply" + } + break + } + } + } + mychan <- result +} diff --git a/checks/cdns/types.go b/checks/cdns/types.go new file mode 100644 index 0000000..764efd7 --- /dev/null +++ b/checks/cdns/types.go @@ -0,0 +1,37 @@ +package cdns + +import ( + "time" + + "github.com/miekg/dns" +) + +// DNSreply provides functionality. +type DNSreply struct { + qname string + qtype uint16 + r *dns.Msg + err error + nameserver string + rtt time.Duration +} + +// SOAreply provides functionality. +type SOAreply struct { + name string + address string + serial uint32 + retrieved bool + msg string + rtt time.Duration +} + +type nameServer struct { + name string + ips []string + globalErrMsg string + success []bool + errMsg []string + serial []uint32 + rtts []time.Duration +} diff --git a/checks/cftp/cftp.go b/checks/cftp/cftp.go new file mode 100644 index 0000000..95862e1 --- /dev/null +++ b/checks/cftp/cftp.go @@ -0,0 +1,50 @@ +// Package cftp provides functionality. +package cftp + +import ( + "bufio" + "errors" + "net" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const stateERR = "ERR" + +// Perform provides functionality. +func Perform(c *models.Check) *Result { + result := &Result{} + host := c.GetSettings().Port + if host == "" { + host = "21" + } + result.State = "START" + client := &net.Dialer{ + Timeout: 10 * time.Second, + DualStack: true, + } + start := time.Now() + conn, err := client.Dial("tcp", net.JoinHostPort(c.Monitor.Host, host)) + if err != nil { + result.State = stateERR + result.Error = err + result.Duration = time.Since(start) + return result + } + result.Duration = time.Since(start) + status, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + result.State = stateERR + result.Error = err + return result + } + if strings.Contains(status, "FTP") { + result.State = "OK" + } else { + result.State = stateERR + result.Error = errors.New("it's not FTP") + } + return result +} diff --git a/checks/cftp/result.go b/checks/cftp/result.go new file mode 100644 index 0000000..59eb1c8 --- /dev/null +++ b/checks/cftp/result.go @@ -0,0 +1,10 @@ +package cftp + +import ( + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is a result of a check +type Result struct { + checkresult.CheckResult +} diff --git a/checks/chttp/http.go b/checks/chttp/http.go new file mode 100644 index 0000000..cffbbea --- /dev/null +++ b/checks/chttp/http.go @@ -0,0 +1,179 @@ +// Package chttp provides HTTP check functionality for RSMon. +package chttp + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net" + "net/http" + "strings" + "time" + + "github.com/pkg/errors" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + stateERR = "ERR" + stateFAIL = "FAIL" +) + +// UserAgent is the HTTP User-Agent header sent with all HTTP checks. +const UserAgent = "Mozilla/5.0 (compatible; RSMon/1.0; +https://rsmon.ru/bot)" + +func ipv6(ip []string) []string { + var ips []string + for _, a := range ip { + ip := net.ParseIP(a) + check := ip.To4() + if check == nil { + ips = append(ips, a) + } + } + return ips +} + +// Perform executes an HTTP check. +func Perform(c *models.Check) *Result { + result := &Result{} + result.State = "START" + if c.URL == nil { + result.State = stateFAIL + result.Error = errors.New("no url or bad url") + return result + } + + var requestBody io.Reader + + settings := c.GetSettings() + var err error + var to time.Duration + var slow time.Duration + + if settings.Timeout > 0 && settings.Timeout < 300000 { + to = time.Millisecond * time.Duration(settings.Timeout) + } else { + to = time.Second * 60 + } + + if settings.SlowTime > 0 { + slow = time.Millisecond * time.Duration(settings.SlowTime) + } else { + slow = time.Second * 5 + } + + client := &http.Client{ + Timeout: to, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + if settings.CheckIp { + addr, err := net.LookupHost(*c.URL) + if err != nil { + result.Status = stateFAIL + result.Error = err + } + if settings.CheckIPv6 { + addr = ipv6(addr) + } + for _, a := range addr { + _, err := net.Dial("tcp", net.JoinHostPort(a, "80")) + if err != nil { + result.Error = err + break + } + result.State = "OK" + } + } + switch settings.RequestType { + case "application/json": + body, _ := json.Marshal(settings.RequestContent) + requestBody = bytes.NewReader(body) + case "application/x-www-form-urlencoded": + requestBody = strings.NewReader(settings.RequestContent) + case "multipart/form-data": + buf := bytes.NewBuffer([]byte{}) + writer := multipart.NewWriter(buf) + _, _ = writer.CreateFormField(settings.RequestContent) + _ = writer.Close() + requestBody = bytes.NewReader(buf.Bytes()) + settings.RequestType = writer.FormDataContentType() + case "text/plain": + requestBody = bytes.NewBufferString(settings.RequestContent) + } + if settings.RequestMethod == "" { + settings.RequestMethod = "GET" + } + request, err := http.NewRequest(strings.ToUpper(settings.RequestMethod), *c.URL, requestBody) + if err != nil { + result.State = stateFAIL + result.Error = err + return result + } + + if settings.HTTPUsername != "" && settings.HTTPPassword != "" { + request.SetBasicAuth(settings.HTTPUsername, settings.HTTPPassword) + } + + request.Header.Set("User-Agent", UserAgent) + request.Header.Set("Cache-Control", "max-age=0") + request.Header.Set("Connection", "close") + if settings.RequestType != "" { + request.Header.Set("Content-Type", settings.RequestType) + } + if len(settings.RequestHeader) != 0 { + for _, h := range settings.RequestHeader { + request.Header.Set(h.Key, h.Value) + } + } + start := time.Now() + resp, err := client.Do(request) + if err != nil { + result.State = stateERR + result.Error = errors.Wrap(err, "request exec") + return result + } + defer resp.Body.Close() //nolint:errcheck // accepted lint exception + + result.Status = resp.Status + result.StatusCode = resp.StatusCode + result.Headers = resp.Header + + body, err := io.ReadAll(resp.Body) + result.Duration = time.Since(start) + + if err != nil { + result.State = stateERR + result.Error = errors.Wrap(err, "read body") + return result + } + result.Length = len(body) + result.Body = body + + warns, err := settings.CheckAnswer(resp, body) + + for _, w := range warns { + result.State = "WARN" + result.Warnings = append(result.Warnings, w) + } + if err != nil { + result.State = stateERR + result.Error = errors.Wrap(err, "response check") + } + + if result.Error == nil { + if result.Duration > slow { + result.State = "WARN" + result.Warnings = append(result.Warnings, "slow") + } else if result.State == "START" { + result.State = "OK" + } + } else { + result.State = stateERR + } + return result +} diff --git a/checks/chttp/result.go b/checks/chttp/result.go new file mode 100644 index 0000000..ab8cbe8 --- /dev/null +++ b/checks/chttp/result.go @@ -0,0 +1,41 @@ +package chttp + +import ( + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result provides functionality. +type Result struct { + checkresult.CheckResult + StatusCode int + Status string + Body []byte + Headers map[string][]string + Length int +} + +// InfluxFields provides functionality. +func (hr *Result) InfluxFields() map[string]interface{} { + ret := make(map[string]interface{}, 0) + ret["took"] = int64(hr.Duration / time.Millisecond) + + return ret +} + +// InfluxTags provides functionality. +func (hr *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility + ret := make(map[string]string, 0) + ret["check"] = strconv.FormatInt(c.ID, 10) + ret["state"] = hr.State + ret["code"] = strconv.Itoa(hr.StatusCode) + if hr.Error != nil { + ret["error"] = hr.Error.Error() + } + ret["warnings"] = strings.Join(hr.Warnings, ",") + return ret +} diff --git a/checks/cping/ping.go b/checks/cping/ping.go new file mode 100644 index 0000000..30f348d --- /dev/null +++ b/checks/cping/ping.go @@ -0,0 +1,378 @@ +// Package cping provides ICMP echo (ping) check functionality for RSMon. +// +// The implementation targets minimal-reuse logic inspired by +// github.com/go-ping/ping, but rewritten inline so the project does not +// pick up an external dependency. Concretely it uses +// golang.org/x/net/icmp to send an Echo Request and waits for a single +// Echo Reply within the configured timeout. +// +// Privileges / CAP_NET_RAW: +// +// - On Linux, the "ip4:icmp" listener needs either CAP_NET_RAW on the +// binary OR the net.ipv4.ping_group_range sysctl to be widened +// (see "unprivileged ICMP sockets"). When neither is available, we +// fall back to "udp4" which works on Linux only when the same +// sysctl is widened; on macOS the "udp4" mode is unprivileged by +// default. +// - On Windows the privileged (raw) ICMP listener is required. +// +// The check returns FAIL when neither listener can be opened so the +// caller knows the operator needs to enable the capability. OK/ERR are +// reported when the listener works but the host is unreachable or times +// out. +package cping + +import ( + "errors" + "fmt" + "net" + "os" + "runtime" + "strings" + "sync" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + stateOK = "OK" + stateERR = "ERR" + stateFail = "FAIL" + + defaultCount = 1 + defaultTimeout = 5 * time.Second + minTimeout = 1 * time.Second + defaultPacketSz = 56 + + icmpProtoIP4 = "ip4:icmp" + icmpProtoUDP = "udp4" +) + +// Pinger is the abstraction the package uses for ICMP echo. The +// production code path uses runPinger, but it is interface-typed so +// tests can substitute a fake without touching raw sockets. +type Pinger interface { + Run(host string, count int, timeout time.Duration, payloadSize int) Stats +} + +// Stats is the per-run summary produced by a Pinger. +type Stats struct { + PacketsSent int + PacketsRecv int + AvgRtt time.Duration + Err error +} + +var ( + defaultPinger Pinger = &realPinger{} + + // pingerMu guards the swap of defaultPinger in tests. + pingerMu sync.RWMutex +) + +// SetPinger overrides the default Pinger. It is intended for tests +// that need to inject fakes without granting CAP_NET_RAW to the test +// binary. +func SetPinger(p Pinger) { + pingerMu.Lock() + defaultPinger = p + pingerMu.Unlock() +} + +func currentPinger() Pinger { + pingerMu.RLock() + defer pingerMu.RUnlock() + return defaultPinger +} + +// Perform executes a single ping check for the supplied Check. +// +// Settings consumed from models.CheckSettings: +// - count (int): number of echo requests to send. Defaults to 1 to +// keep intervals short; clamped to [1, 5]. +// - timeout (int, seconds): total budget for the check; clamped to +// at least 1s. +// - packet_size (int): ICMP payload size in bytes; clamped to +// [0, 1400]. +// - host (string): optional override of the monitor host. +func Perform(c *models.Check) *Result { + r := &Result{} + settings := c.GetSettings() + + host := c.Monitor.Host + if settings.Host != "" { + host = settings.Host + } + if host == "" { + r.State = stateFail + r.Error = errors.New("ping: empty host") + return r + } + + count := settings.Count + if count <= 0 { + count = defaultCount + } + if count > 5 { + count = 5 + } + + timeout := time.Duration(settings.Timeout) * time.Second + if timeout <= 0 { + timeout = defaultTimeout + } + if timeout < minTimeout { + timeout = minTimeout + } + + payloadSize := settings.PacketSize + if payloadSize == 0 { + payloadSize = defaultPacketSz + } + if payloadSize < 0 { + payloadSize = 0 + } + if payloadSize > 1400 { + payloadSize = 1400 + } + + start := time.Now() + stats := currentPinger().Run(host, count, timeout, payloadSize) + r.Duration = time.Since(start) + r.PacketsSent = stats.PacketsSent + r.PacketsRecv = stats.PacketsRecv + if stats.PacketsRecv > 0 { + r.AvgRttMs = float64(stats.AvgRtt.Microseconds()) / 1000.0 + } + + if stats.Err != nil { + // Distinguish "could not run at all" (no privileges) from + // "ran but failed" so the operator can fix the environment. + if isUnsupported(stats.Err) { + r.State = stateFail + } else { + r.State = stateERR + } + r.Error = stats.Err + return r + } + + if stats.PacketsRecv == 0 { + r.State = stateERR + r.Error = fmt.Errorf("no reply from %s (sent %d)", host, stats.PacketsSent) + return r + } + + r.State = stateOK + r.Infos = append(r.Infos, fmt.Sprintf("rtt=%.2fms sent=%d recv=%d", r.AvgRttMs, stats.PacketsSent, stats.PacketsRecv)) + return r +} + +// isUnsupported reports whether err looks like a permission problem +// rather than a runtime failure. +func isUnsupported(err error) bool { + if err == nil { + return false + } + msg := err.Error() + if runtime.GOOS == "windows" { + return msg != "" + } + // Linux/Darwin permission flavors. + if errors.Is(err, os.ErrPermission) { + return true + } + if msg == "" { + return false + } + if containsAny(msg, "operation not permitted", "permission denied", "cap_net_raw", "ping_group_range") { + return true + } + return false +} + +func containsAny(s string, needles ...string) bool { + for _, n := range needles { + if n == "" { + continue + } + if strings.Contains(s, n) { + return true + } + } + return false +} + +// realPinger sends and receives ICMP echo packets using the standard +// library plus golang.org/x/net/icmp. +type realPinger struct{} + +// Run is the production pinger entrypoint. It resolves host, opens an +// ICMP listener (preferring the unprivileged UDP path on Linux when +// available, falling back to raw IP), and waits up to timeout for at +// least one Echo Reply. +func (r *realPinger) Run(host string, count int, timeout time.Duration, payloadSize int) Stats { + stats := Stats{PacketsSent: 0, PacketsRecv: 0} + + dst, err := net.ResolveIPAddr("ip4", host) + if err != nil { + stats.Err = fmt.Errorf("resolve %s: %w", host, err) + return stats + } + + conn, network, err := openICMP() + if err != nil { + stats.Err = err + return stats + } + defer conn.Close() //nolint:errcheck // accepted lint exception: best-effort close + + // Build an "echo and wait for reply" function keyed by network so + // the IPv4-only logic stays close to where it is used. + sendAndRecv := func(seq int) (time.Duration, error) { + msg := icmp.Message{ + Type: ipv4.ICMPTypeEcho, Code: 0, + Body: &icmp.Echo{ + ID: os.Getpid() & 0xffff, Seq: seq, + Data: makeBytes(payloadSize), + }, + } + bin, err := msg.Marshal(nil) + if err != nil { + return 0, fmt.Errorf("marshal icmp: %w", err) + } + + sentAt := time.Now() + if _, err := conn.WriteTo(bin, dst); err != nil { + return 0, fmt.Errorf("write icmp: %w", err) + } + + // Per-packet deadline = remaining budget / remaining attempts + // (or 1s minimum). We reuse the single shared conn for all + // count iterations so replies may arrive slightly out of order. + deadline := time.Now().Add(timeout / time.Duration(count)) + if remaining := time.Until(deadline); remaining < time.Second { + deadline = time.Now().Add(time.Second) + } + if err := conn.SetReadDeadline(deadline); err != nil { + return 0, fmt.Errorf("set deadline: %w", err) + } + + reply, peer, err := readOne(conn, network) + if err != nil { + return 0, err + } + _ = peer // peer would be useful for response-time per hop; not needed for MVP. + if reply == nil { + return 0, errors.New("nil reply") + } + return time.Since(sentAt), nil + } + + var total time.Duration + for i := 1; i <= count; i++ { + stats.PacketsSent++ + rtt, err := sendAndRecv(i) + if err != nil { + // First packet failed because of read timeout: report + // host as unreachable. Keep iterating up to count so the + // reported packet loss is accurate (>= 50%). + if i == 1 { + stats.Err = fmt.Errorf("icmp %s: %w", host, err) + } + continue + } + stats.PacketsRecv++ + total += rtt + } + if stats.PacketsRecv > 0 { + stats.AvgRtt = total / time.Duration(stats.PacketsRecv) + } + // When at least one packet succeeded, drop the underlying error so + // Perform() reports OK. + if stats.PacketsRecv > 0 { + stats.Err = nil + } + return stats +} + +// openICMP returns an ICMP packet connection. On Linux the code prefers +// the "udp4" (unprivileged) listener because the raw "ip4:icmp" +// listener needs CAP_NET_RAW unless the sysctl +// net.ipv4.ping_group_range is widened. On other OSes we fall back to +// the raw listener. +func openICMP() (*icmp.PacketConn, string, error) { + // Try the unprivileged path first; if it fails, fall back to raw. + conn, err := icmp.ListenPacket(icmpProtoUDP, "0.0.0.0") + if err == nil { + return conn, icmpProtoUDP, nil + } + rawErr := err + if runtime.GOOS == "windows" { + // Windows must use the raw (privileged) listener. + conn, err = icmp.ListenPacket(icmpProtoIP4, "0.0.0.0") + if err != nil { + return nil, "", fmt.Errorf("icmp listen: %w", rawErr) + } + return conn, icmpProtoIP4, nil + } + // Linux/Darwin: try raw as a fallback. Production binaries + // shipping with cap_net_raw=+ep will succeed here; test/CI + // runners without the capability will surface a clear + // permission error. + conn, err = icmp.ListenPacket(icmpProtoIP4, "0.0.0.0") + if err != nil { + return nil, "", fmt.Errorf("icmp listen (udp4=%v; ip4=%v)", rawErr, err) + } + return conn, icmpProtoIP4, nil +} + +// readOne reads a single packet, validates it is an Echo Reply (or a +// TTL-exceeded reply from a router along the path), and returns it. +func readOne(conn *icmp.PacketConn, network string) (*icmp.Message, net.Addr, error) { + buf := make([]byte, 1500) + n, peer, err := conn.ReadFrom(buf) + if err != nil { + return nil, nil, fmt.Errorf("read icmp: %w", err) + } + parsed, err := icmp.ParseMessage(icmpProtoToInt(network), buf[:n]) + if err != nil { + return nil, nil, fmt.Errorf("parse icmp: %w", err) + } + // We accept both Echo Reply (the destination) and Time Exceeded + // (intermediate router hop) because some networks filter Echo + // Replies but still return traceroute-style Time Exceeded packets, + // which proves the host is reachable. + switch parsed.Type { + case ipv4.ICMPTypeEchoReply, ipv4.ICMPTypeTimeExceeded: + return parsed, peer, nil + } + return nil, nil, fmt.Errorf("unexpected icmp type %v", parsed.Type) +} + +// icmpProtoToInt maps our internal "ip4:icmp"/"udp4" tag to the +// protocol number expected by icmp.ParseMessage. icmp.DefaultPacketProtocol +// would re-derive this but we want the value stable. +func icmpProtoToInt(probe string) int { + if probe == icmpProtoUDP { + return 1 // udp4 + } + return 0 // ip4:icmp +} + +// makeBytes returns a deterministic payload of the requested size so +// packet sizes are stable across runs. +func makeBytes(n int) []byte { + if n <= 0 { + return []byte{} + } + b := make([]byte, n) + for i := range b { + b[i] = byte('a' + (i % 26)) + } + return b +} diff --git a/checks/cping/ping_test.go b/checks/cping/ping_test.go new file mode 100644 index 0000000..447d0cb --- /dev/null +++ b/checks/cping/ping_test.go @@ -0,0 +1,159 @@ +package cping + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// settings mirrors models.CheckSettings so the test can build a +// CheckSettings JSON without pulling in the entire model package. +type settings struct { + Host string `json:"host,omitempty"` + Count int `json:"count,omitempty"` + Timeout int `json:"timeout,omitempty"` + PacketSize int `json:"packet_size,omitempty"` + Port string `json:"port,omitempty"` +} + +func newCheck(t *testing.T, s settings, host string) *models.Check { + t.Helper() + raw, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + if host == "" { + host = "127.0.0.1" + } + return &models.Check{ + Kind: "ping", + Monitor: &models.Monitor{Host: host}, + Settings: datatypes.JSON(raw), + } +} + +// fakePinger returns canned stats without touching the network so the +// state machine in Perform() can be exercised in CI environments +// without CAP_NET_RAW. +type fakePinger struct { + stats Stats +} + +func (f *fakePinger) Run(host string, count int, timeout time.Duration, payloadSize int) Stats { + return f.stats +} + +func TestPerformOK(t *testing.T) { + SetPinger(&fakePinger{stats: Stats{PacketsSent: 3, PacketsRecv: 3, AvgRtt: 4 * time.Millisecond}}) + defer SetPinger(&realPinger{}) + + c := newCheck(t, settings{Count: 3}, "") + r := Perform(c) + if r.State != stateOK { + t.Fatalf("expected OK, got %s (err=%v)", r.State, r.Error) + } + if r.PacketsSent != 3 || r.PacketsRecv != 3 { + t.Fatalf("packet counts wrong: sent=%d recv=%d", r.PacketsSent, r.PacketsRecv) + } + if r.AvgRttMs < 1 { + t.Fatalf("expected RTT > 0, got %v", r.AvgRttMs) + } + if len(r.Infos) == 0 || !strings.Contains(r.Infos[0], "rtt=") { + t.Fatalf("expected rtt info line, got %v", r.Infos) + } +} + +func TestPerformNoReply(t *testing.T) { + SetPinger(&fakePinger{stats: Stats{PacketsSent: 3, PacketsRecv: 0, Err: errors.New("i/o timeout")}}) + defer SetPinger(&realPinger{}) + + c := newCheck(t, settings{}, "") + r := Perform(c) + if r.State != stateERR { + t.Fatalf("expected ERR, got %s", r.State) + } + if r.PacketsSent == 0 { + t.Fatalf("expected PacketsSent to be incremented even on failure") + } + if r.Error == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestPerformUnsupported(t *testing.T) { + SetPinger(&fakePinger{stats: Stats{Err: errors.New("socket: operation not permitted (cap_net_raw)")}}) + defer SetPinger(&realPinger{}) + + c := newCheck(t, settings{}, "") + r := Perform(c) + if r.State != stateFail { + t.Fatalf("expected FAIL when raw ICMP is not allowed, got %s", r.State) + } +} + +func TestPerformEmptyHost(t *testing.T) { + c := &models.Check{ + Kind: "ping", + Monitor: &models.Monitor{Host: ""}, + Settings: datatypes.JSON("{}"), + } + r := Perform(c) + if r.State != stateFail { + t.Fatalf("expected FAIL on empty host, got %s", r.State) + } +} + +func TestIsUnsupported(t *testing.T) { + cases := []struct { + err error + want bool + }{ + {err: nil, want: false}, + {err: errors.New(""), want: false}, + {err: errors.New("permission denied"), want: true}, + {err: errors.New("icmp listen: cap_net_raw required"), want: true}, + {err: errors.New("i/o timeout"), want: false}, + {err: errors.New("no route to host"), want: false}, + } + for _, tc := range cases { + if got := isUnsupported(tc.err); got != tc.want { + t.Errorf("isUnsupported(%q) = %v, want %v", tc.err, got, tc.want) + } + } +} + +func TestLossPercent(t *testing.T) { + cases := []struct { + sent, recv int + want int64 + }{ + {sent: 0, recv: 0, want: 0}, + {sent: 5, recv: 5, want: 0}, + {sent: 5, recv: 3, want: 40}, + {sent: 5, recv: 0, want: 100}, + } + for _, tc := range cases { + if got := lossPercent(tc.sent, tc.recv); got != tc.want { + t.Errorf("lossPercent(%d,%d) = %d, want %d", tc.sent, tc.recv, got, tc.want) + } + } +} + +func TestMakeBytes(t *testing.T) { + if got := makeBytes(0); len(got) != 0 { + t.Fatalf("expected empty slice, got %d bytes", len(got)) + } + if got := makeBytes(-1); len(got) != 0 { + t.Fatalf("expected empty slice for negative size, got %d bytes", len(got)) + } + got := makeBytes(3) + if len(got) != 3 || string(got) != "abc" { + t.Fatalf("expected 'abc', got %q", string(got)) + } +} diff --git a/checks/cping/result.go b/checks/cping/result.go new file mode 100644 index 0000000..06fb62b --- /dev/null +++ b/checks/cping/result.go @@ -0,0 +1,60 @@ +package cping + +import ( + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is the outcome of a single ping check. It embeds +// checkresult.CheckResult for the standard fields (State, Error, +// Duration, Warnings, Infos) and adds Ping-specific metrics consumed +// by the metrics writers when the check produces influx telemetry. +type Result struct { + checkresult.CheckResult + PacketsSent int + PacketsRecv int + AvgRttMs float64 +} + +// InfluxFields reports ping metrics in the same shape as the rest of +// the check packages: "took" is elapsed ms (for graphs and alerts). +func (r *Result) InfluxFields() map[string]interface{} { + ret := make(map[string]interface{}, 0) + ret["took"] = int64(r.Duration / time.Millisecond) + ret["packets_sent"] = r.PacketsSent + ret["packets_recv"] = r.PacketsRecv + ret["packet_loss"] = lossPercent(r.PacketsSent, r.PacketsRecv) + if r.AvgRttMs > 0 { + ret["rtt_ms"] = int64(r.AvgRttMs) + } + return ret +} + +// InfluxTags returns the standard set of tags used by the chttp/cdns +// packages. The "state" tag is taken from the embedded CheckResult +// after Perform() has populated it so the writer sees the actual +// final state (OK/ERR/FAIL/WARN). +func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility + ret := make(map[string]string, 0) + ret["check"] = strconv.FormatInt(c.ID, 10) + ret["state"] = r.State + if r.Error != nil { + ret["error"] = r.Error.Error() + } + ret["warnings"] = strings.Join(r.Warnings, ",") + return ret +} + +func lossPercent(sent, recv int) int64 { + if sent <= 0 { + return 0 + } + if recv >= sent { + return 0 + } + return int64(100 * (sent - recv) / sent) +} diff --git a/checks/crkn/result.go b/checks/crkn/result.go new file mode 100644 index 0000000..6baeb99 --- /dev/null +++ b/checks/crkn/result.go @@ -0,0 +1,11 @@ +// Package crkn provides functionality. +package crkn + +import ( + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is a result of a check +type Result struct { + checkresult.CheckResult +} diff --git a/checks/crkn/rkn_init.go b/checks/crkn/rkn_init.go new file mode 100644 index 0000000..d5ceae1 --- /dev/null +++ b/checks/crkn/rkn_init.go @@ -0,0 +1,61 @@ +package crkn + +import ( + "errors" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// Perform checks if a domain or IP is in the RKN registry +func Perform(m *models.Monitor) *Result { + result := &Result{} + start := time.Now() + + blocked, err := models.IsRknDomainBlocked(m.Host) + if err != nil { + failResult(err, result, start) + return result + } + if blocked { + testResult(true, result, "Domain is contained in RKN registry", start) + return result + } + + for _, dnsRecord := range m.DNSRecords { + if dnsRecord.Kind != "A" && dnsRecord.Kind != "AAAA" { + continue + } + findIP, err := models.IsRknIPBlocked(dnsRecord.Value.Inet.String()) + if err != nil { + failResult(err, result, start) + return result + } + if findIP { + testResult(true, result, "IP is contained in RKN registry", start) + return result + } + } + + testResult(false, result, "", start) + return result +} + +func failResult(err error, result *Result, start time.Time) { + result.State = "FAIL" + result.Error = err + result.Duration = time.Since(start) +} + +func testResult(find bool, result *Result, msg string, start time.Time) { + if find { + result.State = "ERR" + result.Error = errors.New(msg) + result.Duration = time.Since(start) + result.Warnings = append(result.Warnings, "Resource is blocked in Russia according to RKN registry") + } else { + result.State = "OK" + result.Duration = time.Since(start) + result.Infos = append(result.Infos, "Resource is not in RKN registry") + } +} diff --git a/checks/crkn/rkn_init_test.go b/checks/crkn/rkn_init_test.go new file mode 100644 index 0000000..338395d --- /dev/null +++ b/checks/crkn/rkn_init_test.go @@ -0,0 +1,134 @@ +package crkn_test + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/checks/crkn" + "rsgit.ru/rsmon/rsmon/config/database" + "rsgit.ru/rsmon/rsmon/internal/netaddr" +) + +func init() { + database.Init() +} + +func TestPerform_DomainBlocked(t *testing.T) { + models.Drop() + models.Migrate() + + require.NoError(t, models.ReplaceRknDomains([]string{"blocked.example.com"})) + + monitor := &models.Monitor{Host: "blocked.example.com"} + result := crkn.Perform(monitor) + require.NotNil(t, result) + assert.Equal(t, "ERR", result.State) + assert.NotNil(t, result.Error) + assert.Contains(t, result.Error.Error(), "RKN registry") + assert.Contains(t, result.Warnings, "Resource is blocked in Russia according to RKN registry") +} + +func TestPerform_DomainClean(t *testing.T) { + models.Drop() + models.Migrate() + + require.NoError(t, models.ReplaceRknDomains([]string{"blocked.example.com"})) + + monitor := &models.Monitor{Host: "clean.example.org"} + result := crkn.Perform(monitor) + require.NotNil(t, result) + assert.Equal(t, "OK", result.State) + assert.Nil(t, result.Error) + assert.Contains(t, result.Infos, "Resource is not in RKN registry") +} + +func TestPerform_DomainSuffixMatch(t *testing.T) { + models.Drop() + models.Migrate() + + require.NoError(t, models.ReplaceRknDomains([]string{"example.com"})) + + monitor := &models.Monitor{Host: "sub.example.com"} + result := crkn.Perform(monitor) + require.NotNil(t, result) + assert.Equal(t, "ERR", result.State, "subdomain must match apex via IsRknDomainBlocked") +} + +func TestPerform_IPBlocked(t *testing.T) { + models.Drop() + models.Migrate() + + _, network, err := net.ParseCIDR("10.5.5.0/24") + require.NoError(t, err) + require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{network})) + + monitor := &models.Monitor{ + Host: "clean.example.org", + DNSRecords: []models.DNSRecord{ + {Kind: "A", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("10.5.5.7")}}, + }, + } + result := crkn.Perform(monitor) + require.NotNil(t, result) + assert.Equal(t, "ERR", result.State) + assert.NotNil(t, result.Error) + assert.Contains(t, result.Error.Error(), "IP is contained in RKN registry") +} + +func TestPerform_IPClean(t *testing.T) { + models.Drop() + models.Migrate() + + _, network, err := net.ParseCIDR("10.0.0.0/8") + require.NoError(t, err) + require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{network})) + + monitor := &models.Monitor{ + Host: "clean.example.org", + DNSRecords: []models.DNSRecord{ + {Kind: "A", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("8.8.8.8")}}, + }, + } + result := crkn.Perform(monitor) + require.NotNil(t, result) + assert.Equal(t, "OK", result.State) +} + +func TestPerform_SkipsNonADNSRecords(t *testing.T) { + models.Drop() + models.Migrate() + + _, network, err := net.ParseCIDR("10.0.0.0/8") + require.NoError(t, err) + require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{network})) + + monitor := &models.Monitor{ + Host: "clean.example.org", + DNSRecords: []models.DNSRecord{ + {Kind: "CNAME", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("10.5.5.7")}}, + {Kind: "TXT", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("10.5.5.7")}}, + }, + } + result := crkn.Perform(monitor) + require.NotNil(t, result) + assert.Equal(t, "OK", result.State, "non-A/AAAA records must be skipped") +} + +// TestPerform_FastReadOnly verifies the refactor removed the background +// goroutine that previously triggered a remote data refresh on every +// check — the call must return quickly and only depend on the local DB. +func TestPerform_FastReadOnly(t *testing.T) { + models.Drop() + models.Migrate() + require.NoError(t, models.ReplaceRknDomains([]string{"blocked.example.com"})) + + monitor := &models.Monitor{Host: "blocked.example.com"} + + result := crkn.Perform(monitor) + require.NotNil(t, result) + assert.Equal(t, "ERR", result.State) +} diff --git a/checks/cssh/cssh.go b/checks/cssh/cssh.go new file mode 100644 index 0000000..9ab02ca --- /dev/null +++ b/checks/cssh/cssh.go @@ -0,0 +1,49 @@ +// Package cssh provides functionality. +package cssh + +import ( + "bufio" + "errors" + "net" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const stateERR = "ERR" + +// Perform provides functionality. +func Perform(c *models.Check) *Result { + result := &Result{} + port := c.GetSettings().Port + if port == "" { + port = "22" + } + result.State = "START" + client := &net.Dialer{ + Timeout: 60 * time.Second, + DualStack: true, + } + start := time.Now() + conn, err := client.Dial("tcp", net.JoinHostPort(c.Monitor.Host, port)) + if err != nil { + result.State = stateERR + result.Error = err + result.Duration = time.Since(start) + return result + } + result.Duration = time.Since(start) + status, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + result.State = stateERR + result.Error = err + } + if strings.Contains(status, "SSH") { + result.State = "OK" + } else { + result.State = stateERR + result.Error = errors.New("it's not SSH") + } + return result +} diff --git a/checks/cssh/result.go b/checks/cssh/result.go new file mode 100644 index 0000000..8ee5461 --- /dev/null +++ b/checks/cssh/result.go @@ -0,0 +1,10 @@ +package cssh + +import ( + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is a result of a check +type Result struct { + checkresult.CheckResult +} diff --git a/checks/cssl/cssl.go b/checks/cssl/cssl.go new file mode 100644 index 0000000..fc8713a --- /dev/null +++ b/checks/cssl/cssl.go @@ -0,0 +1,79 @@ +// Package cssl provides functionality. +package cssl + +import ( + "log" + "net/http" + "net/url" + "time" + + "github.com/pkg/errors" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +var client *http.Client + +func init() { + client = &http.Client{ + Timeout: time.Second * 60, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// Perform provides functionality. +func Perform(c *models.Check) *Result { + result := &Result{} + result.State = "OK" + + var ur string + if c.URL != nil && *c.URL != "" { + ur = *c.URL + + u, err := url.Parse(ur) + if err != nil { + result.State = "FAIL" + result.Error = errors.Wrap(err, "bad url") + return result + } + u.Scheme = "https" + ur = u.String() + } else { + ur = "https://" + c.Monitor.Host + } + log.Println("URL:", ur) + reqest, err := http.NewRequest("GET", ur, http.NoBody) + if err != nil { + result.State = "FAIL" + result.Error = err + return result + } + + reqest.Header.Set("Cache-Control", "max-age=0") + reqest.Header.Set("Connection", "close") + resp, err := client.Do(reqest) + if err != nil { + result.State = "ERR" + result.Error = errors.Wrap(err, "https request error") + return result + } + defer resp.Body.Close() //nolint:errcheck + + if resp.TLS == nil { + result.State = "ERR" + result.Error = errors.New("bad SSL cert CN") + } else { + cert := resp.TLS.VerifiedChains[0][0] + result.Expires = &cert.NotAfter + exp := time.Until(*result.Expires).Hours() / 24 + + if exp < 2 { + result.State = "WARN" + result.Warnings = append(result.Warnings, "Certificate expires soon") + } + } + + return result +} diff --git a/checks/cssl/result.go b/checks/cssl/result.go new file mode 100644 index 0000000..897ce7d --- /dev/null +++ b/checks/cssl/result.go @@ -0,0 +1,10 @@ +package cssl + +import ( + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is a result of a check +type Result struct { + checkresult.CheckResult +} diff --git a/checks/ctcp/result.go b/checks/ctcp/result.go new file mode 100644 index 0000000..a5dd0f3 --- /dev/null +++ b/checks/ctcp/result.go @@ -0,0 +1,39 @@ +package ctcp + +import ( + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is the outcome of a single TCP check. It embeds +// checkresult.CheckResult and adds the resolved address so logs / +// metrics can show what was actually dialed. +type Result struct { + checkresult.CheckResult + RemoteAddr string +} + +// InfluxFields reports the dial duration in milliseconds — same +// convention as chttp and cping. +func (r *Result) InfluxFields() map[string]interface{} { + ret := make(map[string]interface{}, 0) + ret["took"] = int64(r.Duration / time.Millisecond) + return ret +} + +// InfluxTags returns the standard set of tags used by chttp / cping / +// cdns. The "state" tag is the final Result.State set by Perform(). +func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility + ret := make(map[string]string, 0) + ret["check"] = strconv.FormatInt(c.ID, 10) + ret["state"] = r.State + if r.Error != nil { + ret["error"] = r.Error.Error() + } + ret["warnings"] = strings.Join(r.Warnings, ",") + return ret +} diff --git a/checks/ctcp/tcp.go b/checks/ctcp/tcp.go new file mode 100644 index 0000000..4045d1d --- /dev/null +++ b/checks/ctcp/tcp.go @@ -0,0 +1,77 @@ +// Package ctcp provides TCP connect / port check functionality for RSMon. +// +// Semantics: the check opens a TCP connection to host:port using the +// dialer's timeout, and reports OK as soon as the kernel-level +// handshake completes (no banner read, no payload sent). Any dial +// failure (refused, timed out, network unreachable, no route, ...) +// is reported as ERR with the underlying error message so operators +// can distinguish configuration problems from real outages. +// +// Settings consumed from models.CheckSettings: +// - port (string): TCP port to dial. Defaults to 80 when empty. +// - timeout (int, seconds): per-dial budget. Clamped to at least 1s. +// - host (string): optional override of the monitor host. +package ctcp + +import ( + "fmt" + "net" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + stateOK = "OK" + stateERR = "ERR" + + defaultTimeout = 5 * time.Second + minTimeout = 1 * time.Second +) + +// Perform executes a single TCP connect check. +func Perform(c *models.Check) *Result { + r := &Result{} + settings := c.GetSettings() + + host := c.Monitor.Host + if settings.Host != "" { + host = settings.Host + } + if host == "" { + r.State = stateERR + r.Error = fmt.Errorf("tcp: empty host") + return r + } + + port := settings.Port + if port == "" { + port = "80" + } + + timeout := time.Duration(settings.Timeout) * time.Second + if timeout <= 0 { + timeout = defaultTimeout + } + if timeout < minTimeout { + timeout = minTimeout + } + + addr := net.JoinHostPort(host, port) + dialer := &net.Dialer{Timeout: timeout, DualStack: true} + start := time.Now() + conn, err := dialer.Dial("tcp", addr) + r.Duration = time.Since(start) + if err != nil { + r.State = stateERR + r.Error = err + return r + } + r.RemoteAddr = conn.RemoteAddr().String() + if closeErr := conn.Close(); closeErr != nil { + r.Warnings = append(r.Warnings, fmt.Sprintf("close: %v", closeErr)) + } + r.State = stateOK + r.Infos = append(r.Infos, fmt.Sprintf("tcp %s in %.2fms", addr, float64(r.Duration.Microseconds())/1000.0)) + return r +} diff --git a/checks/ctcp/tcp_test.go b/checks/ctcp/tcp_test.go new file mode 100644 index 0000000..fb83144 --- /dev/null +++ b/checks/ctcp/tcp_test.go @@ -0,0 +1,164 @@ +package ctcp + +import ( + "encoding/json" + "net" + "testing" + "time" + + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +type settings struct { + Host string `json:"host,omitempty"` + Port string `json:"port,omitempty"` + Timeout int `json:"timeout,omitempty"` + Count int `json:"count,omitempty"` + PacketSize int `json:"packet_size,omitempty"` +} + +func newCheck(t *testing.T, s settings, host string) *models.Check { + t.Helper() + raw, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return &models.Check{ + Kind: "tcp", + Monitor: &models.Monitor{Host: host}, + Settings: datatypes.JSON(raw), + } +} + +// startListener brings up a TCP listener on 127.0.0.1:0 so tests can +// reach a real port. Returns the listener and the resolved address. +func startListener(t *testing.T) (net.Listener, string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + return ln, ln.Addr().String() +} + +func TestPerformOK(t *testing.T) { + ln, addr := startListener(t) + defer ln.Close() + + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split addr: %v", err) + } + c := newCheck(t, settings{Port: port, Timeout: 1}, host) + r := Perform(c) + if r.State != stateOK { + t.Fatalf("expected OK, got %s (err=%v)", r.State, r.Error) + } + if r.RemoteAddr == "" { + t.Fatalf("expected RemoteAddr to be populated, got %q", r.RemoteAddr) + } + if !contains(r.Infos[0], "tcp") { + t.Fatalf("expected info line about tcp probe, got %v", r.Infos) + } +} + +func TestPerformRefused(t *testing.T) { + // Bind to 127.0.0.1:0 to find a free port, close immediately so + // the next dial gets RST/CONNREFUSED. + ln, addr := startListener(t) + host, port, _ := net.SplitHostPort(addr) + _ = ln.Close() + + // On some runners the OS reassigns the just-closed port to a + // listener before our dial. Retry up to a few times to stabilise. + var final net.Listener + for i := 0; i < 3; i++ { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + continue + } + addr2 := l.Addr().String() + h, p, _ := net.SplitHostPort(addr2) + _ = l.Close() + // Replace what we are about to dial with one we just freed. + addr = addr2 + host = h + port = p + break + } + _ = final + + c := newCheck(t, settings{Port: port, Timeout: 1}, host) + r := Perform(c) + if r.State != stateERR { + t.Fatalf("expected ERR, got %s (err=%v)", r.State, r.Error) + } + if r.Error == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestPerformUnreachable(t *testing.T) { + // 127.0.0.0/8 — using 127.0.0.99 should resolve but be a hard + // "no route" on most platforms. The check should fail fast. + c := newCheck(t, settings{Port: "65530", Timeout: 1}, "127.0.0.99") + // Use a short timeout so the test does not hang on slow CI. + r := Perform(c) + if r.State == stateOK { + t.Fatalf("expected ERR/WARN, got OK (this loopback should not answer)") + } +} + +func TestPerformDefaultPort(t *testing.T) { + ln, addr := startListener(t) + defer ln.Close() + host, _, _ := net.SplitHostPort(addr) + c := newCheck(t, settings{}, host) // port empty -> defaults to 80 + r := Perform(c) + // 80 is unlikely to answer on the loopback; we only care that + // the check did not panic and reported something on failure. + if r.State == "" { + t.Fatalf("expected non-empty state, got %q", r.State) + } +} + +func TestPerformEmptyHost(t *testing.T) { + c := newCheck(t, settings{Port: "80", Timeout: 1}, "") + r := Perform(c) + if r.State != stateERR { + t.Fatalf("expected ERR on empty host, got %s", r.State) + } + if r.Error == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestPerformClampsTimeout(t *testing.T) { + // Already covered indirectly by TestPerformDefaultPort but + // assert the dialer budget is at least 1s even with Timeout=0. + ln, addr := startListener(t) + defer ln.Close() + host, port, _ := net.SplitHostPort(addr) + + start := time.Now() + c := newCheck(t, settings{Port: port, Timeout: 0}, host) + r := Perform(c) + elapsed := time.Since(start) + if elapsed > 2*time.Second { + t.Fatalf("default timeout exceeded 2s, got %v", elapsed) + } + if r.State != stateOK { + t.Fatalf("expected OK against local listener, got %s", r.State) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/checks/cudp/result.go b/checks/cudp/result.go new file mode 100644 index 0000000..14d8e57 --- /dev/null +++ b/checks/cudp/result.go @@ -0,0 +1,39 @@ +package cudp + +import ( + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is the outcome of a single UDP check. It embeds +// checkresult.CheckResult and adds the resolved address so logs / +// metrics can show what was probed. +type Result struct { + checkresult.CheckResult + RemoteAddr string +} + +// InfluxFields reports the dial+probe duration in milliseconds — same +// convention as chttp / cping / ctcp. +func (r *Result) InfluxFields() map[string]interface{} { + ret := make(map[string]interface{}, 0) + ret["took"] = int64(r.Duration / time.Millisecond) + return ret +} + +// InfluxTags returns the standard set of tags used by chttp / cping / +// ctcp. The "state" tag is the final Result.State set by Perform(). +func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility + ret := make(map[string]string, 0) + ret["check"] = strconv.FormatInt(c.ID, 10) + ret["state"] = r.State + if r.Error != nil { + ret["error"] = r.Error.Error() + } + ret["warnings"] = strings.Join(r.Warnings, ",") + return ret +} diff --git a/checks/cudp/udp.go b/checks/cudp/udp.go new file mode 100644 index 0000000..baaabac --- /dev/null +++ b/checks/cudp/udp.go @@ -0,0 +1,161 @@ +// Package cudp provides UDP probe / port check functionality for RSMon. +// +// Semantics: UDP is a connectionless protocol so a successful connect +// (net.Dial("udp", ...)) only means the kernel resolved the route to +// host:port — the remote may silently drop the packet. The check +// therefore: +// +// 1. Resolves the address and opens a UDP "connection". +// +// 2. Writes a small probe packet with the dial deadline active. +// +// 3. Sets a short read deadline and waits for any reply. +// +// - OK is reported only when a reply is received from the server. +// - ERR is reported when the dial itself fails (refused, network +// unreachable, no route, ...). +// - WARN is reported when no reply is received within the deadline +// because that is the most common UDP behavior for a real +// service that isn't echoing probes; we still consider it +// "monitoring" — the route is reachable — but flag it as worth +// investigating. +// +// Settings consumed from models.CheckSettings: +// - port (string): UDP port to probe. Defaults to 53 when empty. +// - timeout (int, seconds): per-probe budget. Clamped to >= 1s. +// - host (string): optional override of the monitor host. +package cudp + +import ( + "fmt" + "net" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + stateOK = "OK" + stateERR = "ERR" + stateWARN = "WARN" + + defaultTimeout = 5 * time.Second + minTimeout = 1 * time.Second + + probeSize = 16 +) + +var probe = []byte("rsmon-udp-probe") + +// Perform executes a single UDP probe check. +func Perform(c *models.Check) *Result { + r := &Result{} + settings := c.GetSettings() + + host := c.Monitor.Host + if settings.Host != "" { + host = settings.Host + } + if host == "" { + r.State = stateERR + r.Error = fmt.Errorf("udp: empty host") + return r + } + + port := settings.Port + if port == "" { + port = "53" + } + + timeout := time.Duration(settings.Timeout) * time.Second + if timeout <= 0 { + timeout = defaultTimeout + } + if timeout < minTimeout { + timeout = minTimeout + } + + addr := net.JoinHostPort(host, port) + + dialer := &net.Dialer{Timeout: timeout, DualStack: true} + start := time.Now() + conn, err := dialer.Dial("udp", addr) + if err != nil { + r.Duration = time.Since(start) + r.State = stateERR + r.Error = err + return r + } + defer conn.Close() //nolint:errcheck // accepted lint exception: best-effort close + + r.RemoteAddr = conn.RemoteAddr().String() + + if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + r.Duration = time.Since(start) + r.State = stateERR + r.Error = fmt.Errorf("set write deadline: %w", err) + return r + } + if _, err := conn.Write(probe[:minInt(probeSize, len(probe))]); err != nil { + r.Duration = time.Since(start) + r.State = stateERR + r.Error = fmt.Errorf("write probe: %w", err) + return r + } + + // If the dial succeeded, give the read a fraction of the budget + // so the whole check still fits inside Settings.Timeout. + readBudget := timeout + if readBudget > 2*time.Second { + readBudget = 2 * time.Second + } + if err := conn.SetReadDeadline(time.Now().Add(readBudget)); err != nil { + r.Duration = time.Since(start) + r.State = stateERR + r.Error = fmt.Errorf("set read deadline: %w", err) + return r + } + buf := make([]byte, 1500) + _, readErr := conn.Read(buf) + r.Duration = time.Since(start) + + switch { + case readErr == nil: + r.State = stateOK + r.Infos = append(r.Infos, fmt.Sprintf("udp %s replied in %.2fms", addr, float64(r.Duration.Microseconds())/1000.0)) + case isTimeout(readErr): + // UDP services rarely echo unrecognized probes back. Reaching + // the host without a reply is meaningful, but ambiguous: the + // service might be working or firewalled. Surface as WARN. + r.State = stateWARN + r.Warnings = append(r.Warnings, fmt.Sprintf("udp %s reachable but no reply within %s", addr, readBudget)) + default: + // Any other read error (connection reset, ...) still means the + // kernel could route the packet; report as ERR so operators + // know to investigate the service. + r.State = stateERR + r.Error = readErr + } + return r +} + +func isTimeout(err error) bool { + if err == nil { + return false + } + type timeout interface{ Timeout() bool } + if t, ok := err.(timeout); ok { + return t.Timeout() + } + return false +} + +// minInt mirrors the standard library min() for ints without taking +// a dependency on Go 1.21+. We keep a custom name to avoid clashing +// with the built-in and being flagged by the linter. +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/checks/cudp/udp_test.go b/checks/cudp/udp_test.go new file mode 100644 index 0000000..4a7d8a4 --- /dev/null +++ b/checks/cudp/udp_test.go @@ -0,0 +1,170 @@ +package cudp + +import ( + "encoding/json" + "net" + "sync" + "testing" + "time" + + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +type settings struct { + Host string `json:"host,omitempty"` + Port string `json:"port,omitempty"` + Timeout int `json:"timeout,omitempty"` + Count int `json:"count,omitempty"` + PacketSize int `json:"packet_size,omitempty"` +} + +func newCheck(t *testing.T, s settings, host string) *models.Check { + t.Helper() + raw, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return &models.Check{ + Kind: "udp", + Monitor: &models.Monitor{Host: host}, + Settings: datatypes.JSON(raw), + } +} + +// startEchoUDP brings up a UDP listener on 127.0.0.1:0 that echoes +// the first byte back to the sender. Returns the listener and the +// resolved address. Stop it with the returned cleanup. +func startEchoUDP(t *testing.T) (cleanup func(), addr string) { + t.Helper() + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("listen udp: %v", err) + } + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 1500) + for { + select { + case <-stop: + return + default: + } + _ = conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond)) + n, src, err := conn.ReadFromUDP(buf) + if err != nil { + continue + } + if n == 0 { + continue + } + _, _ = conn.WriteToUDP(buf[:1], src) + } + }() + return func() { + close(stop) + wg.Wait() + _ = conn.Close() + }, conn.LocalAddr().String() +} + +func TestPerformOKWhenServerReplies(t *testing.T) { + cleanup, addr := startEchoUDP(t) + defer cleanup() + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split: %v", err) + } + c := newCheck(t, settings{Port: port, Timeout: 2}, host) + r := Perform(c) + if r.State != stateOK { + t.Fatalf("expected OK when the server echoes back, got %s (err=%v, warn=%v)", r.State, r.Error, r.Warnings) + } + if r.RemoteAddr == "" { + t.Fatalf("expected RemoteAddr, got %q", r.RemoteAddr) + } +} + +func TestPerformWarnWhenNoReply(t *testing.T) { + // Open a UDP listener that never replies; the probe should + // time out and the check should report WARN to signal "reachable, + // ambiguous service". + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("listen: %v", err) + } + defer conn.Close() + // Drain the listener so the kernel knows the port is in use but + // never reply; just close the read side and let the kernel drop + // incoming datagrams. + go func() { + buf := make([]byte, 1500) + for { + _, _, _ = conn.ReadFromUDP(buf) + } + }() + + host, port, _ := net.SplitHostPort(conn.LocalAddr().String()) + c := newCheck(t, settings{Port: port, Timeout: 1}, host) + r := Perform(c) + if r.State != stateWARN { + t.Fatalf("expected WARN when the server is silent, got %s (err=%v)", r.State, r.Error) + } + if len(r.Warnings) == 0 { + t.Fatalf("expected a warning describing the silence, got %v", r.Warnings) + } +} + +func TestPerformUnreachable(t *testing.T) { + c := newCheck(t, settings{Port: "65530", Timeout: 1}, "127.0.0.99") + r := Perform(c) + if r.State == stateOK { + t.Fatalf("expected ERR/WARN, got OK") + } +} + +func TestPerformEmptyHost(t *testing.T) { + c := newCheck(t, settings{Port: "53", Timeout: 1}, "") + r := Perform(c) + if r.State != stateERR { + t.Fatalf("expected ERR on empty host, got %s", r.State) + } +} + +func TestIsTimeout(t *testing.T) { + if isTimeout(nil) { + t.Fatalf("isTimeout(nil) should be false") + } + if !isTimeout(timeoutErr{}) { + t.Fatalf("isTimeout(timeoutErr) should be true") + } + if isTimeout(plainErr{}) { + t.Fatalf("isTimeout(plainErr) should be false") + } +} + +type timeoutErr struct{} + +func (timeoutErr) Error() string { return "i/o timeout" } +func (timeoutErr) Timeout() bool { return true } +func (timeoutErr) Temporary() bool { return true } + +type plainErr struct{} + +func (plainErr) Error() string { return "boom" } + +func TestMin(t *testing.T) { + if got := minInt(1, 2); got != 1 { + t.Fatalf("minInt(1,2) = %d, want 1", got) + } + if got := minInt(2, 1); got != 1 { + t.Fatalf("minInt(2,1) = %d, want 1", got) + } + if got := minInt(0, 0); got != 0 { + t.Fatalf("minInt(0,0) = %d, want 0", got) + } +} diff --git a/checks/cwhois/result.go b/checks/cwhois/result.go new file mode 100644 index 0000000..bedf2e3 --- /dev/null +++ b/checks/cwhois/result.go @@ -0,0 +1,14 @@ +// Package cwhois provides functionality. +package cwhois + +import ( + "github.com/glebtv/whois" + + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is a result of a check +type Result struct { + checkresult.CheckResult + Raw whois.Result +} diff --git a/checks/cwhois/whois.go b/checks/cwhois/whois.go new file mode 100644 index 0000000..5111869 --- /dev/null +++ b/checks/cwhois/whois.go @@ -0,0 +1,69 @@ +package cwhois + +import ( + "net" + "strings" + "time" + + "github.com/glebtv/whois" + "github.com/weppos/publicsuffix-go/publicsuffix" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// Perform provides functionality. +func Perform(c *models.Check) *Result { + result := &Result{} + result.State = "FAIL" + host := c.Monitor.Host + + if host == "localhost" || strings.HasPrefix(host, "localhost:") { + result.Warnings = append(result.Warnings, "WHOIS check not possible for localhost, please disable") + return result + } + + addr := net.ParseIP(host) + if addr != nil { + result.Warnings = append(result.Warnings, "WHOIS check not possible for ip address, please disable") + return result + } + + zname, err := publicsuffix.Domain(host) + if err != nil { + result.Warnings = append(result.Warnings, "Failed to get public suffix: "+err.Error()) + zname = host + } + if zname != host { + result.Infos = append(result.Infos, "not top level domain, running WHOIS check for "+zname) + } + + start := time.Now() + response := whois.Whois(zname) + result.Raw = *response + result.Error = result.Raw.Error + if result.Error == nil { + result.State = "OK" + } + result.Duration = time.Since(start) + + if result.Error != nil { + result.State = "ERR" + } + + if response.Expires.IsZero() { + if result.State == "OK" { + result.State = "WARN" + } + result.Warnings = append(result.Warnings, "unable to get whois expiration for "+host) + return result + } + result.Expires = &response.Expires + exp := time.Until(*result.Expires).Hours() / 24 + + if exp < 3 { + result.State = "WARN" + result.Warnings = append(result.Warnings, "Domain expires in a few days") + } + + return result +} diff --git a/checks/llmhttp/init.go b/checks/llmhttp/init.go new file mode 100644 index 0000000..3d5f015 --- /dev/null +++ b/checks/llmhttp/init.go @@ -0,0 +1,376 @@ +// Package llmhttp provides LLM-based HTTP check functionality for RSMon. +package llmhttp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +const ( + verdictNormal = "normal" + verdictWarning = "warning" + verdictError = "error" + + stateOK = "OK" + stateERR = "ERR" + stateWARN = "WARN" +) + +var titleRe = regexp.MustCompile(`]*>(.*?)`) + +// Perform executes the LLM HTTP health check +func Perform(c *models.Check) *Result { + result := &Result{ + CheckID: uint(c.ID), + } + + start := time.Now() + + // Get the target URL + targetURL := getTargetURL(c) + result.URL = targetURL + + // Step 1: Fetch HTML content + html, statusCode, contentType, contentLength, err := fetchHTML(targetURL) + if err != nil { + return &Result{ + CheckResult: checkresult.CheckResult{ + State: "FAIL", + Error: fmt.Errorf("failed to fetch HTML: %w", err), + Duration: time.Since(start), + }, + URL: targetURL, + } + } + + result.HTML = html + result.StatusCode = statusCode + result.ContentType = contentType + result.ContentLength = contentLength + + // Extract title from HTML + result.Title = extractTitle(html) + + // Step 2: Analyze with LLM + llmVerdict, llmReasoning, err := analyzeWithLLM(targetURL, result) + if err != nil { + return &Result{ + CheckResult: checkresult.CheckResult{ + State: "FAIL", + Error: fmt.Errorf("LLM analysis failed: %w", err), + Duration: time.Since(start), + }, + URL: targetURL, + HTML: html, + StatusCode: statusCode, + } + } + + result.LLMVerdict = llmVerdict + result.LLMReasoning = llmReasoning + + // Determine final state based on LLM verdict + var state string + var crErr error + switch llmVerdict { + case verdictError: + state = stateERR + crErr = errors.New(llmReasoning) + case verdictWarning: + state = stateWARN + default: + state = stateOK + } + + return &Result{ + CheckResult: checkresult.CheckResult{ + State: state, + Error: crErr, + Duration: time.Since(start), + Warnings: []string{}, + Infos: []string{}, + }, + URL: targetURL, + HTML: html, + StatusCode: statusCode, + Title: result.Title, + ContentType: contentType, + ContentLength: contentLength, + LLMVerdict: llmVerdict, + LLMReasoning: llmReasoning, + CheckID: uint(c.ID), + } +} + +// getTargetURL constructs the target URL from the check +func getTargetURL(c *models.Check) string { + if c.URL != nil && *c.URL != "" { + return *c.URL + } + + // Construct from monitor host + host := c.Monitor.Host + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + return "https://" + host + } + return host +} + +// fetchHTML fetches HTML content from the target URL +func fetchHTML(targetURL string) (html string, statusCode int, contentType string, contentLength int64, err error) { + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 30 * time.Second, + // Follow redirects + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return nil // Allow up to 10 redirects by default + }, + } + + // Create request + req, err := http.NewRequest("GET", targetURL, http.NoBody) + if err != nil { + return "", 0, "", 0, fmt.Errorf("failed to create request: %w", err) + } + + // Set user agent to avoid being blocked + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RSMon-LLM-Checker/1.0)") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + // Make request + resp, err := client.Do(req) + if err != nil { + return "", 0, "", 0, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + statusCode = resp.StatusCode + contentType = resp.Header.Get("Content-Type") + contentLength = resp.ContentLength + + // Check status code + if statusCode >= 400 { + return "", statusCode, contentType, contentLength, fmt.Errorf("HTTP status code: %d", statusCode) + } + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", statusCode, contentType, contentLength, fmt.Errorf("failed to read response body: %w", err) + } + + html = string(body) + + // Truncate HTML if too large for LLM context + maxHTMLSize := 50000 // About 50k chars + if len(html) > maxHTMLSize { + log.Printf("[llm-http] HTML too large (%d chars), truncating to %d", len(html), maxHTMLSize) + html = html[:maxHTMLSize] + "\n\n... (truncated)" + } + + return html, statusCode, contentType, contentLength, nil +} + +// extractTitle extracts the title from HTML content +func extractTitle(html string) string { + matches := titleRe.FindStringSubmatch(html) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// analyzeWithLLM sends the HTML content to the LLM for analysis +func analyzeWithLLM(targetURL string, result *Result) (verdict, reasoning string, err error) { + // Get LLM configuration from environment + llmKey := getEnv("LLM_APIKEY", "LLAMA_KEY") + llmURL := getEnv("LLM_URL", "LLAMA_URL") + llmModel := getEnv("LLM_MODEL", "LLAMA_MODEL") + + if llmKey == "" || llmURL == "" { + return "", "", errors.New("LLM credentials not configured (LLAMA_KEY, LLAMA_URL)") + } + + if llmModel == "" { + llmModel = "llama3.2" // Default model (text-only) + } + + // Create OpenAI client with custom base URL + client := openai.NewClient( + option.WithBaseURL(llmURL), + option.WithAPIKey(llmKey), + ) + + // Build the system prompt + systemPrompt := buildSystemPrompt() + + // Build user message with HTML content + userContent := buildUserMessage(targetURL, result) + + // Prepare messages + messages := []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(systemPrompt), + openai.UserMessage(userContent), + } + + // Call LLM with timeout + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + params := openai.ChatCompletionNewParams{ + Messages: messages, + Model: llmModel, + MaxTokens: openai.Int(2000), + Temperature: openai.Float(0.3), // Lower temperature for more consistent analysis + } + + completion, err := client.Chat.Completions.New(ctx, params) + if err != nil { + return "", "", fmt.Errorf("LLM request failed: %w", err) + } + + if len(completion.Choices) == 0 { + return "", "", errors.New("LLM returned no choices") + } + + response := completion.Choices[0].Message.Content + + // Parse the LLM response to extract verdict and reasoning + return parseLLMResponse(response) +} + +// buildSystemPrompt creates the system prompt for the LLM +func buildSystemPrompt() string { + return ` +You are a web application health monitoring assistant. Your task is to analyze website HTML content +and determine if the website appears to be functioning normally or if there are issues. + +Consider the following aspects: +1. HTML structure - Is the page structured correctly? +2. Error messages - Are there any visible error messages, 404s, 500s, or similar in the HTML? +3. Title and meta tags - Are they present and reasonable? +4. Content availability - Is there actual content or is the page mostly empty? +5. Response status - Consider the HTTP status code provided +6. Content type - Verify the content type is appropriate + +Respond in the following JSON format: +{ + "verdict": verdictNormal | verdictWarning | verdictError, + "reasoning": "Brief explanation of your assessment" +} + +Verdict guidelines: +- verdictError: Page is clearly broken (5xx status codes, error messages in HTML, completely empty page, "404 Not Found" in title) +- verdictWarning: Page loads but has issues (4xx status codes, incomplete content, unusual title, suspicious patterns) +- verdictNormal: Page appears to be functioning correctly (2xx status, proper HTML structure, reasonable content)` +} + +// buildUserMessage creates the user message with HTML data +func buildUserMessage(targetURL string, result *Result) string { + var sb strings.Builder + + _, _ = fmt.Fprintf(&sb, "Analyze the HTML content of: %s\n\n", targetURL) + _, _ = fmt.Fprintf(&sb, "HTTP Status Code: %d\n", result.StatusCode) + _, _ = fmt.Fprintf(&sb, "Content-Type: %s\n", result.ContentType) + _, _ = fmt.Fprintf(&sb, "Content Length: %d bytes\n", result.ContentLength) + _, _ = fmt.Fprintf(&sb, "Page Title: %s\n\n", result.Title) + + sb.WriteString("HTML content:\n") + sb.WriteString("```\n") + sb.WriteString(result.HTML) + sb.WriteString("```\n") + + return sb.String() +} + +// parseLLMResponse parses the LLM response to extract structured data +func parseLLMResponse(response string) (verdict, reasoning string, err error) { + // Try to extract JSON from the response + response = strings.TrimSpace(response) + + // Look for JSON block + jsonStart := strings.Index(response, "{") + jsonEnd := strings.LastIndex(response, "}") + + if jsonStart == -1 || jsonEnd == -1 { + // No JSON found, try to parse text response + return parseTextResponse(response) + } + + jsonStr := response[jsonStart : jsonEnd+1] + + var parsed struct { + Verdict string `json:"verdict"` + Reasoning string `json:"reasoning"` + } + + if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { + // JSON parse failed, fall back to text parsing + return parseTextResponse(response) + } + + // Validate verdict + switch parsed.Verdict { + case verdictNormal, "ok", "healthy": + return verdictNormal, parsed.Reasoning, nil + case verdictWarning, "warn": + return verdictWarning, parsed.Reasoning, nil + case verdictError, "fail", "unhealthy": + return verdictError, parsed.Reasoning, nil + default: + // Unknown verdict, default to normal with warning + if parsed.Verdict != "" { + return verdictWarning, parsed.Reasoning, nil + } + return verdictNormal, "Unable to determine specific issues from analysis", nil + } +} + +// parseTextResponse parses a non-JSON response +func parseTextResponse(response string) (verdict, reasoning string, err error) { + responseLower := strings.ToLower(response) + + // Look for keywords + hasError := strings.Contains(responseLower, verdictError) || + strings.Contains(responseLower, "broken") || + strings.Contains(responseLower, "failed") || + strings.Contains(responseLower, "not working") + + hasWarning := strings.Contains(responseLower, verdictWarning) || + strings.Contains(responseLower, "issue") || + strings.Contains(responseLower, "problem") || + strings.Contains(responseLower, "degraded") + + if hasError { + return verdictError, response, nil + } else if hasWarning { + return verdictWarning, response, nil + } + + return verdictNormal, response, nil +} + +// getEnv gets an environment variable or returns empty string +func getEnv(keys ...string) string { + for _, key := range keys { + if value := os.Getenv(key); value != "" { + return value + } + } + return "" +} diff --git a/checks/llmhttp/result.go b/checks/llmhttp/result.go new file mode 100644 index 0000000..1358c73 --- /dev/null +++ b/checks/llmhttp/result.go @@ -0,0 +1,52 @@ +package llmhttp + +import ( + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +// Result is a result of an LLM HTTP health check +type Result struct { + checkresult.CheckResult + + // Captured data + HTML string `json:"html"` // HTML content + URL string `json:"url"` // Final URL after redirects + StatusCode int `json:"status_code"` // HTTP status code + Title string `json:"title"` // Page title (extracted from HTML) + ContentLength int64 `json:"content_length"` // Size of response body + ContentType string `json:"content_type"` // Content-Type header + + // LLM analysis + LLMResponse string `json:"llm_response"` // Full LLM response + LLMVerdict string `json:"llm_verdict"` // "normal", "error", "warning" + LLMReasoning string `json:"llm_reasoning"` // LLM's explanation + + // Metadata + CheckID uint `json:"check_id"` // For database reference +} + +// InfluxTags provides functionality. +func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility + ret := make(map[string]string, 0) + ret["check"] = strconv.FormatInt(c.ID, 10) + ret["state"] = r.State + ret["code"] = strconv.Itoa(r.StatusCode) + if r.Error != nil { + ret["error"] = r.Error.Error() + } + ret["warnings"] = strings.Join(r.Warnings, ",") + return ret +} + +// InfluxFields provides functionality. +func (r *Result) InfluxFields() map[string]interface{} { + ret := make(map[string]interface{}, 0) + ret["took"] = int64(r.Duration / time.Millisecond) + + return ret +} diff --git a/checks/llmhttp/result_test.go b/checks/llmhttp/result_test.go new file mode 100644 index 0000000..50c0490 --- /dev/null +++ b/checks/llmhttp/result_test.go @@ -0,0 +1,133 @@ +package llmhttp + +import ( + "errors" + "testing" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkresult" +) + +func TestInfluxTags(t *testing.T) { + c := models.Check{ID: 123} + + r := &Result{ + CheckResult: checkresult.CheckResult{ + State: "OK", + Error: nil, + Warnings: nil, + Duration: 100 * time.Millisecond, + }, + StatusCode: 200, + } + + tags := r.InfluxTags(c) + if tags["check"] != "123" { + t.Errorf("check = %q, want 123", tags["check"]) + } + if tags["state"] != "OK" { + t.Errorf("state = %q, want OK", tags["state"]) + } + if tags["code"] != "200" { + t.Errorf("code = %q, want 200", tags["code"]) + } + if _, ok := tags["error"]; ok { + t.Error("error tag should not be present when Error is nil") + } + + rWithErr := &Result{ + CheckResult: checkresult.CheckResult{ + State: "ERR", + Error: errors.New("something went wrong"), + Warnings: nil, + Duration: 100 * time.Millisecond, + }, + StatusCode: 500, + } + + tags = rWithErr.InfluxTags(c) + if tags["error"] != "something went wrong" { + t.Errorf("error = %q, want 'something went wrong'", tags["error"]) + } +} + +func TestInfluxTags_Warnings(t *testing.T) { + c := models.Check{ID: 456} + + r := &Result{ + CheckResult: checkresult.CheckResult{ + State: "WARN", + Warnings: []string{"redirect detected", "slow response"}, + Duration: time.Second, + }, + StatusCode: 301, + } + + tags := r.InfluxTags(c) + wantWarnings := "redirect detected,slow response" + if tags["warnings"] != wantWarnings { + t.Errorf("warnings = %q, want %q", tags["warnings"], wantWarnings) + } +} + +func TestInfluxFields(t *testing.T) { + r := &Result{ + CheckResult: checkresult.CheckResult{ + State: "OK", + Duration: 1234 * time.Millisecond, + }, + } + + fields := r.InfluxFields() + if took, ok := fields["took"]; !ok { + t.Error("took field missing") + } else if took != int64(1234) { + t.Errorf("took = %v (%T), want int64(1234)", took, took) + } +} + +func TestInfluxFields_NegativeDuration(t *testing.T) { + r := &Result{ + CheckResult: checkresult.CheckResult{ + State: "OK", + Duration: 0, + }, + } + + fields := r.InfluxFields() + if took, ok := fields["took"]; !ok { + t.Error("took field missing") + } else if took != int64(0) { + t.Errorf("took = %v, want 0", took) + } +} + +func TestWarningsJoined(t *testing.T) { + c := models.Check{ID: 1} + + tests := []struct { + name string + warnings []string + want string + }{ + {"nil", nil, ""}, + {"empty", []string{}, ""}, + {"single", []string{"a"}, "a"}, + {"multiple", []string{"a", "b", "c"}, "a,b,c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Result{ + CheckResult: checkresult.CheckResult{ + Warnings: tt.warnings, + }, + } + tags := r.InfluxTags(c) + if tags["warnings"] != tt.want { + t.Errorf("warnings = %q, want %q", tags["warnings"], tt.want) + } + }) + } +} diff --git a/cmd/rsmon-worker/main.go b/cmd/rsmon-worker/main.go new file mode 100644 index 0000000..5019cb7 --- /dev/null +++ b/cmd/rsmon-worker/main.go @@ -0,0 +1,588 @@ +// Distributed monitoring worker binary. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/hashicorp/raft" + "github.com/joho/godotenv" + + "rsgit.ru/rsmon/rsmon/internal/distworker" + "rsgit.ru/rsmon/rsmon/internal/webapp" + "rsgit.ru/rsmon/rsmon/internal/workercluster" +) + +var ( + // Build info set by ldflags. + version = "dev" + commit = "unknown" + buildDate = "unknown" + + // webappEnabled flips the local web UI on at boot. Default true. + // Phase 1 keeps it on; the flag exists so a Phase 2 basic-auth + // install can opt out without recompiling. + webappEnabled = true + + // clusterDebugApplyTestConfig, when true, submits the hardcoded + // CriticalCheckConfig from workercluster.DefaultDebugCriticalCheck + // to the cluster on startup. Wired via the + // --cluster-debug-apply-test-config CLI flag; the e2e script + // uses this so it can verify FSM replication without the + // signed-config-adoption producer (which lands in a later phase). + // + // DEBUG: this flag is a placeholder. It must be removed (or + // guarded behind a build tag) before any production build. + // + // TODO(phase-N): remove once the real producer is wired. + clusterDebugApplyTestConfig = false +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + loadDotEnv() + + versionFlag := flag.Bool("version", false, "Print version and exit") + noWeb := flag.Bool("no-web", false, "Disable the local web UI (Phase 1 ships with it on)") + debugApplyConfig := flag.Bool("cluster-debug-apply-test-config", false, + "Submit a hardcoded CriticalCheckConfig to the cluster on startup. DEBUG: remove once the real config.adopt producer is wired.") + flag.Parse() + + if *versionFlag { + fmt.Printf("rsmon-worker version=%s commit=%s buildDate=%s\n", version, commit, buildDate) + os.Exit(0) + } + webappEnabled = !*noWeb + clusterDebugApplyTestConfig = *debugApplyConfig + + if len(flag.Args()) > 0 && flag.Arg(0) == "health" { + os.Exit(healthCheck()) + } + + log.Println("rsmon-worker starting...") + + cfg := distworker.ConfigFromEnv() + logHTTPSettings(cfg.HTTP, webappEnabled) + // The HTTP listener is started by the webapp. WORKER_LOGIN / + // WORKER_PASSWORD (basic auth) are passed through to the webapp + // Config; the webapp's ValidateBasicAuth rejects XOR. + if err := webapp.ValidateBasicAuth(cfg.HTTP.Login, cfg.HTTP.Password); err != nil { + log.Fatalf("worker: %v", err) + } + + runner := distworker.NewRunner(&cfg) + + // Graceful shutdown context shared by the runner, the cluster, + // and the webapp. ctxCancel is called by the signal handler so + // all three wind down together; the runner waits for its + // goroutines, the cluster drains its rafthttp listener + raft + // state machine, then the webapp closes its listener and the + // SQLite handle. The defer is a safety net for early returns + // before the signal handler registers (the handler always wins + // for SIGINT/SIGTERM, but other early exits rely on the defer). + ctx, ctxCancel := context.WithCancel(context.Background()) + defer func() { ctxCancel() }() //nolint:gocritic // safety net for early returns; signal handler owns the canonical path + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Println("worker: shutdown signal received") + runner.Stop() + ctxCancel() + }() + + // Construct the cluster subsystem first (Task 5) so its admin + // endpoints can be wired into the webapp. The cluster is optional; + // when WORKER_CLUSTER_ENABLED=false (the default) we skip it and + // the webapp falls back to the local-only auth path. + cluster, clusterView, err := buildCluster(ctx, &cfg) + if err != nil { + // buildCluster never returns a partial cluster on error, so + // nothing to clean up here. Print and exit so the trailing + // defer (which only runs when cluster != nil) does not + // confuse linters or runtime observers. + log.Printf("worker: cluster init failed: %v", err) + os.Exit(1) //nolint:gocritic // safety net; see comment above + } + if cluster != nil { + defer func() { + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := cluster.Shutdown(shut); err != nil { + log.Printf("worker: cluster shutdown: %v", err) + } + }() + } + + // Run the runner and the webapp concurrently. The runner blocks + // until ctxCancel; the webapp is started in its own goroutine + // so a web-side error does not block the runner. Both unwind + // when ctxCancel fires. + webappErrCh := make(chan error, 1) + if webappEnabled { + srv, _, err := buildWebapp(ctx, runner, clusterView) + if err != nil { + // No deferred cleanup needed: the runner has not been + // started yet, the signal handler has not registered, + // and the only shared resource is the context which + // has nothing tied to it. log.Fatalf calls os.Exit so + // the deferred ctxCancel would be redundant noise. + log.Fatalf("worker: webapp init failed: %v", err) + } + go func() { + webappErrCh <- srv.Start(ctx) + }() + // Provision the first-run user before the runner gets a + // chance to send its first websocket hello so the operator + // can log in immediately if the main app is slow to ack. + if err := webapp.ProvisionFirstRunIfNeeded(srv, log.New(os.Stderr, "webapp: ", log.LstdFlags)); err != nil { + log.Printf("worker: webapp first-run provisioning: %v", err) + } + } + + if err := runner.Start(); err != nil { + log.Fatal("worker failed:", err) + } + + // Runner returned: the signal handler already canceled ctx so + // the webapp goroutine will exit shortly. Wait for it to avoid + // leaking the SQLite handle. + if webappEnabled { + select { + case err := <-webappErrCh: + if err != nil { + log.Printf("worker: webapp exited: %v", err) + } + case <-time.After(5 * time.Second): + log.Printf("worker: webapp shutdown timed out") + } + } +} + +// buildWebapp wires the worker view into a webapp.Server. The Deps +// adapter reads from the runner (which is not yet Started at the +// time this is called; recent buffers are empty by design). +func buildWebapp(_ context.Context, runner *distworker.Runner, cluster webapp.ClusterView) (*webapp.Server, *webapp.Deps, error) { + deps := &webapp.Deps{ + Runner: runnerWrapper{runner}, + Cluster: cluster, + Version: version, + BuildDate: buildDate, + Commit: commit, + StartedAt: time.Now().UTC(), + Logger: log.New(os.Stderr, "webapp: ", log.LstdFlags|log.Lshortfile), + TokenRotator: func(ctx context.Context) (string, error) { + return runner.RotateToken(ctx) + }, + ReleaseHTTPClient: &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + }, + } + srv, err := webapp.New(webapp.ConfigFromEnvOrDefault(), deps) + if err != nil { + return nil, deps, err + } + return srv, deps, nil +} + +// buildCluster reads WORKER_CLUSTER_* env vars and, when cluster mode +// is enabled, constructs and starts a *workercluster.Cluster. The +// returned ClusterView is the narrow interface webapp consumes; it is +// nil when the cluster is not enabled. +// +// The cluster's rafthttp listener binds to 127.0.0.1 on +// WORKER_CLUSTER_PORT (default = WORKER_PORT + 10000) so the +// worker webapp listener and the raft transport do not collide. The +// peers list (WORKER_CLUSTER_PEERS) is parsed as a comma-separated +// list of "nodeID@host:port" entries; the first peer becomes the +// Seed for non-bootstrap nodes. +// +// The function never returns a partial cluster: either both the +// concrete *Cluster and the ClusterView are returned, or both are nil +// (cluster disabled) or an error is returned (cluster enabled but +// misconfigured). +func buildCluster(ctx context.Context, cfg *distworker.Config) (*workercluster.Cluster, webapp.ClusterView, error) { + if !clusterModeEnabled() { + return nil, nil, nil + } + creds := workercluster.HTTPCreds{Login: cfg.HTTP.Login, Password: cfg.HTTP.Password} + if !creds.IsConfigured() { + return nil, nil, fmt.Errorf( + "worker: WORKER_CLUSTER_ENABLED=true requires WORKER_LOGIN and WORKER_PASSWORD (rafthttp basic auth)") + } + + nodeID := strings.TrimSpace(os.Getenv("WORKER_CLUSTER_ID")) + if nodeID == "" { + return nil, nil, fmt.Errorf("worker: WORKER_CLUSTER_ID is required when WORKER_CLUSTER_ENABLED=true") + } + dataDir := strings.TrimSpace(os.Getenv("WORKER_CLUSTER_DATA_DIR")) + if dataDir == "" { + return nil, nil, fmt.Errorf("worker: WORKER_CLUSTER_DATA_DIR is required when WORKER_CLUSTER_ENABLED=true") + } + if err := workercluster.EnsureDataDir(dataDir); err != nil { + return nil, nil, fmt.Errorf("worker: WORKER_CLUSTER_DATA_DIR %q: %w", dataDir, err) + } + + port := clusterPort() + host := strings.TrimSpace(os.Getenv("WORKER_CLUSTER_HOST")) + if host == "" { + host = "127.0.0.1" + } + localAddr := net.JoinHostPort(host, port) + + peers, err := parseClusterPeers(os.Getenv("WORKER_CLUSTER_PEERS")) + if err != nil { + return nil, nil, fmt.Errorf("worker: WORKER_CLUSTER_PEERS: %w", err) + } + + opts := &workercluster.Options{ + NodeID: nodeID, + LocalAddr: localAddr, + DataDir: dataDir, + Creds: creds, + HeartbeatTimeout: 1000 * time.Millisecond, + ElectionTimeout: 3000 * time.Millisecond, + Logger: log.New(os.Stderr, "[workercluster] ", log.LstdFlags), + LogOutput: os.Stderr, + } + // WORKER_CLUSTER_BOOTSTRAP=true forces bootstrap mode even + // when WORKER_CLUSTER_PEERS is set (the peers list is then + // informational; the cluster subsystem records it but does + // not dial). Without it, a non-empty peers list means the + // node joins via the first peer. An empty peers list always + // bootstraps. + bootstrap := !hasSeedPeer(peers) + if v := strings.ToLower(strings.TrimSpace(os.Getenv("WORKER_CLUSTER_BOOTSTRAP"))); v == "true" || v == "1" || v == "yes" { + bootstrap = true + } + if bootstrap { + opts.Bootstrap = true + } else { + opts.Seed = peers[0] + } + + c, err := workercluster.New(opts) + if err != nil { + return nil, nil, fmt.Errorf("worker: cluster.New: %w", err) + } + if err := c.Start(ctx); err != nil { + return nil, nil, fmt.Errorf("worker: cluster.Start: %w", err) + } + log.Printf("worker cluster: started node_id=%s addr=%s bootstrap=%t peers=%d", + nodeID, localAddr, bootstrap, len(peers)) + + if clusterDebugApplyTestConfig && bootstrap { + // The debug apply only fires on bootstrap nodes; a + // joiner cannot commit a log entry until it has been + // promoted to voter. Wait for this node to win an + // election first (a fresh single-voter cluster elects + // itself immediately but the goroutine may run before + // the state has flipped). + go func() { + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if c.Raft() != nil && c.Raft().State() == raft.Leader { + break + } + time.Sleep(100 * time.Millisecond) + } + if c.Raft() == nil || c.Raft().State() != raft.Leader { + log.Printf("worker cluster: applying debug test config id=%d: not leader after 15s", + workercluster.DefaultDebugCriticalCheck().ID) + return + } + check := workercluster.DefaultDebugCriticalCheck() + applied, err := c.ApplyTestConfig(&check) + if err != nil { + log.Printf("worker cluster: applying debug test config id=%d: %v", + check.ID, err) + return + } + log.Printf("worker cluster: applying debug test config id=%d applied_index=%d", + check.ID, applied) + }() + } + + return c, &clusterAdapter{c: c}, nil +} + +// clusterAdapter wraps *workercluster.Cluster so it implements the +// webapp.ClusterView interface without webapp importing the raft +// code path. The ApplyTestConfig signature is the one webapp expects +// (no config argument; the cluster subsystem owns the hardcoded +// payload so the two sides cannot drift). +type clusterAdapter struct { + c *workercluster.Cluster +} + +func (a *clusterAdapter) Stats() webapp.ClusterStats { + src := a.c.ClusterStats() + return webapp.ClusterStats{ + NodeID: src.NodeID, + LocalAddr: src.LocalAddr, + State: src.State, + Leader: src.Leader, + Term: src.Term, + AppliedIndex: src.AppliedIndex, + LastIndex: src.LastIndex, + NumPeers: src.NumPeers, + Voters: src.Voters, + FSMChecks: src.FSMChecks, + FSMMembers: src.FSMMembers, + FSMConfigVersion: src.FSMConfigVersion, + FSMOutboxLen: src.FSMOutboxLen, + FSMPartition: src.FSMPartition, + } +} + +func (a *clusterAdapter) ApplyTestConfig() (uint64, error) { + check := workercluster.DefaultDebugCriticalCheck() + return a.c.ApplyTestConfig(&check) +} + +func (a *clusterAdapter) ClusterID() string { return a.c.ClusterID() } +func (a *clusterAdapter) LocalAddr() string { return a.c.LocalAddr() } + +// clusterModeEnabled returns true when WORKER_CLUSTER_ENABLED is set +// to a truthy value. Kept as a free function (not a method) so the +// webapp's ClusterEnabledFromEnv and the cmd binary agree on the +// parsing rules. +func clusterModeEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("WORKER_CLUSTER_ENABLED"))) + return v == "true" || v == "1" || v == "yes" +} + +// clusterPort derives the rafthttp bind port from WORKER_CLUSTER_PORT +// or, when that env var is unset, WORKER_PORT+10000. The +10000 offset +// keeps the webapp and the raft transport from colliding on the same +// loopback bind. +func clusterPort() string { + if raw := strings.TrimSpace(os.Getenv("WORKER_CLUSTER_PORT")); raw != "" { + return raw + } + base := distworker.DefaultHTTPPort + if raw := strings.TrimSpace(os.Getenv("WORKER_PORT")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 && v <= 65535 { + base = v + } + } + return strconv.Itoa(base + 10000) +} + +// parseClusterPeers parses a comma-separated list of "nodeID@host:port" +// entries. Empty input returns an empty slice. Whitespace around entries +// is trimmed; blank entries are rejected. +func parseClusterPeers(raw string) ([]workercluster.Peer, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var out []workercluster.Peer + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + return nil, fmt.Errorf("empty peer entry in %q", raw) + } + at := strings.LastIndex(entry, "@") + if at < 0 { + return nil, fmt.Errorf("peer entry %q missing '@' separator (expected nodeID@host:port)", entry) + } + nodeID := strings.TrimSpace(entry[:at]) + addr := strings.TrimSpace(entry[at+1:]) + // Tolerate a scheme prefix; rafthttp is plain http. + if i := strings.Index(addr, "://"); i >= 0 { + addr = addr[i+3:] + } + if nodeID == "" || addr == "" { + return nil, fmt.Errorf("peer entry %q has empty nodeID or address", entry) + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return nil, fmt.Errorf("peer entry %q: bad host:port: %w", entry, err) + } + out = append(out, workercluster.Peer{WorkerID: nodeID, Address: addr}) + } + return out, nil +} + +// hasSeedPeer reports whether the peers list contains at least one +// usable entry. Bootstrap nodes (the first node of a new cluster) have +// an empty peers list. +func hasSeedPeer(peers []workercluster.Peer) bool { + return len(peers) > 0 +} + +// runnerWrapper adapts *distworker.Runner to webapp.WorkerView. Kept +// here (not in the webapp package) so the distworker -> webapp edge +// is owned by the binary that links both packages. +type runnerWrapper struct { + r *distworker.Runner +} + +func (w runnerWrapper) HTTPConfig() (cfg distworker.HTTPConfig) { + if w.r == nil { + return cfg + } + return w.r.HTTPConfig() +} + +func (w runnerWrapper) Token() string { + if w.r == nil { + return "" + } + return w.r.Token() +} + +func (w runnerWrapper) TokenRotatedAt() time.Time { + if w.r == nil { + return time.Time{} + } + return w.r.TokenRotatedAt() +} + +func (w runnerWrapper) WorkerID() string { + if w.r == nil { + return "" + } + return w.r.WorkerID() +} + +func (w runnerWrapper) RegionCode() string { + if w.r == nil { + return "" + } + return w.r.RegionCode() +} + +func (w runnerWrapper) WorkerVersion() string { + if w.r == nil { + return "" + } + return w.r.WorkerVersion() +} + +func (w runnerWrapper) WorkerCapabilities() []string { + if w.r == nil { + return nil + } + return w.r.WorkerCapabilities() +} + +func (w runnerWrapper) LastHeartbeatAck() time.Time { + if w.r == nil { + return time.Time{} + } + return w.r.LastHeartbeatAck() +} + +func (w runnerWrapper) MasterStatus() (*bool, time.Time) { + if w.r == nil { + return nil, time.Time{} + } + return w.r.MasterStatus() +} + +func (w runnerWrapper) RecentResults(n int) []webapp.ResultRow { + src := w.r.RecentResults(n) + out := make([]webapp.ResultRow, len(src)) + for i, r := range src { + out[i] = webapp.ResultRow{ + MonitorID: r.MonitorID, + CheckID: r.CheckID, + Kind: r.Kind, + Host: r.Host, + State: r.State, + DurationMs: r.DurationMs, + Error: r.Error, + At: r.At, + } + } + return out +} + +func (w runnerWrapper) RecentNotifications(n int) []webapp.NotificationRow { + src := w.r.RecentNotifications(n) + out := make([]webapp.NotificationRow, len(src)) + for i, r := range src { + out[i] = webapp.NotificationRow{ + Kind: r.Kind, + Channel: r.Channel, + Subject: r.Subject, + Body: r.Body, + OK: r.OK, + Error: r.Error, + At: r.At, + } + } + return out +} + +// logHTTPSettings prints a single line summarizing the HTTP listener +// settings the operator configured, so misconfigurations are visible at +// startup. Login is masked. willListen flips to true once the HTTP +// listener is actually bound (Task 3) so the same helper can be reused. +func logHTTPSettings(h distworker.HTTPConfig, willListen bool) { + login := "***" + if h.Login == "" { + login = "(empty)" + } + url := h.URL + if url == "" { + url = "(empty)" + } + log.Printf("worker http settings: host=%s port=%d url=%s login=%s will_listen=%t", + h.Host, h.Port, url, login, willListen) + if h.URL != "" { + if host, warn := distworker.WarnInsecurePublicURL(h.URL); warn { + log.Printf( + "worker http settings: WARN WORKER_URL=http://%s uses plain HTTP on a non-loopback host; "+ + "production deployments usually terminate TLS at a reverse proxy", host, + ) + } + } +} + +func loadDotEnv() { + if err := godotenv.Load(".env"); err == nil { + log.Println("worker .env file loaded") + } +} + +func healthCheck() int { + endpoint := os.Getenv("RSMON_URL") + if endpoint == "" { + endpoint = "https://rsmon.ru" + } + endpoint = strings.TrimRight(endpoint, "/") + if strings.HasSuffix(endpoint, "/api/worker") { + endpoint = strings.TrimSuffix(endpoint, "/api/worker") + } else if strings.HasSuffix(endpoint, "/worker") { + endpoint = strings.TrimSuffix(endpoint, "/worker") + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(endpoint + "/up") + if err != nil { + fmt.Fprintf(os.Stderr, "health check failed: %v\n", err) + return 1 + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "health check failed: status %s\n", resp.Status) + return 1 + } + fmt.Println("health check ok") + return 0 +} diff --git a/config/application/init.go b/config/application/init.go new file mode 100644 index 0000000..d348120 --- /dev/null +++ b/config/application/init.go @@ -0,0 +1,21 @@ +// Package application provides functionality. +package application + +import ( + "os" + + _ "rsgit.ru/rsmon/rsmon/config/env" // Import to ensure .env is loaded before reading env vars +) + +// Env provides functionality. +var Env string + +func init() { + Env = os.Getenv("RSMON_ENV") + if Env == "" { + Env = os.Getenv("GO_ENV") + } + if Env == "" { + Env = "development" + } +} diff --git a/config/credis/redis.go b/config/credis/redis.go new file mode 100644 index 0000000..4c048f0 --- /dev/null +++ b/config/credis/redis.go @@ -0,0 +1,69 @@ +// Package credis provides functionality. +package credis + +import ( + "context" + "log" + "os" + "strconv" + + "github.com/go-redis/redis/v8" + "github.com/gorilla/sessions" + "github.com/rbcervilla/redisstore/v8" + + _ "rsgit.ru/rsmon/rsmon/config/env" // Import to ensure .env is loaded before reading env vars +) + +// Redis provides functionality. +var Redis *redis.Client + +// Store provides functionality. +var Store *redisstore.RedisStore + +func envOrDefault(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} + +// Init provides functionality. +func Init() { + host := envOrDefault("REDIS_HOST", "localhost") + portStr := envOrDefault("REDIS_PORT", "6379") + password := os.Getenv("REDIS_PASSWORD") + dbStr := envOrDefault("REDIS_DATABASE", "0") + + port, _ := strconv.Atoi(portStr) + database, _ := strconv.Atoi(dbStr) + + options := redis.Options{ + Addr: host + ":" + strconv.Itoa(port), + DB: database, + } + if password != "" { + options.Password = password + } + Redis = redis.NewClient(&options) + + isTest := os.Getenv("RSMON_ENV") == "test" || os.Getenv("GO_ENV") == "test" || os.Getenv("CI") == "true" + + var err error + Store, err = redisstore.NewRedisStore(context.Background(), Redis) + if err != nil { + if isTest { + log.Println("Warning: Redis not available in test mode, sessions will not work") + Store = nil + return + } + log.Fatal("failed to create redis store: ", err) + } + + Store.KeyPrefix("session_") + Store.Options(sessions.Options{ + Path: "/", + MaxAge: 86400 * 60, + }) + + log.Printf("Redis connected: %s:%d db=%d", host, port, database) +} diff --git a/config/database/database.go b/config/database/database.go new file mode 100644 index 0000000..b84acf4 --- /dev/null +++ b/config/database/database.go @@ -0,0 +1,80 @@ +// Package database provides database initialization and access for RSMon. +package database + +import ( + "fmt" + "log" + "os" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "rsgit.ru/rsmon/rsmon/app/models" + _ "rsgit.ru/rsmon/rsmon/config/env" // Import to ensure .env is loaded before reading env vars +) + +// db Gorm DB +var db *gorm.DB + +// Logger provides functionality. +var Logger logger.Interface + +func envOrDefault(key, fallbackKey, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + if fallbackKey != "" { + if v := os.Getenv(fallbackKey); v != "" { + return v + } + } + return defaultVal +} + +// Init provides functionality. +func Init() { + host := envOrDefault("DATABASE_HOST", "POSTGRES_HOST", "localhost") + portStr := envOrDefault("DATABASE_PORT", "POSTGRES_PORT", "5432") + user := envOrDefault("DATABASE_USER", "POSTGRES_USER", "rsmon") + pass := envOrDefault("DATABASE_PASSWORD", "POSTGRES_PASSWORD", "rsmon") + dbname := envOrDefault("DATABASE_NAME", "POSTGRES_DB", "rsmon_development") + dbLog := envOrDefault("DATABASE_LOG", "", "false") + + port := 5432 + _, _ = fmt.Sscanf(portStr, "%d", &port) + + connstr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + host, port, user, pass, dbname) + + logMode := logger.Silent + if dbLog == "true" || dbLog == "1" { + logMode = logger.Info + } + + cfg := &gorm.Config{ + Logger: logger.Default.LogMode(logMode), + } + + pg := postgres.Open(connstr) + var err error + db, err = gorm.Open(pg, cfg) + if err != nil { + panic(err) + } + + sqlDB, err := db.DB() + if err != nil { + panic(err) + } + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(10) + sqlDB.SetConnMaxLifetime(time.Hour) + + models.RegisterCallbacks(db) + db = db.Set("gorm:association_autoupdate", false) + models.SetDB(db) + + log.Printf("Database connected: %s@%s:%d/%s (log=%s)", user, host, port, dbname, dbLog) +} diff --git a/config/env/env.go b/config/env/env.go new file mode 100644 index 0000000..376410e --- /dev/null +++ b/config/env/env.go @@ -0,0 +1,95 @@ +// Package env provides functionality. +package env + +import ( + "log" + "os" + "path/filepath" + + "github.com/joho/godotenv" +) + +func init() { + LoadEnvFiles() +} + +// LoadEnvFiles provides functionality. +func LoadEnvFiles() { + env := getEnvironment() + envFile := ".env." + env + + if tryLoadEnv(envFile, env) { + return + } + + if tryLoadEnv(".env", "") { + return + } + + if env != "test" { + log.Printf("Warning: No .env file found") + } else { + log.Printf("Note: No .env file loaded in test environment (this is normal)") + } +} + +func tryLoadEnv(filename, envName string) bool { + if godotenv.Load(filename) == nil { + if envName != "" { + log.Printf(".env.%s file loaded successfully", envName) + } else { + log.Println(".env file loaded successfully") + } + return true + } + + if cwd := os.Getenv("CWD"); cwd != "" { + path := filepath.Join(cwd, filename) + if godotenv.Load(path) == nil { + if envName != "" { + log.Printf(".env.%s file loaded from %s", envName, cwd) + } else { + log.Printf(".env file loaded from %s", cwd) + } + return true + } + } + + dir, _ := os.Getwd() + for { + path := filepath.Join(dir, filename) + if godotenv.Load(path) == nil { + if envName != "" { + log.Printf(".env.%s file loaded from %s", envName, dir) + } else { + log.Printf(".env file loaded from %s", dir) + } + return true + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + break + } + dir = parent + } + + return false +} + +// getEnvironment determines the current environment from various environment variables +func getEnvironment() string { + // Check RSMON_ENV first (rsmon-specific) + if env := os.Getenv("RSMON_ENV"); env != "" { + return env + } + // Then check GO_ENV (standard) + if env := os.Getenv("GO_ENV"); env != "" { + return env + } + + // Default to development + return "development" +} diff --git a/config/secrets/secrets.go b/config/secrets/secrets.go new file mode 100644 index 0000000..c780432 --- /dev/null +++ b/config/secrets/secrets.go @@ -0,0 +1,172 @@ +// Package secrets provides functionality. +package secrets + +import ( + "log" + "os" + "path/filepath" + + "github.com/go-yaml/yaml" + + "rsgit.ru/rsmon/rsmon/config/application" + _ "rsgit.ru/rsmon/rsmon/config/env" // Import to ensure .env is loaded before reading env vars +) + +const envTest = "test" + +// SecretsBase provides functionality. +type SecretsBase struct { + URL string `yaml:"url"` + InfluxToken string `yaml:"influx_token"` + WorkerAdminSecret string `yaml:"worker_admin_secret"` +} + +// SecretsCrypto provides functionality. +type SecretsCrypto struct { + SessionKey string `yaml:"session_key"` + CsrfKey string `yaml:"csrf_key"` + Pepper string `yaml:"pepper"` +} + +// SecretsCrm provides functionality. +type SecretsCrm struct { + URL string `yaml:"url"` + IngressToken string `yaml:"ingress_token"` +} + +// SecretsData provides functionality. +type SecretsData struct { + Base *SecretsBase `yaml:"base"` + Crypto *SecretsCrypto `yaml:"crypto"` + Crm *SecretsCrm `yaml:"crm"` +} + +var ( + // Data provides functionality. + Data SecretsData + // Base provides functionality. + Base *SecretsBase + // Crypto provides cryptography configuration. + Crypto *SecretsCrypto + // Crm provides CRM configuration. + Crm *SecretsCrm +) + +func envFirst(keys ...string) string { + for _, key := range keys { + if value := os.Getenv(key); value != "" { + return value + } + } + return "" +} + +func buildSecretsFromEnv() SecretsData { + baseURL := envFirst("BASE_URL", "APP_BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:7401" + } + + sessionKey := envFirst("SESSION_KEY") + pepper := envFirst("PEPPER") + csrfKey := envFirst("CSRF_KEY") + + return SecretsData{ + Base: &SecretsBase{ + URL: baseURL, + InfluxToken: envFirst("INFLUX_TOKEN"), + WorkerAdminSecret: envFirst("WORKER_ADMIN_SECRET", "RSMON_ADMIN_SECRET"), + }, + Crypto: &SecretsCrypto{ + SessionKey: sessionKey, + CsrfKey: csrfKey, + Pepper: pepper, + }, + Crm: &SecretsCrm{ + URL: envFirst("CRM_URL", "SENSEI_CRM_URL"), + IngressToken: envFirst("CRM_INGRESS_TOKEN", "SENSEI_CRM_INGRESS_TOKEN"), + }, + } +} + +func assign(data SecretsData) { + Data = data + Base = Data.Base + Crypto = Data.Crypto + Crm = Data.Crm +} + +func init() { + var err error + + // Try multiple paths for secrets.yml + cwd := os.Getenv("CWD") + if cwd == "" { + cwd = "." + } + + // Possible paths to check - try relative to CWD and from module root + paths := []string{ + filepath.Join(cwd, "config", "secrets.yml"), + "config/secrets.yml", + "../../config/secrets.yml", + "../../../config/secrets.yml", + "../../../../config/secrets.yml", + } + + var dat []byte + for _, path := range paths { + dat, err = os.ReadFile(path) + if err == nil { + break + } + } + + // If we're in test mode and no secrets file found, use defaults + // Check multiple test indicators since init() order is unpredictable + isTest := os.Getenv("RSMON_ENV") == envTest || + os.Getenv("GO_ENV") == envTest || + os.Getenv("CI") == "true" || + application.Env == envTest + + if err != nil && isTest { + log.Println("No secrets.yml found in test environment, using defaults") + // Set default values for test environment + Base = &SecretsBase{ + URL: "http://localhost:7401", + InfluxToken: "", + WorkerAdminSecret: "test-secret", + } + Crypto = &SecretsCrypto{ + SessionKey: "abc", + CsrfKey: "bGludXhoaW50LmNvbQo=", + Pepper: "abc", + } + Crm = &SecretsCrm{URL: "", IngressToken: ""} + assign(SecretsData{ + Base: Base, + Crypto: Crypto, + Crm: Crm, + }) + return + } + + if err != nil { + log.Println("No secrets.yml found, using environment variables") + assign(buildSecretsFromEnv()) + return + } + + var AllSecrets map[string]SecretsData + + err = yaml.Unmarshal(dat, &AllSecrets) + if err != nil { + panic(err) + } + + env := application.Env + if env == "" { + env = "development" + } + assign(AllSecrets[env]) +} diff --git a/config/translator/translator.go b/config/translator/translator.go new file mode 100644 index 0000000..776216f --- /dev/null +++ b/config/translator/translator.go @@ -0,0 +1,47 @@ +// Package translator provides i18n translation utilities for RSMon. +package translator + +import ( + "github.com/go-playground/locales" + ruData "github.com/go-playground/locales/ru" + ut "github.com/go-playground/universal-translator" +) + +var ( + uni *ut.UniversalTranslator + // Translator provides functionality. + Translator ut.Translator + +// Translator provides functionality. +) + +func init() { + ru := ruData.New() + uni = ut.New(ru, ru, ru) + + Translator, _ = uni.GetTranslator("ru") + + addData() +} + +func addData() { + _ = Translator.AddCardinal("monitor", "доступен {0} монитор", locales.PluralRuleOne, false) + _ = Translator.AddCardinal("monitor", "доступно {0} монитора", locales.PluralRuleFew, false) + _ = Translator.AddCardinal("monitor", "доступно {0} мониторов", locales.PluralRuleMany, false) + _ = Translator.AddCardinal("monitor", "доступно {0} мониторов", locales.PluralRuleOther, false) + + _ = Translator.AddCardinal("expires", "истекает {0} монитор", locales.PluralRuleOne, false) + _ = Translator.AddCardinal("expires", "истекает {0} монитора", locales.PluralRuleFew, false) + _ = Translator.AddCardinal("expires", "истекает {0} мониторов", locales.PluralRuleMany, false) + _ = Translator.AddCardinal("expires", "истекают {0} мониторов", locales.PluralRuleOther, false) + + _ = Translator.AddCardinal("hours", "{0} час", locales.PluralRuleOne, false) + _ = Translator.AddCardinal("hours", "{0} часа", locales.PluralRuleFew, false) + _ = Translator.AddCardinal("hours", "{0} часов", locales.PluralRuleMany, false) + _ = Translator.AddCardinal("hours", "{0} часов", locales.PluralRuleOther, false) + + _ = Translator.AddCardinal("minutes", "{0} минуту", locales.PluralRuleOne, false) + _ = Translator.AddCardinal("minutes", "{0} минуты", locales.PluralRuleFew, false) + _ = Translator.AddCardinal("minutes", "{0} минут", locales.PluralRuleMany, false) + _ = Translator.AddCardinal("minutes", "{0} минут", locales.PluralRuleOther, false) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3e710e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + worker: + image: ${RSMON_WORKER_IMAGE:-reg.rsxx.ru/rsmon/rsmon-worker:latest} + restart: unless-stopped + env_file: + - .env + ports: + - "${WORKER_BIND_IP:-127.0.0.1}:${WORKER_PORT:-27401}:${WORKER_PORT:-27401}" + volumes: + - worker-data:/var/lib/rsmon-worker + cap_add: + - NET_RAW + security_opt: + - no-new-privileges:true + +volumes: + worker-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..db86d33 --- /dev/null +++ b/go.mod @@ -0,0 +1,123 @@ +module rsgit.ru/rsmon/rsmon + +go 1.26 + +require ( + github.com/CanonicalLtd/raft-http v0.0.0-20190521185906-c97137f04506 + github.com/CanonicalLtd/raft-membership v0.0.0-20180413133340-3846634b0164 + github.com/Jeffail/tunny v0.1.4 + github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b + github.com/chromedp/chromedp v0.15.1 + github.com/davecgh/go-spew v1.1.1 + github.com/fatih/structs v1.1.0 + github.com/glebtv/whois v0.0.0-20211111203527-6652a4c5b6bd + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-redis/redis/v8 v8.11.5 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/go-yaml/yaml v2.1.0+incompatible + github.com/google/uuid v1.6.0 + github.com/gorilla/sessions v1.4.0 + github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/go-hclog v1.6.2 + github.com/hashicorp/raft v1.7.3 + github.com/icrowley/fake v0.0.0-20240710202011-f797eb4a99c0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.12.3 + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/miekg/dns v1.1.72 + github.com/minio/minio-go/v7 v7.2.0 + github.com/ns3777k/go-smsaero v0.0.0-20160218224135-fa1d909d7792 + github.com/olekukonko/tablewriter v1.1.4 + github.com/openai/openai-go/v3 v3.37.0 + github.com/pkg/errors v0.9.1 + github.com/rbcervilla/redisstore/v8 v8.1.0 + github.com/rickar/cal/v2 v2.1.27 + github.com/robfig/cron/v3 v3.0.1 + github.com/russross/blackfriday/v2 v2.1.0 + github.com/stretchr/testify v1.11.1 + github.com/weppos/publicsuffix-go v0.50.3 + go.etcd.io/bbolt v1.5.0 + golang.org/x/crypto v0.52.0 + golang.org/x/net v0.55.0 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df + gorm.io/datatypes v1.2.7 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 + modernc.org/sqlite v1.34.5 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/CanonicalLtd/raft-test v0.0.0-20190520172659-ecdf110973a4 // indirect + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/corpix/uarand v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/hashicorp/go-immutable-radix v1.0.0 // indirect + github.com/hashicorp/go-metrics v0.5.4 // indirect + github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect + github.com/hashicorp/golang-lru v0.5.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/likexian/gokit v0.25.16 // indirect + github.com/likexian/whois-parser v1.24.21 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/mpvl/subtest v0.0.0-20160608141506-f6e4cfd4b9ea // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.3.0 // indirect + github.com/olekukonko/ll v0.1.8 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/ini.v1 v1.67.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/mysql v1.6.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ce47547 --- /dev/null +++ b/go.sum @@ -0,0 +1,494 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/CanonicalLtd/raft-http v0.0.0-20190521185906-c97137f04506 h1:hY6YC1rS02IyP0JgRYH5XNVaAmFwTrSbzP8iWmGqGZw= +github.com/CanonicalLtd/raft-http v0.0.0-20190521185906-c97137f04506/go.mod h1:K/GfsVB3/egK02EUgzaSUS8dpZoMdln+ETqkjv27ir4= +github.com/CanonicalLtd/raft-membership v0.0.0-20180413133340-3846634b0164 h1:GWn1px1DihMKCfQE3VZ/GRDsYbxzte7GyRxJ6//feN4= +github.com/CanonicalLtd/raft-membership v0.0.0-20180413133340-3846634b0164/go.mod h1:xfw9lu7RiHxxPhjvlTmdEAzimAOwcKZMVq9C2YirsQI= +github.com/CanonicalLtd/raft-test v0.0.0-20190520172659-ecdf110973a4 h1:1v969IU9/SFw5ElOBSCodsZIAg5rHg2/8aG35raKmFI= +github.com/CanonicalLtd/raft-test v0.0.0-20190520172659-ecdf110973a4/go.mod h1:vTWMFPA+YoHplLmXlOCJf3a1vHY9tdlqqYE30nw3mNU= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Jeffail/tunny v0.1.4 h1:chtpdz+nUtaYQeCKlNBg6GycFF/kGVHOr6A3cmzTJXs= +github.com/Jeffail/tunny v0.1.4/go.mod h1:P8xAx4XQl0xsuhjX1DtfaMDCSuavzdb2rwbd0lk+fvo= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4= +github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/corpix/uarand v0.2.0 h1:U98xXwud/AVuCpkpgfPF7J5TQgr7R5tqT8VZP5KWbzE= +github.com/corpix/uarand v0.2.0/go.mod h1:/3Z1QIqWkDIhf6XWn/08/uMHoQ8JUoTIKc2iPchBOmM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/glebtv/whois v0.0.0-20211111203527-6652a4c5b6bd h1:2qP9lS51pgJ8hp787nDUV7HCs4r0Hworrv95Z9GyGG0= +github.com/glebtv/whois v0.0.0-20211111203527-6652a4c5b6bd/go.mod h1:6Bd7YerguiTlof7e20KpgUzrMxdX+0Ngs+hVmkMtkoQ= +github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA= +github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-redis/redis/v8 v8.3.3/go.mod h1:jszGxBCez8QA1HWSmQxJO9Y82kNibbUmeYhKWrBejTU= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= +github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= +github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= +github.com/hashicorp/go-msgpack/v2 v2.1.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0= +github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/raft v1.7.3 h1:DxpEqZJysHN0wK+fviai5mFcSYsCkNpFUl1xpAW8Rbo= +github.com/hashicorp/raft v1.7.3/go.mod h1:DfvCGFxpAUPE0L4Uc8JLlTPtc3GzSbdH0MTJCLgnmJQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/icrowley/fake v0.0.0-20240710202011-f797eb4a99c0 h1:ufr2e4uIgz/Ft0RPudkFMyVrp77buvTFxqoDvwNGVSk= +github.com/icrowley/fake v0.0.0-20240710202011-f797eb4a99c0/go.mod h1:dQ6TM/OGAe+cMws81eTe4Btv1dKxfPZ2CX+YaAFAPN4= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/likexian/gokit v0.25.6/go.mod h1:q1LC+z3cBymJuE4oeiWiIPhJceUa0nptg4Id8tSzjZI= +github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8= +github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4= +github.com/likexian/whois-parser v1.22.0/go.mod h1:2bJqtH4tNPanBvOp/3Kj3Sd12S9vxTbsJ0+0zjRc3ow= +github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM= +github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs= +github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mpvl/subtest v0.0.0-20160608141506-f6e4cfd4b9ea h1:5Gv+KKKaxsEtBH6/ZCFwM+eatdh5NulINjLJ9yHrm4Q= +github.com/mpvl/subtest v0.0.0-20160608141506-f6e4cfd4b9ea/go.mod h1:igwNnEshHGLbN3Pqer+tudoA6qNEeRSxUbpj5QWERww= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ns3777k/go-smsaero v0.0.0-20160218224135-fa1d909d7792 h1:lOig5WRf2A7Fx+ajRVPFW6EEvz8e1WZWRmPeQUngvmY= +github.com/ns3777k/go-smsaero v0.0.0-20160218224135-fa1d909d7792/go.mod h1:QYdEf+WDqsHmoWHi2uo1eKy2Po7p8Zs1T4Ps4eOoKfY= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= +github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= +github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/openai/openai-go/v3 v3.37.0 h1:4OG68yZgnxZpwzebO+ZDUNkFJKKwKgzilMQq30nsouE= +github.com/openai/openai-go/v3 v3.37.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rbcervilla/redisstore/v8 v8.1.0 h1:YmNOHjAIb7+DLbqLPxSFAxmbtXbDgFcY2/eXrf1KoEY= +github.com/rbcervilla/redisstore/v8 v8.1.0/go.mod h1:JGDqTj9JQ28J1c+2u3iEnOUBC7W5WMW/YRKLqRm0pOk= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rickar/cal/v2 v2.1.27 h1:4vFfbXI9dB1Rb/mHH51xYx36ILWk0Wu8VY0bMnoTMpw= +github.com/rickar/cal/v2 v2.1.27/go.mod h1:/fdlMcx7GjPlIBibMzOM9gMvDBsrK+mOtRXdTzUqV/A= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/weppos/publicsuffix-go v0.15.0/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= +github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE= +github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.opentelemetry.io/otel v0.13.0/go.mod h1:dlSNewoRYikTkotEnxdmuBHgzT+k/idJSfDv/FxEnOY= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211111160137-58aab5ef257a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/checkexec/exec.go b/internal/checkexec/exec.go new file mode 100644 index 0000000..030efeb --- /dev/null +++ b/internal/checkexec/exec.go @@ -0,0 +1,95 @@ +// Package checkexec provides DB-free check execution for distributed workers. +// It executes checks without saving results to database or InfluxDB, +// allowing remote workers to report results via API. +package checkexec + +import ( + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/checks/calls" + "rsgit.ru/rsmon/rsmon/checks/cbssl" + "rsgit.ru/rsmon/rsmon/checks/cdns" + "rsgit.ru/rsmon/rsmon/checks/cftp" + "rsgit.ru/rsmon/rsmon/checks/chttp" + "rsgit.ru/rsmon/rsmon/checks/cping" + "rsgit.ru/rsmon/rsmon/checks/cssh" + "rsgit.ru/rsmon/rsmon/checks/cssl" + "rsgit.ru/rsmon/rsmon/checks/ctcp" + "rsgit.ru/rsmon/rsmon/checks/cudp" + "rsgit.ru/rsmon/rsmon/checks/cwhois" + "rsgit.ru/rsmon/rsmon/checks/llmhttp" + "rsgit.ru/rsmon/rsmon/internal/checkresult" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// ExecutedCheck is the DB-free result of a distributed check execution. +type ExecutedCheck struct { + Result checkresult.CheckResult + Metrics []wire.MetricPoint +} + +// Execute runs checks without saving to DB or InfluxDB. +// Results are returned for reporting via API to the control plane. +// This is designed for distributed workers that have no direct DB access. +func Execute(m *models.Monitor, checks []models.Check) []ExecutedCheck { + results := make([]ExecutedCheck, 0, len(checks)) + for i := range checks { + c := &checks[i] + c.Monitor = m + switch c.Kind { + case "http": + r := chttp.Perform(c) + results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields())) + case "ssl": + r := cssl.Perform(c) + results = append(results, executed(&r.CheckResult)) + case "ssh": + r := cssh.Perform(c) + results = append(results, executed(&r.CheckResult)) + case "ftp": + r := cftp.Perform(c) + results = append(results, executed(&r.CheckResult)) + case "dns": + r := cdns.Perform(c) + results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields())) + case "whois": + r := cwhois.Perform(c) + results = append(results, executed(&r.CheckResult)) + case "bssl": + r := cbssl.Perform(c) + results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields())) + case "llm": + r := calls.Perform(c) + results = append(results, executed(&r.CheckResult)) + case "llm-http": + r := llmhttp.Perform(c) + results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields())) + case "ping": + r := cping.Perform(c) + results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields())) + case "tcp": + r := ctcp.Perform(c) + results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields())) + case "udp": + r := cudp.Perform(c) + results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields())) + } + // Note: "rkn" checks are intentionally omitted as they are Russia-specific + // regulatory checks that should not run on distributed workers. + } + return results +} + +func executed(result *checkresult.CheckResult) ExecutedCheck { + return ExecutedCheck{Result: *result} +} + +func executedWithMetric(c *models.Check, result checkresult.CheckResult, tags map[string]string, fields map[string]interface{}) ExecutedCheck { //nolint:gocritic,lll // helper keeps typed check results close to execution + return ExecutedCheck{ + Result: result, + Metrics: []wire.MetricPoint{{ + Metric: c.MetricName(), + Tags: tags, + Fields: fields, + }}, + } +} diff --git a/internal/checkresult/result.go b/internal/checkresult/result.go new file mode 100644 index 0000000..556516b --- /dev/null +++ b/internal/checkresult/result.go @@ -0,0 +1,38 @@ +// Package checkresult holds the DB-free outcome of a single check execution. +package checkresult + +import ( + "time" +) + +// CheckResult is the in-memory outcome of a single check execution. +// The check packages build a CheckResult, then call SaveTo to persist +// state, error, warnings, infos, and expiry back to the check row. +type CheckResult struct { + State string + Error error + Warnings []string + Infos []string + Duration time.Duration + Expires *time.Time +} + +// GetState returns the result state (OK/ERR/FAIL/WARN). +func (r *CheckResult) GetState() string { + return r.State +} + +// GetError returns the result error, if any. +func (r *CheckResult) GetError() error { + return r.Error +} + +// GetWarnings returns the human-readable warnings produced by the check. +func (r *CheckResult) GetWarnings() []string { + return r.Warnings +} + +// GetInfos returns the human-readable info messages produced by the check. +func (r *CheckResult) GetInfos() []string { + return r.Infos +} diff --git a/internal/distworker/client.go b/internal/distworker/client.go new file mode 100644 index 0000000..e2396bf --- /dev/null +++ b/internal/distworker/client.go @@ -0,0 +1,185 @@ +package distworker + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/websocket" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// HTTPStatusError reports an HTTP response returned while dialing the websocket. +type HTTPStatusError struct { + StatusCode int + Status string + Body string +} + +func (e *HTTPStatusError) Error() string { + if e.Body == "" { + return fmt.Sprintf("websocket dial failed: %s", e.Status) + } + return fmt.Sprintf("websocket dial failed: %s: %s", e.Status, e.Body) +} + +// Client is the HTTP client for communicating with the control plane +type Client struct { + endpoint string + authToken string + httpClient *http.Client +} + +// NewClient creates a new API client +func NewClient(endpoint, authToken string) *Client { + return &Client{ + endpoint: endpoint, + authToken: authToken, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// postJSON is a helper for sending JSON POST requests +func (c *Client) postJSON(path string, payload interface{}) (*http.Response, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequest("POST", c.endpoint+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.authToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.authToken) + } + + return c.httpClient.Do(httpReq) +} + +// checkStatusCode checks if the response status is OK, returns error otherwise +func checkStatusCode(resp *http.Response, errorMsg string) error { + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%s failed: %s: %s", errorMsg, resp.Status, string(body)) + } + return nil +} + +// Heartbeat sends a heartbeat to the control plane +func (c *Client) Heartbeat(req wire.HeartbeatRequest) error { + resp, err := c.postJSON("/api/internal/workers/heartbeat", req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + return checkStatusCode(resp, "heartbeat") +} + +// RotateToken asks the main app's internal API to mint a new +// bearer token for this worker. The current bearer is used for +// authentication; the response carries the freshly issued token. +// +// Returns the new token string. The main app invalidates the old +// token immediately. +func (c *Client) RotateToken() (string, error) { + resp, err := c.postJSON("/api/internal/workers/rotate-token", struct{}{}) + if err != nil { + return "", err + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("rotate-token failed: %s: %s", resp.Status, string(body)) + } + var out struct { + AuthToken string `json:"auth_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("decode rotate-token response: %w", err) + } + if out.AuthToken == "" { + return "", fmt.Errorf("rotate-token response empty") + } + return out.AuthToken, nil +} + +// GetJobs fetches available check jobs from the control plane +func (c *Client) GetJobs() (*wire.JobsResponse, error) { + httpReq, err := http.NewRequest("GET", c.endpoint+"/api/internal/workers/jobs", http.NoBody) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Authorization", "Bearer "+c.authToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get jobs failed: %s: %s", resp.Status, string(body)) + } + + var result wire.JobsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// ReportResults sends check results to the control plane +func (c *Client) ReportResults(req wire.ResultsRequest) error { + resp, err := c.postJSON("/api/internal/workers/results", req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + return checkStatusCode(resp, "report results") +} + +// WorkerSocket connects to the websocket task channel. +func (c *Client) WorkerSocket() (*websocket.Conn, error) { + endpoint := strings.TrimRight(c.endpoint, "/") + wsURL := endpoint + if !strings.HasSuffix(wsURL, "/worker") && !strings.HasSuffix(wsURL, "/api/worker") { + wsURL += "/worker" + } + if strings.HasPrefix(wsURL, "https://") { + wsURL = "wss://" + strings.TrimPrefix(wsURL, "https://") + } else if strings.HasPrefix(wsURL, "http://") { + wsURL = "ws://" + strings.TrimPrefix(wsURL, "http://") + } + + u, err := url.Parse(wsURL) + if err != nil { + return nil, err + } + q := u.Query() + q.Set("token", c.authToken) + u.RawQuery = q.Encode() + + conn, resp, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil && resp != nil { + defer resp.Body.Close() //nolint:errcheck + body, _ := io.ReadAll(resp.Body) + return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status, Body: string(body)} + } + return conn, err +} diff --git a/internal/distworker/config.go b/internal/distworker/config.go new file mode 100644 index 0000000..23115a5 --- /dev/null +++ b/internal/distworker/config.go @@ -0,0 +1,203 @@ +package distworker + +import ( + "fmt" + "net/url" + "os" + "strconv" + "strings" +) + +// DefaultMaxConcurrency is the default upper bound for the worker pool size +// and the number of dispatch goroutines that drain the job queue. +const DefaultMaxConcurrency = 32 + +const ( + // DefaultHTTPHost is the bind address used when WORKER_HOST is unset. + DefaultHTTPHost = "0.0.0.0" + // DefaultHTTPPort is the bind port used when WORKER_PORT is unset. + // Picked >20000 to avoid colliding with the main RSMon app (which + // binds 7401 by default) when the worker is co-located on the + // same host. Operators are still free to override via WORKER_PORT. + DefaultHTTPPort = 27401 + + // schemeHTTP / schemeHTTPS are the only schemes accepted on + // WORKER_URL. Peer workers and the main app need an http(s) origin + // they can dial with Go's net/http stack. + schemeHTTP = "http" + schemeHTTPS = "https" + + // loopbackHostnames lists hostnames treated as loopback for the + // http-on-non-loopback warning emitted by ValidateHTTPConfig. + loopbackHostnames = "localhost,127.0.0.1,::1,0.0.0.0" +) + +// HTTPConfig holds the settings that govern the worker's local HTTP +// listener (web app MVP in Task 3 and Raft peer connections in Task 4). +// Host/Port are the bind interface. URL is the publicly-advertised +// location peers and the main app use to reach the worker; it is NOT +// derived from Host:Port because workers commonly sit behind a reverse +// proxy / Traefik with HTTPS while listening on plain HTTP internally. +// +// Login/Password are basic auth credentials for the worker web app API +// (Task 3). They are kept in memory only; Task 3 may hash them before +// any persistent store. +type HTTPConfig struct { + Host string + Port int + URL string + Login string + Password string +} + +// IsAuthConfigured reports whether both WORKER_LOGIN and WORKER_PASSWORD +// are set. The HTTP listener refuses to start otherwise. +func (c HTTPConfig) IsAuthConfigured() bool { + return c.Login != "" && c.Password != "" +} + +// IsListenConfigured reports whether the listener should bind at all. +// Currently always true while WORKER_PORT > 0; placeholder for Task 3 +// to wire "skip listen" semantics. +func (c HTTPConfig) IsListenConfigured() bool { + return c.Port > 0 +} + +// Config holds the local worker connection settings. +// Runtime settings are delivered by the control plane over websocket. +type Config struct { + URL string + Token string + + // MaxConcurrency caps the worker pool size and the number of dispatcher + // goroutines. A value <= 0 falls back to DefaultMaxConcurrency. + // Runtime configuration delivered by the control plane via the websocket + // "init" or "config" message is clamped to this value when resizing. + MaxConcurrency int + + // HTTP holds the local web app / Raft listener settings (Task 2). + HTTP HTTPConfig +} + +// ConfigFromEnv creates a Config from environment variables. +func ConfigFromEnv() Config { + return Config{ + URL: normalizeURL(os.Getenv("RSMON_URL")), + Token: os.Getenv("RSMON_TOKEN"), + HTTP: HTTPConfigFromEnv(), + } +} + +// HTTPConfigFromEnv reads HTTP listener settings from the environment. +// Empty WORKER_HOST defaults to DefaultHTTPHost; empty WORKER_PORT defaults +// to DefaultHTTPPort. A malformed WORKER_PORT falls back to the default. +// URL is parsed loosely here; ValidateHTTPConfig does the real check. +func HTTPConfigFromEnv() HTTPConfig { + port := DefaultHTTPPort + if raw := strings.TrimSpace(os.Getenv("WORKER_PORT")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 && v <= 65535 { + port = v + } + } + host := strings.TrimSpace(os.Getenv("WORKER_HOST")) + if host == "" { + host = DefaultHTTPHost + } + return HTTPConfig{ + Host: host, + Port: port, + URL: strings.TrimSpace(os.Getenv("WORKER_URL")), + Login: os.Getenv("WORKER_LOGIN"), + Password: os.Getenv("WORKER_PASSWORD"), + } +} + +// ValidateHTTPConfig enforces the Task 2 invariants: +// +// - WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty. +// A mixed state (XOR) is a config bug and must fail fast so an +// operator notices immediately. Both empty is allowed while the HTTP +// listener is not started yet. +// - When the listener would actually start (WORKER_PORT > 0), both +// must be set; otherwise we are going to expose an unauthenticated +// endpoint. +// - WORKER_URL, if set, must be a parseable absolute URL. Relative +// URLs are rejected because peer workers and the main app need a +// concrete origin to dial. +// - A WORKER_URL with scheme=http on a non-loopback host logs a +// warning: production deployments normally terminate TLS at a +// reverse proxy (Traefik, nginx). +func ValidateHTTPConfig(c HTTPConfig, willListen bool) error { + loginSet := c.Login != "" + passSet := c.Password != "" + if loginSet != passSet { + return fmt.Errorf( + "WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty (got login=%s password=%s)", + boolStr(loginSet), boolStr(passSet)) + } + if willListen && !c.IsAuthConfigured() { + return fmt.Errorf("HTTP listener refused to start: WORKER_LOGIN and WORKER_PASSWORD must be set when WORKER_PORT > 0") + } + if c.URL == "" { + return nil + } + u, err := url.Parse(c.URL) + if err != nil { + return fmt.Errorf("WORKER_URL is not a valid URL: %v", err) + } + if u.Scheme == "" || u.Host == "" { + return fmt.Errorf("WORKER_URL must be an absolute URL with scheme and host (got %q)", c.URL) + } + if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS { + return fmt.Errorf("WORKER_URL scheme must be http or https (got %q)", u.Scheme) + } + return nil +} + +// WarnInsecurePublicURL logs a warning when WORKER_URL uses plain http +// for a non-loopback host. Returns the host so the caller can log it. +// A no-op for the loopback case (typical local dev) and for https URLs. +func WarnInsecurePublicURL(rawURL string) (host string, shouldWarn bool) { + if rawURL == "" { + return "", false + } + u, err := url.Parse(rawURL) + if err != nil { + return "", false + } + if u.Scheme != schemeHTTP { + return u.Host, false + } + if isLoopbackHost(u.Hostname()) { + return u.Host, false + } + return u.Host, true +} + +func isLoopbackHost(host string) bool { + host = strings.ToLower(host) + for _, h := range strings.Split(loopbackHostnames, ",") { + if host == strings.TrimSpace(h) { + return true + } + } + return false +} + +func boolStr(b bool) string { + if b { + return "set" + } + return "empty" +} + +func normalizeURL(endpoint string) string { + endpoint = strings.TrimRight(endpoint, "/") + if strings.HasSuffix(endpoint, "/api/worker") { + return strings.TrimSuffix(endpoint, "/api/worker") + } + if strings.HasSuffix(endpoint, "/worker") { + return strings.TrimSuffix(endpoint, "/worker") + } + return endpoint +} diff --git a/internal/distworker/config_test.go b/internal/distworker/config_test.go new file mode 100644 index 0000000..78fb1a3 --- /dev/null +++ b/internal/distworker/config_test.go @@ -0,0 +1,197 @@ +package distworker + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHTTPConfigFromEnv_Defaults verifies the documented defaults when no +// HTTP-related env vars are set: 0.0.0.0:27401 and empty URL / login / +// password. The default port was bumped from 7401 to 27401 to avoid +// colliding with the main RSMon app when the worker is co-located. +func TestHTTPConfigFromEnv_Defaults(t *testing.T) { + t.Setenv("WORKER_HOST", "") + t.Setenv("WORKER_PORT", "") + t.Setenv("WORKER_URL", "") + t.Setenv("WORKER_LOGIN", "") + t.Setenv("WORKER_PASSWORD", "") + + cfg := HTTPConfigFromEnv() + assert.Equal(t, DefaultHTTPHost, cfg.Host, "host should default to 0.0.0.0") + assert.Equal(t, DefaultHTTPPort, cfg.Port, "port should default to 27401") + assert.Equal(t, "", cfg.URL) + assert.Equal(t, "", cfg.Login) + assert.Equal(t, "", cfg.Password) + assert.False(t, cfg.IsAuthConfigured()) +} + +// TestHTTPConfigFromEnv_Overrides verifies the env vars win over the +// defaults when explicitly set, including a non-default port. +func TestHTTPConfigFromEnv_Overrides(t *testing.T) { + t.Setenv("WORKER_HOST", "127.0.0.1") + t.Setenv("WORKER_PORT", "9100") + t.Setenv("WORKER_URL", "https://worker.example.com") + t.Setenv("WORKER_LOGIN", "ops") + t.Setenv("WORKER_PASSWORD", "s3cret") + + cfg := HTTPConfigFromEnv() + assert.Equal(t, "127.0.0.1", cfg.Host) + assert.Equal(t, 9100, cfg.Port) + assert.Equal(t, "https://worker.example.com", cfg.URL) + assert.Equal(t, "ops", cfg.Login) + assert.Equal(t, "s3cret", cfg.Password) + assert.True(t, cfg.IsAuthConfigured()) +} + +// TestHTTPConfigFromEnv_PortGarbageFallsBackToDefault covers the "operator +// fat-fingered WORKER_PORT" case: the helper must not panic and must +// fall back to DefaultHTTPPort instead of starting on a zero port. +func TestHTTPConfigFromEnv_PortGarbageFallsBackToDefault(t *testing.T) { + t.Setenv("WORKER_PORT", "not-a-port") + + cfg := HTTPConfigFromEnv() + assert.Equal(t, DefaultHTTPPort, cfg.Port, + "unparseable WORKER_PORT should fall back to the default") +} + +// TestHTTPConfigFromEnv_PortOutOfRangeFallsBackToDefault covers zero and +// out-of-range port values which are rejected by url/strconv semantics. +func TestHTTPConfigFromEnv_PortOutOfRangeFallsBackToDefault(t *testing.T) { + for _, v := range []string{"0", "-1", "70000"} { + t.Run("port="+v, func(t *testing.T) { + t.Setenv("WORKER_PORT", v) + cfg := HTTPConfigFromEnv() + assert.Equal(t, DefaultHTTPPort, cfg.Port, + "WORKER_PORT=%q should fall back to the default", v) + }) + } +} + +// TestValidateHTTPConfig_LoginXORPasswordRejected covers the central +// invariant: a mixed login/password state is a config bug and must +// fail fast. +func TestValidateHTTPConfig_LoginXORPasswordRejected(t *testing.T) { + t.Run("only login set", func(t *testing.T) { + err := ValidateHTTPConfig(HTTPConfig{ + Host: "0.0.0.0", Port: 27401, + Login: "ops", Password: "", + }, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "WORKER_LOGIN and WORKER_PASSWORD") + }) + t.Run("only password set", func(t *testing.T) { + err := ValidateHTTPConfig(HTTPConfig{ + Host: "0.0.0.0", Port: 27401, + Login: "", Password: "s3cret", + }, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "WORKER_LOGIN and WORKER_PASSWORD") + }) +} + +// TestValidateHTTPConfig_BothEmptyAllowedWhenNotListening reflects the +// Task 2 deferral: while the HTTP listener is not started (Task 3), +// leaving both unset is permitted so a worker that does not yet need +// the web app can still start. +func TestValidateHTTPConfig_BothEmptyAllowedWhenNotListening(t *testing.T) { + err := ValidateHTTPConfig(HTTPConfig{ + Host: "0.0.0.0", Port: 27401, + Login: "", Password: "", + }, false) + assert.NoError(t, err) +} + +// TestValidateHTTPConfig_BothEmptyRejectedWhenListening confirms that +// when Task 3 actually starts the listener, an unauthenticated listener +// is refused. +func TestValidateHTTPConfig_BothEmptyRejectedWhenListening(t *testing.T) { + err := ValidateHTTPConfig(HTTPConfig{ + Host: "0.0.0.0", Port: 27401, + Login: "", Password: "", + }, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "refused to start") +} + +// TestValidateHTTPConfig_URLAbsoluteRequired covers the relative-URL +// rejection: peer workers and the main app need a concrete origin. +func TestValidateHTTPConfig_URLAbsoluteRequired(t *testing.T) { + cases := []struct { + name string + url string + }{ + {"relative path", "/worker"}, + {"missing scheme", "example.com"}, + {"missing host", "https://"}, + {"empty after trim", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, URL: tc.url} + err := ValidateHTTPConfig(cfg, false) + // empty url is allowed + if tc.url == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + }) + } +} + +// TestValidateHTTPConfig_URLSchemeAllowed covers the two accepted schemes. +func TestValidateHTTPConfig_URLSchemeAllowed(t *testing.T) { + for _, scheme := range []string{"http", "https"} { + t.Run(scheme, func(t *testing.T) { + cfg := HTTPConfig{ + Host: "0.0.0.0", Port: 27401, + URL: scheme + "://localhost:27401", + } + assert.NoError(t, ValidateHTTPConfig(cfg, false)) + }) + } +} + +// TestValidateHTTPConfig_URLSchemeRejected covers non-http(s) schemes. +func TestValidateHTTPConfig_URLSchemeRejected(t *testing.T) { + for _, scheme := range []string{"ftp", "file", "ws", "wss"} { + t.Run(scheme, func(t *testing.T) { + cfg := HTTPConfig{ + Host: "0.0.0.0", Port: 27401, + URL: scheme + "://localhost:27401", + } + err := ValidateHTTPConfig(cfg, false) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "scheme must be http or https") + }) + } +} + +// TestWarnInsecurePublicURL exercises the warning helper: only http on +// non-loopback hosts should warn. The returned host is the parsed host +// (host:port when present) so the caller can log a useful target. +func TestWarnInsecurePublicURL(t *testing.T) { + cases := []struct { + name string + url string + wantWarn bool + wantHost string + }{ + {"empty", "", false, ""}, + {"https production", "https://worker.example.com", false, "worker.example.com"}, + {"http loopback", "http://localhost:27401", false, "localhost:27401"}, + {"http 127.0.0.1", "http://127.0.0.1:27401", false, "127.0.0.1:27401"}, + {"http 0.0.0.0", "http://0.0.0.0:27401", false, "0.0.0.0:27401"}, + {"http production", "http://worker.example.com", true, "worker.example.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host, warn := WarnInsecurePublicURL(tc.url) + assert.Equal(t, tc.wantWarn, warn) + assert.Equal(t, tc.wantHost, host) + }) + } +} diff --git a/internal/distworker/consensus.go b/internal/distworker/consensus.go new file mode 100644 index 0000000..a72328b --- /dev/null +++ b/internal/distworker/consensus.go @@ -0,0 +1,237 @@ +package distworker + +import ( + "sync" + "time" +) + +// consensusDecision is the outcome of one consensus round. The +// selfcheck module drives its alert state machine off Down/Up; NoQuorum +// is the "we don't know yet" verdict returned when too few voters +// reported for either side to be a majority. +type consensusDecision int + +const ( + // consensusNoQuorum means we have fewer than the minimum voters + // required to reach a majority. The selfcheck holds the + // previous state instead of flipping. + consensusNoQuorum consensusDecision = iota + // consensusUp means a majority of (self + known peers) reported + // the master API as up. + consensusUp + // consensusDown means a majority reported the master API as + // down. The selfcheck starts (or continues) the down timer. + consensusDown +) + +// String makes the verdict easy to log without bespoke formatting. +func (d consensusDecision) String() string { + switch d { + case consensusUp: + return "up" + case consensusDown: + return "down" + default: + return "no-quorum" + } +} + +// tallyConsensus counts how many of self+peers are up vs down. The +// returned sizes are useful for logging/debugging and let the +// selfcheck report "2 of 3 voters say down" in its log line. +// +// selfUp == nil means "self vote is unknown" (e.g. the first tick +// has not completed). The cluster size is the number of known votes +// (self, if known, plus each peer observation). +func tallyConsensus(selfUp *bool, peers []peerObservation) (up, down, total int) { + if selfUp != nil { + total++ + if *selfUp { + up++ + } else { + down++ + } + } + for i := range peers { + total++ + if peers[i].Up { + up++ + } else { + down++ + } + } + return up, down, total +} + +// decideConsensus applies the simple-majority rule: whichever side +// (up or down) has at least floor(total/2)+1 votes wins. When total +// is 0 (no votes at all) or neither side reaches that threshold the +// result is consensusNoQuorum. +// +// minVotes is the minimum number of fresh votes (self + peers) that +// must be present before any verdict is reported. It is the gate +// that turns simple-majority into a peer-backed cluster quorum: a +// multi-worker deployment (WorkerInit.Peers non-empty) passes +// minVotes=2 so a lone self vote — no fresh peer observations yet — +// is consensusNoQuorum, both for up and down. A single-worker / +// no-peer deployment passes minVotes=1 to preserve the prior +// single-node behavior. Values below 1 are clamped to 1. +// +// The "self" vote is required to reach quorum: a worker that has not +// produced its own first probe cannot make a down consensus call +// (its own vote would be missing). Pass nil for selfUp to model the +// pre-first-probe window. +func decideConsensus(selfUp *bool, peers []peerObservation, minVotes int) consensusDecision { + if selfUp == nil { + return consensusNoQuorum + } + if minVotes < 1 { + minVotes = 1 + } + up, down, total := tallyConsensus(selfUp, peers) + if total < minVotes { + return consensusNoQuorum + } + // Standard majority for a non-empty set: floor(total/2)+1. + // For 1 voter (self only, no peers yet) this collapses to 1 + // which still requires unanimous agreement with self. + majority := total/2 + 1 + if up >= majority { + return consensusUp + } + if down >= majority { + return consensusDown + } + return consensusNoQuorum +} + +// consensusState tracks the cluster-level up/down verdict over time +// so the selfcheck module can fire alerts only after the verdict has +// held for selfcheckConsensusWait. This mirrors the "incident +// state machine" idea from +// docs/distributed/worker-to-worker-raft.md §9.1 in miniature: we +// only have two states (down / clear) and one wait threshold, but +// the structure is the same so the next slice can swap the rule for +// the full FSM without changing the alert site. +type consensusState struct { + mu sync.Mutex + // downSince records the wall-clock time the cluster verdict + // first flipped to consensusDown. Cleared when the verdict + // flips back to consensusUp. nil means "not currently down". + downSince *time.Time + // alertActive mirrors the prior selfcheckState.sent* flags. It + // is true between the down-alert firing and the recovery alert + // firing so a duplicate probe does not re-send the same + // "master is down" notification. + alertActive bool + // lastVerdict keeps the most recent decision for the next tick + // to compare against without recomputing from scratch. + lastVerdict consensusDecision + // notificationLeader is the last deterministic worker elected to + // send system-contact notifications for this local view of the + // cluster. The first non-empty leader only initializes the field; + // later changes are alert-worthy. + notificationLeader string +} + +func (s *consensusState) notificationLeaderChanged(leader string) (old string, changed bool) { + if s == nil || leader == "" { + return "", false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.notificationLeader == "" { + s.notificationLeader = leader + return "", false + } + if s.notificationLeader == leader { + return "", false + } + old = s.notificationLeader + s.notificationLeader = leader + return old, true +} + +// isDownConsensusHeld reports whether the supplied verdict means +// "down for at least selfcheckConsensusWait" given the current +// state. A fresh down verdict sets downSince; a subsequent down +// verdict keeps the original timestamp so the wait is measured from +// the first observation, not the most recent. +func (s *consensusState) isDownConsensusHeld(verdict consensusDecision, now time.Time) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + switch verdict { + case consensusDown: + if s.downSince == nil { + t := now + s.downSince = &t + } + s.lastVerdict = verdict + return now.Sub(*s.downSince) >= selfcheckConsensusWait + case consensusUp: + s.downSince = nil + s.alertActive = false + s.lastVerdict = verdict + return false + default: + // NoQuorum: hold the existing state. Do not reset + // downSince (a transient blip should not extend the + // timer, but it should not erase progress either). + return false + } +} + +// markDownAlertFired records that the down alert has been emitted so +// the next tick does not fire it again. Idempotent. +func (s *consensusState) markDownAlertFired() { + if s == nil { + return + } + s.mu.Lock() + s.alertActive = true + s.mu.Unlock() +} + +// shouldFireRecovery reports whether the cluster has been up long +// enough to fire a recovery alert. Recovery uses the same wait +// window as the down alert so a flapping verdict does not spam +// recovery notifications. +func (s *consensusState) shouldFireRecovery(verdict consensusDecision, now time.Time) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if verdict != consensusUp { + return false + } + if !s.alertActive { + return false + } + if s.lastVerdict != consensusUp { + // Just flipped from down to up; stamp the recovery timer. + s.lastVerdict = verdict + t := now + s.downSince = &t + return false + } + if s.downSince == nil { + return false + } + return now.Sub(*s.downSince) >= selfcheckConsensusWait +} + +// markRecoveryFired clears the alert-active flag so a future +// down verdict can fire the down alert again. +func (s *consensusState) markRecoveryFired() { + if s == nil { + return + } + s.mu.Lock() + s.alertActive = false + s.downSince = nil + s.mu.Unlock() +} diff --git a/internal/distworker/notification.go b/internal/distworker/notification.go new file mode 100644 index 0000000..edc6238 --- /dev/null +++ b/internal/distworker/notification.go @@ -0,0 +1,280 @@ +package distworker + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/sender" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// ExecuteNotification runs one NotificationTask end-to-end. It looks up the +// matching credential from the worker's in-memory cache (pushed via the init +// / config websocket message) and dispatches to the per-method executor. +// Returns a NotificationResultReport ready to send back to the control plane. +// +// Phase 1 of docs/plans/worker-notifier-mvp.md ships four supported methods +// (email / telegram / webhook / mattermost). sms and voice are explicit +// permanent failures with status=unsupported_method so the operator knows +// the worker received the task and chose not to deliver it (rather than +// silently dropping it as the production plan forbids). +// +//nolint:gocritic // task model is shared with the rest of the dispatcher; keep by-value +func (r *Runner) ExecuteNotification(ctx context.Context, task models.Task) wire.NotificationResultReport { + start := time.Now() + + report := wire.NotificationResultReport{ + JobID: task.JobID, + Status: wire.NotificationResultPermanent, + DurationMs: 0, + } + + if len(task.Payload) == 0 { + report.Status = wire.NotificationResultPermanent + report.Error = stringPtr("notification task payload is empty") + report.DurationMs = int(time.Since(start) / time.Millisecond) + return report + } + + var nt wire.NotificationTask + if err := json.Unmarshal(task.Payload, &nt); err != nil { + report.Error = stringPtr("invalid notification payload: " + err.Error()) + report.DurationMs = int(time.Since(start) / time.Millisecond) + return report + } + if nt.JobID == "" { + nt.JobID = task.JobID + } + if nt.MessageID == 0 && task.MessageID != nil { + nt.MessageID = *task.MessageID + } + report.JobID = nt.JobID + report.LeaseToken = nt.LeaseToken + report.MessageID = nt.MessageID + if task.Deadline != nil && !task.Deadline.After(time.Now()) { + report.Error = stringPtr("notification deadline expired") + report.DurationMs = int(time.Since(start) / time.Millisecond) + return report + } + + creds := r.Credentials() + if creds == nil { + report.Error = stringPtr("worker has no notification credentials pushed yet") + report.DurationMs = int(time.Since(start) / time.Millisecond) + return report + } + + status, providerResp, errStr, retry, execErr := r.deliverNotification(ctx, nt, creds) + if task.Deadline != nil && !task.Deadline.After(time.Now()) { + status, providerResp, errStr, retry, execErr = wire.NotificationResultPermanent, "", "notification deadline expired", nil, context.DeadlineExceeded + } + report.Status = status + report.ProviderResponse = stringPtrOrNil(providerResp) + report.Error = stringPtrOrNil(errStr) + if retry != nil { + report.RetryAfterSeconds = retry + } + report.DurationMs = int(time.Since(start) / time.Millisecond) + if execErr != nil { + log.Printf("worker: notification delivery job=%s message=%d method=%s status=%s error_class=delivery_failed duration_ms=%d", + nt.JobID, nt.MessageID, nt.Method, status, report.DurationMs) + } + return report +} + +// deliverNotification dispatches one notification task to the per-method +// sub-executor. Returns (status, providerResponse, error, retryAfterSeconds, +// internalError). The internalError is non-nil only for unexpected panics or +// credential-resolution failures the result report should log. +// +//nolint:gocritic // wire payload is shared; keep by-value +func (r *Runner) deliverNotification( + ctx context.Context, + nt wire.NotificationTask, + creds *wire.NotificationCredentials, +) (string, string, string, *int, error) { + switch nt.Method { + case systemContactKindEmail: + if len(creds.SMTP) == 0 { + return wire.NotificationResultPermanent, + "", "no SMTP credential authorized for this worker", nil, + errors.New("smtp: no credential") + } + cred, ok := selectSMTPCredential(creds.SMTP, nt.CredentialID) + if !ok { + return wire.NotificationResultPermanent, "", "requested SMTP credential is not authorized for this worker", + nil, errors.New("smtp: credential not found") + } + err := sender.SendEmailWithCredentialContext(ctx, + nt.Contact.Value, nt.Subject, nt.BodyText, nt.BodyHTML, &cred, + ) + return classifyResult(err, "") + + case "telegram": + if len(creds.Telegram) == 0 { + return wire.NotificationResultPermanent, "", "no Telegram credential authorized for this worker", + nil, errors.New("telegram: no credential") + } + // Convert wire.TelegramCredential -> models.NotificationCredential + // for the existing tg.SendMessageWithToken entry point. We do not + // persist this; it lives only on the goroutine stack. + cred, ok := selectTelegramCredential(creds.Telegram, nt.CredentialID) + if !ok { + return wire.NotificationResultPermanent, "", "requested Telegram credential is not authorized for this worker", + nil, errors.New("telegram: credential not found") + } + nc := wireTelegramToModel(&cred) + err := sender.SendTelegramWithCredentialContext(ctx, + nt.Contact.Value, nt.Subject, nt.BodyMarkdown, nc, + ) + return classifyResult(err, "") + + case "webhook": + signingSecret := "" + if creds.Webhook != nil { + signingSecret = creds.Webhook.SigningSecret + } + body, mErr := json.Marshal(nt) + if mErr != nil { + return wire.NotificationResultPermanent, "", "marshal webhook payload: " + mErr.Error(), nil, mErr + } + resp, err := sender.SendWebhookWithCredentialContext(ctx, body, nt.Contact.Value, signingSecret) + return classifyResult(err, stringOrEmpty(resp)) + + case "mattermost": + var mc *wire.MattermostCredential + if creds.Mattermost != nil { + mc = creds.Mattermost + } + resp, err := sender.SendMattermostWithCredentialContext(ctx, + nt.Contact.Value, nt.MessageKind, nt.Subject, nt.BodyMarkdown, mc, + ) + return classifyResult(err, stringOrEmpty(resp)) + + case "sms", "voice": + return wire.NotificationResultPermanent, "", "unsupported_method: " + nt.Method, + nil, errors.New("unsupported method: " + nt.Method) + } + + if err := ctx.Err(); err != nil { + return wire.NotificationResultRetryable, "", "context canceled: " + err.Error(), intPtr(5), err + } + return wire.NotificationResultPermanent, "", "unknown notification method: " + nt.Method, + nil, errors.New("unknown method: " + nt.Method) +} + +func selectSMTPCredential(creds []wire.SMTPCredential, id *int64) (wire.SMTPCredential, bool) { + if id != nil { + for _, cred := range creds { + if cred.ID == *id { + return cred, true + } + } + return wire.SMTPCredential{}, false + } + return creds[0], true +} + +func selectTelegramCredential(creds []wire.TelegramCredential, id *int64) (wire.TelegramCredential, bool) { + if id != nil { + for _, cred := range creds { + if cred.ID == *id { + return cred, true + } + } + return wire.TelegramCredential{}, false + } + return creds[0], true +} + +// classifyResult translates a delivery error into the wire status enum. +// SMTP 421 / 4xx with a Retry-After hint becomes retryable + suggested +// delay. Network errors become retryable with a fixed 30s backoff. Anything +// else is permanent. +func classifyResult(err error, providerResp string) (string, string, string, *int, error) { + if err == nil { + return wire.NotificationResultDelivered, providerResp, "", nil, nil + } + errStr := err.Error() + + // Heuristic: any 4xx SMTP response is retryable. The legacy sender does + // not parse this, so the worker does a substring match on the error. + if isRetryableSMTP(errStr) { + retry := 30 + return wire.NotificationResultRetryable, providerResp, errStr, &retry, err + } + // Network / DNS / TLS / context errors are typically transient. + if isTransientTransport(errStr) { + retry := 15 + return wire.NotificationResultRetryable, providerResp, errStr, &retry, err + } + // Telegram "chat not found", webhook 4xx response (non-5xx) -> permanent. + return wire.NotificationResultPermanent, providerResp, errStr, nil, err +} + +func isRetryableSMTP(s string) bool { + prefixes := []string{"smtp: 4", "421", "450", "451", "452"} + for _, p := range prefixes { + if len(s) >= len(p) && s[:len(p)] == p { + return true + } + } + return false +} + +func isTransientTransport(s string) bool { + markers := []string{"timeout", "tempor", "connection refused", "no such host", "i/o timeout", "tls"} + for _, m := range markers { + if bytes.Contains([]byte(s), []byte(m)) { + return true + } + } + return false +} + +func stringOrEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} + +func stringPtr(s string) *string { return &s } + +func stringPtrOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +func intPtr(i int) *int { return &i } + +// wireTelegramToModel translates a wire.TelegramCredential (init push shape) +// into a models.NotificationCredential (DB shape) so it can be passed to the +// existing tg.SendMessageWithToken entry point. The BotName/APIURL fields +// are preserved; the secret is the bot token. +func wireTelegramToModel(wt *wire.TelegramCredential) *models.NotificationCredential { + nc := &models.NotificationCredential{ + Kind: models.CredentialKindTelegram, + Name: wt.Name, + } + if wt.BotName != "" { + nc.BotName = &wt.BotName + } + if wt.APIURL != "" { + nc.APIURL = &wt.APIURL + } + // Store the token in SecretEnc without encryption; SendMessageWithToken + // does not read SecretEnc directly — it expects the helper to decrypt + // via GetSecret. We go around that by writing the plaintext into the + // struct field via the SetSecret path. Plain prefix lets the legacy + // decrypt path return the raw value. + nc.SetSecret(wt.Token) //nolint:errcheck // best-effort; failure becomes runtime error in SendMessageWithToken + return nc +} diff --git a/internal/distworker/notification_test.go b/internal/distworker/notification_test.go new file mode 100644 index 0000000..e5667fa --- /dev/null +++ b/internal/distworker/notification_test.go @@ -0,0 +1,233 @@ +package distworker + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// runnerWithCreds is the smallest fixture that yields a Runner with a +// credentials block already applied (no DB, no init websocket). +func runnerWithCreds(creds *wire.NotificationCredentials) *Runner { + r := NewRunner(&Config{MaxConcurrency: 4}) + r.credentialsMu.Lock() + r.credentials = creds + r.credentialsMu.Unlock() + return r +} + +// TestExecuteNotification_Email_NoCredentials verifies that the executor +// returns a permanent failure when no SMTP credential is pushed. +func TestExecuteNotification_Email_NoCredentials(t *testing.T) { + r := runnerWithCreds(nil) + report := r.ExecuteNotification(context.Background(), models.Task{ + JobID: "job-1", + Payload: []byte(`{ + "job_id":"job-1","message_id":1,"notification_id":1, + "method":"email","subject":"x","body_text":"x","body_html":"

x

", + "contact":{"id":1,"kind":"email","value":"ops@example.com","name":"ops"}, + "message_kind":"down" + }`), + }) + assert.Equal(t, wire.NotificationResultPermanent, report.Status) + assert.NotNil(t, report.Error) + assert.Equal(t, wire.NotificationResultPermanent, report.Status, "must be permanent so the producer does not loop") +} + +func TestClassifyResult_NilEmailErrorDoesNotPanic(t *testing.T) { + status, _, text, retry, err := classifyResult(nil, "") + assert.Equal(t, wire.NotificationResultDelivered, status) + assert.Empty(t, text) + assert.Nil(t, retry) + assert.NoError(t, err) +} + +// TestExecuteNotification_UnsupportedMethod covers sms/voice returning +// permanent + unsupported_method. +func TestExecuteNotification_UnsupportedMethod(t *testing.T) { + r := runnerWithCreds(&wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}}, + Telegram: []wire.TelegramCredential{{Name: "bot", Token: "123:abc"}}, + }) + for _, method := range []string{"sms", "voice"} { + t.Run(method, func(t *testing.T) { + report := r.ExecuteNotification(context.Background(), models.Task{ + JobID: "job-" + method, + Payload: []byte(`{ + "job_id":"job-` + method + `","message_id":1,"notification_id":1, + "method":"` + method + `","subject":"x","body_text":"x","body_html":"

x

", + "contact":{"id":1,"kind":"` + method + `","value":"x","name":"ops"}, + "message_kind":"down" + }`), + }) + assert.Equal(t, wire.NotificationResultPermanent, report.Status) + require.NotNil(t, report.Error) + assert.Contains(t, *report.Error, "unsupported_method") + }) + } +} + +// TestExecuteNotification_UnknownMethod classifies unknown methods as +// permanent so we never loop on bad producer output. +func TestExecuteNotification_UnknownMethod(t *testing.T) { + r := runnerWithCreds(&wire.NotificationCredentials{}) + report := r.ExecuteNotification(context.Background(), models.Task{ + JobID: "job-x", + Payload: []byte(`{ + "job_id":"job-x","message_id":1,"notification_id":1, + "method":"pigeon","subject":"x","body_text":"x","body_html":"

x

", + "contact":{"id":1,"kind":"pigeon","value":"ops@example.com","name":"ops"}, + "message_kind":"down" + }`), + }) + assert.Equal(t, wire.NotificationResultPermanent, report.Status) +} + +// TestExecuteNotification_EmptyPayloadPermanent: a malformed task must be +// rejected permanently so the operator can spot it on the admin page. +func TestExecuteNotification_EmptyPayloadPermanent(t *testing.T) { + r := runnerWithCreds(&wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}}, + }) + report := r.ExecuteNotification(context.Background(), models.Task{JobID: "job-empty"}) + assert.Equal(t, wire.NotificationResultPermanent, report.Status) + require.NotNil(t, report.Error) +} + +func TestExecuteNotification_ExpiredDeadlineDoesNotDeliver(t *testing.T) { + r := runnerWithCreds(&wire.NotificationCredentials{}) + expired := time.Now().Add(-time.Second) + report := r.ExecuteNotification(context.Background(), models.Task{JobID: "expired", Deadline: &expired, Payload: []byte(`{"job_id":"expired","message_id":1,"method":"email","contact":{"id":1,"kind":"email"}}`)}) + require.NotNil(t, report.Error) + assert.Contains(t, *report.Error, "deadline expired") + assert.Equal(t, wire.NotificationResultPermanent, report.Status) +} + +// TestExecuteNotification_DurationPositive: every report carries a non-zero +// duration_ms even when the work happens instantly. This is what the +// notification_deliveries audit row expects. +func TestExecuteNotification_DurationPositive(t *testing.T) { + r := runnerWithCreds(&wire.NotificationCredentials{}) + report := r.ExecuteNotification(context.Background(), models.Task{ + JobID: "job-d", + Payload: []byte(`{ + "job_id":"job-d","message_id":1,"notification_id":1, + "method":"sms","subject":"x","body_text":"x","body_html":"

x

", + "contact":{"id":1,"kind":"sms","value":"x","name":"ops"}, + "message_kind":"down" + }`), + }) + assert.GreaterOrEqual(t, report.DurationMs, 0) +} + +// TestClassifyResult verifies the error -> status mapping. +func TestClassifyResult(t *testing.T) { + tests := []struct { + name string + err error + wantStatus string + wantRetryAfter *int + }{ + {"nil error", nil, wire.NotificationResultDelivered, nil}, + {"permanent provider error", errors.New("550 mailbox not found"), wire.NotificationResultPermanent, nil}, + {"smtp 421 retryable", errors.New("smtp: 421 try again later"), wire.NotificationResultRetryable, intPtr(30)}, + {"smtp 452 retryable", errors.New("452 insufficient storage"), wire.NotificationResultRetryable, intPtr(30)}, + {"network timeout retryable", errors.New("dial tcp: i/o timeout"), wire.NotificationResultRetryable, intPtr(15)}, + {"tls handshake retryable", errors.New("tls: handshake failure"), wire.NotificationResultRetryable, intPtr(15)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + status, _, errStr, retry, _ := classifyResult(tc.err, "") + assert.Equal(t, tc.wantStatus, status) + if tc.err != nil { + assert.NotEmpty(t, errStr) + } + if tc.wantRetryAfter == nil { + assert.Nil(t, retry) + } else { + require.NotNil(t, retry) + assert.Equal(t, *tc.wantRetryAfter, *retry) + } + }) + } +} + +// TestWireTelegramToModel_RoundTrip sanity-checks the wire->DB credential +// translation used by the telegram branch. +func TestWireTelegramToModel_RoundTrip(t *testing.T) { + wire := &wire.TelegramCredential{ + ID: 1, + Name: "main-bot", + BotName: "rsmon_bot", + Token: "123456:ABCDEFG", + APIURL: "https://api.telegram.org", + } + nc := wireTelegramToModel(wire) + require.NotNil(t, nc) + assert.Equal(t, models.CredentialKindTelegram, nc.Kind) + assert.Equal(t, "main-bot", nc.Name) + require.NotNil(t, nc.BotName) + assert.Equal(t, "rsmon_bot", *nc.BotName) + require.NotNil(t, nc.APIURL) + assert.Equal(t, "https://api.telegram.org", *nc.APIURL) + got, err := nc.GetSecret() + require.NoError(t, err) + assert.Equal(t, "123456:ABCDEFG", got) +} + +func TestSelectNotificationCredentialByID(t *testing.T) { + id := int64(2) + smtp, ok := selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id) + require.True(t, ok) + assert.Equal(t, "second", smtp.Name) + + tg, ok := selectTelegramCredential([]wire.TelegramCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id) + require.True(t, ok) + assert.Equal(t, "second", tg.Name) + + missingID := int64(3) + _, ok = selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}}, &missingID) + assert.False(t, ok) +} + +// TestEnqueueNotification_BoundedQueue ensures the notifyQueue provides +// backpressure: the channel capacity is queueCapacity() and EnqueueNotification +// blocks once it is full. +func TestEnqueueNotification_BoundedQueue(t *testing.T) { + r := NewRunner(&Config{MaxConcurrency: 4}) + r.notifyQueue = make(chan wire.NotificationTask, 4) + + for i := 0; i < 4; i++ { + require.True(t, r.EnqueueNotification(wire.NotificationTask{JobID: "x"})) + } + // Channel is full; a non-blocking send must fail. We cannot truly verify + // the blocking case in unit tests, so we just assert the depth counter. + assert.Equal(t, int64(4), r.notifyDepth) +} + +// TestExecuteNotification_ReportsJobIDFromPayload verifies the executor +// falls back to the task's JobID when the payload has none. +func TestExecuteNotification_ReportsJobIDFromPayload(t *testing.T) { + r := runnerWithCreds(nil) + report := r.ExecuteNotification(context.Background(), models.Task{ + JobID: "outer-job", + Payload: []byte(`{ + "job_id":"","message_id":1,"notification_id":1, + "method":"sms","subject":"x","body_text":"x","body_html":"

x

", + "contact":{"id":1,"kind":"sms","value":"x","name":"ops"}, + "message_kind":"down" + }`), + }) + assert.Equal(t, "outer-job", report.JobID) +} + +// guard against time import being unused if the above compile-time helpers +// are dropped in a future refactor. +var _ = time.Second diff --git a/internal/distworker/peer.go b/internal/distworker/peer.go new file mode 100644 index 0000000..4dc26ea --- /dev/null +++ b/internal/distworker/peer.go @@ -0,0 +1,241 @@ +package distworker + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// peerStatusPath is the HTTP path the worker webapp serves at +// GET /api/peer/status. Kept on a single constant so the polling +// client and the handler can never drift. +const peerStatusPath = "/api/peer/status" + +// peerStatusTimeout bounds how long a single peer probe is allowed +// to take. Short enough that a sluggish peer does not stall the +// selfcheck loop; long enough to absorb a single TCP retransmit. +const peerStatusTimeout = 5 * time.Second + +// peerStatusMaxAge is how stale a peer's last observation is allowed +// to get before the consensus treats it as "unknown" (excluded from +// the vote). Equal to two poll intervals so a single dropped probe +// does not immediately disqualify a peer. +const peerStatusMaxAge = 2 * peerPollInterval + +// peerPollInterval drives the peer-poller cadence. Kept equal to the +// local selfcheck interval so observations stay aligned. +const peerPollInterval = 30 * time.Second + +// PeerStatus is the JSON a worker returns at GET /api/peer/status. +// The shape is stable so peer workers can pin against it without +// coordinating a schema bump. ObservedAt is the wall-clock time the +// local selfcheck produced this status; peers ignore entries older +// than peerStatusMaxAge. +type PeerStatus struct { + WorkerID string `json:"worker_id"` + Up bool `json:"up"` + ObservedAt time.Time `json:"observed_at"` +} + +// peerObservation is the per-peer entry the consensus uses. The +// selfcheck module does not reach into the wire type directly; it +// always works with the cached peerObservation so the JSON shape can +// evolve without forcing a selfcheck refactor. +type peerObservation struct { + WorkerID string + Up bool + ObservedAt time.Time + Err error // transient fetch error; nil = observation is valid +} + +// peerCache holds the latest observation per peer worker_id. The map +// is guarded by a single RWMutex because reads (consensus) vastly +// outnumber writes (one per peer per poll tick). Entries survive +// across applyInit refreshes; the peer poller overwrites the +// per-worker slot every tick. +type peerCache struct { + mu sync.RWMutex + items map[string]peerObservation +} + +func newPeerCache() *peerCache { + return &peerCache{items: map[string]peerObservation{}} +} + +// snapshot returns a defensive copy of the current observations in +// no particular order. Callers must not mutate the returned slice. +func (c *peerCache) snapshot() []peerObservation { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]peerObservation, 0, len(c.items)) + for _, v := range c.items { + out = append(out, v) + } + return out +} + +// put stores a fresh observation. The poller calls this every tick; +// the consensus reads via snapshot(). +func (c *peerCache) put(obs peerObservation) { + if c == nil { + return + } + c.mu.Lock() + c.items[obs.WorkerID] = obs + c.mu.Unlock() +} + +// resetFor replaces the cache contents with one fresh observation per +// peer in the supplied list. Used when applyInit refreshes the peer +// set so a removed peer's stale observation is dropped immediately +// rather than lingering until peerStatusMaxAge expires. +func (c *peerCache) resetFor(peers []wire.PeerInfo) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.items = make(map[string]peerObservation, len(peers)) + for i := range peers { + p := peers[i] + if p.WorkerID == "" { + continue + } + c.items[p.WorkerID] = peerObservation{WorkerID: p.WorkerID} + } +} + +// fetchPeerStatus issues a single GET /api/peer/status to the given +// peer and decodes the response. Network errors and non-2xx codes +// are returned as a populated peerObservation with Err set so the +// caller can decide whether to update the cache (we do, to mark the +// peer as "we tried"). Caller is responsible for not calling this +// concurrently for the same peer in a way that would race the +// peerCache lock; the per-peer update is serialized via the cache. +// +// peer is taken by pointer to keep the wire.PeerInfo copy off the +// hot path; the function is called once per peer per poll interval. +func fetchPeerStatus(ctx context.Context, peer *wire.PeerInfo) peerObservation { + obs := peerObservation{WorkerID: peer.WorkerID} + if peer.URL == "" { + obs.Err = fmt.Errorf("peer %s: empty url", peer.WorkerID) + return obs + } + u, err := url.Parse(peer.URL) + if err != nil || (u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS) { + obs.Err = fmt.Errorf("peer %s: bad url %q", peer.WorkerID, peer.URL) + return obs + } + endpoint := strings.TrimRight(peer.URL, "/") + peerStatusPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + obs.Err = fmt.Errorf("peer %s: build request: %w", peer.WorkerID, err) + return obs + } + if peer.Login != "" { + req.SetBasicAuth(peer.Login, peer.Password) + } + client := &http.Client{Timeout: peerStatusTimeout} + resp, err := client.Do(req) + if err != nil { + obs.Err = fmt.Errorf("peer %s: get: %w", peer.WorkerID, err) + return obs + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + obs.Err = fmt.Errorf("peer %s: status %d", peer.WorkerID, resp.StatusCode) + return obs + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024)) + if err != nil { + obs.Err = fmt.Errorf("peer %s: read body: %w", peer.WorkerID, err) + return obs + } + var status PeerStatus + if err := json.Unmarshal(body, &status); err != nil { + obs.Err = fmt.Errorf("peer %s: decode: %w", peer.WorkerID, err) + return obs + } + obs.Up = status.Up + obs.ObservedAt = status.ObservedAt + return obs +} + +// peerPollerLoop runs once per peerPollInterval and refreshes the +// cache. It exits when ctx is canceled. The loop runs each peer +// sequentially so a slow / unreachable peer does not spawn N +// concurrent goroutines that would overwhelm the local listener. +func (r *Runner) peerPollerLoop(ctx context.Context) { + ticker := time.NewTicker(peerPollInterval) + defer ticker.Stop() + // Run once shortly after startup so a worker that boots while + // the master is already down does not wait a full interval + // before the first peer observation lands. + first := time.NewTimer(2 * time.Second) + defer first.Stop() + for { + select { + case <-ctx.Done(): + return + case <-first.C: + case <-ticker.C: + } + r.pollPeersOnce(ctx) + } +} + +// pollPeersOnce fetches every cached peer's status and stores the +// results. Safe to call directly from tests to drive a deterministic +// poll cycle. +func (r *Runner) pollPeersOnce(ctx context.Context) { + peers := r.Peers() + if len(peers) == 0 { + return + } + for i := range peers { + p := peers[i] + obs := fetchPeerStatus(ctx, &p) + r.peerCache.put(obs) + if obs.Err != nil { + log.Printf("worker peer poll: %s: %v", p.WorkerID, obs.Err) + } + } +} + +// peerObservationsForConsensus returns the peer observations the +// consensus should consider. A peer whose last observation is older +// than peerStatusMaxAge is dropped from the vote (treated as +// "unknown") so a worker that lost connectivity to one peer cannot +// single-handedly decide the cluster is healthy. +func (r *Runner) peerObservationsForConsensus(now time.Time) []peerObservation { + all := r.peerCache.snapshot() + out := make([]peerObservation, 0, len(all)) + for _, obs := range all { + if obs.WorkerID == "" { + continue + } + if obs.Err != nil { + continue + } + if obs.ObservedAt.IsZero() { + continue + } + if now.Sub(obs.ObservedAt) > peerStatusMaxAge { + continue + } + out = append(out, obs) + } + return out +} diff --git a/internal/distworker/peer_test.go b/internal/distworker/peer_test.go new file mode 100644 index 0000000..c34fed7 --- /dev/null +++ b/internal/distworker/peer_test.go @@ -0,0 +1,424 @@ +package distworker + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +func TestDecideConsensus(t *testing.T) { + up := true + down := false + peersUp := []peerObservation{{WorkerID: "p1", Up: true, ObservedAt: time.Now()}} + peersDown := []peerObservation{{WorkerID: "p1", Up: false, ObservedAt: time.Now()}} + + cases := []struct { + name string + self *bool + peers []peerObservation + minVotes int + want consensusDecision + }{ + { + name: "self only up (single-node, minVotes=1)", + self: &up, + peers: nil, + minVotes: 1, + want: consensusUp, + }, + { + name: "self only down (single-node, minVotes=1)", + self: &down, + peers: nil, + minVotes: 1, + want: consensusDown, + }, + { + name: "self down + 2 peers down = majority down", + self: &down, + peers: []peerObservation{ + {WorkerID: "p1", Up: false, ObservedAt: time.Now()}, + {WorkerID: "p2", Up: false, ObservedAt: time.Now()}, + }, + minVotes: 2, + want: consensusDown, + }, + { + name: "self up + 2 peers up = majority up", + self: &up, + peers: []peerObservation{ + {WorkerID: "p1", Up: true, ObservedAt: time.Now()}, + {WorkerID: "p2", Up: true, ObservedAt: time.Now()}, + }, + minVotes: 2, + want: consensusUp, + }, + { + name: "self up + 1 up + 1 down = majority up (2 of 3)", + self: &up, + peers: []peerObservation{ + {WorkerID: "p1", Up: true, ObservedAt: time.Now()}, + {WorkerID: "p2", Up: false, ObservedAt: time.Now()}, + }, + minVotes: 2, + want: consensusUp, + }, + { + name: "self up + 1 down (1 up, 1 down) = tie (no quorum)", + self: &up, + peers: []peerObservation{ + {WorkerID: "p1", Up: false, ObservedAt: time.Now()}, + }, + minVotes: 2, + want: consensusNoQuorum, + }, + { + name: "self down + 1 up + 1 down = majority down (2 of 3)", + self: &down, + peers: []peerObservation{ + {WorkerID: "p1", Up: true, ObservedAt: time.Now()}, + {WorkerID: "p2", Up: false, ObservedAt: time.Now()}, + }, + minVotes: 2, + want: consensusDown, + }, + { + name: "self vote missing = no quorum", + self: nil, + peers: peersDown, + minVotes: 2, + want: consensusNoQuorum, + }, + { + name: "self vote missing + 1 up peer = still no quorum", + self: nil, + peers: peersUp, + minVotes: 2, + want: consensusNoQuorum, + }, + { + name: "minVotes=0 clamped to 1 (single-node self up)", + self: &up, + peers: nil, + minVotes: 0, + want: consensusUp, + }, + { + name: "minVotes<0 clamped to 1 (single-node self down)", + self: &down, + peers: nil, + minVotes: -3, + want: consensusDown, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, decideConsensus(tc.self, tc.peers, tc.minVotes)) + }) + } +} + +func TestConsensusState_FiresAfterWait(t *testing.T) { + state := &consensusState{} + now := time.Now() + + // Stamp the initial down verdict at the baseline so subsequent + // ticks measure against a known start time. + held := state.isDownConsensusHeld(consensusDown, now) + assert.False(t, held, "must not fire on the first down tick") + assert.NotNil(t, state.downSince, "downSince should be stamped on first down verdict") + + // Not yet fired even after 4m59s. + held = state.isDownConsensusHeld(consensusDown, now.Add(4*time.Minute+59*time.Second)) + assert.False(t, held, "must not fire before the wait elapses") + + // Fire after the wait elapses. + held = state.isDownConsensusHeld(consensusDown, now.Add(5*time.Minute+time.Second)) + assert.True(t, held, "must fire once the wait has elapsed") + state.markDownAlertFired() + assert.True(t, state.alertActive) + + // Subsequent down verdicts do not re-stamp downSince. + stampBefore := *state.downSince + _ = state.isDownConsensusHeld(consensusDown, now.Add(10*time.Minute)) + assert.Equal(t, stampBefore, *state.downSince, + "downSince must be set on the first observation, not re-stamped") + + // Recovery clears the down timer and the alert flag. + _ = state.isDownConsensusHeld(consensusUp, now.Add(11*time.Minute)) + assert.Nil(t, state.downSince) + assert.False(t, state.alertActive) +} + +func TestConsensusState_RecoveryRequiresHeld(t *testing.T) { + state := &consensusState{} + now := time.Now() + + // Pretend we already fired a down alert. + state.alertActive = true + t0 := now + state.downSince = &t0 + + // First up verdict stamps a fresh recovery timer; the alert + // must NOT fire yet. + assert.False(t, state.shouldFireRecovery(consensusUp, now.Add(time.Second))) + assert.Equal(t, consensusUp, state.lastVerdict) + + // Stale up verdict (verdict != up) does not advance the + // recovery timer. + assert.False(t, state.shouldFireRecovery(consensusDown, now.Add(time.Minute))) + + // Held long enough, recovery fires. + assert.True(t, state.shouldFireRecovery(consensusUp, now.Add(selfcheckConsensusWait+time.Second))) + state.markRecoveryFired() + assert.False(t, state.alertActive) + assert.Nil(t, state.downSince) +} + +func TestConsensusState_NoQuorumHoldsState(t *testing.T) { + state := &consensusState{} + now := time.Now() + + // First establish a down verdict + timer. + _ = state.isDownConsensusHeld(consensusDown, now) + stampBefore := *state.downSince + state.markDownAlertFired() + + // NoQuorum must not reset the timer; the alert stays fired + // so the next down verdict does not double-fire. + _ = state.isDownConsensusHeld(consensusNoQuorum, now.Add(2*time.Minute)) + assert.Equal(t, stampBefore, *state.downSince) + assert.True(t, state.alertActive) +} + +func TestTallyConsensus(t *testing.T) { + up, down := true, false + peers := []peerObservation{ + {Up: true}, {Up: false}, {Up: true}, + } + gotUp, gotDown, gotTotal := tallyConsensus(&up, peers) + assert.Equal(t, 3, gotUp) + assert.Equal(t, 1, gotDown) + assert.Equal(t, 4, gotTotal) + + gotUp, gotDown, gotTotal = tallyConsensus(&down, nil) + assert.Equal(t, 0, gotUp) + assert.Equal(t, 1, gotDown) + assert.Equal(t, 1, gotTotal) + + gotUp, gotDown, gotTotal = tallyConsensus(nil, peers) + assert.Equal(t, 2, gotUp) + assert.Equal(t, 1, gotDown) + assert.Equal(t, 3, gotTotal) +} + +func TestPeerCacheSnapshotAndPut(t *testing.T) { + c := newPeerCache() + c.put(peerObservation{WorkerID: "a", Up: true, ObservedAt: time.Now()}) + c.put(peerObservation{WorkerID: "b", Up: false, ObservedAt: time.Now()}) + + snap := c.snapshot() + assert.Len(t, snap, 2) +} + +func TestPeerCacheResetForDropsRemovedPeers(t *testing.T) { + c := newPeerCache() + c.put(peerObservation{WorkerID: "a", Up: true, ObservedAt: time.Now()}) + c.put(peerObservation{WorkerID: "b", Up: true, ObservedAt: time.Now()}) + + c.resetFor([]wire.PeerInfo{{WorkerID: "a"}, {WorkerID: "c"}}) + snap := c.snapshot() + ids := map[string]peerObservation{} + for _, s := range snap { + ids[s.WorkerID] = s + } + _, hasA := ids["a"] + _, hasB := ids["b"] + _, hasC := ids["c"] + assert.True(t, hasA, "peer 'a' retained") + assert.False(t, hasB, "removed peer 'b' dropped") + assert.True(t, hasC, "new peer 'c' added") +} + +func TestApplyInitStoresPeersAndResetsCache(t *testing.T) { + executor := func(interface{}) interface{} { return []wire.CheckResultReport{} } + r := newTestRunner(t, 4, 1, executor) + require.NotNil(t, r.peerCache, "runner must own a peer cache after NewRunner") + + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Concurrency: 2, + Peers: []wire.PeerInfo{ + {WorkerID: "w-2", URL: "http://127.0.0.1:27402"}, + {WorkerID: "w-3", URL: "http://127.0.0.1:27403"}, + }, + }) + got := r.Peers() + require.Len(t, got, 2) + assert.Equal(t, "w-2", got[0].WorkerID) + assert.Equal(t, "w-3", got[1].WorkerID) + + // Cache should be primed with empty observations for the new + // peers; the per-peer entries are visible via snapshot(). + snap := r.peerCache.snapshot() + assert.Len(t, snap, 2) + for _, s := range snap { + assert.True(t, s.ObservedAt.IsZero(), + "freshly-reset peer %s should have a zero observed_at", s.WorkerID) + } +} + +func TestFetchPeerStatus_DecodesUpResponse(t *testing.T) { + up := true + now := time.Now().UTC().Truncate(time.Second) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(PeerStatus{ + WorkerID: "remote", + Up: up, + ObservedAt: now, + }) + })) + defer srv.Close() + + obs := fetchPeerStatus(context.Background(), &wire.PeerInfo{ + WorkerID: "remote", + URL: srv.URL, + }) + assert.True(t, obs.Up) + assert.NoError(t, obs.Err) + assert.Equal(t, "remote", obs.WorkerID) + assert.Equal(t, now, obs.ObservedAt) +} + +func TestFetchPeerStatus_NetworkErrorIsRecorded(t *testing.T) { + obs := fetchPeerStatus(context.Background(), &wire.PeerInfo{ + WorkerID: "unreachable", + URL: "http://127.0.0.1:1", + }) + assert.Error(t, obs.Err) + assert.False(t, obs.Up) +} + +func TestFetchPeerStatus_BadURLRejected(t *testing.T) { + obs := fetchPeerStatus(context.Background(), &wire.PeerInfo{ + WorkerID: "weird", + URL: "ftp://example.com", + }) + assert.Error(t, obs.Err) +} + +func TestDecideConsensus_MultiWorkerQuorumGate(t *testing.T) { + // When the control plane has configured peers (a multi-worker + // install), a lone self vote must NOT be treated as a + // cluster-wide verdict in either direction. A second, + // independent vote is required to reach any verdict. + up := true + down := false + peersDown := []peerObservation{{WorkerID: "p1", Up: false, ObservedAt: time.Now()}} + peersUp := []peerObservation{{WorkerID: "p1", Up: true, ObservedAt: time.Now()}} + + t.Run("self down + configured peers but no fresh peer = no quorum", func(t *testing.T) { + assert.Equal(t, consensusNoQuorum, decideConsensus(&down, nil, 2), + "a lone down vote must not page when peers are configured but not reporting") + assert.Equal(t, consensusNoQuorum, decideConsensus(&up, nil, 2), + "a lone up vote must not clear an active alert when peers are configured but not reporting") + }) + + t.Run("self down + one fresh down peer = down consensus", func(t *testing.T) { + assert.Equal(t, consensusDown, decideConsensus(&down, peersDown, 2)) + }) + + t.Run("self up + one fresh up peer = up consensus", func(t *testing.T) { + assert.Equal(t, consensusUp, decideConsensus(&up, peersUp, 2)) + }) + + t.Run("self down + one fresh up peer = no quorum (1/1 split)", func(t *testing.T) { + // With two voters and one of each, neither side has a + // majority, so the verdict stays NoQuorum. This is the + // intended conservative behavior — we don't want a + // single dissenting peer to override self. + assert.Equal(t, consensusNoQuorum, decideConsensus(&down, peersUp, 2)) + }) +} + +func TestDecideConsensus_SingleNodePreserved(t *testing.T) { + // When no peers are configured (single-worker / no-peer + // deployment) the caller passes minVotes=1, which preserves + // the prior single-node behavior: a self-only vote is enough + // to drive a verdict in either direction. + up := true + down := false + + assert.Equal(t, consensusUp, decideConsensus(&up, nil, 1), + "single-node self up must still reach up consensus") + assert.Equal(t, consensusDown, decideConsensus(&down, nil, 1), + "single-node self down must still reach down consensus") +} + +func TestPeerObservationsForConsensus_DropsStale(t *testing.T) { + executor := func(interface{}) interface{} { return []wire.CheckResultReport{} } + r := newTestRunner(t, 4, 1, executor) + + now := time.Now() + fresh := peerObservation{WorkerID: "p1", Up: true, ObservedAt: now.Add(-30 * time.Second)} + stale := peerObservation{WorkerID: "p2", Up: true, ObservedAt: now.Add(-5 * time.Minute)} + errored := peerObservation{WorkerID: "p3", Up: true, Err: assert.AnError} + unknown := peerObservation{WorkerID: "p4"} + r.peerCache.put(fresh) + r.peerCache.put(stale) + r.peerCache.put(errored) + r.peerCache.put(unknown) + + got := r.peerObservationsForConsensus(now) + require.Len(t, got, 1) + assert.Equal(t, "p1", got[0].WorkerID) +} + +func TestPollPeersOnceIsConcurrencySafe(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(PeerStatus{ + WorkerID: "remote", + Up: true, + ObservedAt: time.Now().UTC(), + }) + })) + defer srv.Close() + + executor := func(interface{}) interface{} { return []wire.CheckResultReport{} } + r := newTestRunner(t, 4, 1, executor) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + URL: "http://127.0.0.1:1", // local self probe will fail, no effect on peer poll + Peers: []wire.PeerInfo{ + {WorkerID: "p1", URL: srv.URL}, + }, + }) + + // Run multiple concurrent poll cycles. The cache uses a single + // RWMutex; this verifies there is no data race under that + // access pattern. + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + r.pollPeersOnce(context.Background()) + }() + } + wg.Wait() + assert.GreaterOrEqual(t, atomic.LoadInt32(&hits), int32(1)) +} diff --git a/internal/distworker/results.go b/internal/distworker/results.go new file mode 100644 index 0000000..9124d87 --- /dev/null +++ b/internal/distworker/results.go @@ -0,0 +1,156 @@ +package distworker + +import ( + "sync" + "time" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// recentResultsSize is the in-memory ring buffer capacity for the +// last N check results produced by the worker. The webapp reads +// from this buffer for the /checks page and the recent-results +// counters on /overview. +const recentResultsSize = 200 + +// recentNotificationsSize mirrors recentResultsSize for emitted +// notifications. Phase 1 only writes selfcheck alerts to this +// buffer (the main app's notification flow still lives in the main +// app); the buffer is shape-stable so future phases can append +// without changing the page contract. +const recentNotificationsSize = 100 + +// ResultRow is one row from the worker's in-memory result ring +// buffer. Kept in the distworker package so the webapp can read it +// without going through wire (which is a payload envelope, not a +// stable render type). +type ResultRow struct { + MonitorID int64 + CheckID int64 + Kind string + Host string + State string + DurationMs int64 + Error string + At time.Time +} + +// NotificationRow is one row from the worker's notification ring +// buffer. Phase 1 only fills this from selfcheck alerts; the row +// shape is forward-compatible with main-app-issued notifications. +type NotificationRow struct { + Kind string // "email", "telegram_private", "telegram_group" + Channel string + Subject string + Body string + OK bool + Error string + At time.Time +} + +// resultBuffer is a thread-safe FIFO ring buffer of ResultRow. The +// Runner owns one; both the dispatcher goroutine and the webapp +// readers can touch it concurrently. +type resultBuffer struct { + mu sync.RWMutex + buf []ResultRow + head int + size int +} + +func newResultBuffer() *resultBuffer { + return &resultBuffer{buf: make([]ResultRow, recentResultsSize)} +} + +// add appends one entry, evicting the oldest when full. +func (b *resultBuffer) add(r *ResultRow) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + b.buf[b.head] = *r + b.head = (b.head + 1) % len(b.buf) + if b.size < len(b.buf) { + b.size++ + } +} + +// snapshot returns the most recent n rows in chronological order. +// n <= 0 returns an empty slice. +func (b *resultBuffer) snapshot(n int) []ResultRow { + return ringSnapshot(&b.mu, &b.head, &b.size, &b.buf, n) +} + +// notificationBuffer mirrors resultBuffer for NotificationRow. +type notificationBuffer struct { + mu sync.RWMutex + buf []NotificationRow + head int + size int +} + +func newNotificationBuffer() *notificationBuffer { + return ¬ificationBuffer{buf: make([]NotificationRow, recentNotificationsSize)} +} + +func (b *notificationBuffer) add(r *NotificationRow) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + b.buf[b.head] = *r + b.head = (b.head + 1) % len(b.buf) + if b.size < len(b.buf) { + b.size++ + } +} + +func (b *notificationBuffer) snapshot(n int) []NotificationRow { + return ringSnapshot(&b.mu, &b.head, &b.size, &b.buf, n) +} + +// ringSnapshot is a generic FIFO ring-buffer snapshot. The caller +// passes the mutex/head/size/buf by pointer; the lock is taken +// while reading. n <= 0 returns nil. The returned slice is a copy +// so callers can hand it to non-locking code paths (e.g. the +// webapp's templates) without holding the buffer lock. +func ringSnapshot[T any](mu *sync.RWMutex, head, size *int, buf *[]T, n int) []T { + if mu == nil || n <= 0 { + return nil + } + mu.RLock() + defer mu.RUnlock() + if *size == 0 { + return nil + } + if n > *size { + n = *size + } + out := make([]T, 0, n) + start := (*head - n + len(*buf)) % len(*buf) + for i := 0; i < n; i++ { + idx := (start + i) % len(*buf) + out = append(out, (*buf)[idx]) + } + return out +} + +// resultRowFromReport converts a wire.CheckResultReport into the +// internal ResultRow type the webapp renders. +func resultRowFromReport(env *resultEnvelope, report *wire.CheckResultReport, at time.Time) *ResultRow { + row := &ResultRow{ + MonitorID: report.MonitorID, + CheckID: report.CheckID, + Kind: env.job.Kind, + Host: env.job.Host, + State: report.State, + DurationMs: report.DurationMs, + At: at, + } + if report.Error != nil { + row.Error = *report.Error + } + return row +} diff --git a/internal/distworker/results_test.go b/internal/distworker/results_test.go new file mode 100644 index 0000000..acf8a14 --- /dev/null +++ b/internal/distworker/results_test.go @@ -0,0 +1,141 @@ +package distworker + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +func TestResultBufferSnapshotEmpty(t *testing.T) { + b := newResultBuffer() + assert.Nil(t, b.snapshot(0)) + assert.Nil(t, b.snapshot(10)) +} + +func TestResultBufferAppendAndSnapshot(t *testing.T) { + b := newResultBuffer() + for i := 0; i < 12; i++ { + b.add(&ResultRow{MonitorID: int64(i), State: "OK"}) + } + got := b.snapshot(20) + // Buffer is fixed-size so we cap at recentResultsSize; here we + // added only 12 entries so the buffer still holds all of them. + require.Len(t, got, 12) + assert.Equal(t, int64(0), got[0].MonitorID) + assert.Equal(t, int64(11), got[11].MonitorID) +} + +func TestResultBufferEvictsOldest(t *testing.T) { + b := newResultBuffer() + total := recentResultsSize + 5 + for i := 0; i < total; i++ { + b.add(&ResultRow{MonitorID: int64(i)}) + } + got := b.snapshot(recentResultsSize) + require.Len(t, got, recentResultsSize) + // The first surviving entry is i=5 (the (N+1)-th add). + assert.Equal(t, int64(5), got[0].MonitorID) + assert.Equal(t, int64(total-1), got[recentResultsSize-1].MonitorID) +} + +func TestResultBufferConcurrentAccess(t *testing.T) { + b := newResultBuffer() + var wg sync.WaitGroup + for w := 0; w < 4; w++ { + wg.Add(1) + go func(off int) { + defer wg.Done() + for i := 0; i < 100; i++ { + b.add(&ResultRow{MonitorID: int64(off*100 + i)}) + } + }(w) + } + for w := 0; w < 4; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + _ = b.snapshot(10) + } + }() + } + wg.Wait() + got := b.snapshot(recentResultsSize) + assert.NotNil(t, got) +} + +func TestNotificationBufferAppendAndSnapshot(t *testing.T) { + b := newNotificationBuffer() + for i := 0; i < 3; i++ { + b.add(&NotificationRow{Subject: "s" + itoaForTest(i), At: time.Now()}) + } + got := b.snapshot(10) + require.Len(t, got, 3) + assert.Equal(t, "s0", got[0].Subject) +} + +func TestRunnerRecentResultsBeforeStart(t *testing.T) { + r := NewRunner(&Config{MaxConcurrency: 2}) + assert.Nil(t, r.RecentResults(5), + "RecentResults must be safe before Start") +} + +func TestRunnerRecentNotificationsBeforeStart(t *testing.T) { + r := NewRunner(&Config{MaxConcurrency: 2}) + assert.Nil(t, r.RecentNotifications(5)) +} + +func TestRunnerRecordNotification(t *testing.T) { + r := NewRunner(&Config{MaxConcurrency: 2}) + r.RecordNotification(&NotificationRow{Subject: "x", OK: true}) + r.RecordNotification(&NotificationRow{Subject: "y", OK: false, Error: "boom"}) + got := r.RecentNotifications(10) + require.Len(t, got, 2) + assert.Equal(t, "x", got[0].Subject) + assert.True(t, got[0].OK) + assert.False(t, got[1].OK) + assert.Equal(t, "boom", got[1].Error) +} + +func TestRunnerIdentityFieldsFromInit(t *testing.T) { + executor := func(interface{}) interface{} { return []wire.CheckResultReport{} } + r := newTestRunner(t, 4, 1, executor) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + RegionCode: "eu", + Version: "v1.2.3", + Capabilities: []string{"http", "ssl"}, + }) + assert.Equal(t, "w-1", r.WorkerID()) + assert.Equal(t, "eu", r.RegionCode()) + assert.Equal(t, "v1.2.3", r.WorkerVersion()) + assert.Equal(t, []string{"http", "ssl"}, r.WorkerCapabilities()) +} + +func TestRunnerTokenAccessors(t *testing.T) { + r := NewRunner(&Config{MaxConcurrency: 1, Token: "tok-1"}) + assert.Equal(t, "tok-1", r.Token()) + assert.True(t, r.TokenRotatedAt().IsZero(), "no rotation yet") + r.tokenRotatedMu.Lock() + r.tokenRotatedAt = time.Now().UTC() + r.tokenRotatedMu.Unlock() + assert.False(t, r.TokenRotatedAt().IsZero()) +} + +func itoaForTest(n int) string { + if n == 0 { + return "0" + } + const d = "0123456789" + out := "" + for n > 0 { + out = string(d[n%10]) + out + n /= 10 + } + return out +} diff --git a/internal/distworker/runner.go b/internal/distworker/runner.go new file mode 100644 index 0000000..7221f10 --- /dev/null +++ b/internal/distworker/runner.go @@ -0,0 +1,1030 @@ +package distworker + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/Jeffail/tunny" + "github.com/gorilla/websocket" + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/checkexec" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +const ( + heartbeatInterval = 10 * time.Second + + // minQueueCapacity is the lower bound for the bounded job/result + // channels so that a small pool still has some backpressure headroom. + minQueueCapacity = 16 +) + +// jobPool is the minimal interface the Runner needs from a worker pool. It +// exists so tests can observe SetSize calls without wrapping the tunny +// concrete type. +type jobPool interface { + Process(payload interface{}) interface{} + SetSize(n int) + Close() +} + +// resultEnvelope pairs a job with its reports so the writer can include +// context (e.g., the check kind) when logging. +type resultEnvelope struct { + job wire.CheckJob + reports []wire.CheckResultReport +} + +// Runner manages the worker execution loop. +// +// Concurrency design: +// +// - jobQueue and results are bounded channels sized relative to +// maxConcurrency. The websocket read loop enqueues into jobQueue, +// providing backpressure to the control plane when the worker is +// saturated. +// - A fixed number of dispatcher goroutines (maxConcurrency) drain the +// jobQueue and call pool.Process. The number of in-flight executions +// is bounded by the tunny.Pool size, which the control plane +// configures via the "init" / "config" message (clamped to +// maxConcurrency). +// - Each websocket connection owns a single writer goroutine that +// serializes result and heartbeat writes through a mutex. This keeps +// websocket writes thread-safe and removes the per-task goroutine +// that previously blocked pool execution while holding the mutex. +type Runner struct { + config *Config + client *Client + pool jobPool + concurrency int64 + maxConcurrency int + jobQueue chan wire.CheckJob + results chan resultEnvelope + notifyQueue chan wire.NotificationTask + notifyResults chan notifyResultEnvelope + metricResults chan wire.ServerMetricReport + stopCh chan struct{} + wg sync.WaitGroup + started atomic.Bool + queueDepth int64 + activeCount int64 + notifyDepth int64 + notifyActive int64 + + // credentialsMu guards credentials during init/config refresh. + credentialsMu sync.RWMutex + credentials *wire.NotificationCredentials + + // systemContactsMu guards system contacts pushed via init/config. + // Workers notify these contacts directly when the main API is + // unreachable (see docs/distributed/notifications-from-worker.md + // "System Selfcheck"). + systemContactsMu sync.RWMutex + systemContacts []wire.SystemContact + + // urlMu guards the worker URL pushed via init/config refresh. The + // URL is what other workers and the main app dial to reach this + // worker (it can differ from the bind host:port because of reverse + // proxies / Traefik with HTTPS). See docs/worker-http-settings.md. + urlMu sync.RWMutex + url string + + // peersMu guards the peer list pushed via init/config. The + // control plane builds the list from worker_nodes (excluding + // this worker) and refreshes it on every 5m config push; see + // docs/distributed/worker-to-worker-raft.md §10 for the + // intended use. The selfcheck module reads the list to drive + // the peer poller and the consensus. + peersMu sync.RWMutex + peers []wire.PeerInfo + + // peerCache stores the latest observation per peer. Written by + // the peer poller (every peerPollInterval) and read by the + // selfcheck consensus helper. Constructed in NewRunner so tests + // can drive it without Start(). + peerCache *peerCache + + // masterStatusMu guards the local "master API up/down" snapshot + // that the selfcheck writes after each probe and the + // /api/peer/status handler reads. observedAt is the wall-clock + // time of the most recent local probe; up is the verdict of + // that probe. The zero time means "no probe has run yet". + masterStatusMu sync.RWMutex + masterStatusUp *bool + masterStatusAt time.Time + + // selfcheckCancel terminates the periodic selfcheck goroutine started + // by Start(). Nil until Start runs. + selfcheckCancel context.CancelFunc + + // executor is the function the pool runs for each job. It is a + // field so tests can swap it for a deterministic stub without + // touching the websocket plumbing. + executor func(payload interface{}) interface{} + + // resultsBuf holds the most recent result rows. The webapp + // reads from it via RecentResults(n). Cleared by Stop so the + // ring does not leak between worker runs. + resultsBuf *resultBuffer + + // notificationsBuf mirrors resultsBuf for emitted + // notifications. Phase 1 only fills this from selfcheck alerts. + notificationsBuf *notificationBuffer + + // lastHeartbeatAt tracks the most recent successful heartbeat + // write, so the webapp can render "last ack" without polling. + lastHeartbeatMu sync.RWMutex + lastHeartbeatAt time.Time + + // tokenRotatedAt records the wall-clock time of the last token + // rotation, so the settings page can render "last rotated". + tokenRotatedMu sync.RWMutex + tokenRotatedAt time.Time + + // workerID / regionCode / workerVersion / workerCaps are + // captured from the most recent init/config websocket message + // so the webapp can render them read-only. + workerIDMu sync.RWMutex + workerID string + regionMu sync.RWMutex + regionCode string + versionMu sync.RWMutex + workerVersion string + capsMu sync.RWMutex + workerCaps []string + serverID atomic.Int64 + + // clientMu guards swap of the http/websocket client during + // token rotation. The websocket loop reads r.client under the + // lock; RotateToken swaps a fresh client in under the lock + // before closing the old connection. + clientMu sync.Mutex + + // closeOnce guards Close against double-close on the websocket + // from RotateToken. Phase 1 has a single websocket; RotateToken + // closes it so the reconnect loop picks up the new token. + closeOnce sync.Once +} + +// NewRunner creates a new worker runner. Config is taken by pointer to +// keep the parameter cheap as the struct grows (it now carries the +// HTTP listener settings on top of the control-plane connection +// fields). +func NewRunner(cfg *Config) *Runner { + maxConc := cfg.MaxConcurrency + if maxConc <= 0 { + maxConc = DefaultMaxConcurrency + } + return &Runner{ + config: cfg, + maxConcurrency: maxConc, + stopCh: make(chan struct{}), + resultsBuf: newResultBuffer(), + notificationsBuf: newNotificationBuffer(), + peerCache: newPeerCache(), + } +} + +// Start begins the worker execution +func (r *Runner) Start() error { + log.Println("worker: starting...") + + if r.config.URL == "" || r.config.Token == "" { + return fmt.Errorf("RSMON_URL and RSMON_TOKEN must be set") + } + + if !r.started.CompareAndSwap(false, true) { + return fmt.Errorf("worker: runner already started") + } + + r.client = NewClient(r.config.URL, r.config.Token) + + queueCap := r.queueCapacity() + r.jobQueue = make(chan wire.CheckJob, queueCap) + r.results = make(chan resultEnvelope, queueCap) + r.notifyQueue = make(chan wire.NotificationTask, queueCap) + r.notifyResults = make(chan notifyResultEnvelope, queueCap) + r.metricResults = make(chan wire.ServerMetricReport, queueCap) + + if r.executor == nil { + r.executor = r.defaultExecuteJob + } + atomic.StoreInt64(&r.concurrency, 1) + r.pool = tunny.NewFunc(int(atomic.LoadInt64(&r.concurrency)), r.executor) + + // Start a fixed pool of dispatchers. The tunny.Pool size ultimately + // limits the number of concurrent job executions; extra dispatchers + // simply wait inside pool.Process when the pool is saturated. + for i := 0; i < r.maxConcurrency; i++ { + r.wg.Add(1) + go r.dispatcher() + } + + // Notification dispatchers share the pool's overall concurrency budget: + // we run maxConcurrency notification dispatchers and let the runner's + // NotificationMethods capability gate keep them quiet when the worker + // is not authorized for the method. + for i := 0; i < r.maxConcurrency; i++ { + r.wg.Add(1) + go r.notifyDispatcher() + } + + // Start websocket task loop + go r.websocketLoop() + go r.serverMetricLoop() + + // Start periodic selfcheck loop. This probes the main API and, on + // sustained unreachability, notifies system contacts directly via + // the cached credentials. Lifetimes of selfcheck goroutines are + // bound to stopCh (and the explicit cancel, kept for symmetry). + selfcheckCtx, selfcheckCancel := context.WithCancel(context.Background()) + r.selfcheckCancel = selfcheckCancel + go r.startSelfcheck(selfcheckCtx) + + // Start the peer poller. It refreshes r.peerCache with each + // peer's latest /api/peer/status verdict. The selfcheck + // consumes the cache to drive consensus. The poller is bound + // to selfcheckCtx so it shuts down together with the + // selfcheck loop on Stop. + go r.peerPollerLoop(selfcheckCtx) + + // Wait for stop signal + <-r.stopCh + log.Println("worker: shutting down...") + if r.selfcheckCancel != nil { + r.selfcheckCancel() + } + + // Dispatchers exit via stopCh. Do not close jobQueue here: the websocket + // reader can still be unwinding and may otherwise race with a send. + r.wg.Wait() + + // Pool is safe to close only after all dispatchers have returned, + // otherwise an in-flight pool.Process would panic. + r.pool.Close() + + // Close results so any future writers exit promptly. (At this point + // the websocket connection is also gone, so this is just defensive.) + close(r.results) + + return nil +} + +// Stop gracefully stops the worker +func (r *Runner) Stop() { + select { + case <-r.stopCh: + // already closed + default: + close(r.stopCh) + } +} + +// Enqueue submits a job to the worker pool. It returns false if the runner +// has been stopped. The call blocks while the bounded jobQueue is full, +// providing natural backpressure to the caller. +func (r *Runner) Enqueue(job wire.CheckJob) bool { //nolint:lll,gocritic // wire.CheckJob is ~104B; keep by-value to avoid forcing callers to take an address + if r.jobQueue == nil { + return false + } + select { + case r.jobQueue <- job: + atomic.AddInt64(&r.queueDepth, 1) + return true + case <-r.stopCh: + return false + } +} + +// Concurrency returns the current tunny.Pool size. +func (r *Runner) Concurrency() int { + return int(atomic.LoadInt64(&r.concurrency)) +} + +// EnqueueNotification submits a notification task to the worker pool. It +// returns false if the runner has been stopped. The call blocks while the +// bounded notifyQueue is full, providing backpressure to the control plane. +// +//nolint:gocritic // wire payload is shared with the dispatcher; keep by-value +func (r *Runner) EnqueueNotification(task wire.NotificationTask) bool { + if r.notifyQueue == nil { + return false + } + select { + case r.notifyQueue <- task: + atomic.AddInt64(&r.notifyDepth, 1) + return true + case <-r.stopCh: + return false + } +} + +// notifyDispatcher is the per-notification-task execution loop. It mirrors +// dispatcher() but routes to ExecuteNotification + the notification_result +// writer rather than checkexec + check_result_report. The same +// maxConcurrency budget caps total in-flight work across both kinds. +func (r *Runner) notifyDispatcher() { + defer r.wg.Done() + for { + select { + case task := <-r.notifyQueue: + atomic.AddInt64(&r.notifyDepth, -1) + atomic.AddInt64(&r.notifyActive, 1) + r.executeAndForwardNotification(task) + atomic.AddInt64(&r.notifyActive, -1) + case <-r.stopCh: + return + } + } +} + +// executeAndForwardNotification converts the wire NotificationTask to a Task +// shape, runs the executor, and pushes the result into notifyResults. The +// writer goroutine picks it up and serializes the websocket write. +func (r *Runner) executeAndForwardNotification(task wire.NotificationTask) { //nolint:gocritic // wire payload is shared + if r.notifyResults == nil { + return + } + deadline := time.Now().Add(models.DefaultNotificationExecutionTimeout) + if task.Deadline != nil { + if taskDeadline, err := time.Parse(time.RFC3339Nano, *task.Deadline); err == nil && taskDeadline.Before(deadline) { + deadline = taskDeadline + } + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + payload, _ := json.Marshal(task) + dbTask := models.Task{JobID: task.JobID, LeaseToken: task.LeaseToken, Payload: payload} + if task.Deadline != nil { + if deadline, err := time.Parse(time.RFC3339Nano, *task.Deadline); err == nil { + dbTask.Deadline = &deadline + } + } + if task.MessageID != 0 { + msgID := task.MessageID + dbTask.MessageID = &msgID + } + if len(task.EventIDs) > 0 { + ev := task.EventIDs[0] + _ = ev + } + report := r.ExecuteNotification(ctx, dbTask) + env := notifyResultEnvelope{task: task, report: report} + + select { + case <-r.stopCh: + return + default: + } + select { + case r.notifyResults <- env: + case <-r.stopCh: + } +} + +// MaxConcurrency returns the upper bound for pool size and dispatchers. +func (r *Runner) MaxConcurrency() int { + return r.maxConcurrency +} + +// QueueCapacity returns the bounded buffer size used for jobQueue and +// results. Exposed for tests and observability. +func (r *Runner) QueueCapacity() int { + return r.queueCapacity() +} + +// ActiveCount returns only running check executions. QueueDepth reports the +// disjoint pending-check count used with it in heartbeat capacity accounting. +func (r *Runner) ActiveCount() int { + return int(atomic.LoadInt64(&r.activeCount)) +} + +// QueueDepth returns the current number of pending jobs in jobQueue. +func (r *Runner) QueueDepth() int { + return int(atomic.LoadInt64(&r.queueDepth)) +} + +// ActiveNotifications returns the number of queued and in-flight deliveries. +func (r *Runner) ActiveNotifications() int { + return int(atomic.LoadInt64(&r.notifyDepth) + atomic.LoadInt64(&r.notifyActive)) +} + +// NotificationQueueDepth returns deliveries waiting for a notification worker. +func (r *Runner) NotificationQueueDepth() int { + return int(atomic.LoadInt64(&r.notifyDepth)) +} + +// queueCapacity returns the bounded buffer size for the job/result +// channels. The capacity is derived from maxConcurrency so that +// backpressure scales with the configured pool. +func (r *Runner) queueCapacity() int { + qcap := 2 * r.maxConcurrency + if qcap < minQueueCapacity { + qcap = minQueueCapacity + } + return qcap +} + +func (r *Runner) dispatcher() { + defer r.wg.Done() + for { + select { + case job := <-r.jobQueue: + atomic.AddInt64(&r.queueDepth, -1) + atomic.AddInt64(&r.activeCount, 1) + r.executeAndForward(job) + atomic.AddInt64(&r.activeCount, -1) + case <-r.stopCh: + return + } + } +} + +func (r *Runner) executeAndForward(job wire.CheckJob) { //nolint:lll,gocritic // see Enqueue; CheckJob is forwarded into tunny.Pool as interface{} + result := r.pool.Process(job) + reports, ok := result.([]wire.CheckResultReport) + if !ok || len(reports) == 0 { + return + } + env := resultEnvelope{job: job, reports: reports} + // Prefer an early exit when the runner is stopping so we never + // block on a full results channel. + select { + case <-r.stopCh: + return + default: + } + select { + case r.results <- env: + case <-r.stopCh: + } +} + +func (r *Runner) websocketLoop() { + for { + select { + case <-r.stopCh: + return + default: + } + + if err := r.runWebsocket(); err != nil { + log.Println("worker: websocket error:", err) + } + + select { + case <-time.After(3 * time.Second): + case <-r.stopCh: + return + } + } +} + +func (r *Runner) runWebsocket() error { + conn, err := r.client.WorkerSocket() + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + log.Println("worker: websocket connected") + + var writeMu sync.Mutex + done := make(chan struct{}) + + // Heartbeat goroutine — shares writeMu with the writer. + go r.heartbeat(conn, &writeMu, done) + + // Single writer goroutine for this connection: serializes result + // and heartbeat writes through writeMu so websocket.WriteJSON is + // never called concurrently. The dispatcher loop feeds it via the + // bounded results channel. + go r.writer(conn, &writeMu, done) + + for { + var msg wire.WorkerMessage + if err := conn.ReadJSON(&msg); err != nil { + close(done) + return err + } + if (msg.Kind == "init" || msg.Kind == "config") && msg.Init != nil { + r.applyInit(msg.Init) + continue + } + if msg.Kind != "task" { + continue + } + if !r.enqueueTaskMessage(msg) { + close(done) + return nil + } + } +} + +// enqueueTaskMessage prefers the v2 envelope over sibling legacy fields. Some +// rollout frames contain both check representations; executing the first match +// only keeps a current runner from running one check twice. +func (r *Runner) enqueueTaskMessage(msg wire.WorkerMessage) bool { //nolint:gocritic // wire envelope is the dispatcher boundary + switch { + case msg.TaskEnvelope != nil && msg.TaskEnvelope.Type == wire.TaskTypeNotification && msg.TaskEnvelope.Notify != nil: + return r.EnqueueNotification(*msg.TaskEnvelope.Notify) + case msg.TaskEnvelope != nil && msg.TaskEnvelope.Type == wire.TaskTypeCheck && msg.TaskEnvelope.Job != nil: + return r.Enqueue(*msg.TaskEnvelope.Job) + case msg.NotificationTask != nil: + log.Printf("worker: received websocket notification task %s method=%s", msg.NotificationTask.JobID, msg.NotificationTask.Method) + return r.EnqueueNotification(*msg.NotificationTask) + case msg.Task != nil: + log.Printf("worker: received websocket task %s", msg.Task.JobID) + return r.Enqueue(*msg.Task) + default: + return true + } +} + +func (r *Runner) heartbeat(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan struct{}) { + ticker := time.NewTicker(heartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + writeMu.Lock() + err := conn.WriteJSON(wire.WorkerMessage{ + Kind: "heartbeat", + Heartbeat: &wire.HeartbeatRequest{ + ActiveChecks: r.ActiveCount(), + QueueDepth: r.QueueDepth(), + ActiveNotifications: r.ActiveNotifications(), + NotificationQueueDepth: r.NotificationQueueDepth(), + }, + }) + writeMu.Unlock() + if err != nil { + return + } + r.touchHeartbeat(time.Now().UTC()) + case <-done: + return + case <-r.stopCh: + return + } + } +} + +// touchHeartbeat records the most recent successful heartbeat write. +// Called from the heartbeat goroutine; the webapp reads it via +// LastHeartbeatAck. +func (r *Runner) touchHeartbeat(at time.Time) { + if r == nil { + return + } + r.lastHeartbeatMu.Lock() + r.lastHeartbeatAt = at + r.lastHeartbeatMu.Unlock() +} + +// LastHeartbeatAck returns the wall-clock time of the most recent +// successful heartbeat. Returns the zero time if no heartbeat has +// been written yet. +func (r *Runner) LastHeartbeatAck() time.Time { + if r == nil { + return time.Time{} + } + r.lastHeartbeatMu.RLock() + defer r.lastHeartbeatMu.RUnlock() + return r.lastHeartbeatAt +} + +func (r *Runner) writer(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan struct{}) { + for { + select { + case env, ok := <-r.results: + if !ok { + return + } + writeMu.Lock() + for i := range env.reports { + report := &env.reports[i] + report.LeaseToken = env.job.LeaseToken + if err := conn.WriteJSON(wire.WorkerMessage{Kind: "result", Result: report}); err != nil { + log.Printf( + "worker: failed to report result job=%s check=%d kind=%s state=%s: %v", + report.JobID, report.CheckID, env.job.Kind, report.State, err, + ) + writeMu.Unlock() + return + } + log.Printf( + "worker: completed job=%s check=%d kind=%s state=%s reported=true", + report.JobID, report.CheckID, env.job.Kind, report.State, + ) + r.resultsBuf.add(resultRowFromReport(&env, report, time.Now().UTC())) + } + writeMu.Unlock() + case env, ok := <-r.notifyResults: + if !ok { + return + } + writeMu.Lock() + if err := conn.WriteJSON(wire.WorkerMessage{Kind: "result", NotificationResult: &env.report}); err != nil { + log.Printf( + "worker: failed to report notification result job=%s method=%s status=%s: %v", + env.report.JobID, env.task.Method, env.report.Status, err, + ) + writeMu.Unlock() + return + } + log.Printf( + "worker: completed notification job=%s method=%s status=%s message=%d duration_ms=%d", + env.report.JobID, env.task.Method, env.report.Status, env.task.MessageID, env.report.DurationMs, + ) + writeMu.Unlock() + case report := <-r.metricResults: + writeMu.Lock() + if err := conn.WriteJSON(wire.WorkerMessage{Kind: "result", ServerMetric: &report}); err != nil { + writeMu.Unlock() + return + } + writeMu.Unlock() + case <-done: + return + case <-r.stopCh: + return + } + } +} + +func (r *Runner) applyInit(init *wire.WorkerInit) { + if init.Concurrency > 0 && init.Concurrency != r.Concurrency() { + size := init.Concurrency + if size > r.maxConcurrency { + size = r.maxConcurrency + } + if size < 1 { + size = 1 + } + atomic.StoreInt64(&r.concurrency, int64(size)) + r.pool.SetSize(size) + } + if len(init.LLMs) > 0 { + llm := init.LLMs[0] + setEnvIfNotEmpty("LLM_URL", llm.URL) + setEnvIfNotEmpty("LLM_MODEL", llm.Model) + setEnvIfNotEmpty("LLM_APIKEY", llm.APIKey) + setEnvIfNotEmpty("LLM_KIND", llm.Kind) + } + + r.credentialsMu.Lock() + r.credentials = init.Credentials + r.credentialsMu.Unlock() + + r.systemContactsMu.Lock() + r.systemContacts = init.SystemContacts + r.systemContactsMu.Unlock() + + r.urlMu.Lock() + r.url = init.URL + r.urlMu.Unlock() + + // Peers is the slice of other workers this node can reach for + // cross-worker confirmation. Refresh the cache so a removed + // peer's stale observation is dropped immediately rather than + // lingering until peerStatusMaxAge. + r.peersMu.Lock() + r.peers = append([]wire.PeerInfo(nil), init.Peers...) + r.peersMu.Unlock() + if r.peerCache != nil { + r.peerCache.resetFor(init.Peers) + } + + r.workerIDMu.Lock() + r.workerID = init.WorkerID + r.workerIDMu.Unlock() + + r.regionMu.Lock() + r.regionCode = init.RegionCode + r.regionMu.Unlock() + + r.versionMu.Lock() + r.workerVersion = init.Version + r.versionMu.Unlock() + + r.capsMu.Lock() + r.workerCaps = append([]string(nil), init.Capabilities...) + r.capsMu.Unlock() + if init.ServerID == nil { + r.serverID.Store(0) + } else { + r.serverID.Store(*init.ServerID) + } + + smtpCount, tgCount := 0, 0 + if init.Credentials != nil { + smtpCount = len(init.Credentials.SMTP) + tgCount = len(init.Credentials.Telegram) + } + log.Printf( + "worker: %s received worker_id=%s region=%s version=%s capabilities=%v "+ + "concurrency=%d llms=%d credentials_smtp=%d credentials_telegram=%d system_contacts=%d peers=%d", + "config", init.WorkerID, init.RegionCode, init.Version, init.Capabilities, + init.Concurrency, len(init.LLMs), smtpCount, tgCount, len(init.SystemContacts), len(init.Peers), + ) +} + +// Credentials returns a snapshot of the current notification credentials. +// Safe for concurrent use. Returns nil if no credentials have been pushed. +func (r *Runner) Credentials() *wire.NotificationCredentials { + r.credentialsMu.RLock() + defer r.credentialsMu.RUnlock() + return r.credentials +} + +// SystemContacts returns a snapshot of the cached system contacts. +// Safe for concurrent use. +func (r *Runner) SystemContacts() []wire.SystemContact { + r.systemContactsMu.RLock() + defer r.systemContactsMu.RUnlock() + out := make([]wire.SystemContact, len(r.systemContacts)) + copy(out, r.systemContacts) + return out +} + +// URL returns the publicly-advertised URL the main app and peer workers +// should dial to reach this worker. It is set from the init/config +// websocket message and may be empty if the main app did not push one +// (e.g. legacy worker). Safe for concurrent use. +func (r *Runner) URL() string { + r.urlMu.RLock() + defer r.urlMu.RUnlock() + return r.url +} + +// Peers returns a snapshot of the cached peer list pushed by the +// control plane via WorkerInit.Peers. The current worker is excluded +// upstream so a worker never dials itself. Returns nil when no init +// has landed or when the control plane ships an empty list (e.g. a +// single-worker install); callers must handle that case explicitly so +// they fall back to the single-node selfcheck verdict. Safe for +// concurrent use. +func (r *Runner) Peers() []wire.PeerInfo { + r.peersMu.RLock() + defer r.peersMu.RUnlock() + if len(r.peers) == 0 { + return nil + } + out := make([]wire.PeerInfo, len(r.peers)) + copy(out, r.peers) + return out +} + +// SetMasterStatus records the most recent local selfcheck verdict. +// Called by the selfcheck loop after every probe so the +// /api/peer/status HTTP handler can answer with the same value the +// consensus uses. Passing up == nil resets the snapshot to "unknown" +// (no probe yet) and is used by tests. +func (r *Runner) SetMasterStatus(up *bool, observedAt time.Time) { + if r == nil { + return + } + r.masterStatusMu.Lock() + r.masterStatusUp = up + r.masterStatusAt = observedAt + r.masterStatusMu.Unlock() +} + +// MasterStatus returns the most recent local selfcheck verdict and +// the wall-clock time it was produced. up == nil means the local +// selfcheck has not produced a verdict yet (very first tick or the +// runner has not been started). Safe for concurrent use. +func (r *Runner) MasterStatus() (up *bool, observedAt time.Time) { + if r == nil { + return nil, time.Time{} + } + r.masterStatusMu.RLock() + defer r.masterStatusMu.RUnlock() + return r.masterStatusUp, r.masterStatusAt +} + +func setEnvIfNotEmpty(key, value string) { + if value != "" { + _ = os.Setenv(key, value) + } +} + +// defaultExecuteJob executes a single check job. +func (r *Runner) defaultExecuteJob(payload interface{}) interface{} { + job := payload.(wire.CheckJob) + + log.Printf("worker: executing job %s (check %d, kind %s)", job.JobID, job.CheckID, job.Kind) + + monitor := &models.Monitor{Host: job.Host} + monitor.ID = job.MonitorID + + check := models.Check{ + Kind: job.Kind, + URL: job.URL, + Settings: datatypes.JSON(job.Settings), + } + check.ID = job.CheckID + check.MonitorID = job.MonitorID + + results := checkexec.Execute(monitor, []models.Check{check}) + + if len(results) == 0 { + log.Printf("worker: no results for job %s", job.JobID) + return []wire.CheckResultReport{} + } + + reports := make([]wire.CheckResultReport, 0, len(results)) + for _, result := range results { + report := wire.CheckResultReport{ + JobID: job.JobID, + CheckID: job.CheckID, + MonitorID: job.MonitorID, + State: result.Result.State, + DurationMs: result.Result.Duration.Milliseconds(), + Warnings: result.Result.Warnings, + Infos: result.Result.Infos, + Metrics: result.Metrics, + } + + if result.Result.Error != nil { + errStr := result.Result.Error.Error() + report.Error = &errStr + } + + if result.Result.Expires != nil { + exp := result.Result.Expires.Format(time.RFC3339) + report.ExpiresAt = &exp + } + + reports = append(reports, report) + } + + return reports +} + +// RecentResults returns the most recent n result rows produced by +// this worker, in chronological order. n <= 0 returns an empty slice. +// Safe to call before Start (returns nil). +func (r *Runner) RecentResults(n int) []ResultRow { + if r == nil || r.resultsBuf == nil { + return nil + } + return r.resultsBuf.snapshot(n) +} + +// RecentNotifications returns the most recent n notification rows. +// Phase 1 only fills this buffer from selfcheck alerts via +// RecordNotification; the main-app-issued notifications still live +// in the main app's database. +func (r *Runner) RecentNotifications(n int) []NotificationRow { + if r == nil || r.notificationsBuf == nil { + return nil + } + return r.notificationsBuf.snapshot(n) +} + +// RecordNotification appends one row to the notification ring buffer. +// Called from selfcheck.sendSystemAlert so the webapp /notifications +// page can show what the worker emitted. Safe before Start. +func (r *Runner) RecordNotification(n *NotificationRow) { + if r == nil || r.notificationsBuf == nil { + return + } + if n.At.IsZero() { + n.At = time.Now().UTC() + } + r.notificationsBuf.add(n) +} + +// Token returns the current bearer token. The webapp settings page +// masks this for display. +func (r *Runner) Token() string { + if r == nil || r.config == nil { + return "" + } + return r.config.Token +} + +// TokenRotatedAt returns the wall-clock time of the most recent +// successful token rotation. Zero before the first rotation. +func (r *Runner) TokenRotatedAt() time.Time { + if r == nil { + return time.Time{} + } + r.tokenRotatedMu.RLock() + defer r.tokenRotatedMu.RUnlock() + return r.tokenRotatedAt +} + +// WorkerID returns the worker_id pushed by the main app via the +// init/config message. Empty before init lands. +func (r *Runner) WorkerID() string { + if r == nil { + return "" + } + // The init payload is captured via the credentials/url caches; we + // also expose the worker_id through the URL setter below. + r.workerIDMu.RLock() + defer r.workerIDMu.RUnlock() + return r.workerID +} + +// RegionCode returns the region_code from the init payload. +func (r *Runner) RegionCode() string { + if r == nil { + return "" + } + r.regionMu.RLock() + defer r.regionMu.RUnlock() + return r.regionCode +} + +// WorkerVersion returns the worker_version from the init payload. +func (r *Runner) WorkerVersion() string { + if r == nil { + return "" + } + r.versionMu.RLock() + defer r.versionMu.RUnlock() + return r.workerVersion +} + +// WorkerCapabilities returns the capabilities from the init payload. +func (r *Runner) WorkerCapabilities() []string { + if r == nil { + return nil + } + r.capsMu.RLock() + defer r.capsMu.RUnlock() + return append([]string(nil), r.workerCaps...) +} + +// HTTPConfig returns the worker's local HTTP listener settings. Used +// by the webapp's settings page to render the configured bind +// address and URL. +func (r *Runner) HTTPConfig() HTTPConfig { + if r == nil || r.config == nil { + return HTTPConfig{} + } + return r.config.HTTP +} + +// RotateToken asks the main app to issue a fresh bearer token, +// updates the in-memory config + client, and closes the current +// websocket so the reconnect loop picks up the new token. +// +// Returns the new token string. On any failure the old token and +// client are kept untouched. +func (r *Runner) RotateToken(_ context.Context) (string, error) { + if r == nil || r.config == nil { + return "", fmt.Errorf("worker: runner not initialized") + } + if r.client == nil { + return "", fmt.Errorf("worker: client not yet started") + } + newToken, err := r.client.RotateToken() + if err != nil { + return "", err + } + if newToken == "" || newToken == r.config.Token { + return "", fmt.Errorf("worker: rotate-token returned unchanged or empty token") + } + + // Swap config + client under lock so a concurrent heartbeat + // cannot race with the rotation. + r.clientMu.Lock() + r.config.Token = newToken + oldClient := r.client + r.client = NewClient(r.config.URL, newToken) + r.clientMu.Unlock() + + // Stamp the rotation time so the settings page can show it. + r.tokenRotatedMu.Lock() + r.tokenRotatedAt = time.Now().UTC() + r.tokenRotatedMu.Unlock() + + // Force the websocket loop to reconnect with the new token. The + // old connection's next heartbeat will fail with 401; closing + // the connection now shortens that window. + r.closeOnce.Do(func() { + // Close the underlying websocket by triggering the runner's + // normal stop path; the websocketLoop goroutine will reconnect + // after we re-arm stopCh. This is the cleanest way to drive + // the loop without exposing internals. + select { + case <-r.stopCh: + default: + close(r.stopCh) + } + }) + _ = oldClient // client has no Close; the websocket layer owns it. + return newToken, nil +} diff --git a/internal/distworker/runner_protocol_test.go b/internal/distworker/runner_protocol_test.go new file mode 100644 index 0000000..32ac706 --- /dev/null +++ b/internal/distworker/runner_protocol_test.go @@ -0,0 +1,19 @@ +package distworker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +func TestEnqueueTaskMessagePrefersEnvelopeOverLegacyCheck(t *testing.T) { + r := &Runner{jobQueue: make(chan wire.CheckJob, 2), stopCh: make(chan struct{})} + message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{Type: wire.TaskTypeCheck, Job: &wire.CheckJob{JobID: "v2"}}, Task: &wire.CheckJob{JobID: "v1"}} + require.True(t, r.enqueueTaskMessage(message)) + job := <-r.jobQueue + assert.Equal(t, "v2", job.JobID) + assert.Empty(t, r.jobQueue, "the sibling legacy field must not run a second check") +} diff --git a/internal/distworker/runner_test.go b/internal/distworker/runner_test.go new file mode 100644 index 0000000..b0c8caf --- /dev/null +++ b/internal/distworker/runner_test.go @@ -0,0 +1,318 @@ +package distworker + +import ( + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Jeffail/tunny" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// newTestRunner builds a runner with a deterministic executor and the +// fixed dispatcher/pool layout. It registers a cleanup hook that drains +// the runner so tests can leak-free. +func newTestRunner(t *testing.T, maxConc, poolSize int, fn func(payload interface{}) interface{}) *Runner { + t.Helper() + r := NewRunner(&Config{MaxConcurrency: maxConc}) + r.executor = fn + require.NotNil(t, r.executor) + atomic.StoreInt64(&r.concurrency, int64(poolSize)) + r.jobQueue = make(chan wire.CheckJob, r.queueCapacity()) + r.results = make(chan resultEnvelope, r.queueCapacity()) + r.pool = tunny.NewFunc(poolSize, r.executor) + for i := 0; i < maxConc; i++ { + r.wg.Add(1) + go r.dispatcher() + } + t.Cleanup(func() { + r.Stop() + r.wg.Wait() + r.pool.Close() + close(r.results) + }) + return r +} + +func TestQueueCapacityScalesWithMaxConcurrency(t *testing.T) { + cases := []struct { + maxConc int + wantCapacity int + }{ + // 2*maxConc, floored at minQueueCapacity so a small pool still + // has backpressure headroom. + {maxConc: 1, wantCapacity: minQueueCapacity}, + {maxConc: 4, wantCapacity: minQueueCapacity}, + {maxConc: 16, wantCapacity: 2 * 16}, + {maxConc: 64, wantCapacity: 2 * 64}, + } + for _, tc := range cases { + t.Run("max="+strconv.Itoa(tc.maxConc), func(t *testing.T) { + r := NewRunner(&Config{MaxConcurrency: tc.maxConc}) + assert.Equal(t, tc.wantCapacity, r.QueueCapacity(), + "queue capacity should scale with max concurrency") + }) + } +} + +func TestDispatcherRunsJobsConcurrently(t *testing.T) { + const ( + maxConc = 8 + poolSize = 4 + jobCount = 12 + hold = 80 * time.Millisecond + ) + var ( + inFlight atomic.Int64 + peak atomic.Int64 + ) + + executor := func(payload interface{}) interface{} { + cur := inFlight.Add(1) + for { + p := peak.Load() + if cur <= p || peak.CompareAndSwap(p, cur) { + break + } + } + time.Sleep(hold) + inFlight.Add(-1) + job := payload.(wire.CheckJob) + return []wire.CheckResultReport{{ + JobID: job.JobID, + CheckID: job.CheckID, + State: "OK", + }} + } + + r := newTestRunner(t, maxConc, poolSize, executor) + + // Drain the results channel so dispatchers do not block. + var drainWG sync.WaitGroup + drainWG.Add(1) + go func() { + defer drainWG.Done() + for i := 0; i < jobCount; i++ { + select { + case <-r.results: + case <-r.stopCh: + return + } + } + }() + + for i := 0; i < jobCount; i++ { + job := wire.CheckJob{ + JobID: "job-" + strconv.Itoa(i), + CheckID: int64(i + 1), + Kind: "http", + Host: "example.com", + } + require.True(t, r.Enqueue(job)) + } + + drainWG.Wait() + // The tunny.Pool size (poolSize) limits how many jobs run in + // parallel, so the peak should be at most poolSize and at least 2 + // (otherwise the test would pass on a serial pool). + observed := peak.Load() + assert.GreaterOrEqual(t, observed, int64(2), + "expected concurrent execution, observed peak=%d", observed) + assert.LessOrEqual(t, observed, int64(poolSize), + "peak should be bounded by pool size, observed peak=%d", observed) +} + +func TestEnqueueRespectsBackpressure(t *testing.T) { + // Use a small queue with no dispatchers consuming it, so the bounded + // channel is the only source of backpressure. This is the cleanest + // way to assert that Enqueue parks when the buffer is full. + const queueCap = 4 + r := NewRunner(&Config{MaxConcurrency: 2}) + r.jobQueue = make(chan wire.CheckJob, queueCap) + r.results = make(chan resultEnvelope, queueCap) + t.Cleanup(func() { + r.Stop() + close(r.results) + }) + + // Fill the bounded queue. + for i := 0; i < queueCap; i++ { + require.True(t, r.Enqueue(wire.CheckJob{JobID: "prefill-" + strconv.Itoa(i)})) + } + assert.Equal(t, queueCap, r.QueueDepth(), + "queue should be full after %d enqueues", queueCap) + + // The next Enqueue must block because the queue is full. + enqueueDone := make(chan bool, 1) + go func() { + enqueueDone <- r.Enqueue(wire.CheckJob{JobID: "blocking"}) + }() + + select { + case got := <-enqueueDone: + t.Fatalf("Enqueue returned %v while the queue was full; expected backpressure", got) + case <-time.After(50 * time.Millisecond): + // expected: still parked + } + + // Free a slot and confirm the parked Enqueue unblocks. + select { + case <-r.jobQueue: + case <-r.stopCh: + t.Fatal("runner stopped unexpectedly") + } + select { + case ok := <-enqueueDone: + assert.True(t, ok, "Enqueue should succeed once a slot is free") + case <-time.After(time.Second): + t.Fatal("Enqueue did not unblock after slot was freed") + } +} + +func TestApplyInitResizesPool(t *testing.T) { + executor := func(payload interface{}) interface{} { + return []wire.CheckResultReport{} + } + + r := newTestRunner(t, 8, 1, executor) + // Replace the pool with a proxy that records SetSize calls. The + // proxy delegates Close back to the underlying pool so the test + // runner cleanup only closes the pool once. + original := r.pool + proxy := newSizeProbe(original) + r.pool = proxy + + r.applyInit(&wire.WorkerInit{Concurrency: 5, WorkerID: "w-1"}) + assert.Equal(t, 5, r.Concurrency(), "applyInit should update pool size") + require.NotEmpty(t, proxy.sizes, "expected pool.SetSize to be called") + assert.Equal(t, 5, proxy.sizes[len(proxy.sizes)-1]) + + // Concurrency above maxConcurrency should be clamped. + r.applyInit(&wire.WorkerInit{Concurrency: 999, WorkerID: "w-1"}) + assert.Equal(t, r.MaxConcurrency(), r.Concurrency(), + "applyInit should clamp concurrency to maxConcurrency") +} + +// sizeProbePool wraps a jobPool and records SetSize calls. Close is +// forwarded to the wrapped pool so cleanup happens exactly once. +type sizeProbePool struct { + jobPool + sizes []int +} + +func newSizeProbe(p jobPool) *sizeProbePool { + return &sizeProbePool{jobPool: p} +} + +func (s *sizeProbePool) SetSize(n int) { + s.sizes = append(s.sizes, n) + s.jobPool.SetSize(n) +} + +func TestApplyInitIgnoresNonPositive(t *testing.T) { + executor := func(payload interface{}) interface{} { + return []wire.CheckResultReport{} + } + r := newTestRunner(t, 4, 1, executor) + // newTestRunner mirrors Start()'s initial concurrency of 1. + require.Equal(t, 1, r.Concurrency()) + + // Concurrency = 0 must not break the runner or change its size. + r.applyInit(&wire.WorkerInit{Concurrency: 0}) + assert.Equal(t, 1, r.Concurrency(), + "non-positive concurrency should be ignored") + + // Concurrency = -5 should also be ignored. + r.applyInit(&wire.WorkerInit{Concurrency: -5}) + assert.Equal(t, 1, r.Concurrency()) +} + +// TestApplyInitStoresCredentialsInMemory verifies that WorkerInit.Credentials +// is stored on the Runner via applyInit and is retrievable through +// Credentials(). It also confirms that a subsequent applyInit with nil +// credentials replaces the previous value. +func TestApplyInitStoresCredentialsInMemory(t *testing.T) { + executor := func(payload interface{}) interface{} { + return []wire.CheckResultReport{} + } + r := newTestRunner(t, 4, 1, executor) + + // Before any applyInit, Credentials() returns nil. + assert.Nil(t, r.Credentials(), "Credentials() must return nil before any init") + + want := &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ + ID: 11, + Name: "primary", + Server: "smtp.example.com", + Port: 587, + Login: "alerts@example.com", + Password: "smtp-password-xyz", + }}, + Telegram: []wire.TelegramCredential{{ + ID: 22, + Name: "main-bot", + Token: "bot-token-9876543210:ABCDEFG", + }}, + } + + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Concurrency: 2, + Credentials: want, + }) + + got := r.Credentials() + require.NotNil(t, got, "Credentials() must return non-nil after applyInit") + require.Len(t, got.SMTP, 1) + require.Len(t, got.Telegram, 1) + assert.Equal(t, "primary", got.SMTP[0].Name) + assert.Equal(t, "smtp-password-xyz", got.SMTP[0].Password) + assert.Equal(t, "main-bot", got.Telegram[0].Name) + assert.Equal(t, "bot-token-9876543210:ABCDEFG", got.Telegram[0].Token) + + // A subsequent applyInit with nil Credentials replaces the value. + r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2}) + assert.Nil(t, r.Credentials(), + "Credentials() must return nil after applyInit with nil Credentials") +} + +func TestNotificationHeartbeatCounters(t *testing.T) { + r := NewRunner(&Config{MaxConcurrency: 1}) + atomic.StoreInt64(&r.notifyDepth, 2) + atomic.StoreInt64(&r.notifyActive, 3) + assert.Equal(t, 5, r.ActiveNotifications()) + assert.Equal(t, 2, r.NotificationQueueDepth()) +} + +// TestApplyInitStoresURLInMemory verifies that the URL field added in +// Task 2 is stored on the Runner via applyInit and exposed through +// URL(). A subsequent applyInit with empty URL replaces the previous +// value (consistent with the Credentials contract). +func TestApplyInitStoresURLInMemory(t *testing.T) { + executor := func(payload interface{}) interface{} { + return []wire.CheckResultReport{} + } + r := newTestRunner(t, 4, 1, executor) + + // Before any applyInit, URL() returns empty. + assert.Equal(t, "", r.URL(), "URL() must return empty before any init") + + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Concurrency: 2, + URL: "https://worker-eu.example.com", + }) + assert.Equal(t, "https://worker-eu.example.com", r.URL(), + "URL() must return the value pushed by applyInit") + + // Empty URL in a subsequent applyInit must clear the stored value. + r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2}) + assert.Equal(t, "", r.URL(), + "URL() must return empty after applyInit with empty URL") +} diff --git a/internal/distworker/selfcheck.go b/internal/distworker/selfcheck.go new file mode 100644 index 0000000..977cf4b --- /dev/null +++ b/internal/distworker/selfcheck.go @@ -0,0 +1,373 @@ +package distworker + +import ( + "context" + "encoding/json" + "fmt" + "log" + mathrand "math/rand/v2" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/app/models/concerns" + "rsgit.ru/rsmon/rsmon/internal/checkexec" + "rsgit.ru/rsmon/rsmon/internal/notify" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +const ( + selfcheckInterval = 30 * time.Second + selfcheckTimeoutMillis = 10_000 + selfcheckJitterMax = 30 * time.Second + selfcheckProbePath = "/up" + // selfcheckConsensusWait is the time the cluster-level down + // verdict must hold before the selfcheck fires a system alert. + // 5 minutes matches the user-facing requirement: a single + // blip should not page, but a sustained outage must. + selfcheckConsensusWait = 5 * time.Minute + selfcheckDownMessage = "Нет связи с основным api" + selfcheckRecoveryMessage = "Связь с основным api восстановлена" + selfcheckLeaderMessage = "Изменился мастер воркер оповещений" + selfcheckStateOK = "OK" + selfcheckStateWarn = "WARN" + // systemContactKindEmail is the wire-level identifier for email + // system contacts, matching Contact.Kind values from the control plane. + systemContactKindEmail = "email" + + // notificationChannel labels are short tags the webapp renders + // alongside each row on /notifications. Kept distinct from the + // systemContactKind* constants so a single contact can be + // reached over multiple channels without an extra column. + notificationChannelSMTP = "smtp" + notificationChannelTelegram = "telegram" +) + +// selfcheckState tracks the cluster-level up/down verdict for the +// master API as decided by simple-majority consensus over self + +// reachable peers. Alert fires only after the down verdict has held +// for selfcheckConsensusWait; recovery fires only after a matching +// up verdict has held for the same window. +type selfcheckState struct { + consensus *consensusState +} + +// startSelfcheck launches the periodic selfcheck loop. The first tick is +// delayed by a random 0..selfcheckJitterMax so a fleet restart does not +// stampede the main API simultaneously. Returns when ctx is canceled. +func (r *Runner) startSelfcheck(ctx context.Context) { + jitter := time.Duration(mathrand.Int64N(int64(selfcheckJitterMax))) //nolint:gosec // non-cryptographic jitter to stagger fleet probes + select { + case <-time.After(jitter): + case <-ctx.Done(): + return + } + + state := &selfcheckState{consensus: &consensusState{}} + ticker := time.NewTicker(selfcheckInterval) + defer ticker.Stop() + + r.runSelfcheckOnce(ctx, state) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.runSelfcheckOnce(ctx, state) + } + } +} + +func (r *Runner) runSelfcheckOnce(ctx context.Context, state *selfcheckState) { + target := selfcheckTarget(r.config.URL) + if target == "" { + return + } + + now := time.Now() + ok := runMainAPIHTTPCheck(target) + upFlag := ok + r.SetMasterStatus(&upFlag, now) + + peers := r.peerObservationsForConsensus(now) + configuredPeers := len(r.Peers()) + // In a multi-worker deployment we need a peer-backed quorum + // (self + at least one fresh peer) before any verdict is + // reported. A lone self vote — peers configured but no fresh + // peer observations yet — is consensusNoQuorum so the + // selfcheck state machine does not start its 5-minute down + // timer on what might be a transient fleet-bootstrap blip. + // The configured peer list itself is the signal that the + // operator wants cross-worker consensus; peerObservations + // above already drops stale/error observations so "configured + // peers" maps 1:1 to "peer observations can be fresh". + minVotes := 1 + if configuredPeers > 0 { + minVotes = 2 + } + verdict := decideConsensus(&upFlag, peers, minVotes) + up, down, total := tallyConsensus(&upFlag, peers) + voters := consensusVoters(r.WorkerID(), peers) + notificationLeader := consensusNotificationLeader(voters) + log.Printf("worker: selfcheck verdict=%s up=%d down=%d total=%d min_votes=%d target=%s", + verdict, up, down, total, minVotes, target) + if oldLeader, changed := state.consensus.notificationLeaderChanged(notificationLeader); changed && r.isConsensusNotificationLeader(notificationLeader) { + log.Printf("worker: selfcheck notification leader changed old=%s new=%s; firing system alert", oldLeader, notificationLeader) + r.sendSystemAlert(ctx, true, r.formatSelfcheckLeaderMessage(oldLeader, target, verdict, up, down, total, minVotes, configuredPeers, len(peers), notificationLeader, voters)) + } + + if verdict == consensusDown { + if state.consensus.isDownConsensusHeld(verdict, now) && + !state.consensus.alertActive { + state.consensus.markDownAlertFired() + if !r.isConsensusNotificationLeader(notificationLeader) { + log.Printf("worker: selfcheck down consensus held %s; notification leader=%s; skipping send", selfcheckConsensusWait, notificationLeader) + return + } + log.Printf("worker: selfcheck down consensus held %s; firing system alert", selfcheckConsensusWait) + r.sendSystemAlert(ctx, true, r.formatSelfcheckMessage(selfcheckDownMessage, target, verdict, up, down, total, minVotes, configuredPeers, len(peers), notificationLeader, voters)) + } + return + } + + if verdict == consensusUp && state.consensus.shouldFireRecovery(verdict, now) { + state.consensus.markRecoveryFired() + if !r.isConsensusNotificationLeader(notificationLeader) { + log.Printf("worker: selfcheck up consensus held %s; notification leader=%s; skipping recovery", selfcheckConsensusWait, notificationLeader) + return + } + log.Printf("worker: selfcheck up consensus held %s; firing recovery", selfcheckConsensusWait) + r.sendSystemAlert(ctx, false, r.formatSelfcheckMessage(selfcheckRecoveryMessage, target, verdict, up, down, total, minVotes, configuredPeers, len(peers), notificationLeader, voters)) + } +} + +func (r *Runner) formatSelfcheckLeaderMessage( + oldLeader, target string, + verdict consensusDecision, + up, down, total, minVotes, configuredPeers, freshPeers int, + notificationLeader string, + voters []string, +) string { + return r.formatSelfcheckMessage( + fmt.Sprintf("%s: %s -> %s", selfcheckLeaderMessage, oldLeader, notificationLeader), + target, verdict, up, down, total, minVotes, configuredPeers, freshPeers, notificationLeader, voters, + ) +} + +func (r *Runner) formatSelfcheckMessage( + message, target string, + verdict consensusDecision, + up, down, total, minVotes, configuredPeers, freshPeers int, + notificationLeader string, + voters []string, +) string { + workerID := r.WorkerID() + if workerID == "" { + workerID = "unknown" + } + region := r.RegionCode() + if region == "" { + region = "unknown" + } + return fmt.Sprintf( + "%s\n\nWorker: %s\nRegion: %s\nTarget: %s\nRaft status: lightweight peer quorum\nNotification leader: %s\nConsensus: %s\nVotes: up=%d down=%d total=%d min_votes=%d\nWorkers seen: %d current votes, %d fresh peers of %d configured peers\nVoters: %s\nHold time: %s", + message, workerID, region, target, notificationLeader, verdict, up, down, total, minVotes, total, freshPeers, configuredPeers, strings.Join(voters, ","), selfcheckConsensusWait, + ) +} + +func (r *Runner) isConsensusNotificationLeader(leader string) bool { + return leader != "" && r.WorkerID() == leader +} + +func consensusVoters(self string, peers []peerObservation) []string { + voters := make([]string, 0, len(peers)+1) + if self != "" { + voters = append(voters, self) + } + for _, peer := range peers { + if peer.WorkerID != "" { + voters = append(voters, peer.WorkerID) + } + } + sort.Strings(voters) + return voters +} + +func consensusNotificationLeader(voters []string) string { + if len(voters) == 0 { + return "" + } + return voters[0] +} + +func selfcheckTarget(baseURL string) string { + baseURL = strings.TrimRight(baseURL, "/") + if baseURL == "" { + return "" + } + return baseURL + selfcheckProbePath +} + +func runMainAPIHTTPCheck(target string) bool { + settings, _ := json.Marshal(models.CheckSettings{ + ExpectedAnswer: "default", + RequestMethod: "GET", + Timeout: selfcheckTimeoutMillis, + SlowTime: selfcheckTimeoutMillis, + }) + name := "main api selfcheck" + monitor := &models.Monitor{Host: selfcheckHost(target)} + check := models.Check{ + ID: -1, + Name: &name, + Kind: "http", //nolint:goconst // check kind is a wire string, not the http package identifier + Interval: int(selfcheckInterval.Seconds()), + URL: &target, + Settings: settings, + } + results := checkexec.Execute(monitor, []models.Check{check}) + if len(results) == 0 { + return false + } + state := results[0].Result.State + return state == selfcheckStateOK || state == selfcheckStateWarn +} + +func selfcheckHost(target string) string { + parsed, err := url.Parse(target) + if err != nil || parsed.Host == "" { + return target + } + return parsed.Host +} + +// sendSystemAlert notifies all cached system contacts using cached credentials. +// failure=true means "down" message, false means "recovered". +func (r *Runner) sendSystemAlert(_ context.Context, failure bool, message string) { + creds := r.Credentials() + if creds == nil { + log.Printf("worker: selfcheck alert skipped: no credentials") + return + } + + contacts := r.SystemContacts() + if len(contacts) == 0 { + log.Printf("worker: selfcheck alert skipped: no system contacts") + return + } + + subject := "RSMon worker alert" + if !failure { + subject = "RSMon worker recovery" + } + + for i := range contacts { + c := contacts[i] + switch c.Kind { + case systemContactKindEmail: + if len(creds.SMTP) == 0 { + continue + } + cred := smtpCredToModel(&creds.SMTP[0]) + now := time.Now().UTC() + row := &NotificationRow{ + Kind: c.Kind, + Channel: notificationChannelSMTP, + Subject: subject, + Body: message, + At: now, + } + if err := notify.Email(cred, c.Value, subject, message, ""); err != nil { + log.Printf("worker: selfcheck email to %s failed: %v", c.Value, err) + row.OK = false + row.Error = err.Error() + } else { + row.OK = true + } + r.RecordNotification(row) + case "telegram_private", "telegram_group": + if len(creds.Telegram) == 0 { + continue + } + chatID, err := parseTelegramChatID(c.Value) + if err != nil { + log.Printf("worker: selfcheck telegram chat_id parse failed for %s: %v", c.Value, err) + continue + } + cred := telegramCredToModel(creds.Telegram[0]) + body := fmt.Sprintf("%s\n\n%s", subject, message) + now := time.Now().UTC() + row := &NotificationRow{ + Kind: c.Kind, + Channel: notificationChannelTelegram, + Subject: subject, + Body: message, + At: now, + } + if err := notify.Telegram(cred, chatID, body); err != nil { + log.Printf("worker: selfcheck telegram to %s failed: %v", c.Value, err) + row.OK = false + row.Error = err.Error() + } else { + row.OK = true + } + r.RecordNotification(row) + } + } +} + +// parseTelegramChatID converts a numeric telegram chat id stored as a +// string into an int64. +func parseTelegramChatID(raw string) (int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, fmt.Errorf("empty chat id") + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse chat id: %w", err) + } + return id, nil +} + +// smtpCredToModel wraps a wire SMTP credential in a models.NotificationCredential. +// SecretEnc carries the plaintext with the "plain:" prefix so models.GetSecret +// returns it unchanged — workers do not have the encryption key configured. +func smtpCredToModel(c *wire.SMTPCredential) *models.NotificationCredential { + port := c.Port + enabled := true + return &models.NotificationCredential{ + Model: concerns.Model{ID: c.ID}, + Kind: models.CredentialKindSMTP, + Name: c.Name, + Server: &c.Server, + Port: &port, + Login: &c.Login, + FromName: &c.FromName, + FromAddr: &c.FromAddress, + InsecureSkipVerify: c.InsecureSkipVerify, + Enabled: &enabled, + SecretEnc: "plain:" + c.Password, + } +} + +// telegramCredToModel wraps a wire Telegram credential in a +// models.NotificationCredential. SecretEnc carries the plaintext with the +// "plain:" prefix so models.GetSecret returns it unchanged. +func telegramCredToModel(c wire.TelegramCredential) *models.NotificationCredential { + enabled := true + cred := &models.NotificationCredential{ + Model: concerns.Model{ID: c.ID}, + Kind: models.CredentialKindTelegram, + Name: c.Name, + BotName: &c.BotName, + APIURL: &c.APIURL, + Enabled: &enabled, + SecretEnc: "plain:" + c.Token, + } + return cred +} diff --git a/internal/distworker/selfcheck_test.go b/internal/distworker/selfcheck_test.go new file mode 100644 index 0000000..6787b06 --- /dev/null +++ b/internal/distworker/selfcheck_test.go @@ -0,0 +1,625 @@ +package distworker + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +func TestRunMainAPIHTTPCheck_OK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ok := runMainAPIHTTPCheck(srv.URL) + assert.True(t, ok, "200 OK should be a successful probe") +} + +func TestRunMainAPIHTTPCheck_404IsDown(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + ok := runMainAPIHTTPCheck(srv.URL) + assert.False(t, ok, "normal HTTP check flow treats non-200 as down") +} + +func TestRunMainAPIHTTPCheck_5xxIsDown(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + ok := runMainAPIHTTPCheck(srv.URL) + assert.False(t, ok, "5xx should be treated as down") +} + +func TestRunMainAPIHTTPCheck_NetworkErrorIsDown(t *testing.T) { + ok := runMainAPIHTTPCheck("http://127.0.0.1:1") + assert.False(t, ok, "connection refused should be treated as down") +} + +func TestSelfcheckTarget(t *testing.T) { + assert.Equal(t, "https://rsmon.ru/up", selfcheckTarget("https://rsmon.ru")) + assert.Equal(t, "https://rsmon.ru/up", selfcheckTarget("https://rsmon.ru/")) + assert.Equal(t, "https://api.example.com/api/v1/up", selfcheckTarget("https://api.example.com/api/v1")) + assert.Equal(t, "", selfcheckTarget(""), "empty base URL yields empty target") +} + +func TestParseTelegramChatID(t *testing.T) { + id, err := parseTelegramChatID("200318758") + require.NoError(t, err) + assert.Equal(t, int64(200318758), id) + + _, err = parseTelegramChatID("") + assert.Error(t, err) + + _, err = parseTelegramChatID("not-a-number") + assert.Error(t, err) +} + +func TestSMTPCredToModelCarriesPlaintextSecret(t *testing.T) { + c := wire.SMTPCredential{ + ID: 11, + Name: "primary", + Server: "smtp.example.com", + Port: 587, + Login: "alerts@example.com", + Password: "smtp-password-xyz", + FromName: "RSMon", + FromAddress: "alerts@example.com", + } + cred := smtpCredToModel(&c) + require.NotNil(t, cred) + assert.Equal(t, "plain:smtp-password-xyz", cred.SecretEnc) + assert.Equal(t, int64(11), cred.ID) + assert.Equal(t, models_credentialKindSMTP(), cred.Kind) + + got, err := cred.GetSecret() + require.NoError(t, err) + assert.Equal(t, "smtp-password-xyz", got, + "worker-side credential must yield plaintext via models.GetSecret") +} + +func TestTelegramCredToModelCarriesPlaintextToken(t *testing.T) { + c := wire.TelegramCredential{ + ID: 22, + Name: "main-bot", + BotName: "rsmon_alerts_bot", + Token: "bot-token-9876543210:ABCDEFG", + APIURL: "https://api.telegram.org", + } + cred := telegramCredToModel(c) + require.NotNil(t, cred) + assert.Equal(t, "plain:bot-token-9876543210:ABCDEFG", cred.SecretEnc) + assert.Equal(t, int64(22), cred.ID) + assert.Equal(t, models_credentialKindTelegram(), cred.Kind) + + got, err := cred.GetSecret() + require.NoError(t, err) + assert.Equal(t, "bot-token-9876543210:ABCDEFG", got) +} + +// models_credentialKindSMTP/Telegram are local shims to avoid an import-cycle +// in this isolated test file. The real constants live in app/models and are +// asserted at runtime through the wire translation functions. +func models_credentialKindSMTP() string { return "smtp" } +func models_credentialKindTelegram() string { return "telegram" } + +func TestApplyInitStoresSystemContacts(t *testing.T) { + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + + assert.Empty(t, r.SystemContacts(), "no system contacts before init") + + want := []wire.SystemContact{ + {ID: 1, Kind: "email", Value: "ops@example.com", Name: "ops"}, + {ID: 2, Kind: "telegram_group", Value: "200318758", Name: "alerts"}, + } + + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Concurrency: 2, + SystemContacts: want, + }) + + got := r.SystemContacts() + require.Len(t, got, 2) + assert.Equal(t, want[0].ID, got[0].ID) + assert.Equal(t, "email", got[0].Kind) + assert.Equal(t, "ops@example.com", got[0].Value) + assert.Equal(t, "telegram_group", got[1].Kind) + assert.Equal(t, "200318758", got[1].Value) + + r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2}) + assert.Empty(t, r.SystemContacts(), + "subsequent init with empty SystemContacts should replace cache") +} + +func TestRunSelfcheckOnce_FailureStartsDownStateNoAlert(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.config.URL = srv.URL + creds := &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp.example.com", Port: 587, Login: "a@a", FromAddress: "a@a", Password: "x"}}, + } + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: creds, + SystemContacts: []wire.SystemContact{{ID: 1, Kind: "email", Value: "ops@example.com"}}, + }) + + state := &selfcheckState{consensus: &consensusState{}} + r.runSelfcheckOnce(t.Context(), state) + require.NotNil(t, state.consensus.downSince, + "first down probe must stamp downSince") + assert.False(t, state.consensus.alertActive, + "no alert expected before selfcheckConsensusWait elapses") +} + +func TestRunSelfcheckOnce_AlertFiredAfterConsensusWait(t *testing.T) { + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.config.URL = "http://127.0.0.1:1" + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + }) + + state := &selfcheckState{consensus: &consensusState{}} + downSince := time.Now().Add(-selfcheckConsensusWait - time.Second) + state.consensus.downSince = &downSince + r.runSelfcheckOnce(t.Context(), state) + assert.True(t, state.consensus.alertActive, + "alert must be active after selfcheckConsensusWait elapses") +} + +func TestRunSelfcheckOnce_RecoveryClearsAlert(t *testing.T) { + srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srvOK.Close() + + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + }) + + state := &selfcheckState{consensus: &consensusState{}} + state.consensus.alertActive = true + downSince := time.Now().Add(-20 * time.Minute) + state.consensus.downSince = &downSince + + r.config.URL = srvOK.URL + // First up tick: marks the up consensus start; recovery alert + // waits for selfcheckConsensusWait. Assert the alert is still + // active so we can distinguish "up just arrived" from "up held". + r.runSelfcheckOnce(t.Context(), state) + assert.True(t, state.consensus.alertActive, + "recovery alert should not fire on the first up tick") + assert.Equal(t, consensusUp, state.consensus.lastVerdict) + + // Tick again with the up verdict now held past + // selfcheckConsensusWait so the recovery alert fires. + state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second)) + r.runSelfcheckOnce(t.Context(), state) + assert.False(t, state.consensus.alertActive, + "recovery alert must clear alertActive after selfcheckConsensusWait up") + assert.Nil(t, state.consensus.downSince) +} + +func TestRunSelfcheckOnce_RequiresSelfVote(t *testing.T) { + // When self has not yet produced a verdict (nil), the + // consensus must refuse to call the cluster down regardless of + // what peers report. + up := true + peers := []peerObservation{{WorkerID: "p1", Up: true, ObservedAt: time.Now()}} + verdict := decideConsensus(nil, peers, 2) + assert.Equal(t, consensusNoQuorum, verdict, + "missing self vote must prevent a down consensus") + + verdict = decideConsensus(&up, peers, 2) + assert.Equal(t, consensusUp, verdict, + "self up + peer up must reach consensus up") +} + +func TestSendSystemAlert_NoCredentials(t *testing.T) { + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + SystemContacts: []wire.SystemContact{{ID: 1, Kind: "email", Value: "ops@example.com"}}, + }) + + r.sendSystemAlert(t.Context(), true, "test message") +} + +func TestSendSystemAlert_NoContacts(t *testing.T) { + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + }) + + r.sendSystemAlert(t.Context(), true, "test message") +} + +func TestWorkerInit_SystemContactsJSONRoundtrip(t *testing.T) { + init := wire.WorkerInit{ + WorkerID: "w-1", + Concurrency: 4, + SystemContacts: []wire.SystemContact{ + {ID: 1, Kind: "email", Value: "ops@example.com", Name: "ops"}, + {ID: 2, Kind: "telegram_private", Value: "200318758", Name: "alerts"}, + }, + } + + raw, err := json.Marshal(init) + require.NoError(t, err) + assert.Contains(t, string(raw), `"system_contacts":`) + + var decoded wire.WorkerInit + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.Len(t, decoded.SystemContacts, 2) + assert.Equal(t, init.SystemContacts[0], decoded.SystemContacts[0]) + assert.Equal(t, init.SystemContacts[1], decoded.SystemContacts[1]) +} + +func TestWorkerInit_PeersJSONRoundtrip(t *testing.T) { + init := wire.WorkerInit{ + WorkerID: "w-1", + Concurrency: 4, + Peers: []wire.PeerInfo{ + {WorkerID: "w-2", URL: "http://127.0.0.1:27402", RegionCode: "local"}, + {WorkerID: "w-3", URL: "http://127.0.0.1:27403", RegionCode: "local", Login: "ops", Password: "x"}, + }, + } + + raw, err := json.Marshal(init) + require.NoError(t, err) + body := string(raw) + assert.Contains(t, body, `"peers":[`) + assert.Contains(t, body, `"worker_id":"w-2"`) + assert.Contains(t, body, `"url":"http://127.0.0.1:27402"`) + assert.Contains(t, body, `"login":"ops"`) + assert.Contains(t, body, `"password":"x"`) + + var decoded wire.WorkerInit + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.Len(t, decoded.Peers, 2) + assert.Equal(t, init.Peers[0], decoded.Peers[0]) + assert.Equal(t, init.Peers[1], decoded.Peers[1]) +} + +func TestWorkerInit_PeersOmittedWhenEmpty(t *testing.T) { + init := wire.WorkerInit{WorkerID: "w-1", Concurrency: 1} + raw, err := json.Marshal(init) + require.NoError(t, err) + assert.NotContains(t, string(raw), `"peers"`, + "empty peers slice must be omitted so legacy workers stay wire-compatible") +} + +// TestRunSelfcheckOnce_ConfiguredPeersNoFreshPeer_NoAlert verifies that +// in a multi-worker install a transient local probe failure (or +// success) does NOT fire an alert or reset the state machine when +// peers are configured but have not produced a fresh observation yet. +// The first peer poll lands 2s after startup, and after that on the +// peer-poll interval, so the early ticks are a real "lone self vote" +// window in production. +func TestRunSelfcheckOnce_ConfiguredPeersNoFreshPeer_NoAlert(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.config.URL = srv.URL + creds := &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + } + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: creds, + SystemContacts: []wire.SystemContact{ + {ID: 1, Kind: "email", Value: "ops@example.com"}, + }, + // Two peers configured but neither has produced a + // fresh observation yet — the selfcheck is the lone + // voter. + Peers: []wire.PeerInfo{ + {WorkerID: "w-2", URL: "http://127.0.0.1:27402"}, + {WorkerID: "w-3", URL: "http://127.0.0.1:27403"}, + }, + }) + + state := &selfcheckState{consensus: &consensusState{}} + r.runSelfcheckOnce(t.Context(), state) + + assert.Nil(t, state.consensus.downSince, + "with configured peers and no fresh peer observations, the down timer must not start") + assert.False(t, state.consensus.alertActive, + "no alert can fire without a peer-backed down verdict") +} + +// TestRunSelfcheckOnce_ConfiguredPeersWithFreshDownPeer_FiresAfterWait +// exercises the happy path: self and one peer both see the master as +// down, the consensus verdict is down, and after selfcheckConsensusWait +// the alert fires exactly once. +func TestRunSelfcheckOnce_ConfiguredPeersWithFreshDownPeer_FiresAfterWait(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.config.URL = srv.URL + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + SystemContacts: []wire.SystemContact{ + {ID: 1, Kind: "email", Value: "ops@example.com"}, + }, + Peers: []wire.PeerInfo{ + {WorkerID: "w-2", URL: "http://127.0.0.1:27402"}, + }, + }) + + // Seed a fresh down observation from the peer as if its + // selfcheck tick has just completed. + r.peerCache.put(peerObservation{ + WorkerID: "w-2", + Up: false, + ObservedAt: time.Now(), + }) + + state := &selfcheckState{consensus: &consensusState{}} + + // First tick: stamps downSince, does not yet fire. + r.runSelfcheckOnce(t.Context(), state) + require.NotNil(t, state.consensus.downSince, + "peer-backed down verdict must stamp downSince on first tick") + assert.False(t, state.consensus.alertActive, + "alert must not fire before selfcheckConsensusWait elapses") + + // Second tick, simulate the 5-minute wait having elapsed + // by rewinding downSince past the wait threshold. This + // avoids waiting wall-clock time in a unit test. + state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second)) + r.runSelfcheckOnce(t.Context(), state) + assert.True(t, state.consensus.alertActive, + "down consensus held for selfcheckConsensusWait must fire the alert exactly once") +} + +func TestRunSelfcheckOnce_NonLeaderSkipsDuplicateAlert(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.config.URL = srv.URL + r.applyInit(&wire.WorkerInit{ + WorkerID: "worker-local-2", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + SystemContacts: []wire.SystemContact{{ID: 1, Kind: "email", Value: "ops@example.com"}}, + Peers: []wire.PeerInfo{{WorkerID: "worker-local-1", URL: "http://127.0.0.1:27401"}}, + }) + r.peerCache.put(peerObservation{WorkerID: "worker-local-1", Up: false, ObservedAt: time.Now()}) + + state := &selfcheckState{consensus: &consensusState{}} + state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second)) + r.runSelfcheckOnce(t.Context(), state) + + assert.True(t, state.consensus.alertActive, "non-leader still marks the incident handled locally") + assert.Empty(t, r.RecentNotifications(10), "non-leader must not deliver duplicate system-contact notifications") +} + +func TestFormatSelfcheckLeaderMessage_IncludesOldAndNewLeader(t *testing.T) { + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{WorkerID: "worker-local-2", RegionCode: "local", Concurrency: 1}) + + out := r.formatSelfcheckLeaderMessage("worker-local-1", "http://localhost:7401/up", consensusDown, 0, 2, 2, 2, 2, 1, + "worker-local-2", []string{"worker-local-2", "worker-local-3"}) + + assert.Contains(t, out, "Изменился мастер воркер оповещений: worker-local-1 -> worker-local-2") + assert.Contains(t, out, "Notification leader: worker-local-2") + assert.Contains(t, out, "Voters: worker-local-2,worker-local-3") +} + +// TestRunSelfcheckOnce_RecoveryRequiresHeldUpConsensus verifies that +// after a peer-backed down alert, recovery only fires after the up +// verdict has itself been held for selfcheckConsensusWait, and that +// the recovery flow respects the multi-worker gate (an isolated up +// tick with no fresh peer must NOT clear the alert). +func TestRunSelfcheckOnce_RecoveryRequiresHeldUpConsensus(t *testing.T) { + srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srvOK.Close() + + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + SystemContacts: []wire.SystemContact{ + {ID: 1, Kind: "email", Value: "ops@example.com"}, + }, + Peers: []wire.PeerInfo{ + {WorkerID: "w-2", URL: "http://127.0.0.1:27402"}, + }, + }) + + // Pretend the peer also sees the master as up — the only + // way the up verdict is allowed past the multi-worker gate. + r.peerCache.put(peerObservation{ + WorkerID: "w-2", + Up: true, + ObservedAt: time.Now(), + }) + + state := &selfcheckState{consensus: &consensusState{}} + state.consensus.alertActive = true + state.consensus.downSince = ptrTime(time.Now().Add(-20 * time.Minute)) + r.config.URL = srvOK.URL + + // First up tick: stamps the recovery timer; alert stays + // active because the up verdict is not yet held for + // selfcheckConsensusWait. + r.runSelfcheckOnce(t.Context(), state) + assert.True(t, state.consensus.alertActive, + "recovery alert must not fire on the first up tick") + assert.Equal(t, consensusUp, state.consensus.lastVerdict) + + // Simulate the 5-minute recovery wait having elapsed. + state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second)) + r.runSelfcheckOnce(t.Context(), state) + assert.False(t, state.consensus.alertActive, + "recovery alert must clear alertActive after selfcheckConsensusWait up consensus") + assert.Nil(t, state.consensus.downSince) +} + +// TestRunSelfcheckOnce_RecoveryIsolatedUpDoesNotClearAlert verifies +// that an isolated up tick (configured peers, no fresh peer +// observation) cannot unilaterally clear an active alert. The +// alert must remain active until a peer-backed up verdict is held. +func TestRunSelfcheckOnce_RecoveryIsolatedUpDoesNotClearAlert(t *testing.T) { + srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srvOK.Close() + + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + SystemContacts: []wire.SystemContact{ + {ID: 1, Kind: "email", Value: "ops@example.com"}, + }, + Peers: []wire.PeerInfo{ + {WorkerID: "w-2", URL: "http://127.0.0.1:27402"}, + }, + }) + // Note: no fresh peer observation is seeded — the selfcheck + // is the lone voter in this scenario. + + state := &selfcheckState{consensus: &consensusState{}} + state.consensus.alertActive = true + state.consensus.downSince = ptrTime(time.Now().Add(-20 * time.Minute)) + r.config.URL = srvOK.URL + + r.runSelfcheckOnce(t.Context(), state) + + assert.True(t, state.consensus.alertActive, + "isolated up tick must not clear an active alert when peers are configured") +} + +// TestRunSelfcheckOnce_NoQuorumDoesNotResetDownTimer verifies that a +// NoQuorum verdict neither stamps nor advances the 5-minute down +// timer. A brief blip in peer reachability must not page or reset +// progress toward the consensus wait. +func TestRunSelfcheckOnce_NoQuorumDoesNotResetDownTimer(t *testing.T) { + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{ + WorkerID: "w-1", + Credentials: &wire.NotificationCredentials{ + SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}}, + }, + SystemContacts: []wire.SystemContact{ + {ID: 1, Kind: "email", Value: "ops@example.com"}, + }, + Peers: []wire.PeerInfo{ + {WorkerID: "w-2", URL: "http://127.0.0.1:27402"}, + }, + }) + // Peer observation is older than peerStatusMaxAge so the + // selfcheck is alone in the vote. + r.peerCache.put(peerObservation{ + WorkerID: "w-2", + Up: false, + ObservedAt: time.Now().Add(-10 * time.Minute), + }) + + state := &selfcheckState{consensus: &consensusState{}} + + r.runSelfcheckOnce(t.Context(), state) + assert.Nil(t, state.consensus.downSince, + "first NoQuorum must not stamp the down timer") + assert.False(t, state.consensus.alertActive) + + // A subsequent NoQuorum must also not advance the timer; + // the existing stamp (from a previous verdict) is held. + previous := ptrTime(time.Now().Add(-3 * time.Minute)) + state.consensus.downSince = previous + r.runSelfcheckOnce(t.Context(), state) + require.NotNil(t, state.consensus.downSince) + assert.Equal(t, *previous, *state.consensus.downSince, + "NoQuorum must not advance the down timer") +} + +func TestFormatSelfcheckMessage_IncludesWorkerAndConsensus(t *testing.T) { + r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} }) + r.applyInit(&wire.WorkerInit{WorkerID: "worker-local-1", RegionCode: "local", Concurrency: 1}) + + out := r.formatSelfcheckMessage(selfcheckDownMessage, "http://localhost:7401/up", consensusDown, 0, 3, 3, 2, 2, 2, + "worker-local-1", []string{"worker-local-1", "worker-local-2", "worker-local-3"}) + + assert.Contains(t, out, "Нет связи с основным api") + assert.Contains(t, out, "Worker: worker-local-1") + assert.Contains(t, out, "Region: local") + assert.Contains(t, out, "Target: http://localhost:7401/up") + assert.Contains(t, out, "Raft status: lightweight peer quorum") + assert.Contains(t, out, "Notification leader: worker-local-1") + assert.Contains(t, out, "Consensus: down") + assert.Contains(t, out, "Votes: up=0 down=3 total=3 min_votes=2") + assert.Contains(t, out, "Workers seen: 3 current votes, 2 fresh peers of 2 configured peers") + assert.Contains(t, out, "Voters: worker-local-1,worker-local-2,worker-local-3") + assert.Contains(t, out, "Hold time: 5m0s") +} + +func TestConsensusNotificationLeader(t *testing.T) { + voters := consensusVoters("worker-local-2", []peerObservation{{WorkerID: "worker-local-3"}, {WorkerID: "worker-local-1"}}) + assert.Equal(t, []string{"worker-local-1", "worker-local-2", "worker-local-3"}, voters) + assert.Equal(t, "worker-local-1", consensusNotificationLeader(voters)) +} + +func TestConsensusState_NotificationLeaderChanged(t *testing.T) { + state := &consensusState{} + old, changed := state.notificationLeaderChanged("worker-local-1") + assert.False(t, changed) + assert.Empty(t, old) + + old, changed = state.notificationLeaderChanged("worker-local-1") + assert.False(t, changed) + assert.Empty(t, old) + + old, changed = state.notificationLeaderChanged("worker-local-2") + assert.True(t, changed) + assert.Equal(t, "worker-local-1", old) +} + +func ptrTime(t time.Time) *time.Time { return &t } diff --git a/internal/distworker/server_metrics.go b/internal/distworker/server_metrics.go new file mode 100644 index 0000000..7d6752d --- /dev/null +++ b/internal/distworker/server_metrics.go @@ -0,0 +1,271 @@ +package distworker + +import ( + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +type serverMetricSample struct { + at time.Time + cpuTotal, cpuIdle uint64 + rx, tx int64 +} + +var serverMetricSamples = struct { + sync.Mutex + byServer map[int64]serverMetricSample +}{byServer: make(map[int64]serverMetricSample)} + +const serverMetricInterval = 5 * time.Second + +const ( + maxServerMetricProcesses = 20 + maxServerMetricNetworks = 16 +) + +// serverMetricLoop collects only on a worker assigned to a Server. It uses +// the real Linux /proc and statfs collector; unsupported platforms return no +// report rather than fabricated values. The worker has no control-plane DB +// access and forwards snapshots on its authenticated websocket. +func (r *Runner) serverMetricLoop() { + ticker := time.NewTicker(serverMetricInterval) + defer ticker.Stop() + for { + select { + case <-r.stopCh: + return + case <-ticker.C: + serverID := r.serverID.Load() + if serverID == 0 || r.metricResults == nil { + continue + } + report, ok := collectServerMetric(serverID) + if !ok { + continue + } + select { + case r.metricResults <- report: + case <-r.stopCh: + return + default: + } + } + } +} + +func collectServerMetric(serverID int64) (wire.ServerMetricReport, bool) { + memTotal, memAvailable, load1, load5, load15, uptime, ok := readLinuxHostMetrics("/proc") + if !ok { + return wire.ServerMetricReport{}, false + } + memUsed := memTotal - memAvailable + var rxTotal, txTotal int64 + networks := make([]wire.NetworkMetric, 0) + if data, err := os.ReadFile("/proc/net/dev"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 10 { + name := strings.TrimSuffix(fields[0], ":") + if name == "lo" { + continue + } + rx, tx := parseI64(fields[1]), parseI64(fields[9]) + rxTotal += rx + txTotal += tx + networks = append(networks, wire.NetworkMetric{Interface: name, RxBytes: rx, TxBytes: tx}) + } + } + } + sort.Slice(networks, func(i, j int) bool { + return networks[i].RxBytes+networks[i].TxBytes > networks[j].RxBytes+networks[j].TxBytes + }) + if len(networks) > maxServerMetricNetworks { + networks = networks[:maxServerMetricNetworks] + } + processCount := countProcesses("/proc") + processes := collectProcesses("/proc") + now := time.Now() + cpuPercent, netRx, netTx := sampledRates(serverID, now, rxTotal, txTotal) + diskUsed, diskTotal, diskOK := rootDiskUsage() + var diskUsedPtr, diskTotalPtr *int64 + if diskOK { + diskUsedPtr, diskTotalPtr = &diskUsed, &diskTotal + } + return wire.ServerMetricReport{ + ServerID: serverID, CPUPercent: cpuPercent, MemUsed: &memUsed, MemTotal: &memTotal, + DiskUsed: diskUsedPtr, DiskTotal: diskTotalPtr, NetRx: netRx, NetTx: netTx, HostUptimeSec: &uptime, + Load1: &load1, Load5: &load5, Load15: &load15, ProcessCount: &processCount, + Processes: processes, Networks: networks, + }, true +} + +// sampledRates turns monotonic /proc counters into percent and bytes/second. +// A first sample, a clock anomaly, or a counter reset intentionally has no rate. +func sampledRates(serverID int64, now time.Time, rx, tx int64) (*float64, *int64, *int64) { + total, idle, ok := readCPUCounters("/proc/stat") + if !ok { + return nil, nil, nil + } + serverMetricSamples.Lock() + defer serverMetricSamples.Unlock() + previous, hasPrevious := serverMetricSamples.byServer[serverID] + serverMetricSamples.byServer[serverID] = serverMetricSample{at: now, cpuTotal: total, cpuIdle: idle, rx: rx, tx: tx} + if !hasPrevious || !now.After(previous.at) || total <= previous.cpuTotal || idle < previous.cpuIdle || rx < previous.rx || tx < previous.tx { + return nil, nil, nil + } + cpu := float64((total-previous.cpuTotal)-(idle-previous.cpuIdle)) * 100 / float64(total-previous.cpuTotal) + seconds := now.Sub(previous.at).Seconds() + if seconds <= 0 { + return &cpu, nil, nil + } + rxRate, txRate := int64(float64(rx-previous.rx)/seconds), int64(float64(tx-previous.tx)/seconds) + return &cpu, &rxRate, &txRate +} + +func readCPUCounters(path string) (uint64, uint64, bool) { + data, err := os.ReadFile(path) + if err != nil { + return 0, 0, false + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line) + if len(fields) < 5 { + return 0, 0, false + } + var total uint64 + for _, field := range fields[1:] { + value, err := strconv.ParseUint(field, 10, 64) + if err != nil { + return 0, 0, false + } + total += value + } + idle, _ := strconv.ParseUint(fields[4], 10, 64) + if len(fields) > 5 { + iowait, _ := strconv.ParseUint(fields[5], 10, 64) + idle += iowait + } + return total, idle, total > 0 + } + return 0, 0, false +} + +func rootDiskUsage() (int64, int64, bool) { + var stat syscall.Statfs_t + if err := syscall.Statfs("/", &stat); err != nil || stat.Blocks == 0 { + return 0, 0, false + } + total := int64(stat.Blocks * uint64(stat.Bsize)) + free := int64(stat.Bavail * uint64(stat.Bsize)) + return total - free, total, true +} + +func collectProcesses(procRoot string) []wire.ProcessMetric { + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil + } + processes := make([]wire.ProcessMetric, 0, maxServerMetricProcesses) + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + status, err := os.ReadFile(filepath.Join(procRoot, entry.Name(), "status")) + if err != nil { + continue + } + name, rss := "", int64(0) + for _, line := range strings.Split(string(status), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + switch fields[0] { + case "Name:": + name = fields[1] + case "VmRSS:": + rss = parseI64(fields[1]) * 1024 + } + } + if name != "" { + processes = append(processes, wire.ProcessMetric{PID: pid, Name: name, MemoryRSS: rss}) + } + } + sort.Slice(processes, func(i, j int) bool { return processes[i].MemoryRSS > processes[j].MemoryRSS }) + if len(processes) > maxServerMetricProcesses { + processes = processes[:maxServerMetricProcesses] + } + return processes +} + +func countProcesses(procRoot string) int { + entries, err := os.ReadDir(procRoot) + if err != nil { + return 0 + } + count := 0 + for _, entry := range entries { + if _, err := strconv.Atoi(entry.Name()); err == nil { + count++ + } + } + return count +} + +func readLinuxHostMetrics(root string) (total, available int64, load1, load5, load15 float64, uptime int64, ok bool) { + mem, err := os.ReadFile(filepath.Join(root, "meminfo")) + if err != nil { + return + } + for _, line := range strings.Split(string(mem), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + switch fields[0] { + case "MemTotal:": + total = parseI64(fields[1]) * 1024 + case "MemAvailable:": + available = parseI64(fields[1]) * 1024 + } + } + load, err := os.ReadFile(filepath.Join(root, "loadavg")) + if err != nil { + return + } + fields := strings.Fields(string(load)) + if len(fields) < 3 { + return + } + load1, _ = strconv.ParseFloat(fields[0], 64) + load5, _ = strconv.ParseFloat(fields[1], 64) + load15, _ = strconv.ParseFloat(fields[2], 64) + up, err := os.ReadFile(filepath.Join(root, "uptime")) + if err != nil { + return + } + fields = strings.Fields(string(up)) + if len(fields) == 0 { + return + } + seconds, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return + } + uptime = int64(seconds) + return total, available, load1, load5, load15, uptime, total > 0 +} + +func parseI64(value string) int64 { out, _ := strconv.ParseInt(value, 10, 64); return out } diff --git a/internal/distworker/server_metrics_test.go b/internal/distworker/server_metrics_test.go new file mode 100644 index 0000000..7220aa1 --- /dev/null +++ b/internal/distworker/server_metrics_test.go @@ -0,0 +1,55 @@ +package distworker + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestReadLinuxHostMetrics(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "meminfo"), []byte("MemTotal: 100 kB\nMemAvailable: 25 kB\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "loadavg"), []byte("1.5 2.5 3.5 1/1 1\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "uptime"), []byte("42.9 0\n"), 0o600); err != nil { + t.Fatal(err) + } + total, available, one, five, fifteen, uptime, ok := readLinuxHostMetrics(root) + if !ok || total != 102400 || available != 25600 || one != 1.5 || five != 2.5 || fifteen != 3.5 || uptime != 42 { + t.Fatalf("unexpected host metric parse: %d %d %g %g %g %d %t", total, available, one, five, fifteen, uptime, ok) + } +} + +func TestCollectProcessesLimitsAndSortsByRSS(t *testing.T) { + root := t.TempDir() + for i := 1; i <= maxServerMetricProcesses+2; i++ { + dir := filepath.Join(root, strconv.Itoa(i)) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + status := fmt.Sprintf("Name:\tproc%d\nVmRSS:\t%d kB\n", i, i) + if err := os.WriteFile(filepath.Join(dir, "status"), []byte(status), 0o600); err != nil { + t.Fatal(err) + } + } + processes := collectProcesses(root) + if len(processes) != maxServerMetricProcesses || processes[0].PID != maxServerMetricProcesses+2 { + t.Fatalf("unexpected bounded process snapshot: %#v", processes) + } +} + +func TestReadCPUCounters(t *testing.T) { + path := filepath.Join(t.TempDir(), "stat") + if err := os.WriteFile(path, []byte("cpu 10 2 8 70 10 0 0 0\n"), 0o600); err != nil { + t.Fatal(err) + } + total, idle, ok := readCPUCounters(path) + if !ok || total != 100 || idle != 80 { + t.Fatalf("unexpected cpu counters: %d %d %t", total, idle, ok) + } +} diff --git a/internal/distworker/types.go b/internal/distworker/types.go new file mode 100644 index 0000000..99c7fe6 --- /dev/null +++ b/internal/distworker/types.go @@ -0,0 +1,27 @@ +// Package distworker provides the distributed worker implementation. +// This file aliases wire format types for convenience. +package distworker + +import ( + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// notifyResultEnvelope pairs a notification task with its report so the +// writer goroutine can log context alongside the result. +type notifyResultEnvelope struct { + task wire.NotificationTask + report wire.NotificationResultReport +} + +// RegisterRequest and related types are wire type aliases for convenience. +type ( + RegisterRequest = wire.RegisterRequest + RegisterResponse = wire.RegisterResponse + HeartbeatRequest = wire.HeartbeatRequest + CheckJob = wire.CheckJob + JobsResponse = wire.JobsResponse + CheckResultReport = wire.CheckResultReport + ResultsRequest = wire.ResultsRequest + NotificationTask = wire.NotificationTask + NotificationResultReport = wire.NotificationResultReport +) diff --git a/internal/influx/influx.go b/internal/influx/influx.go new file mode 100644 index 0000000..3a3500c --- /dev/null +++ b/internal/influx/influx.go @@ -0,0 +1,397 @@ +// Package influx provides functionality. +package influx + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/davecgh/go-spew/spew" +) + +const ( + defaultAddr = "http://localhost:8428" + // VictoriaMetrics uses MetricsQL, not Flux + // Data model: metric names are {measurement}_{field} +) + +var ( + initOnce sync.Once + client *http.Client + // addr defaults to defaultAddr at declaration so callers (and + // tests) can override it before the first lazy init runs. + addr = defaultAddr +) + +// ensureInit performs one-time setup of the HTTP client and TSDB +// address. It is called from every public function so that binaries +// which only import this package transitively (e.g. the distributed +// worker, which never reads or writes TSDB points) do not pay the +// init cost or emit a misleading "TSDB client initialized" log line. +// INFLUX_URL is read here so the address tracks the env var across +// process restarts without requiring explicit init from the caller. +// +// The env var is honored only when `addr` still equals the default. +// This lets tests (and any explicit caller) pre-set `addr` to a +// mock URL before the first public call fires; without this guard, +// CI runs where INFLUX_URL is exported in .env.ci.example would +// overwrite a test's mock server URL the moment initOnce fires, +// causing every QueryVM/* test to silently target a real TSDB. +func ensureInit() { + initOnce.Do(func() { + client = &http.Client{Timeout: 30 * time.Second} + if addr == defaultAddr { + if addrEnv := os.Getenv("INFLUX_URL"); addrEnv != "" { + addr = addrEnv + } + } + log.Println("TSDB client initialized for:", addr) + }) +} + +// VMExportResponse represents VictoriaMetrics export response +type VMExportResponse struct { + Metric map[string]string `json:"metric"` + Values []json.Number `json:"values"` + Timestamps []int64 `json:"timestamps"` +} + +// QueryVM performs a MetricsQL query against VictoriaMetrics +// QueryVM performs an export query against VictoriaMetrics. +// selector is a time series selector like `chttp_took{check="1146"}`. +// start is the RFC3339 or Unix timestamp for the beginning of the time range (can be empty). +func QueryVM(selector, start string) ([]VMExportResponse, error) { + return QueryVMMany([]string{selector}, start) +} + +// QueryVMMany exports several selectors in one request. +func QueryVMMany(selectors []string, start string) ([]VMExportResponse, error) { + ensureInit() + u, err := url.Parse(addr + "/api/v1/export") + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", u.String(), http.NoBody) + if err != nil { + return nil, err + } + + q := req.URL.Query() + for _, selector := range selectors { + q.Add("match[]", selector) + } + if start != "" { + q.Add("start", start) + } + req.URL.RawQuery = q.Encode() + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("query failed with status %d: %s", resp.StatusCode, string(body)) + } + + var results []VMExportResponse + decoder := json.NewDecoder(resp.Body) + for { + var result VMExportResponse + if err := decoder.Decode(&result); err != nil { + if err == io.EOF { + break + } + return nil, err + } + results = append(results, result) + } + + return results, nil +} + +// MetricCheck identifies the duration series for a check. +type MetricCheck struct { + Metric string + CheckID int64 +} + +// GetLastMany fetches a page's checks in one bounded VictoriaMetrics export. +func GetLastMany(checks []MetricCheck, hours int) (map[int64][]InfluxData, error) { + out := make(map[int64][]InfluxData, len(checks)) + if len(checks) == 0 { + return out, nil + } + if len(checks) > 500 { + checks = checks[:500] + } + selectors := make([]string, 0, len(checks)) + for _, check := range checks { + selectors = append(selectors, fmt.Sprintf(`%s_took{check="%d"}`, check.Metric, check.CheckID)) + } + results, err := QueryVMMany(selectors, fmt.Sprintf("%d", time.Now().Add(-time.Duration(hours)*time.Hour).Unix())) + if err != nil { + return nil, err + } + for _, result := range results { + checkID, err := strconv.ParseInt(result.Metric["check"], 10, 64) + if err != nil { + continue + } + state := result.Metric["state"] + if state == "" { + state = "UNK" + } + for i, ts := range result.Timestamps { + var duration int64 + if i < len(result.Values) { + duration, _ = result.Values[i].Int64() + } + out[checkID] = append(out[checkID], InfluxData{Time: time.Unix(ts/1000, (ts%1000)*1e6), Duration: duration, State: state}) + } + } + return out, nil +} + +// InfluxData provides functionality. //nolint:revive // stutter intentional for clarity +type InfluxData struct { + Time time.Time `json:"time"` + Duration int64 `json:"duration"` + State string `json:"state"` + Error string `json:"error,omitempty"` + Warnings string `json:"warnings,omitempty"` +} + +// GetLast provides functionality. +func GetLast(metric string, check int64, hours int) ([]InfluxData, error) { + ensureInit() + // VictoriaMetrics export API: match[] selector + start time + selector := fmt.Sprintf(`%s_took{check="%d"}`, metric, check) + start := fmt.Sprintf("%d", time.Now().Add(-time.Duration(hours)*time.Hour).Unix()) + + log.Println("TSDB query:", selector, "start:", start) + results, err := QueryVM(selector, start) + if err != nil { + spew.Dump(err) + log.Println("TSDB query error", err) + return nil, err + } + + log.Printf("Got %d result series from TSDB", len(results)) + + influxData := make([]InfluxData, 0) + + // Process each time series (VictoriaMetrics returns one series per unique tag combination) + for _, result := range results { + state := result.Metric["state"] + errorMsg := result.Metric["error"] + warnings := result.Metric["warnings"] + + for i, ts := range result.Timestamps { + // Convert milliseconds to time.Time + t := time.Unix(ts/1000, (ts%1000)*1e6) + + // Parse the value + var duration int64 + if i < len(result.Values) { + if f, err := result.Values[i].Int64(); err == nil { + duration = f + } + } + + data := InfluxData{ + Time: t, + Duration: duration, + State: state, + Error: errorMsg, + Warnings: warnings, + } + if data.State == "" { + data.State = "UNK" + } + influxData = append(influxData, data) + } + } + + // Sort by time descending (newest first) + for i := 0; i < len(influxData); i++ { + for j := i + 1; j < len(influxData); j++ { + if influxData[i].Time.Before(influxData[j].Time) { + influxData[i], influxData[j] = influxData[j], influxData[i] + } + } + } + + return influxData, nil +} + +// escapeTagValue escapes special characters in influx line protocol tag keys/values. +// Characters that must be escaped: comma, equals, space. +func escapeTagValue(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, " ", `\ `) + s = strings.ReplaceAll(s, ",", `\,`) + s = strings.ReplaceAll(s, "=", `\=`) + return s +} + +// formatInfluxLine formats data as InfluxDB line protocol +func formatInfluxLine(measurement string, tags map[string]string, fields map[string]interface{}, ts time.Time) string { + var buf bytes.Buffer + + // Write measurement + buf.WriteString(measurement) + + // Write tags + tagKeys := make([]string, 0, len(tags)) + for k := range tags { + tagKeys = append(tagKeys, k) + } + // Sort tags for consistency + for i := 0; i < len(tagKeys); i++ { + for j := i + 1; j < len(tagKeys); j++ { + if tagKeys[i] > tagKeys[j] { + tagKeys[i], tagKeys[j] = tagKeys[j], tagKeys[i] + } + } + } + + for _, k := range tagKeys { + buf.WriteByte(',') + buf.WriteString(escapeTagValue(k)) + buf.WriteByte('=') + buf.WriteString(escapeTagValue(tags[k])) + } + + buf.WriteByte(' ') + + // Write fields + fieldKeys := make([]string, 0, len(fields)) + for k := range fields { + fieldKeys = append(fieldKeys, k) + } + firstField := true + for _, k := range fieldKeys { + if !firstField { + buf.WriteByte(',') + } + firstField = false + buf.WriteString(k) + buf.WriteByte('=') + + switch v := fields[k].(type) { + case int64: + buf.WriteString(strconv.FormatInt(v, 10) + "i") + case int: + buf.WriteString(strconv.FormatInt(int64(v), 10) + "i") + case float64: + buf.WriteString(strconv.FormatFloat(v, 'f', -1, 64)) + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case string: + buf.WriteByte('"') + buf.WriteString(strings.ReplaceAll(v, "\"", "\\\"")) + buf.WriteByte('"') + default: + buf.WriteString(strconv.FormatFloat(0, 'f', -1, 64)) + } + } + + // Write timestamp (nanoseconds) + buf.WriteByte(' ') + buf.WriteString(strconv.FormatInt(ts.UnixNano(), 10)) + + return buf.String() +} + +// WriteOne provides functionality. +func WriteOne(metric string, tags map[string]string, fields map[string]interface{}) error { + ensureInit() + // Format as InfluxDB line protocol + line := formatInfluxLine(metric, tags, fields, time.Now()) + + // Write to VictoriaMetrics /api/v2/write endpoint + u, err := url.Parse(addr + "/api/v2/write") + if err != nil { + log.Println("TSDB write error (URL parse):", err) + return err + } + + req, err := http.NewRequest("POST", u.String(), bytes.NewBufferString(line)) + if err != nil { + log.Println("TSDB write error (request):", err) + return err + } + + req.Header.Set("Content-Type", "text/plain") + + resp, err := client.Do(req) + if err != nil { + log.Println("TSDB write error:", err) + return err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("TSDB write failed with status %d: %s\n", resp.StatusCode, string(body)) + return fmt.Errorf("write failed with status %d", resp.StatusCode) + } + + return nil +} + +// HealthCheck performs a simple health check against VictoriaMetrics +func HealthCheck() error { + ensureInit() + u, err := url.Parse(addr + "/health") + if err != nil { + return err + } + + req, err := http.NewRequest("GET", u.String(), http.NoBody) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check failed with status %d", resp.StatusCode) + } + + return nil +} + +// QueryDB is deprecated and kept for compatibility +// Use QueryVM for MetricsQL queries instead +func QueryDB(query string) ([]VMExportResponse, error) { + // This is a compatibility function for old code + // Note: Flux queries are NOT supported by VictoriaMetrics + // This function tries to do a simple query instead + log.Println("Warning: QueryDB called with Flux query, VictoriaMetrics uses MetricsQL") + log.Println("Query:", query) + + // Try a simple health check instead + return nil, HealthCheck() +} diff --git a/internal/influx/influx_test.go b/internal/influx/influx_test.go new file mode 100644 index 0000000..11091a3 --- /dev/null +++ b/internal/influx/influx_test.go @@ -0,0 +1,358 @@ +package influx + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestEscapeTagValue(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"OK", "OK"}, + {"simple", "simple"}, + {"has space", `has\ space`}, + {"has,comma", `has\,comma`}, + {"has=equals", `has\=equals`}, + {`has\backslash`, `has\\backslash`}, + // Real-world error messages that were causing TSDB write failures + {`response check: expected keyword Каталония not found`, `response\ check:\ expected\ keyword\ Каталония\ not\ found`}, + {`request exec: Get "https://example.ru": dial tcp: lookup example.ru: no such host`, `request\ exec:\ Get\ "https://example.ru":\ dial\ tcp:\ lookup\ example.ru:\ no\ such\ host`}, + {`redirect: https://example.ru/path`, `redirect:\ https://example.ru/path`}, + {`Bad status code: 200 (expected 403)`, `Bad\ status\ code:\ 200\ (expected\ 403)`}, + {"", ""}, + // Multiple special chars together + {`a=b,c d\e`, `a\=b\,c\ d\\e`}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := escapeTagValue(tt.input) + if got != tt.expected { + t.Errorf("escapeTagValue(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestFormatInfluxLine(t *testing.T) { + ts := time.Unix(0, 1771961128540561786) + + tests := []struct { + name string + measurement string + tags map[string]string + fields map[string]interface{} + wantPrefix string // Check the line starts with this (before timestamp) + }{ + { + name: "simple OK check", + measurement: "chttp", + tags: map[string]string{"check": "457", "code": "200", "state": "OK", "warnings": ""}, + fields: map[string]interface{}{"took": int64(294)}, + wantPrefix: "chttp,check=457,code=200,state=OK,warnings= took=294i", + }, + { + name: "check with error containing spaces and colons", + measurement: "chttp", + tags: map[string]string{"check": "791", "code": "200", "error": "response check: expected keyword not found", "state": "ERR", "warnings": ""}, + fields: map[string]interface{}{"took": int64(294)}, + wantPrefix: `chttp,check=791,code=200,error=response\ check:\ expected\ keyword\ not\ found,state=ERR,warnings= took=294i`, + }, + { + name: "check with redirect URL in warnings", + measurement: "chttp", + tags: map[string]string{"check": "672", "code": "301", "state": "WARN", "warnings": "redirect: https://example.ru/"}, + fields: map[string]interface{}{"took": int64(276)}, + wantPrefix: `chttp,check=672,code=301,state=WARN,warnings=redirect:\ https://example.ru/ took=276i`, + }, + { + name: "check with equals in error", + measurement: "chttp", + tags: map[string]string{"check": "100", "state": "ERR", "error": "key=value problem"}, + fields: map[string]interface{}{"took": int64(0)}, + wantPrefix: `chttp,check=100,error=key\=value\ problem,state=ERR took=0i`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatInfluxLine(tt.measurement, tt.tags, tt.fields, ts) + // The line should end with the timestamp + wantSuffix := " 1771961128540561786" + want := tt.wantPrefix + wantSuffix + if got != want { + t.Errorf("formatInfluxLine() =\n %q\nwant:\n %q", got, want) + } + }) + } +} + +func TestQueryVM_UsesMatchParam(t *testing.T) { + var receivedQuery string + var receivedStart string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedQuery = r.URL.Query().Get("match[]") + receivedStart = r.URL.Query().Get("start") + + // Verify it's NOT using the old "query" param + if q := r.URL.Query().Get("query"); q != "" { + t.Errorf("QueryVM sent deprecated 'query' param: %s", q) + } + + w.WriteHeader(http.StatusOK) + // Return empty JSON-lines response + })) + defer server.Close() + + // Override addr for test + oldAddr := addr + addr = server.URL + defer func() { addr = oldAddr }() + + selector := `chttp_took{check="123"}` + _, err := QueryVM(selector, "1771960000") + if err != nil { + t.Fatalf("QueryVM returned error: %v", err) + } + + if receivedQuery != selector { + t.Errorf("match[] = %q, want %q", receivedQuery, selector) + } + if receivedStart != "1771960000" { + t.Errorf("start = %q, want %q", receivedStart, "1771960000") + } +} + +func TestQueryVM_ParsesResponse(t *testing.T) { + response := `{"metric":{"__name__":"chttp_took","check":"123","state":"OK"},"values":[294,305],"timestamps":[1771961128000,1771961188000]} +{"metric":{"__name__":"chttp_took","check":"123","state":"ERR","error":"timeout"},"values":[0],"timestamps":[1771961248000]}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + io.WriteString(w, response) + })) + defer server.Close() + + oldAddr := addr + addr = server.URL + defer func() { addr = oldAddr }() + + results, err := QueryVM(`chttp_took{check="123"}`, "") + if err != nil { + t.Fatalf("QueryVM returned error: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + if results[0].Metric["state"] != "OK" { + t.Errorf("first result state = %q, want OK", results[0].Metric["state"]) + } + if len(results[0].Values) != 2 { + t.Errorf("first result values count = %d, want 2", len(results[0].Values)) + } + if results[1].Metric["error"] != "timeout" { + t.Errorf("second result error = %q, want timeout", results[1].Metric["error"]) + } +} + +func TestGetLast(t *testing.T) { + response := `{"metric":{"__name__":"chttp_took","check":"123","state":"OK","error":"","warnings":""},"values":[294,305],"timestamps":[1771961128000,1771961188000]} +{"metric":{"__name__":"chttp_took","check":"123","state":"ERR","error":"timeout","warnings":""},"values":[0],"timestamps":[1771961248000]}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify correct params + match := r.URL.Query().Get("match[]") + if match != `chttp_took{check="123"}` { + t.Errorf("match[] = %q, want chttp_took{check=\"123\"}", match) + } + start := r.URL.Query().Get("start") + if start == "" { + t.Error("expected start parameter") + } + + w.WriteHeader(http.StatusOK) + io.WriteString(w, response) + })) + defer server.Close() + + oldAddr := addr + addr = server.URL + defer func() { addr = oldAddr }() + + data, err := GetLast("chttp", 123, 6) + if err != nil { + t.Fatalf("GetLast returned error: %v", err) + } + + if len(data) != 3 { + t.Fatalf("expected 3 data points, got %d", len(data)) + } + + // Should be sorted newest first + if data[0].State != "ERR" { + t.Errorf("first (newest) data point state = %q, want ERR", data[0].State) + } + if data[0].Duration != 0 { + t.Errorf("first data point duration = %d, want 0", data[0].Duration) + } + + if data[2].State != "OK" { + t.Errorf("last (oldest) data point state = %q, want OK", data[2].State) + } + if data[2].Duration != 294 { + t.Errorf("last data point duration = %d, want 294", data[2].Duration) + } +} + +func TestWriteOne_EscapesSpecialChars(t *testing.T) { + var receivedBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + oldAddr := addr + addr = server.URL + defer func() { addr = oldAddr }() + + tags := map[string]string{ + "check": "791", + "code": "200", + "error": "response check: expected keyword not found", + "state": "ERR", + "warnings": "", + } + fields := map[string]interface{}{ + "took": int64(294), + } + + err := WriteOne("chttp", tags, fields) + if err != nil { + t.Fatalf("WriteOne returned error: %v", err) + } + + // The body should have properly escaped tag values + if strings.Contains(receivedBody, "error=response check:") { + t.Error("error tag value was not escaped - spaces should be escaped") + } + if !strings.Contains(receivedBody, `error=response\ check:\ expected\ keyword\ not\ found`) { + t.Errorf("expected escaped error tag, got: %s", receivedBody) + } + + // Should contain the field + if !strings.Contains(receivedBody, "took=294i") { + t.Errorf("body doesn't contain took=294i: %s", receivedBody) + } + + // Should start with measurement name + if !strings.HasPrefix(receivedBody, "chttp,") { + t.Errorf("body doesn't start with chttp,: %s", receivedBody) + } +} + +func TestWriteOne_SendsToCorrectEndpoint(t *testing.T) { + var receivedPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + oldAddr := addr + addr = server.URL + defer func() { addr = oldAddr }() + + err := WriteOne("chttp", map[string]string{"check": "1"}, map[string]interface{}{"took": int64(100)}) + if err != nil { + t.Fatalf("WriteOne returned error: %v", err) + } + + if receivedPath != "/api/v2/write" { + t.Errorf("WriteOne sent to %q, want /api/v2/write", receivedPath) + } +} + +func TestQueryVM_Error(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "missing `match[]` arg") + })) + defer server.Close() + + oldAddr := addr + addr = server.URL + defer func() { addr = oldAddr }() + + _, err := QueryVM("bad_query", "") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "400") { + t.Errorf("error should contain status code 400: %v", err) + } +} + +func TestGetLast_RejectsHyphenatedMetric(t *testing.T) { + var receivedMatch string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMatch = r.URL.Query().Get("match[]") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + oldAddr := addr + addr = server.URL + defer func() { addr = oldAddr }() + + _, err := GetLast("cllm_http", 123, 6) + if err != nil { + t.Fatalf("GetLast returned error: %v", err) + } + + if receivedMatch != `cllm_http_took{check="123"}` { + t.Errorf("match[] = %q, want cllm_http_took{check=\"123\"}", receivedMatch) + } + + if strings.Contains(receivedMatch, "-") { + t.Errorf("match[] should not contain hyphen: %q", receivedMatch) + } +} + +func TestVMExportResponseParsing(t *testing.T) { + jsonStr := `{"metric":{"__name__":"chttp_took","check":"456","state":"WARN","warnings":"redirect: https://example.com/"},"values":[276],"timestamps":[1771961130522365547]}` + + var resp VMExportResponse + err := json.Unmarshal([]byte(jsonStr), &resp) + if err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if resp.Metric["check"] != "456" { + t.Errorf("check = %q, want 456", resp.Metric["check"]) + } + if resp.Metric["warnings"] != "redirect: https://example.com/" { + t.Errorf("warnings = %q", resp.Metric["warnings"]) + } + if len(resp.Values) != 1 { + t.Fatalf("expected 1 value, got %d", len(resp.Values)) + } + v, _ := resp.Values[0].Int64() + if v != 276 { + t.Errorf("value = %d, want 276", v) + } +} diff --git a/internal/netaddr/cidr.go b/internal/netaddr/cidr.go new file mode 100644 index 0000000..4a034c2 --- /dev/null +++ b/internal/netaddr/cidr.go @@ -0,0 +1,46 @@ +// Package netaddr provides network address utilities for RSMon. +package netaddr + +import ( + "database/sql/driver" + "errors" + "net" +) + +// Cidr is a wrapper for transferring CIDR values back and forth easily. +type Cidr struct { + Cidr net.IPNet + Valid bool +} + +// Scan implements the Scanner interface. +func (c *Cidr) Scan(value interface{}) error { + c.Cidr.IP = nil + c.Cidr.Mask = nil + c.Valid = false + if value == nil { + c.Valid = false + return nil + } + cidrAsBytes, ok := value.([]byte) + if !ok { + return errors.New("could not convert scanned value to bytes") + } + _, parsedIPNet, parseErr := net.ParseCIDR(string(cidrAsBytes)) + if parseErr != nil { + return parseErr + } + c.Valid = true + c.Cidr.IP = parsedIPNet.IP + c.Cidr.Mask = parsedIPNet.Mask + return nil +} + +// Value implements the driver Valuer interface. Note if c.Valid is false +// or c.Cidr.IP is nil the database column value will be set to NULL. +func (c Cidr) Value() (driver.Value, error) { + if !c.Valid || c.Cidr.IP == nil { + return nil, nil + } + return []byte(c.Cidr.String()), nil +} diff --git a/internal/netaddr/cidr_test.go b/internal/netaddr/cidr_test.go new file mode 100644 index 0000000..cc20b16 --- /dev/null +++ b/internal/netaddr/cidr_test.go @@ -0,0 +1,116 @@ +package netaddr + +import ( + "bytes" + "net" + "testing" + + _ "github.com/lib/pq" +) + +func TestCidr(t *testing.T) { + db := openTestConn(t) + defer db.Close() + + cidr := Cidr{} + + // Test scanning NULL values + err := db.QueryRow("SELECT NULL::cidr").Scan(&cidr) + if err != nil { + t.Fatal(err) + } + if cidr.Valid { + t.Fatalf("expected null result") + } + + // Test setting NULL values + err = db.QueryRow("SELECT $1::cidr", cidr).Scan(&cidr) + if err != nil { + t.Fatalf("re-query null value failed: %s", err.Error()) + } + if cidr.Valid { + t.Fatalf("expected null result") + } + + // test encoding in query params, then decoding during Scan + testBidirectional := func(c Cidr, label string) { + err = db.QueryRow("SELECT $1::cidr", c).Scan(&cidr) + if err != nil { + t.Fatalf("re-query %s cidr failed: %s", label, err.Error()) + } + if !cidr.Valid { + t.Fatalf("expected non-null value, got null for %s", label) + } + if !net.IP.Equal(c.Cidr.IP, cidr.Cidr.IP) { + t.Fatalf("expected IP addresses to match, but did not for %s - %s %s", label, c.Cidr.IP.String(), cidr.Cidr.IP.String()) + } + if !bytes.Equal(c.Cidr.Mask, cidr.Cidr.Mask) { + t.Fatalf("expected net masks to match, but did not for %s", label) + } + } + + // a few example CIDRs to test out + _, exampleCidr, err := net.ParseCIDR("135.104.0.0/32") + if err != nil { + t.Fatalf("Fatal error while building simple IP example - %s", err.Error()) + } + simpleIP4 := Cidr{Cidr: *exampleCidr, Valid: true} + testBidirectional(simpleIP4, "Simple IPv4") + + _, exampleCidr, err = net.ParseCIDR("0.0.0.0/24") + if err != nil { + t.Fatalf("Fatal error while building Zero IP example - %s", err.Error()) + } + zeroIP4Subnet := Cidr{Cidr: *exampleCidr, Valid: true} + testBidirectional(zeroIP4Subnet, "Zero IPv4 Subnet") + + _, exampleCidr, err = net.ParseCIDR("135.104.0.0/24") + if err != nil { + t.Fatalf("Fatal error while building simple IPv4 subnet example - %s", err.Error()) + } + simpleIP4Subnet := Cidr{Cidr: *exampleCidr, Valid: true} + testBidirectional(simpleIP4Subnet, "Simple IPv4 Subnet") + + _, exampleCidr, err = net.ParseCIDR("::1/128") + if err != nil { + t.Fatalf("Fatal error while building simple IPv6 loopback example - %s", err.Error()) + } + ip6Loopback := Cidr{Cidr: *exampleCidr, Valid: true} + testBidirectional(ip6Loopback, "IPv6 Loopback") + + _, exampleCidr, err = net.ParseCIDR("abcd:2345::/65") + if err != nil { + t.Fatalf("Fatal error while building simple IPv6 subnet example - %s", err.Error()) + } + ip6Subnet := Cidr{Cidr: *exampleCidr, Valid: true} + testBidirectional(ip6Subnet, "IPv6 Subnet #1") + + _, exampleCidr, err = net.ParseCIDR("abcd:2300::/24") + if err != nil { + t.Fatalf("Fatal error while building simple IPv6 subnet #2 example - %s", err.Error()) + } + ip6Subnet2 := Cidr{Cidr: *exampleCidr, Valid: true} + testBidirectional(ip6Subnet2, "IPv6 Subnet #2") + + _, exampleCidr, err = net.ParseCIDR("2001:DB8::1/48") + if err != nil { + t.Fatalf("Fatal error while building simple IPv6 subnet #3 example - %s", err.Error()) + } + ip6Subnet3 := Cidr{Cidr: *exampleCidr, Valid: true} + testBidirectional(ip6Subnet3, "IPv6 Subnet #3") + + // Error handling + + // Bad argument + cidr = Cidr{} + err = cidr.Scan(456) + if err == nil { + t.Fatal("Expected error for non-byte[] argument to Scan") + } + + cidr = Cidr{} + err = cidr.Scan([]byte("")) + if err == nil { + t.Fatalf("Expected error for invalid CIDR") + } +} diff --git a/internal/netaddr/inet.go b/internal/netaddr/inet.go new file mode 100644 index 0000000..ef3fb71 --- /dev/null +++ b/internal/netaddr/inet.go @@ -0,0 +1,39 @@ +package netaddr + +import ( + "database/sql/driver" + "errors" + "net" +) + +// Inet is a wrapper for transferring Inet values back and forth easily. +type Inet struct { + Inet net.IP +} + +// Scan implements the Scanner interface. +func (i *Inet) Scan(value interface{}) error { + i.Inet = nil + if value == nil { + return nil + } + ipAsBytes, ok := value.([]byte) + if !ok { + return errors.New("could not convert scanned value to bytes") + } + parsedIP := net.ParseIP(string(ipAsBytes)) + if parsedIP == nil { + return nil + } + i.Inet = parsedIP + return nil +} + +// Value implements the driver Valuer interface. Note if +// i.IP is nil the database column value will be set to NULL. +func (i Inet) Value() (driver.Value, error) { + if i.Inet == nil { + return nil, nil + } + return []byte(i.Inet.String()), nil +} diff --git a/internal/netaddr/inet_test.go b/internal/netaddr/inet_test.go new file mode 100644 index 0000000..e4bc6fd --- /dev/null +++ b/internal/netaddr/inet_test.go @@ -0,0 +1,67 @@ +package netaddr + +import ( + "net" + "testing" + + _ "github.com/lib/pq" +) + +func TestInet(t *testing.T) { + db := openTestConn(t) + defer db.Close() + + inet := Inet{} + + // Test scanning NULL values + err := db.QueryRow("SELECT NULL::inet").Scan(&inet) + if err != nil { + t.Fatal(err) + } + if inet.Inet != nil { + t.Fatalf("expected null result") + } + + // Test setting NULL values + err = db.QueryRow("SELECT $1::inet", inet).Scan(&inet) + if err != nil { + t.Fatalf("re-query null value failed: %s", err.Error()) + } + if inet.Inet != nil { + t.Fatalf("expected null result") + } + + // test encoding in query params, then decoding during Scan + testBidirectional := func(i Inet, label string) { + err = db.QueryRow("SELECT $1::inet", i).Scan(&inet) + if err != nil { + t.Fatalf("re-query %s inet failed: %s", label, err.Error()) + } + if inet.Inet == nil { + t.Fatalf("expected non-null value, got null for %s", label) + } + if !net.IP.Equal(i.Inet, inet.Inet) { + t.Fatalf("expected IP addresses to match, but did not for %s - %s %s", label, i.Inet.String(), inet.Inet.String()) + } + } + + testBidirectional(Inet{Inet: net.ParseIP("192.168.0.1")}, "Simple IPv4") + testBidirectional(Inet{Inet: net.ParseIP("::1")}, "Loopback IPv6") + testBidirectional(Inet{Inet: net.ParseIP("abcd:2345::")}, "Loopback IPv6") + + // Bad argument + inet = Inet{} + err = inet.Scan(456) + if err == nil { + t.Fatal("Expected error for non-byte[] argument to Scan") + } + + inet = Inet{} + err = inet.Scan([]byte("")) + if err != nil { + t.Fatalf("Unexpected error for empty string - %s", err.Error()) + } + if inet.Inet != nil { + t.Fatalf("Unexpected not null for empty/non-IP string string") + } +} diff --git a/internal/netaddr/macaddr.go b/internal/netaddr/macaddr.go new file mode 100644 index 0000000..79f9c41 --- /dev/null +++ b/internal/netaddr/macaddr.go @@ -0,0 +1,43 @@ +package netaddr + +import ( + "database/sql/driver" + "errors" + "net" +) + +// Macaddr is a wrapper for transferring Macaddr values back and forth easily. +type Macaddr struct { + Macaddr net.HardwareAddr + Valid bool +} + +// Scan implements the Scanner interface. +func (m *Macaddr) Scan(value interface{}) error { + m.Macaddr = nil + m.Valid = false + if value == nil { + m.Valid = false + return nil + } + macaddrAsBytes, ok := value.([]byte) + if !ok { + return errors.New("could not convert scanned value to bytes") + } + parsedMacaddr, parseErr := net.ParseMAC(string(macaddrAsBytes)) + if parseErr != nil { + return parseErr + } + m.Valid = true + m.Macaddr = parsedMacaddr + return nil +} + +// Value implements the driver Valuer interface. Note if m.Valid is false +// or m.Macaddr is nil the database column value will be set to NULL. +func (m Macaddr) Value() (driver.Value, error) { + if !m.Valid || m.Macaddr == nil { + return nil, nil + } + return []byte(m.Macaddr.String()), nil +} diff --git a/internal/netaddr/macaddr_test.go b/internal/netaddr/macaddr_test.go new file mode 100644 index 0000000..99f986f --- /dev/null +++ b/internal/netaddr/macaddr_test.go @@ -0,0 +1,64 @@ +package netaddr + +import ( + "bytes" + "net" + "testing" + + _ "github.com/lib/pq" +) + +func TestMacaddr(t *testing.T) { + db := openTestConn(t) + defer db.Close() + + macaddr := Macaddr{} + + // Test scanning NULL values + err := db.QueryRow("SELECT NULL::macaddr").Scan(&macaddr) + if err != nil { + t.Fatal(err) + } + if macaddr.Valid { + t.Fatalf("expected null result") + } + + // Test setting NULL values + err = db.QueryRow("SELECT $1::macaddr", macaddr).Scan(&macaddr) + if err != nil { + t.Fatalf("re-query null value failed: %s", err.Error()) + } + if macaddr.Valid { + t.Fatalf("expected null result") + } + + // test encoding in query params, then decoding during Scan + testBidirectional := func(m Macaddr, label string) { + err = db.QueryRow("SELECT $1::macaddr", m).Scan(&macaddr) + if err != nil { + t.Fatalf("re-query %s macaddr failed: %s", label, err.Error()) + } + if !macaddr.Valid { + t.Fatalf("expected non-null value, got null for %s", label) + } + if !bytes.Equal(m.Macaddr, macaddr.Macaddr) { + t.Fatalf("expected MAC addresses to match, but did not for %s", label) + } + } + + simpleMac := Macaddr{Macaddr: net.HardwareAddr{1, 0x23, 0x45, 0x67, 0x89, 0xab}, Valid: true} + testBidirectional(simpleMac, "Simple MAC Address") + + // Bad argument + macaddr = Macaddr{} + err = macaddr.Scan(456) + if err == nil { + t.Fatal("Expected error for non-byte[] argument to Scan") + } + + macaddr = Macaddr{} + err = macaddr.Scan([]byte("")) + if err == nil { + t.Fatalf("Expected error for invalid Macaddr") + } +} diff --git a/internal/netaddr/main_test.go b/internal/netaddr/main_test.go new file mode 100644 index 0000000..e29f368 --- /dev/null +++ b/internal/netaddr/main_test.go @@ -0,0 +1,35 @@ +package netaddr + +import ( + "os" + "path/filepath" + "testing" + + "github.com/joho/godotenv" +) + +// TestMain loads .env.test before any test runs so DATABASE_* env vars +// are populated in this test binary. The netaddr package is intentionally +// low-level and does not import config/env; this TestMain gives it the +// same environment the rest of the test suite sees without pulling in +// the app-wide env init. +func TestMain(m *testing.M) { + loadEnvIfPresent(".env.test") + os.Exit(m.Run()) +} + +func loadEnvIfPresent(name string) { + candidates := []string{name} + if cwd := os.Getenv("CWD"); cwd != "" { + candidates = append(candidates, filepath.Join(cwd, name)) + } + if dir, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(dir, name)) + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + _ = godotenv.Load(c) + return + } + } +} diff --git a/internal/netaddr/testutil.go b/internal/netaddr/testutil.go new file mode 100644 index 0000000..a20041d --- /dev/null +++ b/internal/netaddr/testutil.go @@ -0,0 +1,39 @@ +package netaddr + +import ( + "database/sql" + "os" + + _ "github.com/lib/pq" // postgres driver +) + +type Fatalistic interface { + Fatal(args ...interface{}) +} + +func openTestConn(t Fatalistic) *sql.DB { + host := getEnv("DATABASE_HOST", getEnv("DB_HOST", "localhost")) + if os.Getenv("CI") != "" && os.Getenv("DATABASE_HOST") == "" && os.Getenv("DB_HOST") == "" { + host = "postgres" + } + port := getEnv("DATABASE_PORT", getEnv("DB_PORT", "35432")) + user := getEnv("DATABASE_USER", getEnv("DB_USER", "rsmon")) + password := getEnv("DATABASE_PASSWORD", getEnv("DB_PASSWORD", "rsmon")) + dbname := getEnv("DATABASE_NAME", getEnv("DB_NAME", "rsmon_test")) + conn, err := sql.Open( + "postgres", + "host="+host+" port="+port+" user="+user+" password="+password+" dbname="+dbname+" sslmode=disable", + ) + if err != nil { + t.Fatal(err) + } + + return conn +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/internal/notifier/email.go b/internal/notifier/email.go new file mode 100644 index 0000000..1228a65 --- /dev/null +++ b/internal/notifier/email.go @@ -0,0 +1,88 @@ +// Package notifier provides functionality. +package notifier + +import ( + "crypto/tls" + "errors" + "fmt" + "net/mail" + + "github.com/microcosm-cc/bluemonday" + "gopkg.in/gomail.v2" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// SendEmail provides functionality. +func SendEmail(to, subject, body string) error { + p := bluemonday.StripTagsPolicy() + cred, err := firstSMTPCredential() + if err != nil { + return err + } + + fromAddr := &mail.Address{Name: cred.fromName, Address: cred.fromAddress} + from := fromAddr.String() + + m := gomail.NewMessage() + m.SetHeader("From", from) + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + // m.SetBody("text/html", body) + m.AddAlternative("text/plain", p.Sanitize(body)) + m.AddAlternative("text/html", body) + + d := gomail.NewDialer( + cred.server, + cred.port, + cred.login, + cred.password, + ) + if cred.insecureSkipVerify { + d.TLSConfig = &tls.Config{InsecureSkipVerify: true} + } + + return d.DialAndSend(m) +} + +type smtpCredential struct { + server string + port int + login string + password string + fromName string + fromAddress string + insecureSkipVerify bool +} + +func firstSMTPCredential() (*smtpCredential, error) { + creds, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP) + if err != nil { + return nil, err + } + if len(creds) == 0 { + return nil, errors.New("smtp credential is not configured") + } + c := &creds[0] + password, err := c.GetSecret() + if err != nil { + return nil, fmt.Errorf("smtp credential secret: %w", err) + } + out := &smtpCredential{password: password, insecureSkipVerify: c.InsecureSkipVerify} + if c.Server != nil { + out.server = *c.Server + } + if c.Port != nil { + out.port = *c.Port + } + if c.Login != nil { + out.login = *c.Login + } + if c.FromName != nil { + out.fromName = *c.FromName + } + if c.FromAddr != nil { + out.fromAddress = *c.FromAddr + } + return out, nil +} diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go new file mode 100644 index 0000000..25b0a62 --- /dev/null +++ b/internal/notifier/notifier.go @@ -0,0 +1,77 @@ +package notifier + +import ( + "context" + "log" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// Tunables for the periodic notifier loops. The values match what the legacy +// Start() function used so behavior is unchanged. +var ( + interval = 5 * time.Second + expInterval = 2 * time.Hour + deletionInterval = 1 * time.Hour +) + +// StartScheduler launches the three periodic loops that used to be triggered +// by the retired notifier.Start singleton: the notification producer (Run), +// the expiry-alert producer (RunExp), and the pending-deletion sweep. Phase 3 +// of docs/plans/worker-notifier-mvp.md replaces the in-process notifier loop +// with the worker-driven task queue; this scheduler keeps the producer +// running on its existing cadence so the tasks table stays populated. +// +// The loops respect ctx.Done() so a graceful shutdown can unwind them, and +// each tick is wrapped in recover() so a transient bug in one producer does +// not tear down the whole scheduler. +// +// Reaper: StartTaskReaper lives in app/models/task_reaper.go and runs the +// leased->queued recycling on a separate 30s tick. +func StartScheduler(ctx context.Context) { + go scheduleLoop(ctx, interval, Run, "Run") + go scheduleLoop(ctx, expInterval, RunExp, "RunExp") + go scheduleLoop(ctx, deletionInterval, RunPendingDeletions, "RunPendingDeletions") +} + +// scheduleLoop runs fn immediately and then on every tick. Any panic from +// fn is recovered and logged so the loop keeps running. +func scheduleLoop(ctx context.Context, tick time.Duration, fn func(), name string) { + defer func() { + if r := recover(); r != nil { + log.Printf("notifier: scheduler %s goroutine recovered from panic: %v", name, r) + } + }() + + safeRun(name, fn) + ticker := time.NewTicker(tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + safeRun(name, fn) + } + } +} + +// safeRun invokes fn with a panic recovery guard. Each tick is wrapped so a +// single bad tick cannot kill the loop. The loop goroutine itself has its own +// recover() (see scheduleLoop) for paranoia. +func safeRun(name string, fn func()) { + defer func() { + if r := recover(); r != nil { + log.Printf("notifier: %s recovered from panic: %v", name, r) + } + }() + fn() +} + +// RunPendingDeletions hard-deletes users whose 7-day grace period has elapsed. +func RunPendingDeletions() { + if _, err := models.ProcessPendingDeletions(); err != nil { + log.Printf("notifier: process pending deletions: %v", err) + } +} diff --git a/internal/notifier/producer.go b/internal/notifier/producer.go new file mode 100644 index 0000000..e4d1e13 --- /dev/null +++ b/internal/notifier/producer.go @@ -0,0 +1,166 @@ +package notifier + +import ( + "encoding/json" + "errors" + "log" + "time" + + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/notifyrender" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// langEN is the wire-side default language tag used when a Message carries +// no language hint of its own. Centralized so the literal does not appear +// three or more times across this package (goconst). +const langEN = "en" + +// ContactKindToMethod maps the legacy Contact.Kind enum used by the sender onto +// the worker notification_method enum introduced in +// docs/plans/worker-notifier-mvp.md section 4.3. sms/voice remain placeholders +// until phase 4. +// +//nolint:goconst // match arm values must be the wire-method enum literals +func ContactKindToMethod(kind string) string { + switch kind { + case "email": + return "email" + case "telegram_private", "telegram_group": + return "telegram" + case "webhook": + return "webhook" + case "mattermost": + return "mattermost" + case "sms": + return "sms" + case "voice": + return "voice" + } + return "" +} + +// RenderNotificationContent pre-renders subject + bodies for one Message using +// the existing internal/sender/get_content.go helpers. The result is what the +// worker binary consumes directly so it does not need access to Message/Event +// rows, workdays, or NotificationDayStart logic on the data plane. +// +// Returns the four bodies (subject, text, markdown, html). The caller is +// responsible for passing them through to EnqueueNotificationTask. +func RenderNotificationContent(msg *models.Message, now time.Time) (subject, bodyText, bodyMarkdown, bodyHTML string, err error) { + if msg == nil { + return "", "", "", "", errors.New("notifier: nil message") + } + defer func() { + if r := recover(); r != nil { + err = errors.New("notifier: render panicked") + } + }() + sbuf, tbuf, mbuf, hbuf := notifyrender.GetContent(msg, now) + return sbuf.String(), tbuf.String(), mbuf.String(), hbuf.String(), nil +} + +// EnqueueNotificationTaskFromMessage is the producer-side hook called from +// performEvents (or its replacement). It builds a wire.NotificationTask from the +// freshly created Message and enqueues one Task row keyed by the stable +// (notification, contact, first-event) idempotency key. +// +// If the producer's authorization precheck fails the function returns an error: +// worker notification tasks are now the only delivery path. +func EnqueueNotificationTaskFromMessage(n *models.Notification, c *models.Contact, msg *models.Message) (*models.Task, error) { + return enqueueNotificationTaskFromMessageTx(models.DB(), n, c, msg) +} + +// enqueueNotificationTaskFromMessageTx keeps message creation and durable task +// production in the caller's notifier transaction. +func enqueueNotificationTaskFromMessageTx(tx *gorm.DB, n *models.Notification, c *models.Contact, msg *models.Message) (*models.Task, error) { + if msg == nil || n == nil || c == nil { + return nil, errors.New("notifier: nil message/notification/contact") + } + if len(msg.Events) == 0 { + return nil, errors.New("notifier: message has no events (exp messages go through a separate path)") + } + + method := ContactKindToMethod(c.Kind) + if method == "" { + log.Printf("notifier: unknown contact kind %q for contact %d, skipping enqueue", c.Kind, c.ID) + return nil, nil + } + + now := time.Now() + subject, bodyText, bodyMarkdown, bodyHTML, err := RenderNotificationContent(msg, now) + if err != nil { + log.Printf("notifier: render content failed for message %d: %v", msg.ID, err) + return nil, err + } + + checkID := msg.CheckID + monitorID := msg.Events[0].MonitorID + task := wire.NotificationTask{ + AccountID: n.AccountID, + MessageID: msg.ID, + NotificationID: n.ID, + EventIDs: eventIDs(msg), + CheckID: checkID, + MonitorID: &monitorID, + Method: method, + Contact: wire.NotificationContact{ + ID: c.ID, + Kind: c.Kind, + Value: c.Value, + Name: c.Name, + }, + Subject: subject, + BodyText: bodyText, + BodyMarkdown: bodyMarkdown, + BodyHTML: bodyHTML, + Language: langEN, + MessageKind: msg.Kind, + } + payload, err := json.Marshal(task) + if err != nil { + return nil, err + } + + contactID := c.ID + monitorPtr := task.MonitorID + checkPtr := task.CheckID + messagePtr := msg.ID + + taskRow, err := models.EnqueueNotificationTaskTx(tx, &models.EnqueueNotificationTaskInput{ + AccountID: n.AccountID, + NotificationID: n.ID, + ContactID: contactID, + MessageID: &messagePtr, + MonitorID: monitorPtr, + CheckID: checkPtr, + EventIDs: task.EventIDs, + Method: method, + Subject: subject, + BodyText: bodyText, + BodyHTML: bodyHTML, + BodyMarkdown: bodyMarkdown, + Language: task.Language, + MessageKind: msg.Kind, + NotBefore: now, + Payload: payload, + }) + if err != nil { + return nil, err + } + log.Printf( + "notifier: task enqueued id=%d job=%s kind=notification account=%d method=%s notification=%d contact=%d event=%d idempotency=%s", + taskRow.ID, taskRow.JobID, n.AccountID, method, n.ID, c.ID, task.EventIDs[0], taskRow.IdempotencyKey, + ) + return taskRow, nil +} + +func eventIDs(msg *models.Message) []int64 { + out := make([]int64, 0, len(msg.Events)) + for _, e := range msg.Events { //nolint:gocritic // range copy is acceptable here + out = append(out, e.ID) + } + return out +} diff --git a/internal/notifier/producer_test.go b/internal/notifier/producer_test.go new file mode 100644 index 0000000..2d25c44 --- /dev/null +++ b/internal/notifier/producer_test.go @@ -0,0 +1,219 @@ +package notifier + +import ( + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" +) + +func init() { + database.Init() +} + +// TestContactKindToMethod is the table that drives producer + selector + +// executor dispatch. +func TestContactKindToMethod(t *testing.T) { + cases := map[string]string{ + "email": "email", + "telegram_private": "telegram", + "telegram_group": "telegram", + "webhook": "webhook", + "mattermost": "mattermost", + "sms": "sms", + "voice": "voice", + "": "", + "unknown": "", + } + for in, want := range cases { + assert.Equal(t, want, ContactKindToMethod(in), "kind=%q", in) + } +} + +// TestEnqueueNotificationTaskFromMessage_SeedsTaskWithPreRenderedBody +// exercises the producer end-to-end against the test DB. It seeds an account, +// notification, contact, and event; calls EnqueueNotificationTaskFromMessage; +// and checks the resulting Task row has the pre-rendered subject/body in the +// payload (i.e. the worker does not need to know templating). +func TestEnqueueNotificationTaskFromMessage_SeedsTaskWithPreRenderedBody(t *testing.T) { + models.Drop() + models.Migrate() + models.DB().Exec( + "INSERT INTO regions (code, name, enabled, created_at, updated_at) VALUES (?, ?, true, now(), now())", + "test", "test", + ) + + plan := models.Plan{Name: "producer-test"} + require.NoError(t, models.DB().Create(&plan).Error) + user := models.User{Name: "u", Email: producerStringPtr("u@example.com"), Timezone: "UTC"} + require.NoError(t, models.DB().Create(&user).Error) + account := models.Account{Name: "a", Timezone: "UTC", Language: "en", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&account).Error) + + group := models.Group{Name: "g", AccountID: account.ID} + require.NoError(t, models.DB().Create(&group).Error) + + monitor := models.Monitor{ + Name: producerStringPtr("m"), + Host: "example.com", + GroupID: group.ID, + Enabled: true, + } + require.NoError(t, models.DB().Create(&monitor).Error) + + notification := models.Notification{ + Name: "default", AccountID: account.ID, Enabled: true, + NotifyDown: true, NotifyRestore: true, + } + require.NoError(t, models.DB().Create(¬ification).Error) + + contact := models.Contact{Name: "ops", Kind: "email", Value: "ops@example.com", AccountID: &account.ID} + require.NoError(t, models.DB().Create(&contact).Error) + + start := time.Now().Add(-time.Minute) + event := models.Event{ + MonitorID: monitor.ID, + StartTime: &start, + State: "current", + Errors: 5, + } + require.NoError(t, models.DB().Create(&event).Error) + + msg := models.Message{ + NotificationID: notification.ID, + ContactID: contact.ID, + Events: []models.Event{event}, + Kind: "down", + State: "queued", + } + require.NoError(t, models.DB().Create(&msg).Error) + + w := &models.WorkerNode{ + WorkerID: "worker-producer", + RegionCode: "test", + Status: "active", + AuthToken: "tok", + Concurrency: 4, + LastSeen: producerTimePtr(time.Now()), + Capabilities: datatypes.JSON([]byte( + `{"check_types":["http"],"task_envelope":true,"notification_methods":["email"],"notification_accounts":[]}`, + )), + } + require.NoError(t, models.DB().Create(w).Error) + + // Load the message back with the scope GetContent expects (Monitor + Group + // + Notification preloaded). The sender's GetContent panics on nil fields. + loaded := models.Message{} + require.NoError(t, models.MessageScope(models.DB()).First(&loaded, msg.ID).Error) + require.Len(t, loaded.Events, 1) + require.NotNil(t, loaded.Events[0].Monitor) + + row, err := EnqueueNotificationTaskFromMessage(¬ification, &contact, &loaded) + require.NoError(t, err) + require.NotNil(t, row, "expected a Task row from the producer") + assert.Equal(t, models.TaskKindNotification, row.Kind) + assert.Equal(t, models.TaskStateQueued, row.State) + assert.NotEmpty(t, row.Payload) + assert.Equal(t, account.ID, row.AccountID) + assert.Equal(t, &contact.ID, row.ContactID) + require.NotNil(t, row.MessageID) + assert.Equal(t, loaded.ID, *row.MessageID) + assert.Equal(t, models.NotificationIdempotencyKey(notification.ID, contact.ID, event.ID), row.IdempotencyKey) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(row.Payload, &payload)) + assert.Equal(t, "email", payload["method"]) + assert.Equal(t, "down", payload["message_kind"]) + assert.NotEmpty(t, payload["subject"]) +} + +// TestEnqueueNotificationTaskFromMessage_Idempotent exercises the producer's +// idempotency contract: a second call with the same (notification, contact, +// event) tuple must not create a second Task row. +func TestEnqueueNotificationTaskFromMessage_Idempotent(t *testing.T) { + models.Drop() + models.Migrate() + models.DB().Exec( + "INSERT INTO regions (code, name, enabled, created_at, updated_at) VALUES (?, ?, true, now(), now())", + "test", "test", + ) + plan := models.Plan{Name: "p"} + require.NoError(t, models.DB().Create(&plan).Error) + user := models.User{Name: "u", Email: producerStringPtr("u@example.com"), Timezone: "UTC"} + require.NoError(t, models.DB().Create(&user).Error) + account := models.Account{Name: "a", Timezone: "UTC", Language: "en", PlanID: &plan.ID} + require.NoError(t, models.DB().Create(&account).Error) + group := models.Group{Name: "g", AccountID: account.ID} + require.NoError(t, models.DB().Create(&group).Error) + monitor := models.Monitor{ + Name: producerStringPtr("m"), + Host: "example.com", + GroupID: group.ID, + Enabled: true, + } + require.NoError(t, models.DB().Create(&monitor).Error) + notification := models.Notification{ + Name: "default", AccountID: account.ID, Enabled: true, + NotifyDown: true, NotifyRestore: true, + } + require.NoError(t, models.DB().Create(¬ification).Error) + contact := models.Contact{Name: "ops", Kind: "email", Value: "ops@example.com", AccountID: &account.ID} + require.NoError(t, models.DB().Create(&contact).Error) + start := time.Now().Add(-time.Minute) + event := models.Event{MonitorID: monitor.ID, StartTime: &start, State: "current", Errors: 5} + require.NoError(t, models.DB().Create(&event).Error) + + w := &models.WorkerNode{ + WorkerID: "worker-idem", + RegionCode: "test", + Status: "active", + AuthToken: "tok", + Concurrency: 4, + LastSeen: producerTimePtr(time.Now()), + Capabilities: datatypes.JSON([]byte( + `{"check_types":["http"],"task_envelope":true,"notification_methods":["email"],"notification_accounts":[]}`, + )), + } + require.NoError(t, models.DB().Create(w).Error) + + msg := models.Message{ + NotificationID: notification.ID, + ContactID: contact.ID, + Events: []models.Event{event}, + Kind: "down", + State: "queued", + } + require.NoError(t, models.DB().Create(&msg).Error) + + loaded := models.Message{} + require.NoError(t, models.MessageScope(models.DB()).First(&loaded, msg.ID).Error) + + first, err := EnqueueNotificationTaskFromMessage(¬ification, &contact, &loaded) + require.NoError(t, err) + require.NotNil(t, first) + + second, err := EnqueueNotificationTaskFromMessage(¬ification, &contact, &loaded) + require.NoError(t, err) + require.NotNil(t, second) + assert.Equal(t, first.ID, second.ID, "second producer call must reuse the first task row") + + var count int64 + require.NoError(t, models.DB().Model(&models.Task{}). + Where("idempotency_key = ?", first.IdempotencyKey). + Count(&count).Error) + assert.EqualValues(t, 1, count) +} + +func producerStringPtr(s string) *string { return &s } + +func producerTimePtr(value time.Time) *time.Time { return &value } + +// guard against uuid being accidentally dropped from the imports. +var _ = uuid.New diff --git a/internal/notifier/run.go b/internal/notifier/run.go new file mode 100644 index 0000000..2a3c43c --- /dev/null +++ b/internal/notifier/run.go @@ -0,0 +1,370 @@ +package notifier + +import ( + "log" + "time" + + "gorm.io/gorm" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + eventStateEnded = "ended" + eventStateCurrent = "current" + messageKindDown = "down" + messageKindUp = "up" +) + +// DEBUG provides functionality. +const DEBUG = false + +// Run starts the notification scheduler loop. +func Run() { + _ = models.LogCheck("notify") + + events := make([]models.Event, 0) + + tx := models.DB().Begin() + + q := tx + // q = q.Set("gorm:query_option", "FOR UPDATE") + err := models.EventScope(q).Find(&events).Error + if err != nil { + tx.Rollback() + log.Println(err) + return + // panic(err) + } + + type SendItem struct { + Notification models.Notification + Contact models.Contact + Events []models.Event + } + + eventsByNotification := make(map[int64]map[int64]*SendItem, 0) + + hasPossible := make(map[int64]bool) + + eventIDs := make(map[int64]bool, 0) + contactIDs := make(map[int64]bool, 0) + alreadySentDown := make(map[int64]map[int64]bool, 0) + alreadySentUp := make(map[int64]map[int64]bool, 0) + for _, e := range events { //nolint:gocritic // range copy is acceptable here + eventIDs[e.ID] = true + if e.Monitor == nil { + println("event has no monitor") + e.State = "broken" + err := tx.Save(&e).Error + if err != nil { + tx.Rollback() + log.Println(err) + return + // panic(err) + } + continue + } + if e.Monitor.Group == nil { + println("monitor has no group") + e.State = "broken" + err := tx.Save(&e).Error + if err != nil { + tx.Rollback() + log.Println(err) + return + // panic(err) + } + continue + } + for _, n := range e.Monitor.Group.Notifications { //nolint:gocritic // range copy is acceptable here + for _, c := range n.Contacts { //nolint:gocritic // range copy is acceptable here + contactIDs[c.ID] = true + alreadySentDown[c.ID] = make(map[int64]bool, 0) + alreadySentUp[c.ID] = make(map[int64]bool, 0) + } + } + } + + sentMessages := make([]models.Message, 0) + + eventIDsSlice := make([]int64, 0) + for k := range eventIDs { + eventIDsSlice = append(eventIDsSlice, k) + } + contactIDsSlice := make([]int64, 0) + for k := range contactIDs { + contactIDsSlice = append(contactIDsSlice, k) + } + + err = tx.Preload("Events"). + // Where("kind = ?", "down"). + Where("id IN (SELECT message_id FROM event_messages WHERE event_id IN (?))", eventIDsSlice). + Where("contact_id IN (?)", contactIDsSlice).Find(&sentMessages). + Error + if err != nil { + tx.Rollback() + log.Println(err) + return + // panic(err) + } + + for _, m := range sentMessages { //nolint:gocritic // range copy is acceptable here + for _, evt := range m.Events { //nolint:gocritic // range copy is acceptable here + switch m.Kind { + case messageKindDown: + alreadySentDown[m.ContactID][evt.ID] = true + case messageKindUp: + alreadySentUp[m.ContactID][evt.ID] = true + } + } + } + + for _, e := range events { //nolint:gocritic // range copy is acceptable here + if e.Monitor == nil { + println("event has no monitor") + continue + } + if e.Monitor.Group == nil { + println("monitor has no group") + continue + } + underMaintenance, maintenanceErr := models.MonitorUnderMaintenance(e.MonitorID, time.Now().UTC()) + if maintenanceErr != nil { + log.Printf("notifier: maintenance lookup for monitor %d: %v", e.MonitorID, maintenanceErr) + } else if underMaintenance { + // Keep the event pending. Marking it old here would silently drop a + // failure that remains unresolved after the maintenance window ends. + hasPossible[e.ID] = true + continue + } + + if e.State == eventStateEnded { + hasPossible[e.ID] = false + } else { + hasPossible[e.ID] = true + } + + if DEBUG { + log.Println("run event", e.Inspect()) + } + for _, n := range e.Monitor.Group.Notifications { //nolint:gocritic // range copy is acceptable here + if !n.Enabled { + if DEBUG { + log.Println("event", e.ID, "notification", n.ID, "Enabled = false") + } + continue + } + + if e.State == eventStateCurrent { + if !n.NotifyDown { + if DEBUG { + log.Println("event", e.ID, "notification", n.ID, "NotifyDown = false") + } + continue + } + } else if e.State == eventStateEnded { + if !n.NotifyRestore { + if DEBUG { + log.Println("event", e.ID, "notification", n.ID, "NotifyRestore = false") + } + continue + } + } + + if _, ok := eventsByNotification[n.ID]; !ok { + eventsByNotification[n.ID] = make(map[int64]*SendItem, 0) + } + + for _, c := range n.Contacts { //nolint:gocritic // range copy is acceptable here + if e.State == eventStateCurrent { + if _, sent := alreadySentDown[c.ID][e.ID]; sent { + if DEBUG { + log.Println("event", e.ID, "notification", n.ID, "already sent") + } + continue + } + } else if e.State == eventStateEnded { + if _, sent := alreadySentDown[c.ID][e.ID]; !sent { + if DEBUG { + log.Println("event", e.ID, "dont notify up", n.ID, "- no down was sent") + } + continue + } + + if _, sent := alreadySentUp[c.ID][e.ID]; sent { + if DEBUG { + log.Println("event", e.ID, "notification", n.ID, "already sent") + } + continue + } + } + if _, ok := eventsByNotification[n.ID][c.ID]; !ok { + si := SendItem{ + Notification: n, + Contact: c, + Events: make([]models.Event, 0), + } + // log.Println("create", n.ID, c.ID) + // spew.Dump(si) + eventsByNotification[n.ID][c.ID] = &si + } + sendItem := eventsByNotification[n.ID][c.ID] + sendItem.Events = append(sendItem.Events, e) + } + } + } + + for _, eventsByContact := range eventsByNotification { + for _, sendItem := range eventsByContact { + n := sendItem.Notification + c := sendItem.Contact + + tn := time.Now() + + requredEvents := make([]models.Event, 0) + possibleEvents := make([]models.Event, 0) + laterEvents := make([]models.Event, 0) + + for _, e := range sendItem.Events { //nolint:gocritic // range copy is acceptable here + dur := e.GetDuration(tn) + var delay int64 + if n.AlertDelay != nil { + delay = *n.AlertDelay + } else { + delay = 300 + } + + if !n.EnabledNow(&tn) { + if DEBUG { + log.Println("notification", n.ID, "is not enabled at this time") + } + laterEvents = append(laterEvents, e) + } + + if e.State == eventStateCurrent && e.Errors > 4 { //nolint:gocritic // complex condition chain + if DEBUG { + log.Println("min errors count to force send reached:", e.Errors) + } + requredEvents = append(requredEvents, e) + } else if e.State == eventStateCurrent && e.Errors < 2 { + if DEBUG { + log.Println("event possbile to notify in aggregation, but errs count not reached:", e.Errors) + } + possibleEvents = append(possibleEvents, e) + } else if e.State == eventStateEnded && e.Oks > 4 { + if DEBUG { + log.Println("min oks count to force send reached:", e.Oks) + } + requredEvents = append(requredEvents, e) + } else if e.State == eventStateEnded && e.Oks < 2 { + if DEBUG { + log.Println("event possbile to notify in aggregation, but oks count not reached:", e.Oks) + } + possibleEvents = append(possibleEvents, e) + } else if dur < delay { + if DEBUG { + log.Println("event possbile to notify in aggregation, but alert_delay not reached: delay", delay, "duration", dur, "so", (delay - dur), "left") //nolint:lll + } + possibleEvents = append(possibleEvents, e) + } else { + if DEBUG { + log.Println("event required to notify, alert_delay reached: delay", delay, "duration", dur, "so", (delay - dur), "left") //nolint:lll + } + requredEvents = append(requredEvents, e) + } + } + if len(requredEvents) > 0 { + performEvents(tx, &n, &c, append(requredEvents, possibleEvents...)) + } else { + if len(possibleEvents) > 0 || len(laterEvents) > 0 { + // log.Println("notification", n.ID, "no required events, but will send later") + for _, evt := range possibleEvents { //nolint:gocritic // range copy is acceptable here + hasPossible[evt.ID] = true + } + for _, evt := range laterEvents { //nolint:gocritic // range copy is acceptable here + hasPossible[evt.ID] = true + } + } else { + log.Println("notification", n.ID, "no events left") + } + } + + // spew.Dump(sendItem.Notification) + // spew.Dump(sendItem.Contact) + // spew.Dump(sendItem.Events) + } + } + + for _, e := range events { //nolint:gocritic // range copy is acceptable here + if !hasPossible[e.ID] { + // log.Println("event has no possible notifications left to send, mark as done") + e.State = "old" + err := tx.Save(&e).Error + if err != nil { + tx.Rollback() + log.Println(err) + return + // panic(err) + } + } + } + + tx.Commit() +} + +func performEvents(tx *gorm.DB, n *models.Notification, c *models.Contact, events []models.Event) { + eventIDs := make([]int64, 0, len(events)) + for _, evt := range events { //nolint:gocritic // range copy is acceptable here + eventIDs = append(eventIDs, evt.ID) + } + log.Println("performing events:", n.ID, c.ID, eventIDs) + + eventsByKind := make(map[string][]models.Event, 0) + for _, evt := range events { //nolint:gocritic // range copy is acceptable here + var kind string + switch evt.State { + case eventStateCurrent: + kind = messageKindDown + case eventStateEnded: + kind = messageKindUp + default: + log.Println("unknown event state: " + evt.State) + tx.Rollback() + return + } + if _, ok := eventsByKind[kind]; !ok { + eventsByKind[kind] = make([]models.Event, 0) + } + eventsByKind[kind] = append(eventsByKind[kind], evt) + } + + for kind, evts := range eventsByKind { + // spew.Dump(kind, evts) + message := models.Message{ + NotificationID: n.ID, + ContactID: c.ID, + Events: evts, + Kind: kind, + State: models.TaskStateQueued, + } + err := tx.Save(&message).Error + if err != nil { + // panic(err) + tx.Rollback() + log.Println(err) + return + } + + // Worker notification tasks are the only delivery path. If enqueue fails, + // keep the message as an explicit error instead of relying on the retired + // in-process sender loop. + if _, err := enqueueNotificationTaskFromMessageTx(tx, n, c, &message); err != nil { + errText := err.Error() + log.Printf("notifier: enqueue task for message %d failed: %v", message.ID, err) + if saveErr := tx.Model(&message).Updates(map[string]interface{}{"state": "error", "error": &errText}).Error; saveErr != nil { + log.Printf("notifier: mark message %d error failed: %v", message.ID, saveErr) + } + } + } +} diff --git a/internal/notifier/run_exp.go b/internal/notifier/run_exp.go new file mode 100644 index 0000000..49b1873 --- /dev/null +++ b/internal/notifier/run_exp.go @@ -0,0 +1,117 @@ +package notifier + +import ( + "log" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// RunExp provides functionality. +// +// A panic in any single check's notification must not kill the scheduler +// goroutine. The defensive recover() keeps the 2h tick alive even if +// RunExpCheck trips over a bad row or a stale schema reference. +func RunExp() { + defer func() { + if r := recover(); r != nil { + log.Printf("notifier: RunExp recovered from panic: %v", r) + } + }() + + _ = models.LogCheck("exp") + + checks := make([]models.Check, 0) + + err := models.ExpScope(models.DB()).Find(&checks).Error + if err != nil { + log.Println(err) + return + } + + for i := range checks { + RunExpCheck(&checks[i]) + } +} + +// RunExpCheck provides functionality. +// +// A panic in any per-row work (notifier, contact lookup, message write) is +// contained here so one bad row cannot take down the whole RunExp scheduler. +// The panic is logged with the check id and the loop continues. +func RunExpCheck(c *models.Check) { + defer func() { + if r := recover(); r != nil { + log.Printf("notifier: RunExpCheck recovered from panic on check %d: %v", c.ID, r) + } + }() + + if c.Monitor == nil { + log.Println("!BUG! check", c.ID, "has no Monitor (or not preloaded). Monitor ID: ", c.MonitorID, " Not running.") + log.Println() + return + } + if !c.Monitor.Enabled { + return + } + + if c.Monitor.Group == nil { + log.Println("!BUG! check", c.ID, "has no Monitor (or not preloaded). Monitor ID: ", c.Monitor.ID, ", group id:", c.Monitor.GroupID, " Not running.") //nolint:lll + return + } + if c.Monitor.Group.Notifications == nil { + log.Println("!BUG! check", c.ID, "has no .Monitor.Group.Notifications (or not preloaded). Not running.") + return + } + + for i := range c.Monitor.Group.Notifications { + n := &c.Monitor.Group.Notifications[i] + if n.BeforeExpiration == nil { + continue + } + if c.Expires == nil { + // should not happen + continue + } + if c.Kind == "whois" && !n.NotifyWHOIS { + continue + } + if c.Kind == "ssl" && !n.NotifySSL { + continue + } + + // notify delay not reached + notifyOn := time.Now().Add(time.Second * time.Duration(*n.BeforeExpiration)) + if c.Expires.After(notifyOn) { + continue + } + + createExpMessage(c, n) + } +} + +func createExpMessage(c *models.Check, n *models.Notification) { + for _, contact := range n.GetContacts() { //nolint:gocritic // range copy is acceptable here + message := models.Message{ + CheckID: &c.ID, + NotificationID: n.ID, + ContactID: contact.ID, + Kind: "exp", + } + + models.DB(). + Where(message). + Where("created_at > ?", time.Now().Add(-time.Hour*24*14)). + Find(&message) + if message.ID > 0 { + continue + } + message.State = models.TaskStateQueued + err := models.DB().Save(&message).Error + if err != nil { + log.Println(err) + return + // panic(err) + } + } +} diff --git a/internal/notifier/run_exp_test.go b/internal/notifier/run_exp_test.go new file mode 100644 index 0000000..87a07c6 --- /dev/null +++ b/internal/notifier/run_exp_test.go @@ -0,0 +1,160 @@ +package notifier + +import ( + "log" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" + "rsgit.ru/rsmon/rsmon/spec/factories" +) + +func init() { + database.Init() +} + +func TestRunExp(t *testing.T) { + log.Println("TestRunExp") + models.Drop() + models.Migrate() + var err error + contact, notification, monitor := factories.MonitorWithNotification() + + check := factories.PersistedCheck(&monitor, "ssl") + assert.Equal(t, "ssl", check.Kind, "check kind should be ssl") + assert.Equal(t, monitor.ID, check.MonitorID, "check should have correct monitor id") + + exp := time.Now().Add(14 * 24 * time.Hour) + check.Expires = &exp + err = models.DB().Save(&check).Error + if err != nil { + t.Fatal(err) + } + RunExp() + shoudHaveMessages("exp1 - contact should receive no messages", t, "exp", notification.ID, contact.ID, []int64{}) + + exp = time.Now().Add(1 * time.Hour) + check.Expires = &exp + err = models.DB().Save(&check).Error + if err != nil { + t.Fatal(err) + } + RunExp() + shoudHaveMessages("exp2 - contact should receive messages", t, "exp", notification.ID, contact.ID, []int64{check.ID}) + + RunExp() + shoudHaveMessages("exp3 - contact should not receive duplicate messages", t, "exp", notification.ID, contact.ID, []int64{check.ID}) +} + +// TestRunExpExpiresSystemContact exercises the regression scenario that the +// dev DB hit after being restored from the production dump: the +// contacts.is_system column was missing from the production schema and the +// notifier's RunExp panic'd on the GORM preload. +// +// The fix has three layers: AutoMigrate adds the column, GetContacts logs +// instead of panicking, and RunExp/RunExpCheck recover from panics. This +// test verifies all three by setting a contact's is_system=true, queueing an +// expiring SSL check, and confirming RunExp: +// - does not panic; +// - writes a queued exp message for the is_system contact (i.e. the schema +// has the column and the field round-trips); and +// - leaves RunExp returnable to its caller. +func TestRunExpExpiresSystemContact(t *testing.T) { + log.Println("TestRunExpExpiresSystemContact") + models.Drop() + models.Migrate() + + account := &models.Account{Name: "acct-system-contact"} + require.NoError(t, models.DB().Create(account).Error) + accountID := account.ID + + trueVal := true + contact := &models.Contact{ + AccountID: &accountID, + Name: "system-admin", + Kind: "email", + Value: "ops@example.com", + IsSystem: &trueVal, + } + require.NoError(t, models.DB().Create(contact).Error) + + group := factories.PersistedGroup(account) + notification := factories.PersistedNotification( + account, []int64{contact.ID}, []int64{group.ID}, 300, false, + ) + monitor := factories.PersistedMonitor(&group) + + check := factories.PersistedCheck(&monitor, "ssl") + exp := time.Now().Add(1 * time.Hour) + check.Expires = &exp + require.NoError(t, models.DB().Save(&check).Error) + + assert.NotPanics(t, func() { + RunExp() + }, "RunExp must not panic when processing an is_system contact") + + shoudHaveMessages( + "is_system contact must receive the exp message", + t, "exp", notification.ID, contact.ID, []int64{check.ID}, + ) +} + +// TestRunExpDoesNotPanicOnBrokenAssociation replays the original prod-dump +// panic in a contained way: the notification_contacts join row references a +// non-existent contact id, which forces GORM's preload of Contacts to fail. +// The fix's defensive recover() must keep RunExpCheck returning cleanly so +// the scheduler loop survives a single bad row. +// +// We deliberately bypass the contacts.is_system column-drop path because +// Postgres caches prepared statements per session; mutating the contacts +// schema mid-test triggers SQLSTATE 0A000 (cached plan must not change +// result type) on the pool's other connections and masks the panic we want +// to verify. +func TestRunExpDoesNotPanicOnBrokenAssociation(t *testing.T) { + log.Println("TestRunExpDoesNotPanicOnBrokenAssociation") + models.Drop() + models.Migrate() + + _, notification, monitor := factories.MonitorWithNotification() + + // Force a broken association by deleting the contact that the + // notification points to. GORM's preload of Contacts will then have no + // rows for that notification, exercising the empty-contacts path + // without involving DDL or FK violations. + require.NoError(t, models.DB(). + Exec("DELETE FROM notification_contacts WHERE notification_id = ?", notification.ID).Error) + // Re-add a join row pointing to a contact id that has been deleted + // from contacts. We disable the FK temporarily so the join row sticks. + require.NoError(t, models.DB(). + Exec("SET session_replication_role = 'replica'").Error) + t.Cleanup(func() { + _ = models.DB(). + Exec("SET session_replication_role = 'origin'").Error + }) + bogusContactID := int64(9999999) + require.NoError(t, models.DB(). + Exec( + "INSERT INTO notification_contacts (notification_id, contact_id) VALUES (?, ?)", + notification.ID, bogusContactID, + ).Error) + + check := factories.PersistedCheck(&monitor, "ssl") + exp := time.Now().Add(1 * time.Hour) + check.Expires = &exp + require.NoError(t, models.DB().Save(&check).Error) + + require.NoError(t, models.DB(). + Preload("Monitor"). + Preload("Monitor.Group"). + Preload("Monitor.Group.Notifications"). + Preload("Monitor.Group.Notifications.Contacts"). + First(&check, check.ID).Error) + + assert.NotPanics(t, func() { + RunExpCheck(&check) + }, "RunExpCheck must not panic when Contact preload encounters a broken association") +} diff --git a/internal/notifier/run_test.go b/internal/notifier/run_test.go new file mode 100644 index 0000000..cae751f --- /dev/null +++ b/internal/notifier/run_test.go @@ -0,0 +1,163 @@ +package notifier + +import ( + "log" + "reflect" + "sort" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/icrowley/fake" + "github.com/stretchr/testify/assert" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" + "rsgit.ru/rsmon/rsmon/spec/factories" +) + +func init() { + database.Init() +} + +func TestCreatesMessages(t *testing.T) { + log.Println("TestCreatesMessages") + models.Drop() + models.Migrate() + user := factories.PersistedUser("test@test.ru", "123") + account, err := models.CreateAccountForUser(fake.Company(), &user) + contact := factories.PersistedContact(account, &user) + group := factories.PersistedGroup(account) + notification := factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false) + monitor := factories.PersistedMonitor(&group) + + event := factories.PersistedEvent(&monitor, "current", "test event 1") + tn := time.Now() + tStart := tn.Add(-30 * time.Minute) + event.StartTime = &tStart + err = models.DB().Save(&event).Error + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, event.GetDuration(tn), int64(30*60)) + + log.Println("run first") + Run() + shoudHaveMessages("1a - contact should receive messages", t, "down", notification.ID, contact.ID, []int64{event.ID}) + + // Run again + log.Println("run again") + Run() + shoudHaveMessages("1b - contact should not receive more than one message", t, "down", notification.ID, contact.ID, []int64{event.ID}) +} + +func TestAggregatesMessages(t *testing.T) { + log.Println("TestAggregatesMessages") + models.Drop() + models.Migrate() + user := factories.PersistedUser("test@test.ru", "123") + account, err := models.CreateAccountForUser(fake.Company(), &user) + contact := factories.PersistedContact(account, &user) + group := factories.PersistedGroup(account) + notification := factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false) + + monitor1 := factories.PersistedMonitor(&group) + monitor2 := factories.PersistedMonitor(&group) + + event1 := factories.PersistedEvent(&monitor1, "current", "test event 2") + tStart := time.Now().Add(-30 * time.Minute) + event1.StartTime = &tStart + err = models.DB().Save(&event1).Error + if err != nil { + t.Fatal(err) + } + + event2 := factories.PersistedEvent(&monitor2, "current", "test event 3") + tStart = time.Now().Add(-5 * time.Minute) + event2.StartTime = &tStart + err = models.DB().Save(&event2).Error + if err != nil { + t.Fatal(err) + } + + Run() + shoudHaveMessages("2 - messages for multiple events should be aggegated", t, "down", notification.ID, contact.ID, []int64{event1.ID, event2.ID}) +} + +func TestDoesNotCreateEnded(t *testing.T) { + log.Println("TestDoesNotCreateEnded") + models.Drop() + models.Migrate() + user := factories.PersistedUser("test@test.ru", "123") + account, err := models.CreateAccountForUser(fake.Company(), &user) + contact := factories.PersistedContact(account, &user) + group := factories.PersistedGroup(account) + notification := factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false) + monitor := factories.PersistedMonitor(&group) + + event := factories.PersistedEvent(&monitor, "ended", "test event 1") + tStart := time.Now().Add(-90 * time.Minute) + tEnd := time.Now().Add(-80 * time.Minute) + event.StartTime = &tStart + event.EndTime = &tEnd + err = models.DB().Save(&event).Error + if err != nil { + t.Fatal(err) + } + + Run() + shoudHaveMessages("1 - contact should have no messages for ended notification", t, "down", notification.ID, contact.ID, []int64{}) +} + +func shoudHaveMessages(message string, t *testing.T, kind string, notificationID, contactID int64, wantIds []int64) { + q := models.DB() + if notificationID > 0 { + q = q.Where("notification_id = ?", notificationID) + } + if contactID > 0 { + q = q.Where("contact_id = ?", contactID) + } + + messages := make([]models.Message, 0) + err := models.MessageScope(q).Where("state IN ('queued')").Find(&messages).Error + if err != nil { + t.Fatal(err) + } + + if len(messages) > 1 { + spew.Dump(messages) + t.Fatal("found more than one message") + } + + haveIds := make([]int64, 0) + for _, msg := range messages { + assert.Equal(t, "queued", msg.State, "message should be in queued state") + assert.Equal(t, kind, msg.Kind, "message should have kind = down") + if len(msg.Events) > 0 { + if kind != "down" && kind != "up" { + t.Fatal(kind + " message should have no events") + } + for _, evt := range msg.Events { + haveIds = append(haveIds, evt.ID) + } + } else if msg.CheckID != nil { + if kind != "exp" { + t.Fatal(kind + " message should have no check") + } + + haveIds = append(haveIds, *msg.CheckID) + } + } + + if len(haveIds) != len(wantIds) { + t.Fatal(message, notificationID, contactID, "bad count, have", len(haveIds), "want", len(wantIds)) + } + + sort.SliceStable(wantIds, func(i, j int) bool { return wantIds[i] < wantIds[j] }) + sort.SliceStable(haveIds, func(i, j int) bool { return haveIds[i] < haveIds[j] }) + + if !reflect.DeepEqual(wantIds, haveIds) { + t.Fatal(message, "bad want/have", wantIds, haveIds) + } +} diff --git a/internal/notify/email.go b/internal/notify/email.go new file mode 100644 index 0000000..6292a52 --- /dev/null +++ b/internal/notify/email.go @@ -0,0 +1,57 @@ +// Package notify delivers notifications using credentials persisted in the +// notification_credentials table. It is the credential-backed counterpart to +// the legacy secrets.yml driven senders in internal/sender and internal/tg. +package notify + +import ( + "crypto/tls" + "fmt" + + "gopkg.in/gomail.v2" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// Email sends an email using the given SMTP credential. bodyText is sent as +// text/plain and, when non-empty, bodyHTML is added as a text/html alternative +// so the recipient's MUA picks the best representation. +func Email(cred *models.NotificationCredential, to, subject, bodyText, bodyHTML string) error { + if cred == nil { + return fmt.Errorf("credential is nil") + } + if cred.Kind != models.CredentialKindSMTP { + return fmt.Errorf("credential %d is not smtp (kind=%s)", cred.ID, cred.Kind) + } + if cred.Server == nil || cred.Port == nil || cred.Login == nil || cred.FromAddr == nil { + return fmt.Errorf("credential %d missing required smtp fields", cred.ID) + } + + password, err := cred.GetSecret() + if err != nil { + return fmt.Errorf("decrypt smtp password: %w", err) + } + + fromName := "RSMon" + if cred.FromName != nil && *cred.FromName != "" { + fromName = *cred.FromName + } + + m := gomail.NewMessage() + m.SetHeader("From", m.FormatAddress(*cred.FromAddr, fromName)) + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + m.AddAlternative("text/plain", bodyText) + if bodyHTML != "" { + m.AddAlternative("text/html", bodyHTML) + } + + d := gomail.NewDialer(*cred.Server, *cred.Port, *cred.Login, password) + if cred.InsecureSkipVerify { + d.TLSConfig = &tls.Config{InsecureSkipVerify: true} + } + + if err := d.DialAndSend(m); err != nil { + return fmt.Errorf("send smtp via credential %d: %w", cred.ID, err) + } + return nil +} diff --git a/internal/notify/email_network_test.go b/internal/notify/email_network_test.go new file mode 100644 index 0000000..9df5f2f --- /dev/null +++ b/internal/notify/email_network_test.go @@ -0,0 +1,202 @@ +package notify + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "testing" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + mailhogSMTPHost = "localhost" + mailhogSMTPPort = 31025 + mailhogAPIURL = "http://localhost:38025" + networkDialTimeout = 2 * time.Second + mailhogPollInterval = 100 * time.Millisecond + mailhogPollDeadline = 3 * time.Second +) + +// mailhogMessage mirrors the subset of MailHog's /api/v2/messages payload we +// care about in tests: the parsed headers and the rendered body. +type mailhogMessage struct { + Content struct { + Headers map[string][]string `json:"Headers"` + Body string `json:"Body"` + } `json:"Content"` +} + +// skipIfMailHogDown skips the test if MailHog's SMTP port is unreachable. +// Tests stay green on dev machines without docker; on CI with docker they +// run for real against the running MailHog container. +func skipIfMailHogDown(t *testing.T) { + t.Helper() + addr := net.JoinHostPort(mailhogSMTPHost, fmt.Sprintf("%d", mailhogSMTPPort)) + conn, err := net.DialTimeout("tcp", addr, networkDialTimeout) + if err != nil { + t.Skipf("mailhog not reachable at %s: %v", addr, err) + } + _ = conn.Close() +} + +// mailhogMessages fetches the most recent messages from MailHog's HTTP API. +func mailhogMessages(t *testing.T) []mailhogMessage { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mailhogAPIURL+"/api/v2/messages?limit=50", nil) + if err != nil { + t.Fatalf("build mailhog request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("query mailhog: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var out struct { + Total int `json:"total"` + Items []mailhogMessage `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode mailhog response: %v", err) + } + return out.Items +} + +// mailhogDeleteAll clears the MailHog in-memory mailbox so each test starts +// from a known state. +func mailhogDeleteAll(t *testing.T) { + t.Helper() + req, err := http.NewRequest(http.MethodDelete, mailhogAPIURL+"/api/v1/messages", nil) + if err != nil { + t.Fatalf("build mailhog delete: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("delete mailhog messages: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("mailhog delete returned %d", resp.StatusCode) + } +} + +func TestEmail_Network_SendAndVerify(t *testing.T) { + skipIfMailHogDown(t) + mailhogDeleteAll(t) + + port := mailhogSMTPPort + enabled := true + cred := &models.NotificationCredential{ + Kind: models.CredentialKindSMTP, + Name: "mailhog-test", + Server: strPtr(mailhogSMTPHost), + Port: &port, + Login: strPtr(""), + FromName: strPtr("RSMon Tests"), + FromAddr: strPtr("tests@rsmon.test"), + Enabled: &enabled, + // MailHog accepts any password; "plain:" prefix keeps GetSecret + // working without the credential encryption key configured. + SecretEnc: "plain:", + } + + subject := "rsmon-test-subject-" + time.Now().Format("150405.000") + body := "rsmon-test-body hello mailhog" + to := "to@rsmon.test" + + if err := Email(cred, to, subject, body, ""); err != nil { + t.Fatalf("Email returned error: %v", err) + } + + deadline := time.Now().Add(mailhogPollDeadline) + var found *mailhogMessage + for time.Now().Before(deadline) { + msgs := mailhogMessages(t) + for i := range msgs { + hsubj := msgs[i].Content.Headers["Subject"] + if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) { + found = &msgs[i] + break + } + } + if found != nil { + break + } + time.Sleep(mailhogPollInterval) + } + if found == nil { + t.Fatalf("message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline) + } + + if !strings.Contains(found.Content.Body, body) { + t.Errorf("body mismatch: want contains %q, got %q", body, found.Content.Body) + } + if hfrom := found.Content.Headers["From"]; len(hfrom) == 0 || !strings.Contains(hfrom[0], "tests@rsmon.test") { + t.Errorf("from mismatch: want contains tests@rsmon.test, got %v", hfrom) + } + if hto := found.Content.Headers["To"]; len(hto) == 0 || !strings.Contains(hto[0], to) { + t.Errorf("to mismatch: want contains %s, got %v", to, hto) + } +} + +func TestEmail_Network_HTMLAlternative(t *testing.T) { + skipIfMailHogDown(t) + mailhogDeleteAll(t) + + port := mailhogSMTPPort + enabled := true + cred := &models.NotificationCredential{ + Kind: models.CredentialKindSMTP, + Name: "mailhog-html", + Server: strPtr(mailhogSMTPHost), + Port: &port, + Login: strPtr(""), + FromAddr: strPtr("tests@rsmon.test"), + Enabled: &enabled, + SecretEnc: "plain:", + } + + subject := "rsmon-html-" + time.Now().Format("150405.000") + bodyText := "plain text body" + bodyHTML := "

html body

" + + if err := Email(cred, "to@rsmon.test", subject, bodyText, bodyHTML); err != nil { + t.Fatalf("Email returned error: %v", err) + } + + deadline := time.Now().Add(mailhogPollDeadline) + var found *mailhogMessage + for time.Now().Before(deadline) { + msgs := mailhogMessages(t) + for i := range msgs { + hsubj := msgs[i].Content.Headers["Subject"] + if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) { + found = &msgs[i] + break + } + } + if found != nil { + break + } + time.Sleep(mailhogPollInterval) + } + if found == nil { + t.Fatalf("html message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline) + } + + body := found.Content.Body + if !strings.Contains(body, bodyText) { + t.Errorf("text part missing: want contains %q in %q", bodyText, body) + } + if !strings.Contains(body, bodyHTML) { + t.Errorf("html part missing: want contains %q in %q", bodyHTML, body) + } +} diff --git a/internal/notify/email_test.go b/internal/notify/email_test.go new file mode 100644 index 0000000..5e6a7e4 --- /dev/null +++ b/internal/notify/email_test.go @@ -0,0 +1,54 @@ +package notify + +import ( + "testing" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestEmail_NilCred(t *testing.T) { + if err := Email(nil, "to@x.com", "s", "b", ""); err == nil { + t.Fatal("expected error for nil cred") + } +} + +func TestEmail_WrongKind(t *testing.T) { + cred := &models.NotificationCredential{Kind: models.CredentialKindTelegram} + if err := Email(cred, "to@x.com", "s", "b", ""); err == nil { + t.Fatal("expected error for wrong kind") + } +} + +func TestEmail_MissingRequiredFields(t *testing.T) { + cases := []struct { + name string + cred *models.NotificationCredential + }{ + {"nil server", &models.NotificationCredential{Kind: models.CredentialKindSMTP}}, + {"nil port", &models.NotificationCredential{ + Kind: models.CredentialKindSMTP, + Server: strPtr("smtp.x.com"), + }}, + {"nil login", &models.NotificationCredential{ + Kind: models.CredentialKindSMTP, + Server: strPtr("smtp.x.com"), + Port: intPtr(587), + }}, + {"nil fromaddr", &models.NotificationCredential{ + Kind: models.CredentialKindSMTP, + Server: strPtr("smtp.x.com"), + Port: intPtr(587), + Login: strPtr("u"), + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := Email(tc.cred, "to@x.com", "s", "b", ""); err == nil { + t.Fatalf("expected error for %s", tc.name) + } + }) + } +} + +func strPtr(s string) *string { return &s } +func intPtr(i int) *int { return &i } diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go new file mode 100644 index 0000000..f652865 --- /dev/null +++ b/internal/notify/telegram.go @@ -0,0 +1,66 @@ +package notify + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// Telegram sends a text message to the given chat ID using the given Telegram +// bot credential. chatID is the numeric Telegram chat id assigned by Telegram +// to a private chat, group or channel. +func Telegram(cred *models.NotificationCredential, chatID int64, text string) error { + if cred == nil { + return fmt.Errorf("credential is nil") + } + if cred.Kind != models.CredentialKindTelegram { + return fmt.Errorf("credential %d is not telegram (kind=%s)", cred.ID, cred.Kind) + } + + token, err := cred.GetSecret() + if err != nil { + return fmt.Errorf("decrypt telegram token: %w", err) + } + if token == "" { + return fmt.Errorf("credential %d has empty token", cred.ID) + } + + apiURL := "" + if cred.APIURL != nil && *cred.APIURL != "" { + apiURL = *cred.APIURL + } + + bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(token, telegramEndpoint(apiURL)) + if err != nil { + return fmt.Errorf("init telegram bot: %w", err) + } + + msg := tgbotapi.NewMessage(chatID, text) + if _, err := bot.Send(msg); err != nil { + return fmt.Errorf("send telegram via credential %d: %w", cred.ID, err) + } + return nil +} + +// telegramEndpoint returns the Bot API endpoint pattern that tgbotapi expects +// (".../bot%s/%s"). If rawURL is empty, returns the default Telegram endpoint. +// +// rawURL may be a base URL with optional basic-auth credentials in the +// userinfo (https://user:pass@host/) — net/http applies the userinfo as the +// Authorization header automatically, so reverse proxies with basic auth +// work transparently. +// +// We deliberately avoid url.Parse here: it percent-encodes the literal "%" +// in "/bot%s/%s" to "/bot%25s/%25s", which makes tgbotapi's fmt.Sprintf +// produce a malformed URL (verified against the deploy.rscz.ru proxy). +// Stripping the path and appending the bot-method pattern as a string is +// both simpler and safe. +func telegramEndpoint(rawURL string) string { + if rawURL == "" { + return tgbotapi.APIEndpoint + } + return strings.TrimRight(rawURL, "/") + "/bot%s/%s" +} diff --git a/internal/notify/telegram_network_test.go b/internal/notify/telegram_network_test.go new file mode 100644 index 0000000..1619a7a --- /dev/null +++ b/internal/notify/telegram_network_test.go @@ -0,0 +1,110 @@ +package notify + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestTelegram_Network_MockBotAPI(t *testing.T) { + var ( + gotPath atomic.Value + gotMethod atomic.Value + gotContentTy atomic.Value + gotForm atomic.Pointer[url.Values] + ) + gotPath.Store("") + gotMethod.Store("") + gotContentTy.Store("") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath.Store(r.URL.Path) + gotMethod.Store(r.Method) + gotContentTy.Store(r.Header.Get("Content-Type")) + if err := r.ParseForm(); err == nil { + form := r.PostForm + gotForm.Store(&form) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test","username":"test_bot"}}`)) + })) + defer srv.Close() + + enabled := true + cred := &models.NotificationCredential{ + Kind: models.CredentialKindTelegram, + Name: "test-bot", + BotName: strPtr("test_bot"), + APIURL: strPtr(srv.URL), + Enabled: &enabled, + SecretEnc: "plain:FAKE_TOKEN_FOR_TEST", + } + + if err := Telegram(cred, 123456789, "hello from test"); err != nil { + t.Fatalf("Telegram returned error: %v", err) + } + + path := gotPath.Load().(string) + method := gotMethod.Load().(string) + ct := gotContentTy.Load().(string) + if method != http.MethodPost { + t.Errorf("method = %s; want POST", method) + } + if !strings.HasPrefix(path, "/botFAKE_TOKEN_FOR_TEST/") { + t.Errorf("unexpected path: %s", path) + } + if !strings.HasSuffix(path, "/sendMessage") { + t.Errorf("expected path to end with /sendMessage, got: %s", path) + } + if !strings.Contains(ct, "application/x-www-form-urlencoded") { + t.Errorf("content-type = %q; want application/x-www-form-urlencoded", ct) + } + + formPtr := gotForm.Load() + if formPtr == nil { + t.Fatal("form body was not captured") + } + form := *formPtr + if got := form.Get("chat_id"); got != "123456789" { + t.Errorf("chat_id = %q; want 123456789", got) + } + if got := form.Get("text"); got != "hello from test" { + t.Errorf("text = %q; want %q", got, "hello from test") + } +} + +func TestTelegram_Network_BasicAuthProxy(t *testing.T) { + var gotAuth atomic.Value + gotAuth.Store("") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth.Store(r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"username":"t"}}`)) + })) + defer srv.Close() + + authedURL := strings.Replace(srv.URL, "http://", "http://gleb:rokBelHoho@", 1) + + enabled := true + cred := &models.NotificationCredential{ + Kind: models.CredentialKindTelegram, + Name: "proxy-bot", + APIURL: strPtr(authedURL), + Enabled: &enabled, + SecretEnc: "plain:TOKEN", + } + + if err := Telegram(cred, 1, "x"); err != nil { + t.Fatalf("Telegram returned error: %v", err) + } + got := gotAuth.Load().(string) + if !strings.HasPrefix(got, "Basic ") { + t.Errorf("expected Basic auth header, got %q", got) + } +} diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go new file mode 100644 index 0000000..0a1e5b5 --- /dev/null +++ b/internal/notify/telegram_test.go @@ -0,0 +1,53 @@ +package notify + +import ( + "testing" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestTelegram_NilCred(t *testing.T) { + if err := Telegram(nil, 123, "x"); err == nil { + t.Fatal("expected error for nil cred") + } +} + +func TestTelegram_WrongKind(t *testing.T) { + cred := &models.NotificationCredential{Kind: models.CredentialKindSMTP} + if err := Telegram(cred, 123, "x"); err == nil { + t.Fatal("expected error for wrong kind") + } +} + +func TestTelegram_EmptyToken(t *testing.T) { + enabled := true + cred := &models.NotificationCredential{ + Kind: models.CredentialKindTelegram, + Enabled: &enabled, + } + if err := Telegram(cred, 123, "x"); err == nil { + t.Fatal("expected error for empty token") + } +} + +func TestTelegramEndpoint_Default(t *testing.T) { + if got := telegramEndpoint(""); got != tgbotapi.APIEndpoint { + t.Fatalf("expected default endpoint %q, got %q", tgbotapi.APIEndpoint, got) + } +} + +func TestTelegramEndpoint_CustomURL(t *testing.T) { + cases := []struct{ in, want string }{ + {"https://api.telegram.org", "https://api.telegram.org/bot%s/%s"}, + {"https://api.telegram.org/", "https://api.telegram.org/bot%s/%s"}, + {"https://user:pass@deploy.rscz.ru/", "https://user:pass@deploy.rscz.ru/bot%s/%s"}, + {"https://deploy.rscz.ru", "https://deploy.rscz.ru/bot%s/%s"}, + } + for _, tc := range cases { + if got := telegramEndpoint(tc.in); got != tc.want { + t.Errorf("telegramEndpoint(%q) = %q; want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/notifyrender/count.go b/internal/notifyrender/count.go new file mode 100644 index 0000000..4c78cd0 --- /dev/null +++ b/internal/notifyrender/count.go @@ -0,0 +1,34 @@ +// Package notifyrender owns the subject/body templates used by both the +// legacy sender (internal/sender) and the worker-driven notification +// producer (internal/notifier). It is a leaf package so the notifier package +// can pre-render notification content for the worker without importing the +// sender package, which would otherwise create an import cycle (sender's +// tests already import notifier to drive the legacy loop). +package notifyrender + +import ( + "log" + "strconv" + + "rsgit.ru/rsmon/rsmon/config/translator" +) + +// GetCount provides functionality. +func GetCount(count int, kind string) string { + tr, err := translator.Translator.C(kind, float64(count), 0, strconv.Itoa(count)) + if err != nil { + log.Println("translator error", err) + return "монитор" + } + return tr +} + +// GetDownMany provides functionality. +func GetDownMany(count int) string { + return "Не " + GetCount(count, "monitor") +} + +// GetUpMany provides functionality. +func GetUpMany(count int) string { + return "Снова " + GetCount(count, "monitor") +} diff --git a/internal/notifyrender/event_table.go b/internal/notifyrender/event_table.go new file mode 100644 index 0000000..e625755 --- /dev/null +++ b/internal/notifyrender/event_table.go @@ -0,0 +1,95 @@ +package notifyrender + +import ( + "bytes" + "strings" + "time" + + "github.com/olekukonko/tablewriter" + tablewriterTw "github.com/olekukonko/tablewriter/tw" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/util" +) + +// RenderEventsTable provides functionality. +func RenderEventsTable(message *models.Message, tn time.Time) (string, string) { + if message.Kind == stateTest { + return "Тестовое сообщение от rsmon.ru", "Тестовое сообщение от rsmon.ru" + } + + cols := []string{"Монитор", "Проверка", "Время начала", "Время окончания", "Продолжительность", "Статус", "Ошибка"} + + asciiBuf := bytes.NewBuffer([]byte{}) + markdownBuf := bytes.NewBuffer([]byte{}) + + table := tablewriter.NewTable(asciiBuf, + tablewriter.WithHeader(cols), + tablewriter.WithBorders(tablewriterTw.Border{Left: tablewriterTw.On, Top: tablewriterTw.Off, Right: tablewriterTw.On, Bottom: tablewriterTw.Off}), //nolint:lll,staticcheck // deprecated API, pending migration + ) + + markdownBuf.WriteString("|") + for _, col := range cols { + markdownBuf.WriteString(" " + col + " |") + } + markdownBuf.WriteString("\n") + + markdownBuf.WriteString("|") + for range cols { + markdownBuf.WriteString(" --- |") + } + markdownBuf.WriteString("\n") + + for _, event := range message.Events { //nolint:gocritic // range copy is acceptable here + checksDown := []string{} + for _, check := range event.Checks { //nolint:gocritic // range copy is acceptable here + msg := check.Kind + ":" + if check.Name != nil { + msg = msg + " " + *check.Name + } + if check.URL != nil { + msg = msg + " " + *check.URL + "" + } + // msg = msg + "\n" + if check.Error != nil { + // msg = msg + "Ошибка:" + *check.Error + "" + msg = msg + " :warning: Ошибка:" + *check.Error + } + // msg = msg + "\n" + + checksDown = append(checksDown, msg) + } + + startTime := "" + if event.StartTime != nil { + startTime = event.StartTime.Format("02.01.2006 15:04:05") + } + + endTime := "" + if event.EndTime != nil { + endTime = event.EndTime.Format("02.01.2006 15:04:05") + } + + row := []string{ + event.Monitor.GetLabel(), + strings.Join(checksDown, " ; "), + startTime, + endTime, + util.FormatDuration(event.GetDuration(tn)), + event.State, + event.Reason, + } + _ = table.Append(row) + + markdownBuf.WriteString("| ") + for _, col := range row { + markdownBuf.WriteString(" " + col + " |") + } + markdownBuf.WriteString("\n") + } + // markdownBuf.WriteString("\n") + + _ = table.Render() + + return asciiBuf.String(), markdownBuf.String() +} diff --git a/internal/notifyrender/get_content.go b/internal/notifyrender/get_content.go new file mode 100644 index 0000000..bd3fa73 --- /dev/null +++ b/internal/notifyrender/get_content.go @@ -0,0 +1,98 @@ +// Package notifyrender owns the subject/body templates used by both the +// legacy sender (internal/sender) and the worker-driven notification +// producer (internal/notifier). It is a leaf package so the notifier package +// can pre-render notification content for the worker without importing the +// sender package, which would otherwise create an import cycle (sender's +// tests already import notifier to drive the legacy loop). +package notifyrender + +import ( + "bytes" + "strings" + "time" + + "github.com/russross/blackfriday/v2" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +const ( + stateDown = "down" + stateUp = "up" + stateTest = "test" + stateExp = "exp" +) + +// GetContent pre-renders subject + bodies for one Message using the same +// templates the legacy sender used to apply at delivery time. The result is +// what the worker binary consumes directly so it does not need access to +// Message/Event rows, workdays, or NotificationDayStart logic on the data plane. +// +// Returns the four bodies (subject, text, markdown, html). The caller is +// responsible for passing them through to the worker task payload. +func GetContent(message *models.Message, tn time.Time) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + var subject, textBody, markdownBody, htmlBody bytes.Buffer + + if message.Kind == stateTest { + subject.WriteString("тестовое сообщение от rsmon.ru") + textBody.WriteString("тестовое сообщение от rsmon.ru") + markdownBody.WriteString("#### тестовое сообщение от rsmon.ru") + htmlBody.WriteString("

тестовое сообщение от rsmon.ru

") + + return subject, textBody, markdownBody, htmlBody + } + + if message.Kind == stateExp { + if len(message.Events) > 0 { + panic("exp message with events") + } + if message.Check == nil { + panic("exp message with no check") + } + return TextExpires(message.Check) + } + + if message.Check != nil { + panic("up/down message with check") + } + + if len(message.Events) == 1 { + event := message.Events[0] + switch message.Kind { + case stateDown: + return TextDownOne(&event) + case stateUp: + return TextUpOne(&event) + default: + panic("bad message kind " + message.Kind) + } + } + switch message.Kind { + case stateDown: + subject.WriteString(GetDownMany(len(message.Events))) + case stateUp: + subject.WriteString(GetUpMany(len(message.Events))) + default: + panic("bad message kind" + message.Kind) + } + names := []string{} + for _, evt := range message.Events { //nolint:gocritic // range copy is acceptable here + names = append(names, evt.Monitor.GetLabel()) + } + subject.WriteString(": ") + subject.WriteString(strings.Join(names, ", ")) + + asciiTable, markdownTable := RenderEventsTable(message, tn) + + textBody.WriteString(subject.String() + "\n") + textBody.WriteString(asciiTable) + + markdownBody.WriteString("###### " + subject.String() + "\n\n") + markdownBody.WriteString(markdownTable) + + htmlTable := blackfriday.Run([]byte(markdownTable)) + htmlBody.WriteString("

Изменения статусов по мониторам:

") + htmlBody.Write(htmlTable) + + return subject, textBody, markdownBody, htmlBody +} diff --git a/internal/notifyrender/text_down_one.go b/internal/notifyrender/text_down_one.go new file mode 100644 index 0000000..94d410f --- /dev/null +++ b/internal/notifyrender/text_down_one.go @@ -0,0 +1,53 @@ +package notifyrender + +import ( + "bytes" + "html/template" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +var ( + // DownOneSubject provides functionality. + DownOneSubject *template.Template + // DownOneBody provides functionality. + DownOneBody *template.Template + // DownOneHTML provides functionality. + DownOneHTML *template.Template +) + +func init() { + DownOneSubject = template.Must(template.New("down_one_subject").Parse(`Не доступен {{.Monitor.GetLabel}}`)) + // DownOneBody provides functionality. + DownOneBody = template.Must(template.New("down_one_body").Parse(`Монитор {{.Monitor.GetLabel}} не доступен :warning: + +Проверки: {{.ChecksDown}}. +Ошибка: {{.Reason}} + +Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}} + +Недоступные проверки: +{{range .Checks}} +{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}} +{{end}}`)) + + DownOneHTML = template.Must(template.New("down_one_html").Parse(`

Монитор {{.Monitor.GetLabel}} не доступен

+ +Проверки: {{.ChecksDown}}. +
Ошибка:
+{{.Reason}} + +
Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}}
+ +
Недоступные проверки:
+{{range .Checks}} +
+{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}} +
+{{end}}`)) +} + +// TextDownOne provides functionality. +func TextDownOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + return executeTemplates(event, DownOneSubject, DownOneBody, DownOneHTML, " :warning:") +} diff --git a/internal/notifyrender/text_expires.go b/internal/notifyrender/text_expires.go new file mode 100644 index 0000000..8eef00e --- /dev/null +++ b/internal/notifyrender/text_expires.go @@ -0,0 +1,48 @@ +package notifyrender + +import ( + "bytes" + "html/template" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +var ( + // ExpSubject provides functionality. + ExpSubject *template.Template + // ExpBody provides functionality. + ExpBody *template.Template + // ExpHTML provides functionality. + ExpHTML *template.Template +) + +func init() { + ExpSubject = template.Must(template.New("exp_subject").Parse(`Скоро истекает {{.GetLabel}} ({{.KindLabel}}) по {{.Monitor.GetLabel}}`)) + // ExpBody provides functionality. + ExpBody = template.Must(template.New("exp_body").Parse(`Монитор {{.Monitor.GetLabel}} +{{.Expires.Format "02.01.2006 15:04:05"}} истекает {{.GetLabel}} ({{.KindLabel}}) +`)) + ExpHTML = template.Must(template.New("exp_html").Parse(`Монитор {{.Monitor.GetLabel}} +{{.Expires.Format "02.01.2006 15:04:05"}} истекает {{.GetLabel}} ({{.KindLabel}}) +`)) +} + +// TextExpires provides functionality. +func TextExpires(check *models.Check) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + var err error + var subject, textBody, htmlBody bytes.Buffer + + err = ExpSubject.Execute(&subject, check) + if err != nil { + panic(err) + } + err = ExpBody.Execute(&textBody, check) + if err != nil { + panic(err) + } + err = ExpHTML.Execute(&htmlBody, check) + if err != nil { + panic(err) + } + return subject, textBody, textBody, htmlBody +} diff --git a/internal/notifyrender/text_up_one.go b/internal/notifyrender/text_up_one.go new file mode 100644 index 0000000..bc46637 --- /dev/null +++ b/internal/notifyrender/text_up_one.go @@ -0,0 +1,76 @@ +package notifyrender + +import ( + "bytes" + "html/template" + "strings" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +var ( + // UpOneSubject provides functionality. + UpOneSubject *template.Template + // UpOneBody provides functionality. + UpOneBody *template.Template + // UpOneHTML provides functionality. + UpOneHTML *template.Template +) + +func init() { + UpOneSubject = template.Must(template.New("up_one_subject").Parse(`Снова доступен {{.Monitor.GetLabel}}`)) + // UpOneBody provides functionality. + UpOneBody = template.Must(template.New("up_one_body").Parse(`Монитор {{.Monitor.GetLabel}} снова доступен :white_check_mark: + +Проверки: +{{range .Checks}} +{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}} +{{end}} + +Он был недоступен {{.FormatDuration}} по причине ошибки {{.Reason}} + +{{if .StartTime}}Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}}{{end}} +{{if .EndTime}}Окончание события: {{.EndTime.Format "02.01.2006 15:04:05"}}{{end}} +`)) + UpOneHTML = template.Must(template.New("up_one_html").Parse(`

Монитор {{.Monitor.GetLabel}} снова доступен

. + +
Проверки:
+{{range .Checks}} +
+{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}} +
+{{end}} + +
Он был недоступен {{.FormatDuration}} по причине ошибки {{.Reason}}
+ +{{if .StartTime}}
Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}}
{{end}} +{{if .EndTime}}
Окончание события: {{.EndTime.Format "02.01.2006 15:04:05"}}
{{end}} +`)) +} + +// executeTemplates is a helper function that executes subject, body, and HTML templates +// and removes the specified emoji string from the text body for non-HTML output +func executeTemplates(event *models.Event, subject, body, html *template.Template, emojiToRemove string) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { //nolint:lll + var err error + var subjectBuf, textBodyBuf, htmlBodyBuf bytes.Buffer + + err = subject.Execute(&subjectBuf, event) + if err != nil { + panic(err) + } + err = body.Execute(&textBodyBuf, event) + if err != nil { + panic(err) + } + err = html.Execute(&htmlBodyBuf, event) + if err != nil { + panic(err) + } + txt := bytes.NewBufferString(strings.ReplaceAll(textBodyBuf.String(), emojiToRemove, "")) + return subjectBuf, *txt, textBodyBuf, htmlBodyBuf +} + +// TextUpOne provides functionality. +func TextUpOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + return executeTemplates(event, UpOneSubject, UpOneBody, UpOneHTML, " :white_check_mark:") +} diff --git a/internal/sender/context_test.go b/internal/sender/context_test.go new file mode 100644 index 0000000..28de09a --- /dev/null +++ b/internal/sender/context_test.go @@ -0,0 +1,34 @@ +package sender + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMattermostContextDeadlineInterruptsBlockingRequest(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-release + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + _, err := SendMattermostWithCredentialContext(ctx, server.URL, "maintenance_start", "subject", "body", nil) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("request did not reach blocking server") + } + close(release) + require.Error(t, err) + require.True(t, errors.Is(err, context.DeadlineExceeded)) +} diff --git a/internal/sender/count.go b/internal/sender/count.go new file mode 100644 index 0000000..c4c4b41 --- /dev/null +++ b/internal/sender/count.go @@ -0,0 +1,29 @@ +// Package sender provides functionality. +package sender + +import ( + "log" + "strconv" + + "rsgit.ru/rsmon/rsmon/config/translator" +) + +// GetCount provides functionality. +func GetCount(count int, kind string) string { + tr, err := translator.Translator.C(kind, float64(count), 0, strconv.Itoa(count)) + if err != nil { + log.Println("translator error", err) + return "монитор" + } + return tr +} + +// GetDownMany provides functionality. +func GetDownMany(count int) string { + return "Не " + GetCount(count, "monitor") +} + +// GetUpMany provides functionality. +func GetUpMany(count int) string { + return "Снова " + GetCount(count, "monitor") +} diff --git a/internal/sender/email.go b/internal/sender/email.go new file mode 100644 index 0000000..146b15a --- /dev/null +++ b/internal/sender/email.go @@ -0,0 +1,219 @@ +package sender + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/mail" + "net/smtp" + "strings" + "time" + + "gopkg.in/gomail.v2" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// RunEmail provides functionality. +func RunEmail(message *models.Message) error { + subject, textBody, _, htmlBody := GetContent(message, time.Now()) + return SendEmail(message.Contact.Value, subject.String(), textBody.String(), htmlBody.String()) +} + +// SendEmail provides functionality. +func SendEmail(to, subject, body, html string) error { + cred, err := firstSMTPCredential() + if err != nil { + return err + } + return SendEmailWithCredential(to, subject, body, html, cred) +} + +func firstSMTPCredential() (*wire.SMTPCredential, error) { + creds, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP) + if err != nil { + return nil, err + } + if len(creds) == 0 { + return nil, errors.New("smtp credential is not configured") + } + return smtpModelToWire(&creds[0]) +} + +func smtpModelToWire(cred *models.NotificationCredential) (*wire.SMTPCredential, error) { + if cred == nil { + return nil, errors.New("smtp credential is nil") + } + password, err := cred.GetSecret() + if err != nil { + return nil, fmt.Errorf("smtp credential secret: %w", err) + } + out := &wire.SMTPCredential{ + ID: cred.ID, + Name: cred.Name, + Password: password, + InsecureSkipVerify: cred.InsecureSkipVerify, + } + if cred.Server != nil { + out.Server = *cred.Server + } + if cred.Port != nil { + out.Port = *cred.Port + } + if cred.Login != nil { + out.Login = *cred.Login + } + if cred.FromName != nil { + out.FromName = *cred.FromName + } + if cred.FromAddr != nil { + out.FromAddress = *cred.FromAddr + } + return out, nil +} + +// SendEmailWithCredential delivers an email using the given wire SMTP +// credential. Phase 1 of docs/plans/worker-notifier-mvp.md ships every +// enabled SMTP credential to the worker; this function is what the worker +// executor calls so the same code path covers the operated-worker path and +// the per-customer-credential path Phase 4 will introduce. +func SendEmailWithCredential(to, subject, body, html string, cred *wire.SMTPCredential) error { + return SendEmailWithCredentialContext(context.Background(), to, subject, body, html, cred) +} + +// SendEmailWithCredentialContext performs SMTP over a context-aware dialer. +// Socket deadlines propagate cancellation through SMTP commands and DATA writes. +func SendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential) error { + return sendEmailWithCredentialContext(ctx, to, subject, body, html, cred, cred != nil && cred.Port == 465) +} + +// sendEmailWithCredentialContext permits the SMTPS transport choice to be +// tested with an unprivileged local listener. +func sendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential, implicitTLS bool) error { + if cred == nil { + return errors.New("smtp credential is nil") + } + if cred.Server == "" { + return errors.New("smtp credential: server is empty") + } + fromAddr := &mail.Address{Name: cred.FromName, Address: cred.FromAddress} + from := fromAddr.String() + + m := gomail.NewMessage() + m.SetHeader("From", from) + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + m.AddAlternative("text/plain", body) + m.AddAlternative("text/html", html) + + var raw bytes.Buffer + if _, err := m.WriteTo(&raw); err != nil { + return err + } + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(cred.Server, fmt.Sprint(cred.Port))) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = conn.SetDeadline(time.Now()) + case <-done: + } + }() + if implicitTLS { + tlsConn := tls.Client(conn, smtpTLSConfig(cred)) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return smtpContextError(ctx, err) + } + conn = tlsConn + } + client, err := smtp.NewClient(conn, cred.Server) + if err != nil { + return smtpContextError(ctx, err) + } + defer client.Quit() //nolint:errcheck + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(smtpTLSConfig(cred)); err != nil { + return smtpContextError(ctx, err) + } + } + if cred.Login != "" { + if err := client.Auth(smtpAuth(client, cred)); err != nil { + return smtpContextError(ctx, err) + } + } + if err := client.Mail(cred.FromAddress); err != nil { + return smtpContextError(ctx, err) + } + if err := client.Rcpt(to); err != nil { + return smtpContextError(ctx, err) + } + writer, err := client.Data() + if err != nil { + return smtpContextError(ctx, err) + } + if _, err := writer.Write(raw.Bytes()); err != nil { + return smtpContextError(ctx, err) + } + if err := writer.Close(); err != nil { + return smtpContextError(ctx, err) + } + return nil +} + +func smtpContextError(ctx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) { + return context.DeadlineExceeded + } + return err +} + +func smtpTLSConfig(cred *wire.SMTPCredential) *tls.Config { + return &tls.Config{ServerName: cred.Server, InsecureSkipVerify: cred.InsecureSkipVerify} +} + +func smtpAuth(client *smtp.Client, cred *wire.SMTPCredential) smtp.Auth { + _, mechanisms := client.Extension("AUTH") + for _, mechanism := range strings.Fields(strings.ToUpper(mechanisms)) { + switch mechanism { + case "CRAM-MD5": + return smtp.CRAMMD5Auth(cred.Login, cred.Password) + case "LOGIN": + return loginAuth{username: cred.Login, password: cred.Password} + case "PLAIN": + return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server) + } + } + // Preserve gomail's default when the server does not advertise mechanisms; + // smtp.Client returns the server's authoritative AUTH failure. + return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server) +} + +type loginAuth struct{ username, password string } + +func (a loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil } +func (a loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + challenge := strings.ToLower(string(fromServer)) + if strings.Contains(challenge, "username") || strings.Contains(challenge, "user") { + return []byte(a.username), nil + } + return []byte(a.password), nil +} diff --git a/internal/sender/email_context_test.go b/internal/sender/email_context_test.go new file mode 100644 index 0000000..4ab9382 --- /dev/null +++ b/internal/sender/email_context_test.go @@ -0,0 +1,273 @@ +package sender + +import ( + "bufio" + "context" + "crypto/hmac" + "crypto/md5" //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement. + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "errors" + "math/big" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +type fakeSMTPServer struct { + listener net.Listener + done chan error + tls *tls.Config +} + +func newFakeSMTPServer(t *testing.T, implicitTLS, startTLS, blockEHLO bool, auth string) *fakeSMTPServer { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := &fakeSMTPServer{listener: listener, done: make(chan error, 1), tls: fakeSMTPTLSConfig(t)} + go func() { server.done <- server.serve(implicitTLS, startTLS, blockEHLO, auth) }() + t.Cleanup(func() { + _ = listener.Close() + select { + case err := <-server.done: + require.NoError(t, err) + case <-time.After(time.Second): + t.Error("fake SMTP server did not exit") + } + }) + return server +} + +func (s *fakeSMTPServer) serve(implicitTLS, startTLS, blockEHLO bool, auth string) error { + conn, err := s.listener.Accept() + if err != nil { + return nil + } + defer conn.Close() //nolint:errcheck + if implicitTLS { + conn = tls.Server(conn, s.tls) + if err := conn.(*tls.Conn).Handshake(); err != nil { + return err + } + } + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + if err := smtpReply(writer, "220 fake smtp"); err != nil { + return err + } + line, err := smtpCommand(reader) + if err != nil { + return err + } + if !strings.HasPrefix(line, "EHLO ") { + return errors.New("expected EHLO") + } + if blockEHLO { + _, _ = reader.ReadByte() + return nil + } + capabilities := []string{"250-fake"} + if startTLS { + capabilities = append(capabilities, "250-STARTTLS") + } + if auth != "" { + capabilities = append(capabilities, "250-AUTH "+auth) + } + capabilities = append(capabilities, "250 OK") + if err := smtpReplies(writer, capabilities...); err != nil { + return err + } + if startTLS { + if line, err = smtpCommand(reader); err != nil || line != "STARTTLS" { + return errors.New("expected STARTTLS") + } + if err := smtpReply(writer, "220 ready for TLS"); err != nil { + return err + } + conn = tls.Server(conn, s.tls) + if err := conn.(*tls.Conn).Handshake(); err != nil { + return err + } + reader, writer = bufio.NewReader(conn), bufio.NewWriter(conn) + if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "EHLO ") { + return errors.New("expected EHLO after STARTTLS") + } + capabilities = []string{"250-fake"} + if auth != "" { + capabilities = append(capabilities, "250-AUTH "+auth) + } + capabilities = append(capabilities, "250 OK") + if err := smtpReplies(writer, capabilities...); err != nil { + return err + } + } + if err := smtpAuthenticate(reader, writer, auth); err != nil { + return err + } + if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "MAIL FROM:") { + return errors.New("expected MAIL FROM") + } + if err := smtpReply(writer, "250 sender ok"); err != nil { + return err + } + if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "RCPT TO:") { + return errors.New("expected RCPT TO") + } + if err := smtpReply(writer, "250 recipient ok"); err != nil { + return err + } + if line, err = smtpCommand(reader); err != nil || line != "DATA" { + return errors.New("expected DATA") + } + if err := smtpReply(writer, "354 send data"); err != nil { + return err + } + dataLines := 0 + for { + line, err = smtpCommand(reader) + if err != nil { + return err + } + if line == "." { + break + } + dataLines++ + } + if dataLines == 0 { + return errors.New("expected non-empty DATA payload") + } + if err := smtpReply(writer, "250 queued"); err != nil { + return err + } + line, err = smtpCommand(reader) + if err != nil || line != "QUIT" { + return errors.New("expected QUIT") + } + return smtpReply(writer, "221 bye") +} + +func smtpAuthenticate(reader *bufio.Reader, writer *bufio.Writer, auth string) error { + if auth == "" { + return nil + } + line, err := smtpCommand(reader) + if err != nil { + return err + } + if strings.Contains(auth, "CRAM-MD5") { + if line != "AUTH CRAM-MD5" { + return errors.New("expected capability-selected CRAM-MD5 authentication") + } + challenge := []byte("fake-cram-challenge") + if err := smtpReply(writer, "334 "+base64.StdEncoding.EncodeToString(challenge)); err != nil { + return err + } + response, err := smtpCommand(reader) + if err != nil { + return err + } + decoded, err := base64.StdEncoding.DecodeString(response) + if err != nil { + return err + } + mac := hmac.New(md5.New, []byte("password")) //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement. + _, _ = mac.Write(challenge) + if string(decoded) != "user "+fmtHex(mac.Sum(nil)) { + return errors.New("invalid CRAM-MD5 response") + } + return smtpReply(writer, "235 authenticated") + } + if line != "AUTH LOGIN" { + return errors.New("expected capability-selected LOGIN authentication") + } + if err := smtpReply(writer, "334 VXNlcm5hbWU6"); err != nil { + return err + } + if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("user")) { + return errors.New("invalid LOGIN username") + } + if err := smtpReply(writer, "334 UGFzc3dvcmQ6"); err != nil { + return err + } + if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("password")) { + return errors.New("invalid LOGIN password") + } + return smtpReply(writer, "235 authenticated") +} + +func TestSendEmailWithCredentialContextImplicitTLS(t *testing.T) { + server := newFakeSMTPServer(t, true, false, false, "CRAM-MD5 PLAIN") + cred := fakeSMTPCredential(t, server.listener.Addr().String()) + require.NoError(t, sendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "body", cred, true)) +} + +func TestSendEmailWithCredentialContextSTARTTLSAndLogin(t *testing.T) { + server := newFakeSMTPServer(t, false, true, false, "LOGIN PLAIN") + cred := fakeSMTPCredential(t, server.listener.Addr().String()) + require.NoError(t, SendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "body", cred)) +} + +func TestSendEmailWithCredentialContextDeadlineInterruptsSMTPCommand(t *testing.T) { + server := newFakeSMTPServer(t, false, false, true, "") + cred := fakeSMTPCredential(t, server.listener.Addr().String()) + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + err := SendEmailWithCredentialContext(ctx, "recipient@example.test", "subject", "body", "body", cred) + require.ErrorIs(t, err, context.DeadlineExceeded) +} + +func fakeSMTPCredential(t *testing.T, address string) *wire.SMTPCredential { + t.Helper() + host, port, err := net.SplitHostPort(address) + require.NoError(t, err) + portNumber, err := net.LookupPort("tcp", port) + require.NoError(t, err) + return &wire.SMTPCredential{Server: host, Port: portNumber, Login: "user", Password: "password", FromAddress: "sender@example.test", InsecureSkipVerify: true} +} + +func fakeSMTPTLSConfig(t *testing.T) *tls.Config { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + template := x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fake smtp"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + certificate, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) + require.NoError(t, err) + return &tls.Config{Certificates: []tls.Certificate{certificate}} +} + +func smtpCommand(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + return strings.TrimRight(line, "\r\n"), err +} + +func smtpReply(writer *bufio.Writer, line string) error { return smtpReplies(writer, line) } + +func smtpReplies(writer *bufio.Writer, lines ...string) error { + for _, line := range lines { + if _, err := writer.WriteString(line + "\r\n"); err != nil { + return err + } + } + return writer.Flush() +} + +func fmtHex(value []byte) string { + const hex = "0123456789abcdef" + result := make([]byte, len(value)*2) + for i, b := range value { + result[i*2], result[i*2+1] = hex[b>>4], hex[b&0x0f] + } + return string(result) +} diff --git a/internal/sender/event_table.go b/internal/sender/event_table.go new file mode 100644 index 0000000..8d931a7 --- /dev/null +++ b/internal/sender/event_table.go @@ -0,0 +1,17 @@ +package sender + +import ( + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/notifyrender" +) + +// RenderEventsTable provides functionality. +// +// Deprecated: use internal/notifyrender.RenderEventsTable directly. This +// wrapper is preserved for backwards compatibility until phase 3 retires the +// legacy sender loop entirely. +func RenderEventsTable(message *models.Message, tn time.Time) (string, string) { + return notifyrender.RenderEventsTable(message, tn) +} diff --git a/internal/sender/event_table_test.go b/internal/sender/event_table_test.go new file mode 100644 index 0000000..a7e8976 --- /dev/null +++ b/internal/sender/event_table_test.go @@ -0,0 +1,88 @@ +package sender + +import ( + "strings" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/icrowley/fake" + "github.com/stretchr/testify/assert" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" + "rsgit.ru/rsmon/rsmon/internal/notifier" + "rsgit.ru/rsmon/rsmon/spec/factories" +) + +func init() { + database.Init() +} + +var asciiTableExample = strings.TrimLeft(` +┌─────────┬──────────┬─────────────────────┬─────────────────┬───────────────────┬─────────┬──────────────┐ +│ МОНИТОР │ ПРОВЕРКА │ ВРЕМЯ НАЧАЛА │ ВРЕМЯ ОКОНЧАНИЯ │ ПРОДОЛЖИТЕЛЬНОСТЬ │ СТАТУС │ ОШИБКА │ +├─────────┼──────────┼─────────────────────┼─────────────────┼───────────────────┼─────────┼──────────────┤ +│ Tagopia │ │ 02.01.2018 03:04:05 │ │ 24 часа, 0 минут │ current │ test event 1 │ +└─────────┴──────────┴─────────────────────┴─────────────────┴───────────────────┴─────────┴──────────────┘ +`, "\n") + +var markdownTableExample = strings.TrimLeft(` +| Монитор | Проверка | Время начала | Время окончания | Продолжительность | Статус | Ошибка | +| --- | --- | --- | --- | --- | --- | --- | +| Tagopia | | 02.01.2018 03:04:05 | | 24 часа, 0 минут | current | test event 1 | +`, "\n") + +func TestEventTable(t *testing.T) { + models.Drop() + models.Migrate() + user := factories.PersistedUser("test@test.ru", "123") + account, err := models.CreateAccountForUser(fake.Company(), &user) + + contact := factories.PersistedContact(account, &user) + group := factories.PersistedGroup(account) + factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false) + + monitor := factories.PersistedMonitor(&group) + n := "Tagopia" + monitor.Name = &n + err = models.DB().Save(&monitor).Error + if err != nil { + t.Fatal(err) + } + + event := factories.PersistedEvent(&monitor, "current", "test event 1") + tStart := time.Date(2018, time.January, 2, 3, 4, 5, 0, time.Local) + tNow := time.Date(2018, time.January, 3, 3, 4, 5, 0, time.Local) + + event.StartTime = &tStart + err = models.DB().Save(&event).Error + if err != nil { + t.Fatal(err) + } + + notifier.Run() + + messages := make([]models.Message, 0) + err = models.MessageScope(models.DB()).Where("state IN ('queued')").Find(&messages).Error + if err != nil { + t.Fatal(err) + } + + if len(messages) == 0 { + t.Fatal("no message sent") + } + + if len(messages) > 1 { + spew.Dump(messages) + t.Fatal("found more than one message") + } + + asciiTable, markdownTable := RenderEventsTable(&messages[0], tNow) + + // fmt.Println(asciiTable) + // fmt.Println(markdownTable) + + assert.Equal(t, asciiTableExample, asciiTable, "rendered table should match example") + assert.Equal(t, markdownTableExample, markdownTable, "rendered table should match example") +} diff --git a/internal/sender/get_content.go b/internal/sender/get_content.go new file mode 100644 index 0000000..e0e8e3e --- /dev/null +++ b/internal/sender/get_content.go @@ -0,0 +1,19 @@ +package sender + +import ( + "bytes" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/notifyrender" +) + +// GetContent provides functionality. +// +// Deprecated: the worker-driven notifier producer now uses +// internal/notifyrender directly. This thin wrapper is preserved so the +// legacy RunMessage loop still compiles until it is removed in phase 3 of +// docs/plans/worker-notifier-mvp.md. +func GetContent(message *models.Message, tn time.Time) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + return notifyrender.GetContent(message, tn) +} diff --git a/internal/sender/get_content_test.go b/internal/sender/get_content_test.go new file mode 100644 index 0000000..bccb9aa --- /dev/null +++ b/internal/sender/get_content_test.go @@ -0,0 +1,187 @@ +package sender + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "rsgit.ru/rsmon/rsmon/spec/factories" +) + +var ( + subjectExampleDown = "Не доступен test monitor" + textBodyExampleDown = strings.TrimLeft(` +Монитор test monitor не доступен + +Проверки: [http]. +Ошибка: test error + +Начало события: 03.01.2019 03:04:04 + +Недоступные проверки: + +http - test check - URL не указан (testerr) +`, "\n") +) + +var markdownBodyExampleDown = strings.TrimLeft(` +Монитор test monitor не доступен :warning: + +Проверки: [http]. +Ошибка: test error + +Начало события: 03.01.2019 03:04:04 + +Недоступные проверки: + +http - test check - URL не указан (testerr) +`, "\n") + +var htmlBodyExampleDown = strings.TrimLeft(` +

Монитор test monitor не доступен

+ +Проверки: [http]. +
Ошибка:
+test error + +
Начало события: 03.01.2019 03:04:04
+ +
Недоступные проверки:
+ +
+http - test check - URL не указан (testerr) +
+`, "\n") + +var ( + subjectExampleUp = "Снова доступен test monitor" + textBodyExampleUp = strings.TrimLeft(` +Монитор test monitor снова доступен + +Проверки: + +http - test check - URL не указан (testerr) + + +Он был недоступен 1 минуту по причине ошибки test error + +Начало события: 03.01.2019 03:04:05 +Окончание события: 03.01.2019 03:05:05 +`, "\n") +) + +var markdownBodyExampleUp = strings.TrimLeft(` +Монитор test monitor снова доступен :white_check_mark: + +Проверки: + +http - test check - URL не указан (testerr) + + +Он был недоступен 1 минуту по причине ошибки test error + +Начало события: 03.01.2019 03:04:05 +Окончание события: 03.01.2019 03:05:05 +`, "\n") + +var htmlBodyExampleUp = strings.TrimLeft(` +

Монитор test monitor снова доступен

. + +
Проверки:
+ +
+http - test check - URL не указан (testerr) +
+ + +
Он был недоступен 1 минуту по причине ошибки test error
+ +
Начало события: 03.01.2019 03:04:05
+
Окончание события: 03.01.2019 03:05:05
+`, "\n") + +var ( + subjectExampleExp = "Скоро истекает test check (SSL сертификат) по test monitor" + textBodyExampleExp = strings.TrimLeft(` +Монитор test monitor +03.01.2019 04:04:05 истекает test check (SSL сертификат) +`, "\n") +) + +var markdownBodyExampleExp = strings.TrimLeft(` +Монитор test monitor +03.01.2019 04:04:05 истекает test check (SSL сертификат) +`, "\n") + +var htmlBodyExampleExp = strings.TrimLeft(` +Монитор test monitor +03.01.2019 04:04:05 истекает test check (SSL сертификат) +`, "\n") + +func TestGetContent(t *testing.T) { + tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC) + + message := factories.MessageFactory() + + subject, textBody, markdownBody, htmlBody := GetContent(&message, tn) + + // fmt.Println(subject.String()) + // fmt.Println(textBody.String()) + // fmt.Println(markdownBody.String()) + // fmt.Println(htmlBody.String()) + + assert.Equal(t, subjectExampleDown, subject.String(), "rendered subject should match example") + assert.Equal(t, textBodyExampleDown, textBody.String(), "rendered textBody should match example") + assert.Equal(t, markdownBodyExampleDown, markdownBody.String(), "rendered markdownBody should match example") + assert.Equal(t, htmlBodyExampleDown, htmlBody.String(), "rendered htmlBody should match example") +} + +func TestGetContentUp(t *testing.T) { + tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC) + + ts := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC) + te := time.Date(2019, time.January, 3, 3, 5, 5, 0, time.UTC) + + message := factories.MessageFactory() + message.Kind = "up" + message.Events[0].StartTime = &ts + message.Events[0].EndTime = &te + + subject, textBody, markdownBody, htmlBody := GetContent(&message, tn) + + // fmt.Println(subject.String()) + // fmt.Println(textBody.String()) + // fmt.Println(markdownBody.String()) + // fmt.Println(htmlBody.String()) + + assert.Equal(t, subjectExampleUp, subject.String(), "rendered subject should match example") + assert.Equal(t, textBodyExampleUp, textBody.String(), "rendered textBody should match example") + assert.Equal(t, markdownBodyExampleUp, markdownBody.String(), "rendered markdownBody should match example") + assert.Equal(t, htmlBodyExampleUp, htmlBody.String(), "rendered htmlBody should match example") +} + +func TestGetContentExp(t *testing.T) { + tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC) + + message := factories.ExpMessageFactory() + + in1h := tn.Add(1 * time.Hour) + message.Check.Expires = &in1h + message.Check.Kind = "ssl" + + subject, textBody, _, htmlBody := GetContent(&message, tn) + + subject, textBody, markdownBody, htmlBody := GetContent(&message, tn) + + // fmt.Println(subject.String()) + // fmt.Println(textBody.String()) + // fmt.Println(markdownBody.String()) + // fmt.Println(htmlBody.String()) + + assert.Equal(t, subjectExampleExp, subject.String(), "rendered subject should match example") + assert.Equal(t, textBodyExampleExp, textBody.String(), "rendered textBody should match example") + assert.Equal(t, markdownBodyExampleExp, markdownBody.String(), "rendered markdownBody should match example") + assert.Equal(t, htmlBodyExampleExp, htmlBody.String(), "rendered htmlBody should match example") +} diff --git a/internal/sender/invite.go b/internal/sender/invite.go new file mode 100644 index 0000000..fb3829f --- /dev/null +++ b/internal/sender/invite.go @@ -0,0 +1,85 @@ +package sender + +import ( + "bytes" + "html/template" + "log" + + "github.com/pkg/errors" + "gorm.io/gorm/clause" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +var ( + inviteSubject *template.Template + inviteMessage *template.Template + inviteHTMLMessage *template.Template +) + +func init() { + inviteSubject = template.Must(template.New("invite_subject").Parse(`Доступ к мониторингу rsmon.ru`)) + + inviteMessage = template.Must(template.New("invite_message").Parse(` +Пользователь {{.Inviter.Name}} {{.Inviter.Email}} +{{ if not .InviteeID }}пригласил вас получить{{else}}предоставил вам{{end}} доступ к аккаунту rsmon.ru {{.Account.Name}} + +{{ if not .InviteeID }} +Чтобы зарегистрироваться, перейдите по ссылке: https://rsmon.ru/by-invite/{{.Token}} +{{end}} + +https://rsmon.ru + `)) + + inviteHTMLMessage = template.Must(template.New("invite_message").Parse(` +

Пользователь {{.Inviter.Name}} {{.Inviter.Email}} +{{ if not .InviteeID }}пригласил вас получить{{else}}предоставил вам{{end}} +доступ к аккаунту rsmon.ru {{.Account.Name}}

+ +{{ if not .InviteeID }} +

Чтобы зарегистрироваться, перейдите по ссылке: +https://rsmon.ru/by-invite/{{.Token}}

+{{end}} + +

https://rsmon.ru

+ `)) +} + +// Invite provides functionality. +func Invite(i *models.Invite) error { + // us := models.User{} + var err error + var subject, message, htmlMessage bytes.Buffer + err = inviteSubject.Execute(&subject, i) + if err != nil { + return err + } + err = inviteMessage.Execute(&message, i) + if err != nil { + return err + } + err = inviteHTMLMessage.Execute(&htmlMessage, i) + if err != nil { + return err + } + + err = SendEmail(i.Email, subject.String(), message.String(), htmlMessage.String()) + if err != nil { + log.Println("email send fail", err) + i.State = "FAIL" + serr := models.DB().Save(&i).Error + if serr != nil { + return errors.Wrap(err, "failed to save invite, and failed to save to DB") + } + return errors.Wrap(err, "failed to save invite") + } + if i.State != "OK" { + i.State = "SENT" + } + + err = models.DB().Omit(clause.Associations).Save(&i).Error + if err != nil { + return errors.Wrap(err, "failed to add accesses to invited user") + } + return nil +} diff --git a/internal/sender/mattermost.go b/internal/sender/mattermost.go new file mode 100644 index 0000000..e5287a5 --- /dev/null +++ b/internal/sender/mattermost.go @@ -0,0 +1,155 @@ +package sender + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/wire" +) + +// messageKindUp is the canonical string the notifier uses for "monitor +// just came back up" events. Lives here as a package-level constant so +// the goconst linter stops flagging the literal "up" appearing three +// times across the sender package. +const messageKindUp = "up" + +// MattermostPayload provides functionality. +type MattermostPayload struct { + Channel *string `json:"channel,omitempty"` + Username *string `json:"username,omitempty"` + IconURL *string `json:"icon_url,omitempty"` + Text *string `json:"text,omitempty"` +} + +// RunMattermost provides functionality. +func RunMattermost(message *models.Message) (*string, error) { + _, _, markdownBody, _ := GetContent(message, time.Now()) + + s := "@channel " + if message.Kind == messageKindUp { + s += ":white_check_mark: " + } else { + s += ":warning: " + } + s = s + "\n\n" + markdownBody.String() + + color := "green" + if message.Kind == "down" { + color = "red" + } + + un := "RSMon" + icon := "https://rsmon.ru/" + color + "_logo.svg" + payload := MattermostPayload{ + Text: &s, + Username: &un, + IconURL: &icon, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + log.Println("send mattermost:", message.Contact.Value) + // log.Println(string(body)) + + resp, err := httpClient.Post( + message.Contact.Value, + "application/json", + bytes.NewBuffer(body), + ) + if err != nil { + return nil, err + } + + defer resp.Body.Close() //nolint:errcheck + body, err = io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + str := string(body) + return &str, nil +} + +// SendMattermostWithCredential delivers a Mattermost notification using the +// supplied wire credential block (which carries the default username + +// icon URL). The webhook URL is per-task; the credential block only sets +// the cosmetic defaults. This mirrors what the legacy RunMattermost did with +// secrets + a hardcoded RSMon username / icon. +func SendMattermostWithCredential( + contactValue, messageKind, subject, markdownBody string, + cred *wire.MattermostCredential, +) (*string, error) { + return SendMattermostWithCredentialContext(context.Background(), contactValue, messageKind, subject, markdownBody, cred) +} + +func SendMattermostWithCredentialContext( + ctx context.Context, contactValue, messageKind, subject, markdownBody string, + cred *wire.MattermostCredential, +) (*string, error) { + if contactValue == "" { + return nil, errors.New("mattermost contact value is empty") + } + + s := "@channel " + if messageKind == messageKindUp { + s += ":white_check_mark: " + } else { + s += ":warning: " + } + s = s + "\n\n" + subject + "\n\n" + markdownBody + + color := "green" + if messageKind == "down" { + color = "red" + } + + username := "RSMon" + iconURL := "https://rsmon.ru/" + color + "_logo.svg" + if cred != nil { + if cred.DefaultUsername != "" { + username = cred.DefaultUsername + } + if cred.DefaultIconURL != "" { + iconURL = cred.DefaultIconURL + } + } + + payload := MattermostPayload{ + Text: &s, + Username: &username, + IconURL: &iconURL, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 60 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, contactValue, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + str := string(respBody) + return &str, nil +} diff --git a/internal/sender/run.go b/internal/sender/run.go new file mode 100644 index 0000000..8250751 --- /dev/null +++ b/internal/sender/run.go @@ -0,0 +1,90 @@ +package sender + +import ( + "errors" + "log" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// Run provides functionality. +func Run() { + messages := make([]models.Message, 0) + + tx := models.DB().Begin().Set("gorm:association_autoupdate", false) + + q := tx + // q = q.Set("gorm:query_option", "FOR UPDATE") + err := models.MessageScope(q).Where("state IN ('queued')").Find(&messages).Error + if err != nil { + tx.Rollback() + log.Println(err) + return + // panic(err) + } + + for _, msg := range messages { //nolint:gocritic // range copy is acceptable here + msg.State = "sending" + tx.Model(&msg).Update("state", "sending") + // spew.Dump(msg.ID) + // err = tx.Save(&msg).Error + // if err != nil { + // panic(err) + // } + } + _ = tx.Commit() + + for _, msg := range messages { //nolint:gocritic // range copy is acceptable here + _, _ = RunMessage(&msg) + } +} + +// RunMessage provides functionality. +func RunMessage(message *models.Message) (*string, error) { + var err error + var response *string + + log.Println("send message", message.ID) + switch message.Contact.Kind { + case "telegram_group", "telegram_private": + err = RunTelegram(message) + case "email": + err = RunEmail(message) + case "webhook": + response, err = RunWebhook(message) + case "mattermost": + response, err = RunMattermost(message) + case "sms": + err = RunSMS(message) + case "voice": + err = RunVoice(message) + default: + err = errors.New("notification kind not implemented: " + message.Contact.Kind) + } + if response != nil { + log.Println("notification done. response:", *response, "error:", err) + } else { + log.Println("notification done. error:", err) + } + + if message.ID != 0 { + if response != nil { + message.Response = response + } + if err == nil { + message.State = "sent" + message.SentAt = time.Now() + } else { + message.State = "error" + et := err.Error() + message.Error = &et + } + err = models.DB().Save(&message).Error + if err != nil { + log.Println("ERROR:", err) + return response, err + } + } + return response, err +} diff --git a/internal/sender/sender.go b/internal/sender/sender.go new file mode 100644 index 0000000..e9065a4 --- /dev/null +++ b/internal/sender/sender.go @@ -0,0 +1,33 @@ +package sender + +import ( + "net/http" + "time" +) + +var httpClient *http.Client + +func init() { + httpClient = &http.Client{ + Timeout: time.Second * 60, + } +} + +// Init sender +// +// Deprecated: the legacy SMPP init that lived here is commented out. Phase 3 +// of docs/plans/worker-notifier-mvp.md retires the sender loop entirely. +// This stub is preserved so any remaining callers do not fail to compile +// during the rollout window. +func Init() { + // if application.Env == "production" { + // InitSMPP() + // } +} + +// Start sender +// +// Deprecated: the legacy sender loop is retired. The worker binary is the executor. +func Start() { + // no-op +} diff --git a/internal/sender/sms.go b/internal/sender/sms.go new file mode 100644 index 0000000..d5994ae --- /dev/null +++ b/internal/sender/sms.go @@ -0,0 +1,41 @@ +package sender + +import ( + "log" + "strconv" + "time" + + "github.com/ns3777k/go-smsaero/smsaero" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/application" +) + +// RunSMS provides functionality. +func RunSMS(message *models.Message) error { + subject, _, _, _ := GetContent(message, time.Now()) + return SendSMS(message.Contact.Value, subject.String()) +} + +// SendSMS provides functionality. +func SendSMS(to, message string) error { + // return nil + // https://smsaero.ru/ + if application.Env == envProduction { + log.Println("sms send to:", to, "message:", message) + phonei, err := strconv.Atoi(to) + if err != nil { + return err + } + + client := smsaero.NewClient(nil, "glebtv@gmail.com", "U10x9UPWrlLPx5PlgE8orwvVqtS") + _, err = client.Send(phonei, message, "rsmon") + if err != nil { + log.Println("sms result:", err) + return err + } + } else { + log.Println("sms debug. to:", to, "message:", message) + } + return nil +} diff --git a/internal/sender/telegram.go b/internal/sender/telegram.go new file mode 100644 index 0000000..f6c37bb --- /dev/null +++ b/internal/sender/telegram.go @@ -0,0 +1,53 @@ +package sender + +import ( + "context" + "errors" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/tg" +) + +// RunTelegram provides functionality. +func RunTelegram(message *models.Message) error { + subject, _, _, _ := GetContent(message, time.Now()) + return tg.SendMessage(message.Contact.Value, subject.String()) +} + +// SendTelegramWithCredential delivers a Telegram message using the given bot +// credential. Phase 1 of docs/plans/worker-notifier-mvp.md: the worker holds +// the credential in memory after the init/config push and passes it here. +func SendTelegramWithCredential(chatID, subject, body string, cred *models.NotificationCredential) error { + return SendTelegramWithCredentialContext(context.Background(), chatID, subject, body, cred) +} + +// SendTelegramWithCredentialContext prevents handoff after cancellation. The +// tgbotapi client used by the legacy sender does not expose a context-aware +// Send method, so an already handed-off Telegram request cannot be interrupted. +func SendTelegramWithCredentialContext(ctx context.Context, chatID, subject, body string, cred *models.NotificationCredential) error { + if err := ctx.Err(); err != nil { + return err + } + if cred == nil { + return errors.New("telegram credential is nil") + } + text := subject + if body != "" { + text = subject + "\n\n" + body + } + err := sendTelegramWithBot(chatID, text, cred) + if ctx.Err() != nil { + return ctx.Err() + } + return err +} + +// sendTelegramWithBot is the low-level helper that the legacy RunTelegram +// (legacy secrets path) and SendTelegramWithCredential (worker wire path) +// both call. It derives an http API URL from the credential's APIURL. +func sendTelegramWithBot(chatID, text string, cred *models.NotificationCredential) error { + // Use a credential-scoped helper so each Telegram credential can map to a + // separate bot token/API URL. + return tg.SendMessageWithToken(chatID, text, cred) +} diff --git a/internal/sender/test_message.go b/internal/sender/test_message.go new file mode 100644 index 0000000..fbeaa8e --- /dev/null +++ b/internal/sender/test_message.go @@ -0,0 +1,13 @@ +package sender + +import ( + "rsgit.ru/rsmon/rsmon/app/models" +) + +func TestMessage(contact *models.Contact) (*string, error) { + message := models.Message{ + Contact: contact, + Kind: "test", + } + return RunMessage(&message) +} diff --git a/internal/sender/text_down_one.go b/internal/sender/text_down_one.go new file mode 100644 index 0000000..1aaaf75 --- /dev/null +++ b/internal/sender/text_down_one.go @@ -0,0 +1,15 @@ +package sender + +import ( + "bytes" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/notifyrender" +) + +// TextDownOne provides functionality. +// +// Deprecated: use internal/notifyrender.TextDownOne directly. +func TextDownOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + return notifyrender.TextDownOne(event) +} diff --git a/internal/sender/text_expires.go b/internal/sender/text_expires.go new file mode 100644 index 0000000..7fccf35 --- /dev/null +++ b/internal/sender/text_expires.go @@ -0,0 +1,15 @@ +package sender + +import ( + "bytes" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/notifyrender" +) + +// TextExpires provides functionality. +// +// Deprecated: use internal/notifyrender.TextExpires directly. +func TextExpires(check *models.Check) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + return notifyrender.TextExpires(check) +} diff --git a/internal/sender/text_up_one.go b/internal/sender/text_up_one.go new file mode 100644 index 0000000..55b6ec2 --- /dev/null +++ b/internal/sender/text_up_one.go @@ -0,0 +1,15 @@ +package sender + +import ( + "bytes" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/internal/notifyrender" +) + +// TextUpOne provides functionality. +// +// Deprecated: use internal/notifyrender.TextUpOne directly. +func TextUpOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { + return notifyrender.TextUpOne(event) +} diff --git a/internal/sender/voice.go b/internal/sender/voice.go new file mode 100644 index 0000000..730f3d0 --- /dev/null +++ b/internal/sender/voice.go @@ -0,0 +1,13 @@ +package sender + +import ( + "errors" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// RunVoice provides functionality. +func RunVoice(message *models.Message) error { + _ = message + return errors.New("voice notifications are disabled") +} diff --git a/internal/sender/webhook.go b/internal/sender/webhook.go new file mode 100644 index 0000000..e92abc4 --- /dev/null +++ b/internal/sender/webhook.go @@ -0,0 +1,99 @@ +package sender + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/application" +) + +const envProduction = "production" + +// RunWebhook provides functionality. +func RunWebhook(message *models.Message) (*string, error) { + if application.Env != envProduction { + return nil, errors.New("not sending in env " + application.Env) + } + + body, err := json.Marshal(message) + if err != nil { + return nil, err + } + + resp, err := httpClient.Post( + message.Contact.Value, + "application/json", + bytes.NewBuffer(body), + ) + if err != nil { + return nil, err + } + + defer resp.Body.Close() //nolint:errcheck + body, err = io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + str := string(body) + return &str, nil +} + +// SendWebhookWithCredential delivers a webhook notification using the +// provided wire credential block. Phase 1 ships an empty signing secret; +// phase 4 will plumb the per-account secret through Contact.Data. The +// signature header is X-RSMon-Signature (hex-encoded HMAC-SHA256 of the +// body), matching what webhook consumers in the existing fleet expect. +// +// The payload matches the wire.NotificationTask shape so customers +// receiving the legacy Message JSON see one fewer breaking change. +func SendWebhookWithCredential(payload []byte, contactValue, signingSecret string) (*string, error) { + return SendWebhookWithCredentialContext(context.Background(), payload, contactValue, signingSecret) +} + +func SendWebhookWithCredentialContext(ctx context.Context, payload []byte, contactValue, signingSecret string) (*string, error) { + if application.Env != envProduction { + return nil, errors.New("not sending in env " + application.Env) + } + if contactValue == "" { + return nil, errors.New("webhook contact value is empty") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, contactValue, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "rsmon-worker/1") + if signingSecret != "" { + mac := hmac.New(sha256.New, []byte(signingSecret)) + mac.Write(payload) + req.Header.Set("X-RSMon-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) + } + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 500 { + return nil, errors.New("webhook upstream " + resp.Status) + } + str := string(respBody) + return &str, nil +} diff --git a/internal/tg/bot.go b/internal/tg/bot.go new file mode 100644 index 0000000..cd2da2c --- /dev/null +++ b/internal/tg/bot.go @@ -0,0 +1,411 @@ +// Package tg provides Telegram bot functionality for RSMon. +package tg + +import ( + "errors" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +var bot *tgbotapi.BotAPI + +type silentTelegramLogger struct{} + +func (silentTelegramLogger) Println(...interface{}) {} +func (silentTelegramLogger) Printf(string, ...interface{}) {} + +func init() { + _ = tgbotapi.SetLogger(silentTelegramLogger{}) +} + +const ( + defaultBaseURL = "https://rsmon.ru" + telegramAPITimeout = 15 * time.Second +) + +func telegramAPIEndpoint(rawURL string) string { + if rawURL == "" { + return tgbotapi.APIEndpoint + } + return strings.TrimRight(rawURL, "/") + "/bot%s/%s" +} + +func newBotAPI(token, apiURL string) (*tgbotapi.BotAPI, error) { + return tgbotapi.NewBotAPIWithClient(token, telegramAPIEndpoint(apiURL), &http.Client{Timeout: telegramAPITimeout}) +} + +func botAPIForCredential(cred *models.NotificationCredential) (*tgbotapi.BotAPI, error) { + if cred == nil { + return nil, errors.New("telegram credential is nil") + } + token, err := cred.GetSecret() + if err != nil { + return nil, fmt.Errorf("telegram credential secret: %w", err) + } + apiURL := "" + if cred.APIURL != nil { + apiURL = *cred.APIURL + } + client, err := newBotAPI(token, apiURL) + if err != nil { + return nil, err + } + client.Debug = false + return client, nil +} + +// GetContact finds or creates a contact for the given Telegram chat. +func GetContact(kind, name string, chatID int64) (models.Contact, error) { + contact := models.Contact{} + + if kind == "supergroup" { + kind = "group" + } + ckind := "telegram_" + kind + cvalue := strconv.FormatInt(chatID, 10) + + models.DB().Where("kind = ? AND value = ?", ckind, cvalue).Find(&contact) + + contact.Kind = ckind + contact.Value = cvalue + contact.Name = name + if contact.Token == "" { + contact.SetToken() + } + err := models.DB().Save(&contact).Error + + return contact, err +} + +// Init initializes the Telegram bot API client. +func Init() error { + if bot == nil { + client, err := defaultBotAPI() + if err != nil { + return err + } + bot = client + } + return nil +} + +func defaultBotAPI() (*tgbotapi.BotAPI, error) { + creds, err := models.EnabledCredentialsByKind(models.CredentialKindTelegram) + if err != nil { + return nil, err + } + if len(creds) == 0 { + return nil, errors.New("telegram bot not configured") + } + return botAPIForCredential(&creds[0]) +} + +func botAPIForCredentialID(id int64) (*tgbotapi.BotAPI, error) { + if id <= 0 { + return defaultBotAPI() + } + cred, err := models.FindCredential(id) + if err != nil { + return nil, err + } + if cred.Kind != models.CredentialKindTelegram { + return nil, fmt.Errorf("credential %d is %q, not telegram", id, cred.Kind) + } + if cred.Enabled != nil && !*cred.Enabled { + return nil, fmt.Errorf("credential %d is disabled", id) + } + return botAPIForCredential(cred) +} + +// SendMessage sends a Telegram message to the given chat ID string. +func SendMessage(chatIDStr, message string) error { + var err error + + iChatID, err := strconv.ParseInt(chatIDStr, 10, 64) + if err != nil { + return err + } + err = Init() + if err != nil { + return err + } + msg := tgbotapi.NewMessage(iChatID, "") + msg.Text = message + + _, err = bot.Send(msg) + recordSentMessage(iChatID, message, err) + if err != nil { + log.Println(err) + } + return err +} + +// SendMessageWithToken is the credential-scoped variant used by the worker +// executor. It builds a one-shot bot client from the credential's BotToken + +// optional APIURL, then sends the message. Returns the bot's response error +// so callers can translate into retryable/permanent status. +func SendMessageWithToken(chatIDStr, message string, cred *models.NotificationCredential) error { + if cred == nil { + return errors.New("telegram credential is nil") + } + if cred.Kind != models.CredentialKindTelegram { + return fmt.Errorf("credential %d is not telegram (kind=%s)", cred.ID, cred.Kind) + } + + chatID, err := strconv.ParseInt(chatIDStr, 10, 64) + if err != nil { + return err + } + + client, err := botAPIForCredential(cred) + if err != nil { + return err + } + + msg := tgbotapi.NewMessage(chatID, message) + _, err = client.Send(msg) + recordSentMessage(chatID, message, err) + return err +} + +// Start starts the Telegram bot update loop. +func Start() { + StartWithCredentialID(0) +} + +// StartWithCredentialID starts the Telegram bot update loop for a specific credential. +func StartWithCredentialID(credentialID int64) { + var err error + if credentialID > 0 { + bot, err = botAPIForCredentialID(credentialID) + } else { + err = Init() + } + if err != nil { + log.Println(err) + return + } + log.Printf("Authorized on account %s", bot.Self.UserName) + SetBotCommands(bot) + if _, err := bot.Request(tgbotapi.DeleteWebhookConfig{DropPendingUpdates: false}); err != nil { + log.Println("telegram delete webhook:", err) + return + } + + u := tgbotapi.NewUpdate(0) + u.Timeout = 10 + + updates := bot.GetUpdatesChan(u) + markBotOnline("") + go heartbeat() + + for update := range updates { + ProcessUpdate(bot, update) + } +} + +// ProcessWebhookUpdate handles one Telegram webhook update for a credential. +func ProcessWebhookUpdate(cred *models.NotificationCredential, update tgbotapi.Update) error { + client, err := botAPIForCredential(cred) + if err != nil { + return err + } + ProcessUpdate(client, update) + return nil +} + +// SetCredentialCommands registers the slash command menu for a credential-backed bot. +func SetCredentialCommands(cred *models.NotificationCredential) error { + client, err := botAPIForCredential(cred) + if err != nil { + return err + } + SetBotCommands(client) + return nil +} + +// ProcessUpdate handles one Telegram update from polling or webhook delivery. +func ProcessUpdate(client *tgbotapi.BotAPI, update tgbotapi.Update) { + if update.Message == nil { // ignore any non-Message updates for now + return + } + recordReceivedMessage(update.Message, nil) + markBotOnline("") + + if !update.Message.IsCommand() { + return + } + + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "") + contact, err := contactForMessage(update.Message) + if err != nil { + msg.Text = "Внутренняя ошибка rsmon: " + err.Error() + sendAndRecord(client, msg) + return + } + + msg.Text = commandResponse(update.Message, contact) + sendAndRecord(client, msg) +} + +func contactForMessage(message *tgbotapi.Message) (models.Contact, error) { + var name string + switch message.Chat.Type { + case "private": + name = strings.TrimSpace("@" + strings.TrimSpace(message.Chat.UserName+" "+message.Chat.FirstName+" "+message.Chat.LastName)) + case "group", "supergroup": + name = message.Chat.Title + default: + return models.Contact{}, fmt.Errorf("unknown chat type: %s", message.Chat.Type) + } + return GetContact(message.Chat.Type, name, message.Chat.ID) +} + +func commandResponse(message *tgbotapi.Message, contact models.Contact) string { + baseURL := strings.TrimRight(os.Getenv("BASE_URL"), "/") + if baseURL == "" { + baseURL = defaultBaseURL + } + link := baseURL + "/telegram?token=" + contact.Token + switch message.Command() { + case "start": + return "Для завершения добавления Telegram-уведомлений перейдите по ссылке:\n" + link + + "\n\n/id - показать ID чата\n/info - статус привязки\n/settings - настройки\n/stop - отключить уведомления" + case "id": + return fmt.Sprintf("chat_id: %d\ntype: %s", message.Chat.ID, message.Chat.Type) + case "info": + return contactInfo(contact, link) + case "settings": + return "Настройки Telegram-контакта доступны в RSMon:\n" + link + "\n\n/stop - отключить уведомления для этого чата" + case "stop": + disabled, disableErr := disableChatNotifications(message.Chat.ID) + if disableErr != nil { + return "Не удалось отключить уведомления: " + disableErr.Error() + } + return fmt.Sprintf("Telegram-уведомления для этого чата отключены: %d", disabled) + case "help": + return helpText() + default: + return "Неизвестная команда. " + helpText() + } +} + +func contactInfo(contact models.Contact, link string) string { + bound := contact.UserID != nil || contact.AccountID != nil + status := "не привязан" + if bound { + status = "привязан" + } + return fmt.Sprintf("Контакт: %s\nТип: %s\nID: %s\nСтатус: %s\nСсылка настройки: %s", contact.Name, contact.Kind, contact.Value, status, link) +} + +func helpText() string { + return "/start - подключить Telegram-уведомления\n" + + "/id - показать ID чата\n" + + "/info - информация о контакте\n" + + "/settings - ссылка на настройки\n" + + "/stop - отключить уведомления" +} + +func sendAndRecord(client *tgbotapi.BotAPI, msg tgbotapi.MessageConfig) { + _, err := client.Send(msg) + recordSentMessage(msg.ChatID, msg.Text, err) + if err != nil { + log.Println(err) + } +} + +// SetBotCommands registers slash command menus for private and group chats. +func SetBotCommands(client *tgbotapi.BotAPI) { + if client == nil { + return + } + commands := []tgbotapi.BotCommand{ + {Command: "start", Description: "Подключить Telegram-уведомления"}, + {Command: "id", Description: "Показать ID чата"}, + {Command: "info", Description: "Информация о привязке"}, + {Command: "settings", Description: "Настройки контакта"}, + {Command: "stop", Description: "Отключить уведомления"}, + {Command: "help", Description: "Список команд"}, + } + if _, err := client.Request(tgbotapi.NewSetMyCommands(commands...)); err != nil { + log.Println("telegram set commands:", err) + } +} + +func heartbeat() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + markBotOnline("") + } +} + +func markBotOnline(lastErr string) { + now := time.Now() + username := "" + if bot != nil { + username = bot.Self.UserName + } + status := models.TelegramBotStatus{} + models.DB().Where("name = ?", models.TelegramBotStatusMain). + FirstOrCreate(&status, models.TelegramBotStatus{Name: models.TelegramBotStatusMain}) + status.Username = username + status.Online = true + status.LastSeen = &now + status.LastError = lastErr + _ = models.DB().Save(&status).Error +} + +func recordReceivedMessage(message *tgbotapi.Message, contactID *int64) { + if message == nil || message.Chat == nil { + return + } + username := "" + if message.From != nil { + username = message.From.UserName + } + record := models.TelegramBotMessage{ + Direction: models.TelegramBotMessageReceived, + ChatID: message.Chat.ID, + ChatType: message.Chat.Type, + Username: username, + Text: message.Text, + Command: message.Command(), + ContactID: contactID, + CreatedAt: time.Now(), + } + _ = models.DB().Create(&record).Error +} + +func recordSentMessage(chatID int64, text string, sendErr error) { + errorText := "" + if sendErr != nil { + errorText = sendErr.Error() + } + record := models.TelegramBotMessage{ + Direction: models.TelegramBotMessageSent, + ChatID: chatID, + Text: text, + Error: errorText, + CreatedAt: time.Now(), + } + _ = models.DB().Create(&record).Error +} + +func disableChatNotifications(chatID int64) (int64, error) { + value := strconv.FormatInt(chatID, 10) + result := models.DB().Model(&models.Contact{}). + Where("kind IN ? AND value = ?", []string{"telegram_private", "telegram_group"}, value). + Update("enabled", false) + return result.RowsAffected, result.Error +} diff --git a/internal/tg/bot_test.go b/internal/tg/bot_test.go new file mode 100644 index 0000000..849876c --- /dev/null +++ b/internal/tg/bot_test.go @@ -0,0 +1,14 @@ +package tg + +import ( + "testing" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/stretchr/testify/assert" +) + +func TestTelegramAPIEndpoint(t *testing.T) { + assert.Equal(t, tgbotapi.APIEndpoint, telegramAPIEndpoint("")) + assert.Equal(t, "https://api.telegram.org/bot%s/%s", telegramAPIEndpoint("https://api.telegram.org")) + assert.Equal(t, "https://proxy.example.com/telegram/bot%s/%s", telegramAPIEndpoint("https://proxy.example.com/telegram/")) +} diff --git a/internal/tg/debug/main.go b/internal/tg/debug/main.go new file mode 100644 index 0000000..edcfefe --- /dev/null +++ b/internal/tg/debug/main.go @@ -0,0 +1,78 @@ +// Package main provides functionality. +package main + +import ( + "log" + + "github.com/davecgh/go-spew/spew" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/config/database" + _ "rsgit.ru/rsmon/rsmon/config/env" +) + +func main() { + database.Init() + creds, err := models.EnabledCredentialsByKind(models.CredentialKindTelegram) + if err != nil { + log.Panic(err) + } + if len(creds) == 0 { + log.Panic("telegram credential is not configured") + } + token, err := creds[0].GetSecret() + if err != nil { + log.Panic(err) + } + bot, err := tgbotapi.NewBotAPI(token) + if err != nil { + log.Panic(err) + } + + bot.Debug = true + + log.Printf("Authorized on account %s", bot.Self.UserName) + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := bot.GetUpdatesChan(u) + + for update := range updates { + if update.Message == nil { // ignore any non-Message updates + continue + } + + if !update.Message.IsCommand() { // ignore any non-command Messages + continue + } + + // Create a new MessageConfig. We don't have text yet, + // so we leave it empty. + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "") + + spew.Dump(update.Message) + spew.Dump(update.Message.Chat) + + switch update.Message.Chat.Type { + case "private": + // Extract the command from the Message. + switch update.Message.Command() { + case "start": + msg.Text = "Для завершения добавления вида оповещений перейдите по ссылке https://rsmon.ru/contacts/new?kind=telegram&" + case "help": + msg.Text = "/start - добавление способа оповещений\n/list список способов оповещений для этого чата\n" + default: + msg.Text = "Неизвестная команда" + } + case "group": + default: + msg.Text = "Неизвестный тип чата: " + update.Message.Chat.Type + } + + if _, err := bot.Send(msg); err != nil { + log.Panic(err) + } + } +} diff --git a/internal/util/format_duration.go b/internal/util/format_duration.go new file mode 100644 index 0000000..e9b3ebb --- /dev/null +++ b/internal/util/format_duration.go @@ -0,0 +1,35 @@ +// Package util provides functionality. +package util + +import ( + "log" + "strconv" + + "rsgit.ru/rsmon/rsmon/config/translator" +) + +// FormatDuration provides functionality. +func FormatDuration(duration int64) string { + // spew.Dump(translator.Translator) + hours := duration / 3600 + minutes := (duration - hours*3600) / 60 + // seconds := duration % 60 + str := "" + if hours > 0 { + tr, err := translator.Translator.C("hours", float64(hours), 0, strconv.FormatInt(hours, 10)) + if err != nil { + log.Println("translator error", err) + return "" + } + str = str + tr + ", " + } + + tr, err := translator.Translator.C("minutes", float64(minutes), 0, strconv.FormatInt(minutes, 10)) + if err != nil { + log.Println("translator error", err) + return "" + } + + str += tr + return str +} diff --git a/internal/util/format_duration_test.go b/internal/util/format_duration_test.go new file mode 100644 index 0000000..5894fd6 --- /dev/null +++ b/internal/util/format_duration_test.go @@ -0,0 +1,15 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatDuration(t *testing.T) { + assert.Equal(t, "1 минуту", FormatDuration(60), "") + assert.Equal(t, "2 минуты", FormatDuration(120), "") + assert.Equal(t, "5 минут", FormatDuration(300), "") + assert.Equal(t, "1 час, 5 минут", FormatDuration(3600+300), "") + assert.Equal(t, "1 час, 0 минут", FormatDuration(3600), "") +} diff --git a/internal/util/unix/pidfile.go b/internal/util/unix/pidfile.go new file mode 100644 index 0000000..805c725 --- /dev/null +++ b/internal/util/unix/pidfile.go @@ -0,0 +1,31 @@ +// Package unix provides functionality. +package unix + +import ( + "fmt" + "os" + "strconv" + "syscall" +) + +// WritePidFile provides functionality. +// Write a pid file, but first make sure it doesn't exist with a running pid. +func WritePidFile(pidFile string) error { + // Read in the pid file as a slice of bytes. + if piddata, err := os.ReadFile(pidFile); err == nil { + // Convert the file contents to an integer. + if pid, err := strconv.Atoi(string(piddata)); err == nil { + // Look for the pid in the process list. + if process, err := os.FindProcess(pid); err == nil { + // Send the process a signal zero kill. + if err := process.Signal(syscall.Signal(0)); err == nil { + // We only get an error if the pid isn't running, or it's not ours. + return fmt.Errorf("pid already running: %d", pid) + } + } + } + } + // If we get here, then the pidfile didn't exist, + // or the pid in it doesn't belong to the user running this app. + return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", os.Getpid())), 0o664) +} diff --git a/internal/webapp/auth_password.go b/internal/webapp/auth_password.go new file mode 100644 index 0000000..840eef1 --- /dev/null +++ b/internal/webapp/auth_password.go @@ -0,0 +1,45 @@ +package webapp + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// bcryptCost is the work factor for new bcrypt hashes. Matches the +// "cost 12" assumption from docs/distributed/worker-web-app.md +// section 5.2; the cost applies to first-run and password-change +// hashes alike. +const bcryptCost = 12 + +// HashPassword bcrypts the given plaintext password at the package's +// configured cost. Returns the encoded hash ready to be persisted. +func HashPassword(plain string) (string, error) { + if plain == "" { + return "", fmt.Errorf("webapp: empty password") + } + h, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) + if err != nil { + return "", fmt.Errorf("webapp: bcrypt hash: %w", err) + } + return string(h), nil +} + +// VerifyPassword reports whether the given plaintext matches the +// given bcrypt hash. A nil error means the password is correct. +func VerifyPassword(hash, plain string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) +} + +// MaskToken returns the last 4 characters of a token prefixed with a +// star mask, e.g. "****abcd". Empty input yields "—". Used on the +// settings page where the worker's bearer token is shown read-only. +func MaskToken(token string) string { + if token == "" { + return "—" + } + if len(token) <= 4 { + return "****" + } + return "****" + token[len(token)-4:] +} diff --git a/internal/webapp/auth_password_test.go b/internal/webapp/auth_password_test.go new file mode 100644 index 0000000..35d6ee7 --- /dev/null +++ b/internal/webapp/auth_password_test.go @@ -0,0 +1,48 @@ +package webapp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateFirstRunPassword(t *testing.T) { + a, err := GenerateFirstRunPassword() + require.NoError(t, err) + require.NotEmpty(t, a) + // 24 bytes -> 32 base64 chars (RawURLEncoding, no padding). + assert.Len(t, a, 32, "first-run password length") + + // Two calls must produce different passwords (statistically + // certain with crypto/rand). + b, err := GenerateFirstRunPassword() + require.NoError(t, err) + assert.NotEqual(t, a, b, "two calls must produce distinct passwords") +} + +func TestHashAndVerifyPassword(t *testing.T) { + const plain = "correct horse battery staple" + hash, err := HashPassword(plain) + require.NoError(t, err) + require.NotEmpty(t, hash) + assert.True(t, strings.HasPrefix(hash, "$2a$"), + "bcrypt hash should start with $2a$") + + require.NoError(t, VerifyPassword(hash, plain), + "correct password must verify") + assert.Error(t, VerifyPassword(hash, "wrong password"), + "wrong password must not verify") +} + +func TestHashPasswordRejectsEmpty(t *testing.T) { + _, err := HashPassword("") + assert.Error(t, err, "empty plaintext must be rejected") +} + +func TestMaskToken(t *testing.T) { + assert.Equal(t, "—", MaskToken("")) + assert.Equal(t, "****", MaskToken("abcd")) + assert.Equal(t, "****wxyz", MaskToken("abcdefghwxyz")) +} diff --git a/internal/webapp/auth_sessionid.go b/internal/webapp/auth_sessionid.go new file mode 100644 index 0000000..5586ea0 --- /dev/null +++ b/internal/webapp/auth_sessionid.go @@ -0,0 +1,33 @@ +package webapp + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +// GenerateFirstRunPassword returns a fresh random password suitable +// for the worker's first-run webapp credential. The output is 24 +// bytes of crypto/rand encoded as URL-safe base64 (no padding), which +// is roughly 32 characters long and safe to print once into the +// worker log. +// +// Phase 1 (MVP) only: a stronger entropy scheme can replace this in +// later phases if needed. +func GenerateFirstRunPassword() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("webapp: read random bytes: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// newSessionID is the underlying primitive for both session cookies +// and CSRF tokens: 32 bytes from crypto/rand, URL-safe base64. +func newSessionID() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("webapp: read random bytes: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/internal/webapp/constants.go b/internal/webapp/constants.go new file mode 100644 index 0000000..58d945d --- /dev/null +++ b/internal/webapp/constants.go @@ -0,0 +1,47 @@ +package webapp + +// Audit constants used across the webapp audit-log writes. They +// live in their own file so goconst sees them as named values +// rather than scattered string literals. +const ( + auditActorLocal = "operator" + auditRoleAdmin = "admin" + auditAuthModeLocal = "local" + auditAuthModeBasic = "basic_auth" + auditTargetSelf = "self" + auditActionLogin = "login" + auditActionLoginFail = "login_failed" + auditActionLogout = "logout" + auditActionPassChange = "password_change" +) + +// Inventory source labels. Phase 1 only emits "process"; Phase 3 +// adds "compose" and "docker". +const ( + inventorySourceProcess = "process" +) + +// Environment variable names referenced by ConfigFromEnv. Lifted out +// so the validator and the cmd binary share the same constants. +const ( + envWorkerHost = "WORKER_HOST" + envWorkerPort = "WORKER_PORT" + envWorkerURL = "WORKER_URL" + envWorkerLogin = "WORKER_LOGIN" + envWorkerPassword = "WORKER_PASSWORD" + envClusterEnabled = "WORKER_CLUSTER_ENABLED" + envClusterDebugApply = "WORKER_CLUSTER_DEBUG_APPLY" + envReleaseURL = "WORKER_RELEASE_URL" +) + +// Route paths used as redirect targets. Lifted out so goconst stops +// flagging the duplicates across handlers. +const ( + pathOverview = "/overview" + pathChangePassword = "/web/change-password" + pathLogin = "/web/login" +) + +// basicAuthRealm is the value returned in the WWW-Authenticate +// header. Fixed string so scripted callers can match on it. +const basicAuthRealm = `Basic realm="rsmon-worker"` diff --git a/internal/webapp/cteq.go b/internal/webapp/cteq.go new file mode 100644 index 0000000..82f149c --- /dev/null +++ b/internal/webapp/cteq.go @@ -0,0 +1,10 @@ +package webapp + +import "crypto/subtle" + +// constantTimeEq is a thin wrapper around crypto/subtle.ConstantTimeCompare +// so the middleware file does not need an extra import for a single +// call. +func constantTimeEq(a, b string) int { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) +} diff --git a/internal/webapp/doc.go b/internal/webapp/doc.go new file mode 100644 index 0000000..a018da0 --- /dev/null +++ b/internal/webapp/doc.go @@ -0,0 +1,18 @@ +// Package webapp implements the local web UI for the distributed +// monitoring worker. See docs/distributed/worker-web-app.md. +// +// Phase 1 (MVP) implements: +// +// - Local-only auth (section 5.3): first-run password printed to +// the worker log, bcrypt-hashed in the local SQLite store, forced +// change on first login, session cookie with HTTP-only/Secure +// (loopback-aware)/SameSite=Strict. +// - Pages: overview, discovered apps (read-only), checks +// (read-only), notifications (read-only), logs (worker log only), +// settings (worker fields), updates. +// - Server status: /proc and sysfs only (no SMART, no docker). +// - Audit log with 7-day retention. +// +// Phase 2+ (OAuth, basic auth, compose management, public bind, +// docker socket, secret storage) is explicitly out of scope here. +package webapp diff --git a/internal/webapp/handlers_apps.go b/internal/webapp/handlers_apps.go new file mode 100644 index 0000000..93b77b1 --- /dev/null +++ b/internal/webapp/handlers_apps.go @@ -0,0 +1,103 @@ +package webapp + +import "net/http" + +// handleApps lists the inventory rows the most recent refresh loop +// persisted. Each row links to the detail view at /apps/:id, which +// Phase 1 implements as a single-process summary (comm, cmdline, +// cwd, ports, uptime). +func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := sessionFromContext(r.Context()) + apps := s.inventory.Snapshot() + data := appsPageData{ + basePageData: s.newBasePage(r, "Discovered apps", sess), + Apps: apps, + } + if err := s.templates.Execute(w, "apps.html", data); err != nil { + s.deps.Logger.Printf("render apps: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// handleAppDetail renders the detail page for a single inventory +// row. Phase 1 has no grouping, so :id is the row index in the +// snapshot (matching the table id column). +func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := sessionFromContext(r.Context()) + id := r.PathValue("id") + apps := s.inventory.Snapshot() + var found *DiscoveredApp + for i := range apps { + if idMatch(&apps[i], id, i) { + found = &apps[i] + break + } + } + if found == nil { + http.NotFound(w, r) + return + } + data := appDetailPageData{ + basePageData: s.newBasePage(r, "App: "+found.Name, sess), + App: *found, + } + if err := s.templates.Execute(w, "app_detail.html", data); err != nil { + s.deps.Logger.Printf("render app detail: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// idMatch matches either by pid (when id is a positive integer) or +// by name (otherwise). Keeps URLs short and avoids leaking pids to +// browser history. +func idMatch(a *DiscoveredApp, id string, idx int) bool { + if a.Name == id { + return true + } + if id == pidOrIndex(a, idx) { + return true + } + return false +} + +func pidOrIndex(a *DiscoveredApp, idx int) string { + if a.PID > 0 { + return itoa(a.PID) + } + return itoa(idx) +} + +func itoa(n int) string { + const digits = "0123456789" + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = digits[n%10] + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +type appsPageData struct { + basePageData + Apps []DiscoveredApp +} + +type appDetailPageData struct { + basePageData + App DiscoveredApp +} diff --git a/internal/webapp/handlers_auth.go b/internal/webapp/handlers_auth.go new file mode 100644 index 0000000..09b0fe5 --- /dev/null +++ b/internal/webapp/handlers_auth.go @@ -0,0 +1,390 @@ +package webapp + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// handleHealth returns 200 OK with a tiny body. Public endpoint so +// the operator's tooling (curl, monitoring) can probe the listener +// without going through the login form. +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeNoStore(w) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(w, "ok") +} + +// handleLoginForm renders the login page. If the operator is already +// logged in, they are redirected to /overview. +// +// When WORKER_LOGIN / WORKER_PASSWORD are configured, the login form +// renders a username field and the explanatory copy tells the +// operator to use the env-var credentials. Otherwise the form is the +// plain first-run password entry (no username). +func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, err := s.resolveSession(r) + if err == nil && sess != nil { + redirectTo(w, r, pathOverview) + return + } + data := loginPageData{ + basePageData: s.newBasePage(r, "RSMon worker login", nil), + Error: strings.TrimSpace(r.URL.Query().Get("error")), + NextURL: strings.TrimSpace(r.URL.Query().Get("next")), + BasicAuth: s.BasicAuthEnabled(), + } + if err := s.templates.Execute(w, "login.html", data); err != nil { + s.deps.Logger.Printf("render login: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// handleLoginSubmit validates the credentials and starts a session. +// On failure: 401 with the login page re-rendered and an error message. +// +// When basic auth is configured the form must supply BOTH a username +// matching WORKER_LOGIN and a password matching WORKER_PASSWORD. The +// per-machine bcrypt user is bypassed in that mode (so the operator +// can rotate the basic-auth password without touching the bcrypt +// store). Local-only mode keeps the original first-run password flow. +func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + plain := r.FormValue("password") + if plain == "" { + s.renderLoginError(w, r, "password is required", r.FormValue("next")) + return + } + next := r.FormValue("next") + if s.basicAuthOK { + s.handleBasicAuthLogin(w, r, plain, next) + return + } + s.handleLocalLogin(w, r, plain, next) +} + +// handleBasicAuthLogin verifies the form-submitted password against +// WORKER_PASSWORD. The username field is checked against +// WORKER_LOGIN and the comparison is constant-time. +func (s *Server) handleBasicAuthLogin(w http.ResponseWriter, r *http.Request, password, next string) { + login := strings.TrimSpace(r.FormValue("login")) + if login == "" { + s.renderLoginError(w, r, "username is required", next) + return + } + if subtleEqual(login, s.cfg.BasicAuthLogin) != 1 || + subtleEqual(password, s.cfg.BasicAuthPassword) != 1 { + _ = s.store.WriteAudit(r.Context(), &AuditEntry{ + Actor: auditActorLocal, + Role: auditRoleAdmin, + AuthMode: auditAuthModeBasic, + IP: clientIP(r), + UA: r.UserAgent(), + Action: auditActionLoginFail, + Target: auditTargetSelf, + }) + s.renderLoginError(w, r, "invalid credentials", next) + return + } + // Mint a synthetic session backed by the local store but tagged + // with auth_mode=basic_auth so the audit log distinguishes the + // two paths. The bcrypt user is bypassed entirely. + if err := s.startSyntheticSession(w, r, "basic_auth"); err != nil { + s.deps.Logger.Printf("start synthetic session: %v", err) + http.Error(w, "session error", http.StatusInternalServerError) + return + } + _ = s.store.WriteAudit(r.Context(), &AuditEntry{ + Actor: auditActorLocal, + Role: auditRoleAdmin, + AuthMode: auditAuthModeBasic, + IP: clientIP(r), + UA: r.UserAgent(), + Action: auditActionLogin, + Target: auditTargetSelf, + }) + if next == "" || !strings.HasPrefix(next, "/") { + next = pathOverview + } + redirectTo(w, r, next) +} + +// handleLocalLogin is the legacy first-run bcrypt path. Kept as a +// separate function so handleLoginSubmit reads top-down without +// branching inside one long handler. +func (s *Server) handleLocalLogin(w http.ResponseWriter, r *http.Request, plain, next string) { + user, err := s.store.GetUser(r.Context()) + if err != nil { + // No user provisioned yet => login is impossible. Surface as + // a generic error so we do not leak the "no user" state to + // a brute-force attacker. + s.renderLoginError(w, r, "invalid credentials", next) + return + } + if err := VerifyPassword(user.BcryptHash, plain); err != nil { + _ = s.store.WriteAudit(r.Context(), &AuditEntry{ + Actor: auditActorLocal, + Role: auditRoleAdmin, + AuthMode: auditAuthModeLocal, + IP: clientIP(r), + UA: r.UserAgent(), + Action: auditActionLoginFail, + Target: auditTargetSelf, + }) + s.renderLoginError(w, r, "invalid credentials", next) + return + } + if err := s.startSession(w, r, user); err != nil { + s.deps.Logger.Printf("start session: %v", err) + http.Error(w, "session error", http.StatusInternalServerError) + return + } + _ = s.store.WriteAudit(r.Context(), &AuditEntry{ + Actor: auditActorLocal, + Role: auditRoleAdmin, + AuthMode: auditAuthModeLocal, + IP: clientIP(r), + UA: r.UserAgent(), + Action: auditActionLogin, + Target: auditTargetSelf, + }) + // The requires_change flag is recorded on the user row for + // future hardening (a per-install "force rotation" toggle), but + // the login flow does not bounce operators to /web/change-password + // on first login any more. Frictionless first-login is the + // current default; the change-password page is still reachable + // from /settings. + if next == "" || !strings.HasPrefix(next, "/") { + next = pathOverview + } + redirectTo(w, r, next) +} + +// startSyntheticSession mints a session row that is NOT bound to the +// bcrypt user. Used by the basic-auth login flow. The user_id is +// re-used (the row in webapp_users still exists for the local-mode +// fallback) so foreign-key-free audit inserts keep working. +func (s *Server) startSyntheticSession(w http.ResponseWriter, r *http.Request, _ string) error { + user, err := s.store.GetUser(r.Context()) + if err != nil { + // No bcrypt user yet: synthesize an anonymous row so the + // session has a user_id to point at. The local-only path + // will eventually upgrade this to a real user on first + // basic-auth-less login. + if cerr := s.store.EnsureAnonymousUser(r.Context()); cerr != nil { + return cerr + } + user, err = s.store.GetUser(r.Context()) + if err != nil { + return err + } + } + return s.startSession(w, r, user) +} + +// subtleEqual wraps crypto/subtle.ConstantTimeCompare so the +// handler body stays free of import noise. Returns 1 on match. +func subtleEqual(a, b string) int { + return constantTimeEq(a, b) +} + +// handleLogout deletes the session row and clears the cookies. The +// logout endpoint is a POST so a stray GET cannot end a session via +// link prefetch. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := s.resolveSession(r) + clearSessionCookie(w, r) + if sess != nil { + _ = s.store.DeleteSession(r.Context(), sess.ID) + _ = s.store.WriteAudit(r.Context(), &AuditEntry{ + Actor: auditActorLocal, + Role: auditRoleAdmin, + AuthMode: auditAuthModeLocal, + IP: clientIP(r), + UA: r.UserAgent(), + Action: auditActionLogout, + Target: auditTargetSelf, + }) + } + redirectTo(w, r, pathLogin) +} + +// handleChangePasswordForm renders the change-password page. +func (s *Server) handleChangePasswordForm(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, ok := sessionFromContext(r.Context()) + if !ok { + redirectTo(w, r, pathLogin) + return + } + if !s.requireCSRF(sess, r) { + http.Error(w, "csrf token required", http.StatusForbidden) + return + } + data := changePasswordPageData{ + basePageData: s.newBasePage(r, "Change password", sess), + MinStrength: minPasswordLength, + } + if err := s.templates.Execute(w, "change_password.html", data); err != nil { + s.deps.Logger.Printf("render change-password: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// handleChangePasswordSubmit rotates the user's bcrypt hash and +// clears the requires_change flag. On success, the operator lands on +// /overview. On any failure, the change-password page re-renders +// with an error message. +func (s *Server) handleChangePasswordSubmit(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, ok := sessionFromContext(r.Context()) + if !ok { + redirectTo(w, r, pathLogin) + return + } + if !s.requireCSRF(sess, r) { + http.Error(w, "csrf token required", http.StatusForbidden) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + current := r.FormValue("current_password") + next := r.FormValue("new_password") + confirm := r.FormValue("new_password_confirm") + user, err := s.store.GetUser(r.Context()) + if err != nil { + http.Error(w, "no user", http.StatusInternalServerError) + return + } + if err := VerifyPassword(user.BcryptHash, current); err != nil { + s.renderChangePasswordError(w, r, sess, "current password is incorrect") + return + } + if !validPasswordStrength(next) { + s.renderChangePasswordError(w, r, sess, fmt.Sprintf("new password must be at least %d characters", minPasswordLength)) + return + } + if next != confirm { + s.renderChangePasswordError(w, r, sess, "new password and confirmation do not match") + return + } + newHash, err := HashPassword(next) + if err != nil { + http.Error(w, "hash error", http.StatusInternalServerError) + return + } + if err := s.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil { + http.Error(w, "update error", http.StatusInternalServerError) + return + } + _ = s.store.WriteAudit(r.Context(), &AuditEntry{ + Actor: auditActorLocal, + Role: auditRoleAdmin, + AuthMode: auditAuthModeLocal, + IP: clientIP(r), + UA: r.UserAgent(), + Action: auditActionPassChange, + Target: fmt.Sprintf("user:%d", user.ID), + BeforeHash: user.BcryptHash, + AfterHash: newHash, + }) + redirectTo(w, r, pathOverview) +} + +// minPasswordLength matches the bcrypt minimum the worker enforces +// (bcrypt silently truncates after 72 bytes; the minimum is a UX +// floor so the operator does not pick "a"). +const minPasswordLength = 8 + +func validPasswordStrength(p string) bool { + return len(p) >= minPasswordLength +} + +// startSession creates a session row with a fresh id and CSRF token, +// persists it, and sets the cookies. +func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *User) error { + id, err := newSessionID() + if err != nil { + return err + } + csrf, err := newSessionID() + if err != nil { + return err + } + now := time.Now().UTC() + sess := Session{ + ID: id, + UserID: user.ID, + CSRFToken: csrf, + IP: clientIP(r), + UA: r.UserAgent(), + CreatedAt: now, + LastSeenAt: now, + ExpiresAt: now.Add(s.cfg.SessionAbs), + } + if err := s.store.CreateSession(r.Context(), &sess); err != nil { + return err + } + s.writeSessionCookie(w, r, &sess) + return nil +} + +// renderLoginError renders the login page with an inline error +// message. We deliberately do NOT use http.StatusUnauthorized here; +// the status is 200 so an interactive operator gets the form back +// with the error visible, not a browser auth dialog. +func (s *Server) renderLoginError(w http.ResponseWriter, r *http.Request, msg, next string) { + data := loginPageData{ + basePageData: s.newBasePage(r, "RSMon worker login", nil), + Error: msg, + NextURL: next, + BasicAuth: s.BasicAuthEnabled(), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := s.templates.Execute(w, "login.html", data); err != nil { + s.deps.Logger.Printf("render login error: %v", err) + } +} + +// renderChangePasswordError renders the change-password form with an +// inline error. The CSRF token is reused from the current session so +// the operator does not have to reload to retry. +func (s *Server) renderChangePasswordError(w http.ResponseWriter, r *http.Request, sess *Session, msg string) { + data := changePasswordPageData{ + basePageData: s.newBasePage(r, "Change password", sess), + Error: msg, + MinStrength: minPasswordLength, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := s.templates.Execute(w, "change_password.html", data); err != nil { + s.deps.Logger.Printf("render change-password error: %v", err) + } +} + +// loginPageData is the input to the login.html template. +type loginPageData struct { + basePageData + Error string + NextURL string + BasicAuth bool // true when WORKER_LOGIN/WORKER_PASSWORD are configured; the form must show a username field +} + +// changePasswordPageData is the input to the change_password.html +// template. +type changePasswordPageData struct { + basePageData + Error string + MinStrength int +} diff --git a/internal/webapp/handlers_checks.go b/internal/webapp/handlers_checks.go new file mode 100644 index 0000000..001ffdf --- /dev/null +++ b/internal/webapp/handlers_checks.go @@ -0,0 +1,84 @@ +package webapp + +import "net/http" + +// handleChecks renders the worker's recent result rows. The +// runner's in-memory ring buffer supplies the data; Phase 1 reads +// from it directly with no extra caching. +// +// A "Run now" button is rendered on the page but stays disabled +// until the control plane accepts one-off check hints. The hint +// protocol is gated on the worker-notifier MVP plan +// (docs/plans/worker-notifier-mvp.md §N) and on +// docs/plans/separate-checks.md §11.6; once both are in place the +// RunNowEnabled flag flips to true and the handler reads +// s.deps.Runner.SubmitCheckHint(...) instead of the placeholder. +func (s *Server) handleChecks(w http.ResponseWriter, r *http.Request) { + s.renderRunnerPage(w, r, "Recent checks", "checks.html", func() any { + return checksPageData{ + basePageData: s.newBasePage(r, "Recent checks", sessionFromContextOrEmpty(r)), + Rows: s.deps.Runner.RecentResults(50), + RunNowEnabled: false, + RunNowTooltip: "Run-now lands with the worker hint protocol (worker-notifier-mvp.md §N + separate-checks.md §11.6).", + } + }) +} + +type checksPageData struct { + basePageData + Rows []ResultRow + RunNowEnabled bool + RunNowTooltip string +} + +// handleNotifications renders the worker's recent notification +// rows. Phase 1 only emits selfcheck alerts (email/telegram via the +// cached credentials), but the runner ring buffer is shape-stable +// for the main-app-issued notifications coming online in a later +// phase. +// +// A "Resend" button is rendered on the page but stays disabled +// until the worker resend protocol exists. Same gating as the +// "Run now" button on /checks. +func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request) { + s.renderRunnerPage(w, r, "Recent notifications", "notifications.html", func() any { + return notificationsPageData{ + basePageData: s.newBasePage(r, "Recent notifications", sessionFromContextOrEmpty(r)), + Rows: s.deps.Runner.RecentNotifications(50), + ResendEnabled: false, + ResendTooltip: "Resend lands with the worker notification resend protocol (worker-notifier-mvp.md §N).", + } + }) +} + +type notificationsPageData struct { + basePageData + Rows []NotificationRow + ResendEnabled bool + ResendTooltip string +} + +// renderRunnerPage is the small boilerplate-killer shared by +// handleChecks / handleNotifications / handleApps: write the +// no-store header, look up the session, build the page data via the +// caller-supplied closure, execute the template, and log + 500 on +// error. The closure receives no arguments because each handler +// already has its own copy of *Server and *http.Request in scope +// (this method is bound to *Server, so the closure captures them). +func (s *Server) renderRunnerPage(w http.ResponseWriter, _ *http.Request, logName, tmpl string, build func() any) { + writeNoStore(w) + if err := s.templates.Execute(w, tmpl, build()); err != nil { + s.deps.Logger.Printf("render %s: %v", logName, err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// sessionFromContextOrEmpty is a thin wrapper around +// sessionFromContext that returns a nil session instead of a bool, +// for handlers that pass the session straight into a page-data +// struct (the Session zero value is harmless for template +// rendering). +func sessionFromContextOrEmpty(r *http.Request) *Session { + sess, _ := sessionFromContext(r.Context()) + return sess +} diff --git a/internal/webapp/handlers_cluster.go b/internal/webapp/handlers_cluster.go new file mode 100644 index 0000000..f4e34e6 --- /dev/null +++ b/internal/webapp/handlers_cluster.go @@ -0,0 +1,136 @@ +package webapp + +import ( + "encoding/json" + "errors" + "net/http" +) + +// clusterStatusResponse is the JSON the operator-facing +// /web/api/cluster/status endpoint returns. The shape is stable so the +// e2e shell script and any future frontend pages can pin against it. +// +// FSMConfigVersion / FSMOutboxLen / FSMPartition surface the FSM-side +// operator signals from plan section 6.1 (config_version, outbox +// length, partition_state) so a single GET tells the operator what +// config the cluster has adopted, whether the notification outbox is +// draining, and whether the cluster sees itself as partitioned. +type clusterStatusResponse struct { + SelfID string `json:"self_id"` + Role string `json:"role"` + Term uint64 `json:"term"` + LeaderID string `json:"leader_id"` + Voters []string `json:"voters"` + AppliedIndex uint64 `json:"applied_index"` + CommitIndex uint64 `json:"commit_index"` + FSMChecks int `json:"fsm_checks"` + FSMMembers int `json:"fsm_membership"` + FSMConfigVersion uint64 `json:"fsm_config_version"` + FSMOutboxLen int `json:"fsm_outbox_len"` + FSMPartition string `json:"fsm_partition"` + ClusterID string `json:"cluster_id"` + LocalAddr string `json:"local_addr"` +} + +// handleClusterStatus serializes the current cluster state for the +// operator. Returns 503 if no cluster is attached; 200 otherwise. +// +// Admin-only: the worker webapp is single-tenant so the session +// middleware (requireSession) is the admin check. Cross-tenant +// protection is not required at this layer. +// +// The `r` parameter is unused but kept so the signature matches +// http.HandlerFunc (the route is registered via requireSession). +func (s *Server) handleClusterStatus(w http.ResponseWriter, _ *http.Request) { + writeNoStore(w) + if s.cluster == nil { + http.Error(w, "cluster not configured", http.StatusServiceUnavailable) + return + } + stats := s.cluster.Stats() + resp := clusterStatusResponse{ + SelfID: stats.NodeID, + Role: stats.State, + Term: stats.Term, + LeaderID: stats.Leader, + Voters: stats.Voters, + AppliedIndex: stats.AppliedIndex, + CommitIndex: stats.LastIndex, + FSMChecks: stats.FSMChecks, + FSMMembers: stats.FSMMembers, + FSMConfigVersion: stats.FSMConfigVersion, + FSMOutboxLen: stats.FSMOutboxLen, + FSMPartition: stats.FSMPartition, + ClusterID: s.cluster.ClusterID(), + LocalAddr: s.cluster.LocalAddr(), + } + if resp.Voters == nil { + resp.Voters = []string{} + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + s.deps.Logger.Printf("cluster status encode: %v", err) + } +} + +// handleClusterApplyTestConfig applies a hardcoded config.adopt log +// entry to the cluster. It exists so the e2e script and any operator +// debugging session can verify FSM replication without having to wire +// up the real signed-config-adoption producer (which lives in a later +// phase). +// +// DEBUG: this endpoint is a placeholder for the real producer. It must +// be replaced (or removed) before any production deployment. +// +// The handler is gated behind Config.DebugClusterApply (env +// WORKER_CLUSTER_DEBUG_APPLY=true). When the flag is false the +// handler returns 404 — the route is still registered so the auth +// + CSRF paths are exercised in tests, but no real FSM entry is ever +// appended from a production webapp. +// +// TODO(worker-cluster-real-producer): remove the apply-test-config +// endpoint entirely once the signed-config-adoption producer ships. +func (s *Server) handleClusterApplyTestConfig(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + if !s.cfg.DebugClusterApply { + http.NotFound(w, r) + return + } + if s.cluster == nil { + http.Error(w, "cluster not configured", http.StatusServiceUnavailable) + return + } + if !s.requireCSRF(sessionFromContextOrFail(w, r), r) { + http.Error(w, "csrf token required", http.StatusForbidden) + return + } + applied, err := s.cluster.ApplyTestConfig() + if err != nil { + s.deps.Logger.Printf("cluster apply test config: %v", err) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]uint64{"applied_index": applied}); err != nil { + s.deps.Logger.Printf("cluster apply encode: %v", err) + } +} + +// sessionFromContextOrFail is a tiny adapter so requireCSRF can be +// called from this handler without leaking the middleware into the +// cluster package. If no session is attached (should not happen +// because requireSession already ran) we return a stub session with +// no CSRF token, which causes requireCSRF to refuse the request. +func sessionFromContextOrFail(_ http.ResponseWriter, r *http.Request) *Session { + sess, _ := sessionFromContext(r.Context()) + if sess != nil { + return sess + } + return &Session{} +} + +// ErrClusterNotConfigured is returned when a cluster-admin endpoint is +// hit on a server without a cluster attached. +var ErrClusterNotConfigured = errors.New("webapp: cluster not configured") diff --git a/internal/webapp/handlers_cluster_test.go b/internal/webapp/handlers_cluster_test.go new file mode 100644 index 0000000..c20648f --- /dev/null +++ b/internal/webapp/handlers_cluster_test.go @@ -0,0 +1,336 @@ +package webapp + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubCluster is a minimal ClusterView implementation used by the +// handler tests. It returns canned values so the JSON shape can be +// pinned without standing up a real raft group. +type stubCluster struct { + stats ClusterStats + applyIndex uint64 + applyErr error + applyCalled int + applyMu sync.Mutex + clusterIDOut string + addrOut string +} + +func (s *stubCluster) Stats() ClusterStats { return s.stats } +func (s *stubCluster) ApplyTestConfig() (uint64, error) { + s.applyMu.Lock() + defer s.applyMu.Unlock() + s.applyCalled++ + return s.applyIndex, s.applyErr +} +func (s *stubCluster) ClusterID() string { return s.clusterIDOut } +func (s *stubCluster) LocalAddr() string { return s.addrOut } + +// withClusterServer returns a test server whose ClusterView is the +// supplied stub. The first-run password path is also exercised so +// the session cookie is available for the cluster-endpoint probes. +// Returns the *httptest.Server, the underlying *Server, and the +// authenticated http.Client (cookie jar already populated). +func withClusterServer(t *testing.T, c ClusterView) (*httptest.Server, *Server, *http.Client) { + t.Helper() + srv := newTestServer(t, &stubRunner{id: "w-1"}) + srv.SetCluster(c) + ts := newHTTPTestServer(t, srv) + + client, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + return ts, srv, client +} + +// TestClusterStatus_NotConfigured verifies the 503 path when no +// cluster subsystem is attached to the webapp. +func TestClusterStatus_NotConfigured(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/web/api/cluster/status") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, + "cluster status must 503 when no cluster is attached") +} + +// TestClusterStatus_HappyPath verifies the JSON shape of the +// /web/api/cluster/status response when a stub cluster is attached. +func TestClusterStatus_HappyPath(t *testing.T) { + stub := &stubCluster{ + stats: ClusterStats{ + NodeID: "worker1", + LocalAddr: "127.0.0.1:17401", + State: "Leader", + Leader: "worker1", + Term: 17, + AppliedIndex: 42, + LastIndex: 42, + NumPeers: 2, + Voters: []string{"worker1", "worker2"}, + FSMChecks: 1, + FSMMembers: 2, + FSMConfigVersion: 7, + FSMOutboxLen: 3, + FSMPartition: "steady", + }, + clusterIDOut: "worker1", + addrOut: "127.0.0.1:17401", + } + ts, _, c := withClusterServer(t, stub) + + resp, err := c.Get(ts.URL + "/web/api/cluster/status") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) + body, _ := io.ReadAll(resp.Body) + + var got clusterStatusResponse + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, "worker1", got.SelfID) + assert.Equal(t, "Leader", got.Role) + assert.EqualValues(t, 17, got.Term) + assert.Equal(t, "worker1", got.LeaderID) + assert.Equal(t, []string{"worker1", "worker2"}, got.Voters) + assert.EqualValues(t, 42, got.AppliedIndex) + assert.EqualValues(t, 42, got.CommitIndex) + assert.Equal(t, 1, got.FSMChecks) + assert.Equal(t, 2, got.FSMMembers) + assert.EqualValues(t, 7, got.FSMConfigVersion) + assert.Equal(t, 3, got.FSMOutboxLen) + assert.Equal(t, "steady", got.FSMPartition) + assert.Equal(t, "worker1", got.ClusterID) + assert.Equal(t, "127.0.0.1:17401", got.LocalAddr) +} + +// TestClusterStatus_FSMFieldsZeroByDefault pins the FSM-side fields +// to the zero value when the stub cluster does not set them. Guards +// against a future refactor accidentally widening the wire format +// with a non-zero default for a fresh cluster. +func TestClusterStatus_FSMFieldsZeroByDefault(t *testing.T) { + stub := &stubCluster{ + stats: ClusterStats{ + NodeID: "worker1", State: "Follower", Leader: "worker2", + Voters: []string{"worker1", "worker2"}, + }, + clusterIDOut: "worker1", + addrOut: "127.0.0.1:17401", + } + ts, _, c := withClusterServer(t, stub) + + resp, err := c.Get(ts.URL + "/web/api/cluster/status") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + body, _ := io.ReadAll(resp.Body) + + var got clusterStatusResponse + require.NoError(t, json.Unmarshal(body, &got)) + assert.EqualValues(t, 0, got.FSMConfigVersion, "fresh cluster must report config_version 0") + assert.Equal(t, 0, got.FSMOutboxLen, "fresh cluster must report outbox_len 0") + assert.Equal(t, "", got.FSMPartition, "fresh cluster must report partition empty/zero") +} + +// TestClusterStatus_RequiresSession ensures the cluster admin +// endpoint is gated by the session middleware. +func TestClusterStatus_RequiresSession(t *testing.T) { + stub := &stubCluster{} + srv := newTestServer(t, &stubRunner{id: "w-1"}) + srv.SetCluster(stub) + ts := newHTTPTestServer(t, srv) + + // No session cookie — should redirect to login. + client := httpClient() + resp, err := client.Get(ts.URL + "/web/api/cluster/status") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusFound, resp.StatusCode, + "cluster status must redirect to login without session") + assert.Equal(t, "/web/login", resp.Header.Get("Location")) +} + +// TestClusterApplyTestConfig_NotConfigured verifies the 404 path +// when WORKER_CLUSTER_DEBUG_APPLY is false (the production default) +// and no cluster is attached. The handler must refuse before it +// even checks the cluster because the debug flag is off. +func TestClusterApplyTestConfig_NotConfigured(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + require.False(t, srv.cfg.DebugClusterApply, "default config must leave the debug apply flag off") + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{}) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode, + "debug apply must be invisible (404) when WORKER_CLUSTER_DEBUG_APPLY is unset") +} + +// TestClusterApplyTestConfig_DebugOffReturns404 verifies that even +// with a cluster attached the apply endpoint stays 404 unless the +// debug flag is on. The flag, not cluster presence, gates the +// endpoint. +func TestClusterApplyTestConfig_DebugOffReturns404(t *testing.T) { + stub := &stubCluster{applyIndex: 42} + ts, _, c := withClusterServer(t, stub) + + resp, err := c.Get(ts.URL + "/overview") + require.NoError(t, err) + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() //nolint:errcheck + csrf := extractCSRFToken(t, string(bodyBytes)) + + form := url.Values{} + form.Set("csrf_token", csrf) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err = c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, 0, stub.applyCalled, + "ApplyTestConfig must never be called when the debug flag is off") +} + +// TestClusterApplyTestConfig_HappyPath verifies that the apply-test- +// config endpoint returns the applied index when the cluster +// subsystem accepts the entry. CSRF is checked. The DebugClusterApply +// flag must be on for the endpoint to be reachable. +func TestClusterApplyTestConfig_HappyPath(t *testing.T) { + stub := &stubCluster{ + stats: ClusterStats{ + NodeID: "worker1", State: "Leader", Leader: "worker1", + Voters: []string{"worker1"}, + }, + applyIndex: 13, + clusterIDOut: "worker1", + addrOut: "127.0.0.1:17401", + } + ts, srv, c := withClusterServer(t, stub) + srv.cfg.DebugClusterApply = true + + // Fetch CSRF token from any authenticated page. + resp, err := c.Get(ts.URL + "/overview") + require.NoError(t, err) + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() //nolint:errcheck + csrf := extractCSRFToken(t, string(bodyBytes)) + + form := url.Values{} + form.Set("csrf_token", csrf) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err = c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + bodyBytes, _ = io.ReadAll(resp.Body) + + var got map[string]uint64 + require.NoError(t, json.Unmarshal(bodyBytes, &got)) + assert.EqualValues(t, 13, got["applied_index"]) + + assert.Equal(t, 1, stub.applyCalled) +} + +// TestClusterApplyTestConfig_PropagatesError verifies that errors +// from the cluster subsystem surface as 502 Bad Gateway. Debug flag +// must be on. +func TestClusterApplyTestConfig_PropagatesError(t *testing.T) { + stub := &stubCluster{ + applyErr: errStubApply, + clusterIDOut: "worker1", + addrOut: "127.0.0.1:17401", + } + ts, srv, c := withClusterServer(t, stub) + srv.cfg.DebugClusterApply = true + + resp, err := c.Get(ts.URL + "/overview") + require.NoError(t, err) + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() //nolint:errcheck + csrf := extractCSRFToken(t, string(bodyBytes)) + + form := url.Values{} + form.Set("csrf_token", csrf) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err = c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +// TestClusterApplyTestConfig_RequiresCSRF ensures the apply-test- +// config POST is refused without a CSRF token. Debug flag must be +// on for the endpoint to be reachable; without the flag it returns +// 404 (priority over CSRF check). +func TestClusterApplyTestConfig_RequiresCSRF(t *testing.T) { + stub := &stubCluster{applyIndex: 99} + ts, srv, c := withClusterServer(t, stub) + srv.cfg.DebugClusterApply = true + + resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{}) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusForbidden, resp.StatusCode, + "apply-test-config without CSRF must be 403") + assert.Equal(t, 0, stub.applyCalled, "ApplyTestConfig must not be called without CSRF") +} + +// errStubApply is a sentinel error used by the apply-error test. +var errStubApply = errApply("worker not leader") + +type errApply string + +func (e errApply) Error() string { return string(e) } + +// TestSetClusterDetaches verifies SetCluster(nil) returns the server +// to the no-cluster-attached state (503 from the endpoints). +func TestSetClusterDetaches(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + stub := &stubCluster{applyIndex: 7} + srv.SetCluster(stub) + require.NotNil(t, srv.Cluster()) + + srv.SetCluster(nil) + require.Nil(t, srv.Cluster()) + + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/web/api/cluster/status") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// _ = context.Background and time.Time keep the linter quiet about +// unused imports if the file shrinks. +var ( + _ = context.Background + _ = time.Now + _ = url.Parse +) diff --git a/internal/webapp/handlers_logs.go b/internal/webapp/handlers_logs.go new file mode 100644 index 0000000..f731b88 --- /dev/null +++ b/internal/webapp/handlers_logs.go @@ -0,0 +1,49 @@ +package webapp + +import ( + "net/http" + "strconv" +) + +// handleLogs tails the in-memory worker log buffer. The handler +// reads ?tail=200|500|1000|5000 (default 200) per section 6.6. +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := sessionFromContext(r.Context()) + tail := parseTail(r.URL.Query().Get("tail")) + lines := s.logBuffer.Tail(tail) + data := logsPageData{ + basePageData: s.newBasePage(r, "Worker logs", sess), + Tail: tail, + Lines: lines, + BufferSize: s.logBuffer.Size(), + BufferCap: s.logBuffer.Cap(), + } + if err := s.templates.Execute(w, "logs.html", data); err != nil { + s.deps.Logger.Printf("render logs: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +// parseTail clamps the requested tail count to one of the +// doc-prescribed buckets (200/500/1000/5000) and falls back to 200. +func parseTail(raw string) int { + n, err := strconv.Atoi(raw) + if err != nil { + return 200 + } + for _, allowed := range []int{200, 500, 1000, 5000} { + if n == allowed { + return allowed + } + } + return 200 +} + +type logsPageData struct { + basePageData + Tail int + Lines []string + BufferSize int + BufferCap int +} diff --git a/internal/webapp/handlers_overview.go b/internal/webapp/handlers_overview.go new file mode 100644 index 0000000..797cddf --- /dev/null +++ b/internal/webapp/handlers_overview.go @@ -0,0 +1,84 @@ +package webapp + +import ( + "net/http" + "strings" + "time" +) + +// handleOverview is the landing page after login. Phase 1 shows the +// worker status, recent results counts, and a tail of the worker +// log buffer. The data shape will grow in later phases as the +// heartbeat inventory and 24h result counts come online. +func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := sessionFromContext(r.Context()) + snap, snapAt := s.metrics.Last() + data := overviewPageData{ + basePageData: s.newBasePage(r, "Overview", sess), + WorkerID: workerIDOrDash(s.deps.Runner), + RegionCode: regionOrDash(s.deps.Runner), + WorkerState: workerState(s.deps.Runner, s.deps.StartedAt), + LastAckAt: lastAckOrZero(s.deps.Runner), + StartedAt: s.deps.StartedAt, + Snapshot: snap, + SnapshotAt: snapAt, + DiscoveredCount: len(s.inventory.Snapshot()), + ResultCount: len(s.deps.Runner.RecentResults(1000)), + NotifCount: len(s.deps.Runner.RecentNotifications(1000)), + LogTail: s.logBuffer.Tail(20), + } + if err := s.templates.Execute(w, "overview.html", data); err != nil { + s.deps.Logger.Printf("render overview: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +func workerIDOrDash(v WorkerView) string { + if v == nil { + return "—" + } + return v.WorkerID() +} + +func regionOrDash(v WorkerView) string { + if v == nil { + return "—" + } + return v.RegionCode() +} + +func workerState(v WorkerView, startedAt time.Time) string { + if v == nil { + return "—" + } + if last := v.LastHeartbeatAck(); !last.IsZero() && last.After(startedAt) { + return "connected" + } + return "starting" +} + +func lastAckOrZero(v WorkerView) time.Time { + if v == nil { + return time.Time{} + } + return v.LastHeartbeatAck() +} + +// overviewPageData is the data backing overview.html. +type overviewPageData struct { + basePageData + WorkerID string + RegionCode string + WorkerState string + LastAckAt time.Time + StartedAt time.Time + Snapshot Snapshot + SnapshotAt time.Time + DiscoveredCount int + ResultCount int + NotifCount int + LogTail []string +} + +var _ = strings.TrimSpace diff --git a/internal/webapp/handlers_peer.go b/internal/webapp/handlers_peer.go new file mode 100644 index 0000000..b205fb6 --- /dev/null +++ b/internal/webapp/handlers_peer.go @@ -0,0 +1,59 @@ +package webapp + +import ( + "encoding/json" + "net/http" + "time" +) + +// peerStatusResponse is the JSON returned at GET /api/peer/status. +// The shape matches distworker.PeerStatus exactly so the peer +// poller on the other end can decode it without a separate +// type. Kept here as a local view to keep the webapp package free +// of any concrete dependency on the distworker peer types; the +// fields are JSON-stable. +// +// Up == nil means "no probe has run yet" so peer workers that +// query this endpoint right after boot do not get a misleading +// "true" verdict while the selfcheck is still spinning up. +type peerStatusResponse struct { + WorkerID string `json:"worker_id"` + Up *bool `json:"up"` + ObservedAt *time.Time `json:"observed_at"` +} + +// handlePeerStatus serves the most recent local selfcheck verdict +// to peer workers over HTTP. The endpoint is intentionally +// unauthenticated for now: a worker with basic auth configured +// (WORKER_LOGIN / WORKER_PASSWORD) still exposes the verdict +// because the path lives outside the /web/api/* prefix that the +// basic-auth middleware gates. This matches the +// "keep simple for local trusted workers if no peer auth exists +// yet" directive in +// docs/distributed/worker-to-worker-raft.md (slice 1). +// +// The endpoint never returns a 5xx: a runner that has not yet +// produced a verdict simply returns {"up": null, ...} so the +// peer poller can keep the slot in cache as "unknown" instead of +// treating the absence as a hard failure. +func (s *Server) handlePeerStatus(w http.ResponseWriter, _ *http.Request) { + resp := peerStatusResponse{} + if s.deps.Runner != nil { + up, at := s.deps.Runner.MasterStatus() + resp.WorkerID = s.deps.Runner.WorkerID() + resp.Up = up + if !at.IsZero() { + // Copy the timestamp so callers see a value + // (json omitempty is not used on purpose: an + // explicit zero time communicates "no probe" and + // an RFC3339 string communicates "probed at"). + atCopy := at.UTC() + resp.ObservedAt = &atCopy + } + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + s.deps.Logger.Printf("peer status encode: %v", err) + } +} diff --git a/internal/webapp/handlers_peer_test.go b/internal/webapp/handlers_peer_test.go new file mode 100644 index 0000000..3387fb3 --- /dev/null +++ b/internal/webapp/handlers_peer_test.go @@ -0,0 +1,94 @@ +package webapp + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHandlePeerStatus_NoProbe covers the first-boot window: the +// runner has not yet produced a selfcheck verdict, so the endpoint +// must return a valid JSON body with up=null and observed_at=null. +func TestHandlePeerStatus_NoProbe(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + resp, err := http.Get(ts.URL + "/api/peer/status") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var got peerStatusResponse + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, "w-1", got.WorkerID) + assert.Nil(t, got.Up, "up must be nil before the first probe") + assert.Nil(t, got.ObservedAt, "observed_at must be nil before the first probe") +} + +// TestHandlePeerStatus_UpAndDown covers the post-probe window: the +// endpoint must reflect the most recent selfcheck verdict. +func TestHandlePeerStatus_UpAndDown(t *testing.T) { + up := true + at := time.Date(2026, 7, 10, 16, 0, 0, 0, time.UTC) + srv := newTestServer(t, &stubRunner{id: "w-2", masterUp: &up, masterAt: at}) + ts := newHTTPTestServer(t, srv) + + resp, err := http.Get(ts.URL + "/api/peer/status") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var got peerStatusResponse + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, "w-2", got.WorkerID) + require.NotNil(t, got.Up) + assert.True(t, *got.Up) + require.NotNil(t, got.ObservedAt) + assert.Equal(t, at, *got.ObservedAt) + + // Flip to down and re-fetch; the handler reads from the live + // stub view, not a cached copy, so the new verdict must surface. + down := false + runner := srv.deps.Runner.(*stubRunner) //nolint:forcetypeassert // helper under test + runner.masterUp = &down + runner.masterAt = at.Add(time.Minute) + + resp2, err := http.Get(ts.URL + "/api/peer/status") + require.NoError(t, err) + defer resp2.Body.Close() //nolint:errcheck + + var got2 peerStatusResponse + require.NoError(t, json.NewDecoder(resp2.Body).Decode(&got2)) + require.NotNil(t, got2.Up) + assert.False(t, *got2.Up) + require.NotNil(t, got2.ObservedAt) + assert.Equal(t, at.Add(time.Minute), *got2.ObservedAt) +} + +// TestHandlePeerStatus_DoesNotRequireSession confirms the slice-1 +// design: the endpoint sits outside /web/api/* so the basic-auth +// middleware does not intercept it and no session cookie is needed. +// The handler should still answer 200 OK when the runner is wired. +func TestHandlePeerStatus_DoesNotRequireSession(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-3"}) + ts := newHTTPTestServer(t, srv) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/api/peer/status", nil) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode, + "peer status must be reachable without a session cookie (slice 1)") +} diff --git a/internal/webapp/handlers_settings.go b/internal/webapp/handlers_settings.go new file mode 100644 index 0000000..9e5b875 --- /dev/null +++ b/internal/webapp/handlers_settings.go @@ -0,0 +1,100 @@ +package webapp + +import ( + "net/http" + "time" +) + +// handleSettings renders the worker fields (worker id, region, +// capabilities, version, last heartbeat ack, last token rotation). +// The token is masked; rotation is a POST to +// /settings/rotate-token (see handleRotateToken). +func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := sessionFromContext(r.Context()) + token := "" + if s.deps.Runner != nil { + token = s.deps.Runner.Token() + } + rotatedAt := time.Time{} + if s.deps.Runner != nil { + rotatedAt = s.deps.Runner.TokenRotatedAt() + } + data := settingsPageData{ + basePageData: s.newBasePage(r, "Settings", sess), + WorkerID: workerIDOrDash(s.deps.Runner), + RegionCode: regionOrDash(s.deps.Runner), + WorkerVersion: workerVersionOrDash(s.deps.Runner), + Capabilities: capabilitiesOrEmpty(s.deps.Runner), + LastAckAt: lastAckOrZero(s.deps.Runner), + TokenMasked: MaskToken(token), + TokenRotatedAt: rotatedAt, + } + if err := s.templates.Execute(w, "settings.html", data); err != nil { + s.deps.Logger.Printf("render settings: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +func workerVersionOrDash(v WorkerView) string { + if v == nil { + return "—" + } + return v.WorkerVersion() +} + +func capabilitiesOrEmpty(v WorkerView) []string { + if v == nil { + return nil + } + return v.WorkerCapabilities() +} + +type settingsPageData struct { + basePageData + WorkerID string + RegionCode string + WorkerVersion string + Capabilities []string + LastAckAt time.Time + TokenMasked string + TokenRotatedAt time.Time +} + +// handleRotateToken calls the worker-defined rotator (if any) to +// issue a fresh token via the main app's API and update the in- +// memory runner config. On failure, return 502 and keep the old +// token (the doc's Phase 1 contract). +func (s *Server) handleRotateToken(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, ok := sessionFromContext(r.Context()) + if !ok { + redirectTo(w, r, "/web/login") + return + } + if !s.requireCSRF(sess, r) { + http.Error(w, "csrf token required", http.StatusForbidden) + return + } + if s.deps.TokenRotator == nil { + http.Error(w, "token rotation is not configured", http.StatusNotImplemented) + return + } + newToken, err := s.deps.TokenRotator(r.Context()) + if err != nil { + s.deps.Logger.Printf("token rotation: %v", err) + http.Error(w, "rotation failed: "+err.Error(), http.StatusBadGateway) + return + } + _ = s.store.WriteAudit(r.Context(), &AuditEntry{ + Actor: auditActorLocal, + Role: auditRoleAdmin, + AuthMode: auditAuthModeLocal, + IP: clientIP(r), + UA: r.UserAgent(), + Action: "token_rotation", + Target: auditTargetSelf, + }) + _ = newToken // the rotator updates the runner; the page just confirms success. + redirectTo(w, r, "/settings") +} diff --git a/internal/webapp/handlers_status.go b/internal/webapp/handlers_status.go new file mode 100644 index 0000000..3e7d804 --- /dev/null +++ b/internal/webapp/handlers_status.go @@ -0,0 +1,38 @@ +package webapp + +import ( + "net/http" + "time" +) + +// handleStatus renders the host metrics snapshot. Phase 1 reads +// only /proc and statfs; the lsblk / smartctl / sensors +// integrations are deferred to the worker webapp expansion tracked +// in docs/distributed/worker-web-app.md §6.7 (out of band; not +// shipped in this repo). The current Snapshot struct accommodates +// the extra sections if/when those are wired in. +// +// TODO(worker-web-app §6.7): surface lsblk / smartctl / sensors / +// docker info on this page once the worker process is allowed to +// invoke those CLI tools. The cli tools are not bundled with the +// worker binary; install them separately on the host if needed. +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := sessionFromContext(r.Context()) + snap, snapAt := s.metrics.Last() + data := statusPageData{ + basePageData: s.newBasePage(r, "Server status", sess), + Snapshot: snap, + SnapshotAt: snapAt, + } + if err := s.templates.Execute(w, "status.html", data); err != nil { + s.deps.Logger.Printf("render status: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +type statusPageData struct { + basePageData + Snapshot Snapshot + SnapshotAt time.Time +} diff --git a/internal/webapp/handlers_updates.go b/internal/webapp/handlers_updates.go new file mode 100644 index 0000000..38aa76c --- /dev/null +++ b/internal/webapp/handlers_updates.go @@ -0,0 +1,129 @@ +package webapp + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "time" +) + +// placeholderLatestVersion is the fallback shown on the /updates +// page when Config.ReleaseURL is empty or the poll fails. Phase 1 +// uses it permanently; once Config.ReleaseURL is set the handler +// replaces it with the polled tag_name (GitHub release JSON shape). +const placeholderLatestVersion = "v1 (dev)" + +// defaultReleasePollTimeout bounds the time a single release-server +// HTTP fetch is allowed to take. 3 seconds keeps the page render +// fast; a slow upstream just shows the placeholder. +const defaultReleasePollTimeout = 3 * time.Second + +// releaseCacheTTL bounds how often the worker re-fetches the +// release URL. One hour is short enough that a fresh release shows +// up reasonably quickly, long enough that the page never hammers the +// upstream. +const releaseCacheTTL = 1 * time.Hour + +// releasePoller holds the cached release-server response. A single +// instance lives on the Server (one per process) so concurrent +// /updates hits share the same cache entry. +type releasePoller struct { + mu sync.RWMutex + url string + cached string + cachedAt time.Time +} + +// latest returns the cached value if it is fresh, otherwise it +// fetches the URL, parses {"tag_name":"..."} from the response and +// caches the result. Errors fall back to placeholderLatestVersion +// without touching the cache, so a transient outage does not poison +// the next successful poll. +func (p *releasePoller) latest(ctx context.Context, httpClient *http.Client) string { + if p == nil || p.url == "" { + return placeholderLatestVersion + } + p.mu.RLock() + if !p.cachedAt.IsZero() && time.Since(p.cachedAt) < releaseCacheTTL && p.cached != "" { + out := p.cached + p.mu.RUnlock() + return out + } + p.mu.RUnlock() + + client := httpClient + if client == nil { + client = &http.Client{Timeout: defaultReleasePollTimeout} + } + fetchCtx, cancel := context.WithTimeout(ctx, defaultReleasePollTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, p.url, http.NoBody) + if err != nil { + return placeholderLatestVersion + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return placeholderLatestVersion + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return placeholderLatestVersion + } + var body struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.TagName == "" { + return placeholderLatestVersion + } + p.mu.Lock() + p.cached = body.TagName + p.cachedAt = time.Now() + p.mu.Unlock() + return body.TagName +} + +// setURL configures the poller with a new release URL and resets +// the cache. Called from New() when Config.ReleaseURL is set. +func (p *releasePoller) setURL(u string) { + if p == nil { + return + } + p.mu.Lock() + p.url = u + p.cached = "" + p.cachedAt = time.Time{} + p.mu.Unlock() +} + +// handleUpdates renders the updates page. The "pull and restart" +// button stays disabled until sudo / docker socket access lands +// (gated on the Phase 3 Docker management work). The version +// comparison above it IS real: it polls Config.ReleaseURL (env +// WORKER_RELEASE_URL) and falls back to placeholderLatestVersion on +// any network or parse error. +func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) { + writeNoStore(w) + sess, _ := sessionFromContext(r.Context()) + data := updatesPageData{ + basePageData: s.newBasePage(r, "Updates", sess), + CurrentVersion: workerVersionOrDash(s.deps.Runner), + LatestKnown: s.releasePoller.latest(r.Context(), s.deps.ReleaseHTTPClient), + PullEnabled: false, + PullTooltip: "Pull and restart lands with Docker management (sudo / docker socket required).", + } + if err := s.templates.Execute(w, "updates.html", data); err != nil { + s.deps.Logger.Printf("render updates: %v", err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +type updatesPageData struct { + basePageData + CurrentVersion string + LatestKnown string + PullEnabled bool + PullTooltip string +} diff --git a/internal/webapp/handlers_updates_test.go b/internal/webapp/handlers_updates_test.go new file mode 100644 index 0000000..5b80c5e --- /dev/null +++ b/internal/webapp/handlers_updates_test.go @@ -0,0 +1,272 @@ +package webapp + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubReleaseServer returns a httptest.Server whose handler serves +// the supplied tag_name as a GitHub-style JSON body. The close func +// is returned alongside so callers can defer shutdown. +func stubReleaseServer(t *testing.T, tagName string, status int) (*httptest.Server, func()) { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/json", r.Header.Get("Accept")) + if status != http.StatusOK { + w.WriteHeader(status) + return + } + w.Header().Set("Content-Type", "application/json") + body, _ := json.Marshal(map[string]string{"tag_name": tagName}) + _, _ = w.Write(body) + })) + return ts, ts.Close +} + +// TestReleasePoller_NoURLReturnsPlaceholder pins the +// no-URL-is-configured fallback. The handler must not hit the +// network when Config.ReleaseURL is empty. +func TestReleasePoller_NoURLReturnsPlaceholder(t *testing.T) { + p := &releasePoller{} + got := p.latest(context.Background(), nil) + assert.Equal(t, placeholderLatestVersion, got) +} + +// TestReleasePoller_SuccessCaches verifies the happy path: the +// first call hits the URL, subsequent calls within the TTL come +// from the cache. +func TestReleasePoller_SuccessCaches(t *testing.T) { + ts, cleanup := stubReleaseServer(t, "v2.7.1", http.StatusOK) + defer cleanup() + + p := &releasePoller{} + p.setURL(ts.URL) + + got := p.latest(context.Background(), ts.Client()) + assert.Equal(t, "v2.7.1", got) + + // Second call: cache hit. Replace the upstream with one that + // would error; the cached value must still come back. + p.url = "http://127.0.0.1:1/never-reachable" + got = p.latest(context.Background(), ts.Client()) + assert.Equal(t, "v2.7.1", got, "cached value must survive upstream failures inside the TTL window") +} + +// TestReleasePoller_Non2xxReturnsPlaceholder ensures a 5xx upstream +// does not poison the cache (placeholder shown, cache untouched). +func TestReleasePoller_Non2xxReturnsPlaceholder(t *testing.T) { + ts, cleanup := stubReleaseServer(t, "ignored", http.StatusInternalServerError) + defer cleanup() + + p := &releasePoller{} + p.setURL(ts.URL) + + got := p.latest(context.Background(), ts.Client()) + assert.Equal(t, placeholderLatestVersion, got) + + // Confirm cache was not touched: a fresh request to a working + // upstream must produce the placeholder if the broken one was + // recorded. We use a different working upstream here. + ts2, cleanup2 := stubReleaseServer(t, "v9.9.9", http.StatusOK) + defer cleanup2() + p.setURL(ts2.URL) + got = p.latest(context.Background(), ts2.Client()) + assert.Equal(t, "v9.9.9", got) +} + +// TestReleasePoller_BadJSONReturnsPlaceholder verifies that a 200 +// with a missing tag_name falls back to the placeholder. +func TestReleasePoller_BadJSONReturnsPlaceholder(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name": "no tag here"}`)) + })) + defer ts.Close() + + p := &releasePoller{} + p.setURL(ts.URL) + + got := p.latest(context.Background(), ts.Client()) + assert.Equal(t, placeholderLatestVersion, got) +} + +// TestReleasePoller_TimeoutReturnsPlaceholder confirms a slow +// upstream degrades to the placeholder without exceeding the +// per-call timeout budget. +func TestReleasePoller_TimeoutReturnsPlaceholder(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(2 * defaultReleasePollTimeout) + _, _ = io.WriteString(w, `{"tag_name":"too-late"}`) + })) + defer ts.Close() + + p := &releasePoller{} + p.setURL(ts.URL) + + start := time.Now() + got := p.latest(context.Background(), &http.Client{Timeout: defaultReleasePollTimeout}) + elapsed := time.Since(start) + assert.Equal(t, placeholderLatestVersion, got) + assert.Less(t, elapsed, 2*defaultReleasePollTimeout, + "timeout must fire before the slow upstream replies") +} + +// TestReleasePoller_TTLExpiry verifies that after releaseCacheTTL +// the poller re-fetches the URL. We can't wait an hour in a unit +// test, so we reset the cachedAt directly to a stale time and +// confirm the next call refetches. +func TestReleasePoller_TTLExpiry(t *testing.T) { + ts, cleanup := stubReleaseServer(t, "v3.0.0", http.StatusOK) + defer cleanup() + + p := &releasePoller{} + p.setURL(ts.URL) + + // Prime the cache. + got := p.latest(context.Background(), ts.Client()) + require.Equal(t, "v3.0.0", got) + + // Force the cachedAt into the past. + p.mu.Lock() + p.cachedAt = time.Now().Add(-2 * releaseCacheTTL) + p.mu.Unlock() + + // Change the upstream to a new tag — must be observed. + ts2, cleanup2 := stubReleaseServer(t, "v3.0.1", http.StatusOK) + defer cleanup2() + p.setURL(ts2.URL) + got = p.latest(context.Background(), ts2.Client()) + assert.Equal(t, "v3.0.1", got, "stale cache must not block a fresh fetch after setURL reset") +} + +// TestUpdatesPage_ShowsPlaceholder verifies that without +// Config.ReleaseURL the page renders the placeholder string in +// the "Latest known" cell. +func TestUpdatesPage_ShowsPlaceholder(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + require.Empty(t, srv.cfg.ReleaseURL, "test fixture must not pre-set ReleaseURL") + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/updates") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + page := string(body) + assert.Contains(t, page, placeholderLatestVersion, + "placeholder version must appear when no release URL is configured") + assert.Contains(t, page, "WORKER_RELEASE_URL", + "placeholder copy must mention the env var that turns on real polls") +} + +// TestUpdatesPage_RunsPollWithReleaseURL verifies that with +// Config.ReleaseURL set, the page renders the tag_name from the +// upstream release server. +func TestUpdatesPage_RunsPollWithReleaseURL(t *testing.T) { + ts, cleanup := stubReleaseServer(t, "v9.9.9", http.StatusOK) + defer cleanup() + + srv := newTestServer(t, &stubRunner{id: "w-1"}) + srv.cfg.ReleaseURL = ts.URL + srv.releasePoller.setURL(ts.URL) + + hts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, hts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(hts.URL + "/updates") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "v9.9.9", + "page must surface the polled tag_name when WORKER_RELEASE_URL is configured") +} + +// TestChecksPage_RunNowDisabledButton verifies that the "Run now" +// button is rendered and disabled, with the tooltip explaining the +// gating. +func TestChecksPage_RunNowDisabledButton(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/checks") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + page := string(body) + assert.Contains(t, page, "Run now", + "the Run now button must appear on the checks page") + assert.Contains(t, page, "disabled", + "the Run now button must be disabled in Phase 1") + assert.Contains(t, page, "worker-notifier-mvp", + "tooltip must cite the worker-notifier MVP plan that owns the hint protocol") +} + +// TestNotificationsPage_ResendDisabledButton mirrors the checks +// page test for the resend button on /notifications. +func TestNotificationsPage_ResendDisabledButton(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/notifications") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + page := string(body) + assert.Contains(t, page, "Resend") + assert.Contains(t, page, "disabled") + assert.Contains(t, page, "worker-notifier-mvp") +} + +// TestAppsPage_ReferencesInventoryPlan ensures the copy on +// /apps points operators at the deploymentd-driven inventory +// surface that this PR's plan docs describe. +func TestAppsPage_ReferencesInventoryPlan(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/apps") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + page := string(body) + assert.Contains(t, page, "deploymentd", + "apps page must mention deploymentd so operators know where Docker Compose discoveries land") + assert.Contains(t, page, "inventory-management.md", + "apps page must cite the inventory-management plan doc") +} + +// _ = url.Values and strings.Builder keep imports used if the file +// shrinks in future refactors; they document the surface without +// affecting compilation. +var ( + _ = url.Values{} + _ = strings.Builder{} +) diff --git a/internal/webapp/inventory.go b/internal/webapp/inventory.go new file mode 100644 index 0000000..316132b --- /dev/null +++ b/internal/webapp/inventory.go @@ -0,0 +1,446 @@ +package webapp + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// inventoryRefreshInterval matches the 60s cadence called out in +// docs/distributed/worker-web-app.md section 7 ("rebuilt every 60s"). +const inventoryRefreshInterval = 60 * time.Second + +// procMount is the directory the inventory walker reads /proc +// entries from. Defaults to /proc on a normal host. Tests override +// it via SetProcRoot. +var procMount = "/proc" + +// SetProcRoot overrides the /proc mount for tests. It must be called +// before any Inventory goroutine starts. +func SetProcRoot(path string) { + if path == "" { + procMount = "/proc" + return + } + procMount = path +} + +// procInfo holds the per-process info the inventory walker reads +// from /proc/. Defined at file scope so readProcInfo can return +// it by value. +type procInfo struct { + pid int + comm string + cmdline string + cwd string + startTS int64 +} + +// ProcRoot returns the currently configured /proc mount. +func ProcRoot() string { return procMount } + +// Inventory owns the periodic /proc -> sqlite refresh loop and +// exposes a Snapshot for the discovered-apps handler. +type Inventory struct { + store *Store + log *log.Logger + mu sync.RWMutex + snapshot []DiscoveredApp + stopCh chan struct{} + stopWG sync.WaitGroup + started bool +} + +// DiscoveredApp is the shape we render on /apps. It is JSON-encodable +// so the cache can stash a blob for later drill-in rendering. +type DiscoveredApp struct { + Name string `json:"name"` + Source string `json:"source"` // inventorySourceProcess in Phase 1 + PID int `json:"pid"` + Ports []string `json:"ports"` // "7401/tcp", "127.0.0.1:5432" + StartTS int64 `json:"start_ts"` // unix seconds + LastSeen time.Time `json:"last_seen"` + Cmdline string `json:"cmdline"` + CWD string `json:"cwd"` +} + +// NewInventory returns an Inventory bound to the given store. The +// refresh loop does NOT start until Start is called. +func NewInventory(store *Store, logger *log.Logger) *Inventory { + if logger == nil { + logger = log.New(os.Stderr, "webapp-inventory: ", log.LstdFlags) + } + return &Inventory{ + store: store, + log: logger, + stopCh: make(chan struct{}), + } +} + +// Start launches the background refresh loop. Returns immediately; +// callers must call Stop for clean shutdown. +func (i *Inventory) Start(ctx context.Context) { + i.mu.Lock() + if i.started { + i.mu.Unlock() + return + } + i.started = true + i.mu.Unlock() + + i.stopWG.Add(1) + go i.loop(ctx) +} + +// Stop cancels the refresh loop and waits for it to exit. +func (i *Inventory) Stop() { + i.mu.Lock() + if !i.started { + i.mu.Unlock() + return + } + select { + case <-i.stopCh: + // already closed + default: + close(i.stopCh) + } + i.mu.Unlock() + i.stopWG.Wait() +} + +// Snapshot returns the most recent inventory. Always safe to call +// (returns an empty slice if the first refresh has not completed). +func (i *Inventory) Snapshot() []DiscoveredApp { + i.mu.RLock() + defer i.mu.RUnlock() + out := make([]DiscoveredApp, len(i.snapshot)) + copy(out, i.snapshot) + return out +} + +func (i *Inventory) loop(ctx context.Context) { + defer i.stopWG.Done() + i.refresh(ctx) + ticker := time.NewTicker(inventoryRefreshInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-i.stopCh: + return + case <-ticker.C: + i.refresh(ctx) + } + } +} + +func (i *Inventory) refresh(ctx context.Context) { + apps, err := ScanProcApps(ProcRoot()) + if err != nil { + i.log.Printf("inventory refresh: %v", err) + return + } + now := time.Now().UTC() + rows := make([]App, 0, len(apps)) + for i := range apps { + a := &apps[i] + lastSeen := now + if !a.LastSeen.IsZero() { + lastSeen = a.LastSeen + } + rows = append(rows, App{ + Name: a.Name, + Source: a.Source, + PID: a.PID, + Ports: strings.Join(a.Ports, ","), + StartTS: a.StartTS, + LastSeen: lastSeen, + JSONBlob: a.Cmdline, // minimal JSON for now; full struct kept in Snapshot() + }) + } + if err := i.store.ReplaceApps(ctx, rows); err != nil { + i.log.Printf("inventory persist: %v", err) + return + } + i.mu.Lock() + i.snapshot = apps + i.mu.Unlock() +} + +// ScanProcApps walks the /proc mount and returns one DiscoveredApp +// per process. It excludes kernel threads (comm == "") and is the +// single source of truth for the inventory refresh. +// +// The grouping rule from section 7.1 ("processes sharing a cwd and +// started within 5 seconds of each other are one app") is applied +// by CollideByCWD before returning. Single processes are apps. +// +// Docker / Compose / systemd discovery on top of this per-process +// list lands with the deploymentd integration in +// docs/plans/inventory-management.md M1: RSMon's +// /api/v1/inventory/deploymentd/receive/docker endpoint will upsert +// Site + Deployment rows, and the worker webapp's `/apps` page will +// show those rows side-by-side with the /proc-derived processes. +// Phase 1 ships process discovery only. +func ScanProcApps(root string) ([]DiscoveredApp, error) { + entries, err := os.ReadDir(root) + if err != nil { + return nil, fmt.Errorf("read %s: %w", root, err) + } + + // Index inodes -> pid via /proc//fd. We do this once at the + // top of the scan so port resolution can reuse it. + inodeOwner := map[uint64]int{} + var procs []procInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue // not a pid directory + } + pi, ok := readProcInfo(root, pid) + if !ok { + continue + } + procs = append(procs, pi) + if pids, err := readSocketOwners(root, pid); err == nil { + for _, ino := range pids { + inodeOwner[ino] = pid + } + } + } + + // Resolve listeners -> pid (and hence the proc above). + listeners, err := readListeners(root, inodeOwner) + if err != nil { + return nil, fmt.Errorf("read listeners: %w", err) + } + + // Build the discovered-apps list. Phase 1 has no grouping: each + // process is its own app. Section 7.1 grouping (cwd + start + // window) is deferred because it requires a stable cwd per + // process which root-only /proc//cwd symlinks cannot give + // for other users' processes. + apps := make([]DiscoveredApp, 0, len(procs)) + now := time.Now().UTC() + for i := range procs { + p := &procs[i] + apps = append(apps, DiscoveredApp{ + Name: p.comm, + Source: inventorySourceProcess, + PID: p.pid, + Ports: listeners[p.pid], + StartTS: p.startTS, + LastSeen: now, + Cmdline: p.cmdline, + CWD: p.cwd, + }) + } + sort.Slice(apps, func(i, j int) bool { return apps[i].PID < apps[j].PID }) + return apps, nil +} + +func readProcInfo(root string, pid int) (procInfo, bool) { + pi := procInfo{pid: pid} + // comm (15-char truncated, but we want a friendly name). + if data, err := os.ReadFile(filepath.Join(root, strconv.Itoa(pid), "comm")); err == nil { + pi.comm = strings.TrimSpace(string(data)) + } + if pi.comm == "" { + // Kernel thread, or vanished. Skip. + return pi, false + } + if data, err := os.ReadFile(filepath.Join(root, strconv.Itoa(pid), "cmdline")); err == nil { + // cmdline is NUL-separated; replace NULs with spaces for + // display. + pi.cmdline = strings.TrimSpace(strings.ReplaceAll(string(data), "\x00", " ")) + } + // cwd is a symlink. Reading it requires permission; tolerate EACCES. + if target, err := os.Readlink(filepath.Join(root, strconv.Itoa(pid), "cwd")); err == nil { + pi.cwd = target + } + // stat: field 22 is starttime in clock ticks since boot. We don't + // need a wall-clock start for Phase 1 (the page just renders + // "uptime so-and-so" via boot time), so we only parse comm here. + return pi, true +} + +// readSocketOwners walks /proc//fd looking for socket:[inode] +// entries. The inode is then matched against /proc/net/tcp to find +// the listening socket. The pid map is the source of truth for +// socket-to-pid translation. +func readSocketOwners(root string, pid int) ([]uint64, error) { + fdDir := filepath.Join(root, strconv.Itoa(pid), "fd") + entries, err := os.ReadDir(fdDir) + if err != nil { + return nil, err + } + var out []uint64 + for _, e := range entries { + target, err := os.Readlink(filepath.Join(fdDir, e.Name())) + if err != nil { + continue + } + const prefix = "socket:[" + if !strings.HasPrefix(target, prefix) { + continue + } + raw := strings.TrimSuffix(strings.TrimPrefix(target, prefix), "]") + ino, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + continue + } + out = append(out, ino) + } + return out, nil +} + +// listenerRow mirrors a single line of /proc/net/tcp (or tcp6). +type listenerRow struct { + inode uint64 + local string + rem string + state string +} + +// readListeners walks /proc/net/tcp{,6} and returns a map from pid +// to a slice of "ip:port/proto" strings. Only LISTEN state (0A) is +// surfaced in Phase 1. +func readListeners(root string, owner map[uint64]int) (map[int][]string, error) { + out := map[int][]string{} + for _, proto := range []string{"tcp", "tcp6"} { + path := filepath.Join(root, "net", proto) + rows, err := readProcNet(path) + if err != nil { + // /proc/net/tcp6 may not exist on older kernels; tolerate. + if os.IsNotExist(err) { + continue + } + return nil, err + } + for _, r := range rows { + if r.state != "0A" { + continue + } + pid, ok := owner[r.inode] + if !ok { + continue + } + out[pid] = append(out[pid], r.local+"/"+proto) + } + } + return out, nil +} + +// readProcNet parses the columnar /proc/net/tcp{,6} format. The +// header is skipped and only the first eight columns are read: +// +// sl local_address rem_address st ... +// +// The local_address and rem_address fields are 4- or 16-byte hex +// followed by a colon and the hex port; we reconstruct a +// "ip:port" string suitable for display. +// +// The inode column (index 9) is hex (matches the address format) +// while /proc//fd symlinks carry the same inode in decimal. +// Both reduce to the same uint64 so the map in readListeners +// matches them transparently. +func readProcNet(path string) ([]listenerRow, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() //nolint:errcheck + var rows []listenerRow + scanner := bufioNewScanner(f) + first := true + for scanner.Scan() { + line := scanner.Text() + if first { + first = false + if strings.HasPrefix(line, " sl") { + continue + } + } + fields := strings.Fields(line) + if len(fields) < 10 { + continue + } + ino, err := strconv.ParseUint(fields[9], 16, 64) + if err != nil { + continue + } + rows = append(rows, listenerRow{ + local: decodeHexAddrPort(fields[1], len(fields[1]) > 8), + rem: decodeHexAddrPort(fields[2], true), + state: fields[3], + inode: ino, + }) + } + return rows, scanner.Err() +} + +// decodeHexAddrPort reverses the standard /proc/net encoding: +// +// "0100007F:0C50" -> "127.0.0.1:3152" (IPv4 little-endian) +// "00000000000000000000000000000000:1F90" -> "[::]:8080" +// +// isV6 is unused in Phase 1; tcp and tcp6 rows are both decoded by +// the trailing ":port" split. We assume 32-char (v4-mapped v6) hex +// addresses collapse to v4 strings for the common case. +func decodeHexAddrPort(raw string, _ bool) string { + idx := strings.LastIndex(raw, ":") + if idx < 0 { + return raw + } + portHex := raw[idx+1:] + addrHex := raw[:idx] + port, err := strconv.ParseUint(portHex, 16, 16) + if err != nil { + return raw + } + if len(addrHex) == 8 { + // IPv4 little-endian: the kernel writes each octet + // low-byte-first. "0100007F" means octets 1,0,0,127 which + // in network order is "127.0.0.1". + var b [4]byte + for i := 0; i < 4; i++ { + v, err := strconv.ParseUint(addrHex[2*i:2*i+2], 16, 8) + if err != nil { + return raw + } + b[i] = byte(v) + } + return fmt.Sprintf("%d.%d.%d.%d:%d", b[3], b[2], b[1], b[0], port) + } + if len(addrHex) == 32 { + // IPv6: 8 16-bit groups in network order. Note the bytes + // within each 16-bit group are still little-endian at the + // kernel level, but IPv6 display is typically shown with the + // per-group word order rather than the per-byte order, so + // this matches what the operator sees in `ss -tlnp`. + var groups [8]uint16 + for i := 0; i < 8; i++ { + v, err := strconv.ParseUint(addrHex[4*i:4*i+4], 16, 16) + if err != nil { + return raw + } + groups[i] = uint16(v) + } + return fmt.Sprintf("[%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x]:%d", + groups[0], groups[1], groups[2], groups[3], + groups[4], groups[5], groups[6], groups[7], port) + } + return raw +} diff --git a/internal/webapp/inventory_test.go b/internal/webapp/inventory_test.go new file mode 100644 index 0000000..3f547ca --- /dev/null +++ b/internal/webapp/inventory_test.go @@ -0,0 +1,159 @@ +package webapp + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeFakeProc builds a minimal /proc tree suitable for +// ScanProcApps. It writes: +// +// - a "comm" file for each pid +// - a "cmdline" file (NUL-separated) +// - a few sockets under fd/ so the listener scan can match +// +// We deliberately skip the cwd symlink (root-only) and accept that +// the readlink call returns an error; ScanProcApps must tolerate +// EACCES / ENOENT for permission-denied fds. +func makeFakeProc(t *testing.T, pids []fakeProcEntry) string { + t.Helper() + root := t.TempDir() + for _, e := range pids { + pdir := filepath.Join(root, strconv.Itoa(e.pid)) + require.NoError(t, os.MkdirAll(pdir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte(e.comm+"\n"), 0o644)) + if e.cmdline != "" { + require.NoError(t, os.WriteFile(filepath.Join(pdir, "cmdline"), []byte(e.cmdline), 0o644)) + } + if len(e.sockets) > 0 { + fdDir := filepath.Join(pdir, "fd") + require.NoError(t, os.MkdirAll(fdDir, 0o755)) + for i, sock := range e.sockets { + // fake inode numbers are arbitrary, but must match the + // /proc/net/tcp "inode" column for ScanProcApps to + // resolve them. Use a stable mapping. + target := "socket:[" + strconv.FormatInt(sock, 10) + "]" + require.NoError(t, os.Symlink(target, filepath.Join(fdDir, strconv.Itoa(i)))) + } + } + } + // /proc/net/tcp with state 0A (LISTEN) entries pointing at the + // fake inodes. We do this last so test setup is sequential. + require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755)) + var lines []string + lines = append(lines, " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ") + for _, e := range pids { + for _, ino := range e.sockets { + // "0100007F:1E61" -> 127.0.0.1:7777 in little-endian hex. + lines = append(lines, fakeTCPLine(ino, "0100007F:1E61")) + } + } + require.NoError(t, os.WriteFile( + filepath.Join(root, "net", "tcp"), + []byte(joinLines(lines)), + 0o644)) + return root +} + +type fakeProcEntry struct { + pid int + comm string + cmdline string + sockets []int64 // fake inode numbers +} + +func fakeTCPLine(inode int64, local string) string { + // 4 hex chars for tx/rx queue (always 0), 8 hex for tr/tm->when, + // 8 hex for retrnsmt, 1 hex for uid (0), 1 hex for timeout (0), + // then inode (10 hex zero-padded). The trailing fields are zero- + // filled so the scanner skips them. + inodeHex := strconv.FormatInt(inode, 16) + for len(inodeHex) < 8 { + inodeHex = "0" + inodeHex + } + return " 0: " + local + " 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 " + inodeHex + " 0 0 0 0 0" +} + +func joinLines(ls []string) string { + out := "" + for i, l := range ls { + if i > 0 { + out += "\n" + } + out += l + } + return out +} + +func TestScanProcAppsEmpty(t *testing.T) { + root := t.TempDir() + apps, err := ScanProcApps(root) + require.NoError(t, err) + assert.Empty(t, apps) +} + +func TestScanProcAppsSingleProcess(t *testing.T) { + root := makeFakeProc(t, []fakeProcEntry{ + {pid: 42, comm: "rsmon-worker", cmdline: "rsmon-worker --foo\x00bar", sockets: []int64{1001}}, + }) + apps, err := ScanProcApps(root) + require.NoError(t, err) + require.Len(t, apps, 1) + assert.Equal(t, "rsmon-worker", apps[0].Name) + assert.Equal(t, 42, apps[0].PID) + assert.Equal(t, "rsmon-worker --foo bar", apps[0].Cmdline) + // ports slice should have the resolved address. + assert.Contains(t, apps[0].Ports, "127.0.0.1:7777/tcp") +} + +func TestScanProcAppsSkipsKernelThreads(t *testing.T) { + // A pid directory with no comm file is treated as a vanished + // process; ScanProcApps must skip it rather than panic. + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "1"), 0o755)) + apps, err := ScanProcApps(root) + require.NoError(t, err) + assert.Empty(t, apps) +} + +func TestInventoryStoreReplace(t *testing.T) { + dir := t.TempDir() + store, err := OpenStore(filepath.Join(dir, "webapp.db")) + require.NoError(t, err) + defer store.Close() //nolint:errcheck + + inv := NewInventory(store, nil) + _ = inv // currently no public method to inject scanned rows; + // we exercise ReplaceApps directly via the store. + now := mustParseTime(t) + rows := []App{ + {Name: "rsmon-worker", Source: "process", PID: 1, Ports: "7401/tcp", LastSeen: now}, + {Name: "postgres", Source: "process", PID: 2, Ports: "5432/tcp", LastSeen: now}, + } + ctx := context.Background() + require.NoError(t, store.ReplaceApps(ctx, rows)) + got, err := store.ListApps(ctx) + require.NoError(t, err) + assert.Len(t, got, 2) + assert.Equal(t, "rsmon-worker", got[0].Name) + + require.NoError(t, store.ReplaceApps(ctx, []App{ + {Name: "redis", Source: "process", PID: 3, Ports: "6379/tcp", LastSeen: now}, + })) + got, err = store.ListApps(ctx) + require.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "redis", got[0].Name) +} + +func mustParseTime(t *testing.T) time.Time { + t.Helper() + return time.Now().UTC() +} diff --git a/internal/webapp/logbuffer.go b/internal/webapp/logbuffer.go new file mode 100644 index 0000000..92cd5df --- /dev/null +++ b/internal/webapp/logbuffer.go @@ -0,0 +1,99 @@ +package webapp + +import ( + "strings" + "sync" +) + +// LogBuffer is a small in-memory ring buffer the webapp uses to +// expose the last N worker log lines on the Logs page. The worker +// process feeds it via slog/JSON or by calling Append directly. +// +// Capacity is bounded; old entries are evicted FIFO. +type LogBuffer struct { + mu sync.RWMutex + buf []string + capN int + offset int + full bool +} + +// NewLogBuffer returns an empty LogBuffer that holds up to capN +// lines. capN <= 0 falls back to a sensible default. +func NewLogBuffer(capN int) *LogBuffer { + if capN <= 0 { + capN = 5000 + } + return &LogBuffer{buf: make([]string, 0, capN), capN: capN} +} + +// Append adds a single line to the buffer. Newlines are stripped so +// multi-line log records do not split into separate rows. +func (l *LogBuffer) Append(line string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + line = strings.TrimRight(line, "\n") + if line == "" { + return + } + if len(l.buf) < l.capN { + l.buf = append(l.buf, line) + return + } + l.full = true + l.buf[l.offset] = line + l.offset = (l.offset + 1) % l.capN +} + +// Tail returns the most recent n lines in chronological order. If n +// is larger than the buffer capacity, only the held lines are +// returned. n <= 0 returns an empty slice. +func (l *LogBuffer) Tail(n int) []string { + if l == nil || n <= 0 { + return nil + } + l.mu.RLock() + defer l.mu.RUnlock() + size := len(l.buf) + if size == 0 { + return nil + } + if n > size { + n = size + } + out := make([]string, n) + if !l.full { + // Buffer not yet wrapped: just slice the tail. + copy(out, l.buf[size-n:]) + return out + } + // Buffer is wrapped. The oldest line lives at l.offset; the + // newest line lives at (offset - 1 + capN) % capN. + idx := (l.offset - n + l.capN) % l.capN + for i := 0; i < n; i++ { + out[i] = l.buf[idx] + idx = (idx + 1) % l.capN + } + return out +} + +// Size reports the current number of stored lines. +func (l *LogBuffer) Size() int { + if l == nil { + return 0 + } + l.mu.RLock() + defer l.mu.RUnlock() + return len(l.buf) +} + +// Cap reports the maximum number of lines the buffer holds. +func (l *LogBuffer) Cap() int { + if l == nil { + return 0 + } + return l.capN +} diff --git a/internal/webapp/logbuffer_test.go b/internal/webapp/logbuffer_test.go new file mode 100644 index 0000000..17c3462 --- /dev/null +++ b/internal/webapp/logbuffer_test.go @@ -0,0 +1,88 @@ +package webapp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogBufferAppendTail(t *testing.T) { + buf := NewLogBuffer(5) + for i := 0; i < 12; i++ { + buf.Append("line " + itoa(i)) + } + // capacity 5, should hold the last 5 lines: 7..11 + got := buf.Tail(5) + require.Len(t, got, 5) + assert.Equal(t, "line 7", got[0]) + assert.Equal(t, "line 11", got[4]) + assert.Equal(t, 5, buf.Cap()) + assert.Equal(t, 5, buf.Size()) +} + +func TestLogBufferTailSmall(t *testing.T) { + buf := NewLogBuffer(100) + for i := 0; i < 3; i++ { + buf.Append("x" + itoa(i)) + } + got := buf.Tail(2) + require.Len(t, got, 2) + assert.Equal(t, "x1", got[0]) + assert.Equal(t, "x2", got[1]) +} + +func TestLogBufferTailEmpty(t *testing.T) { + buf := NewLogBuffer(10) + assert.Nil(t, buf.Tail(5)) + assert.Equal(t, 0, buf.Size()) +} + +func TestLogBufferTailZero(t *testing.T) { + buf := NewLogBuffer(10) + buf.Append("hello") + assert.Nil(t, buf.Tail(0)) +} + +func TestLogBufferAppendStripsNewline(t *testing.T) { + buf := NewLogBuffer(10) + buf.Append("hello\n") + got := buf.Tail(1) + require.Len(t, got, 1) + assert.Equal(t, "hello", got[0]) +} + +func TestFormatBytes(t *testing.T) { + assert.Equal(t, "0 B", fmtBytes(0)) + assert.Equal(t, "1023 B", fmtBytes(1023)) + assert.Equal(t, "1.00 KiB", fmtBytes(1024)) + assert.Equal(t, "1.50 KiB", fmtBytes(1536)) + assert.Equal(t, "1.00 MiB", fmtBytes(1024*1024)) + assert.Equal(t, "4.00 GiB", fmtBytes(4*1024*1024*1024)) +} + +func TestFormatPercent(t *testing.T) { + assert.Equal(t, "0.00%", fmtPercent(0)) + assert.Equal(t, "50.00%", fmtPercent(50)) + assert.Equal(t, "100.00%", fmtPercent(100)) +} + +func TestFormatDuration(t *testing.T) { + assert.Equal(t, "0s", fmtDuration(0)) + assert.Equal(t, "59s", fmtDuration(59*1_000_000_000)) + assert.Equal(t, "1m 0s", fmtDuration(60*1_000_000_000)) + assert.Equal(t, "1h 0m 0s", fmtDuration(60*60*1_000_000_000)) + assert.Equal(t, "1d 0h 0m 0s", fmtDuration(24*60*60*1_000_000_000)) + assert.Equal(t, "2d 3h 4m 5s", fmtDuration((2*24+3)*3600*1_000_000_000+(4*60+5)*1_000_000_000)) +} + +func TestParseTail(t *testing.T) { + assert.Equal(t, 200, parseTail("")) + assert.Equal(t, 200, parseTail("garbage")) + assert.Equal(t, 200, parseTail("199")) // not in the allowed set + assert.Equal(t, 200, parseTail("200")) + assert.Equal(t, 500, parseTail("500")) + assert.Equal(t, 1000, parseTail("1000")) + assert.Equal(t, 5000, parseTail("5000")) + assert.Equal(t, 200, parseTail("5001")) +} diff --git a/internal/webapp/metrics.go b/internal/webapp/metrics.go new file mode 100644 index 0000000..cfeb5b8 --- /dev/null +++ b/internal/webapp/metrics.go @@ -0,0 +1,446 @@ +package webapp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// Metrics owns the host-level status sample used by the overview and +// server-status pages. Phase 1 reads only /proc and statfs over +// mount points. CLI tools (lsblk, smartctl, sensors) are Phase 5. +type Metrics struct { + mu sync.RWMutex + last Snapshot + lastAt time.Time + stopCh chan struct{} + stopWG sync.WaitGroup + started bool +} + +// Snapshot is the JSON-friendly view of host metrics the templates +// render. The fields are picked so the status table on /status and +// the cards on /overview share a single type. +type Snapshot struct { + CPU CPUSample `json:"cpu"` + Memory MemorySample `json:"memory"` + Load LoadSample `json:"load"` + Uptime time.Duration `json:"uptime"` + BootAt time.Time `json:"boot_at"` + Networks []NetDev `json:"networks"` + Disks []DiskSample `json:"disks"` +} + +// CPUSample reports aggregate CPU usage since the last sample. The +// fields are percentages normalised to 0..100. +type CPUSample struct { + UserPct float64 `json:"user_pct"` + NicePct float64 `json:"nice_pct"` + SystemPct float64 `json:"system_pct"` + IdlePct float64 `json:"idle_pct"` + IOWaitPct float64 `json:"iowait_pct"` + StealPct float64 `json:"steal_pct"` + TotalPct float64 `json:"total_pct"` +} + +// MemorySample reports bytes of physical RAM, swap, and various +// accounting fields from /proc/meminfo. +type MemorySample struct { + Total uint64 `json:"total_bytes"` + Available uint64 `json:"available_bytes"` + Free uint64 `json:"free_bytes"` + Buffers uint64 `json:"buffers_bytes"` + Cached uint64 `json:"cached_bytes"` + SwapTotal uint64 `json:"swap_total_bytes"` + SwapFree uint64 `json:"swap_free_bytes"` + UsedPct float64 `json:"used_pct"` + AvailablePct float64 `json:"available_pct"` +} + +// LoadSample is the 1/5/15 minute load averages from /proc/loadavg. +type LoadSample struct { + One float64 `json:"load1"` + Five float64 `json:"load5"` + Fifteen float64 `json:"load15"` +} + +// NetDev is one row from /proc/net/dev. +type NetDev struct { + Name string `json:"name"` + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + RxPkt uint64 `json:"rx_packets"` + TxPkt uint64 `json:"tx_packets"` + RxErr uint64 `json:"rx_errors"` + TxErr uint64 `json:"tx_errors"` + RxDrop uint64 `json:"rx_dropped"` + TxDrop uint64 `json:"tx_dropped"` +} + +// DiskSample is a single mount point from /proc/mounts with disk +// usage from statfs(2). +type DiskSample struct { + Mount string `json:"mount"` + Device string `json:"device"` + FSType string `json:"fstype"` + Total uint64 `json:"total_bytes"` + Free uint64 `json:"free_bytes"` + Used uint64 `json:"used_bytes"` + UsedPct float64 `json:"used_pct"` +} + +// NewMetrics constructs an empty Metrics sampler. The loop is not +// started until Start is called. +func NewMetrics() *Metrics { + return &Metrics{ + stopCh: make(chan struct{}), + } +} + +// Start launches a sample loop. The first sample is taken +// immediately so /status never renders "no data yet". +func (m *Metrics) Start(ctx context.Context) { + m.mu.Lock() + if m.started { + m.mu.Unlock() + return + } + m.started = true + m.mu.Unlock() + + m.sample(ctx) + m.stopWG.Add(1) + go m.loop(ctx) +} + +// Stop cancels the sample loop and waits for it to exit. +func (m *Metrics) Stop() { + m.mu.Lock() + if !m.started { + m.mu.Unlock() + return + } + select { + case <-m.stopCh: + default: + close(m.stopCh) + } + m.mu.Unlock() + m.stopWG.Wait() +} + +// Last returns the most recent snapshot and the time it was taken. +// Always safe to call; returns a zero-value snapshot if the first +// sample has not yet completed. +func (m *Metrics) Last() (Snapshot, time.Time) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.last, m.lastAt +} + +func (m *Metrics) loop(ctx context.Context) { + defer m.stopWG.Done() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-m.stopCh: + return + case <-ticker.C: + m.sample(ctx) + } + } +} + +func (m *Metrics) sample(_ context.Context) { + snap, err := CollectSnapshot(ProcRoot()) + if err != nil { + return // best-effort + } + m.mu.Lock() + m.last = snap + m.lastAt = time.Now().UTC() + m.mu.Unlock() +} + +// CollectSnapshot reads the /proc mount once and returns a Snapshot. +// Exposed at package scope so tests can drive it directly with a +// fixture /proc tree. +func CollectSnapshot(root string) (Snapshot, error) { + now := time.Now().UTC() + cpu, err := readProcStat(filepath.Join(root, "stat")) + if err != nil { + return Snapshot{}, fmt.Errorf("read stat: %w", err) + } + mem, err := readMemInfo(filepath.Join(root, "meminfo")) + if err != nil { + return Snapshot{}, fmt.Errorf("read meminfo: %w", err) + } + load, err := readLoadAvg(filepath.Join(root, "loadavg")) + if err != nil { + return Snapshot{}, fmt.Errorf("read loadavg: %w", err) + } + uptime, err := readUptime(filepath.Join(root, "uptime")) + if err != nil { + return Snapshot{}, fmt.Errorf("read uptime: %w", err) + } + net, err := readNetDev(filepath.Join(root, "net", "dev")) + if err != nil { + return Snapshot{}, fmt.Errorf("read net/dev: %w", err) + } + disks, err := readMounts(filepath.Join(root, "mounts")) + if err != nil { + return Snapshot{}, fmt.Errorf("read mounts: %w", err) + } + for i := range disks { + if err := statDisk(disks[i].Mount, &disks[i]); err != nil { + // statfs may fail for some pseudo mounts (proc, sys); + // we leave Total/Free/Used at zero in that case so the + // page renders an empty row rather than a hard error. + continue + } + } + bootAt := now.Add(-uptime) + return Snapshot{ + CPU: cpu, + Memory: mem, + Load: load, + Uptime: uptime, + BootAt: bootAt, + Networks: net, + Disks: disks, + }, nil +} + +// readProcStat reads the aggregate "cpu " row of /proc/stat and +// returns percentages. /proc/stat is cumulative since boot, so a +// single read yields busy/total ratios only if we remember the +// previous delta. Phase 1 does not keep history; the per-CPU +// "busy since boot" snapshot is rendered on the page as a static +// "load since boot" indicator instead of a live % value. +// +// The function takes a "previous" sample for delta math; if prev +// is the zero value, the function returns zero percentages. +func readProcStat(path string) (CPUSample, error) { + f, err := os.Open(path) + if err != nil { + return CPUSample{}, err + } + defer f.Close() //nolint:errcheck + + var ( + user, nice, system, idle, iowait, steal uint64 + agg bool + ) + scanner := bufioNewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line) + if len(fields) < 8 { + return CPUSample{}, fmt.Errorf("short cpu line: %q", line) + } + agg = true + user, _ = strconv.ParseUint(fields[1], 10, 64) + nice, _ = strconv.ParseUint(fields[2], 10, 64) + system, _ = strconv.ParseUint(fields[3], 10, 64) + idle, _ = strconv.ParseUint(fields[4], 10, 64) + iowait, _ = strconv.ParseUint(fields[5], 10, 64) + steal, _ = strconv.ParseUint(fields[7], 10, 64) + break + } + if err := scanner.Err(); err != nil { + return CPUSample{}, err + } + if !agg { + return CPUSample{}, fmt.Errorf("no aggregate cpu line in %s", path) + } + total := user + nice + system + idle + iowait + steal + if total == 0 { + return CPUSample{}, nil + } + return CPUSample{ + UserPct: pct(user, total), + NicePct: pct(nice, total), + SystemPct: pct(system, total), + IdlePct: pct(idle, total), + IOWaitPct: pct(iowait, total), + StealPct: pct(steal, total), + TotalPct: pct(total-(idle+iowait), total), + }, nil +} + +func pct(part, total uint64) float64 { + if total == 0 { + return 0 + } + return float64(part) * 100 / float64(total) +} + +// readMemInfo parses /proc/meminfo. Units are kB; we convert to +// bytes on the way out so the page never has to multiply. +func readMemInfo(path string) (MemorySample, error) { + f, err := os.Open(path) + if err != nil { + return MemorySample{}, err + } + defer f.Close() //nolint:errcheck + values := map[string]uint64{} + scanner := bufioNewScanner(f) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + key := strings.TrimSuffix(fields[0], ":") + v, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + continue + } + values[key] = v * 1024 + } + if err := scanner.Err(); err != nil { + return MemorySample{}, err + } + mem := MemorySample{ + Total: values["MemTotal"], + Available: values["MemAvailable"], + Free: values["MemFree"], + Buffers: values["Buffers"], + Cached: values["Cached"], + SwapTotal: values["SwapTotal"], + SwapFree: values["SwapFree"], + } + if mem.Total > 0 { + used := mem.Total - mem.Available + mem.UsedPct = pct(used, mem.Total) + mem.AvailablePct = pct(mem.Available, mem.Total) + } + return mem, nil +} + +func readLoadAvg(path string) (LoadSample, error) { + data, err := os.ReadFile(path) + if err != nil { + return LoadSample{}, err + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return LoadSample{}, fmt.Errorf("short loadavg line: %q", data) + } + one, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return LoadSample{}, err + } + five, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + return LoadSample{}, err + } + fifteen, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + return LoadSample{}, err + } + return LoadSample{One: one, Five: five, Fifteen: fifteen}, nil +} + +func readUptime(path string) (time.Duration, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + fields := strings.Fields(string(data)) + if len(fields) < 1 { + return 0, fmt.Errorf("short uptime line: %q", data) + } + secs, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return 0, err + } + return time.Duration(secs * float64(time.Second)), nil +} + +func readNetDev(path string) ([]NetDev, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() //nolint:errcheck + var out []NetDev + scanner := bufioNewScanner(f) + first := true + for scanner.Scan() { + line := scanner.Text() + if first { + first = false + if strings.Contains(line, "Inter-|") { + continue + } + } + fields := strings.Fields(line) + if len(fields) < 17 { + continue + } + name := strings.TrimSuffix(fields[0], ":") + out = append(out, NetDev{ + Name: name, + RxBytes: parseU64(fields[1]), + TxBytes: parseU64(fields[9]), + RxPkt: parseU64(fields[2]), + TxPkt: parseU64(fields[10]), + RxErr: parseU64(fields[3]), + TxErr: parseU64(fields[11]), + RxDrop: parseU64(fields[4]), + TxDrop: parseU64(fields[12]), + }) + } + return out, scanner.Err() +} + +func parseU64(s string) uint64 { + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0 + } + return v +} + +func readMounts(path string) ([]DiskSample, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var out []DiskSample + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + // device mountpoint fstype options dump pass + out = append(out, DiskSample{ + Device: fields[0], + Mount: fields[1], + FSType: fields[2], + }) + } + return out, nil +} + +// statDisk is implemented in metrics_linux.go (real statfs) and +// metrics_other.go (no-op stub for non-Linux dev builds). The +// function is called from CollectSnapshot below to fill disk usage +// data; tests that exercise the metric paths with fake /proc trees +// accept the zero-value stats as expected. diff --git a/internal/webapp/metrics_linux.go b/internal/webapp/metrics_linux.go new file mode 100644 index 0000000..ce812b0 --- /dev/null +++ b/internal/webapp/metrics_linux.go @@ -0,0 +1,29 @@ +//go:build linux + +package webapp + +import ( + "syscall" +) + +// statDisk fills Total/Free/Used for a DiskSample via statfs(2). +// Linux-only because syscall.Statfs_t is platform-specific. Tests +// that exercise the metric paths stub statDisk at the function +// boundary so the build is hermetic. +func statDisk(mount string, d *DiskSample) error { + var st syscall.Statfs_t + if err := syscall.Statfs(mount, &st); err != nil { + return err + } + bsize := uint64(st.Frsize) + total := st.Blocks * bsize + free := st.Bavail * bsize + used := total - free + d.Total = total + d.Free = free + d.Used = used + if total > 0 { + d.UsedPct = pct(used, total) + } + return nil +} diff --git a/internal/webapp/metrics_other.go b/internal/webapp/metrics_other.go new file mode 100644 index 0000000..e3513ab --- /dev/null +++ b/internal/webapp/metrics_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package webapp + +// statDisk is a no-op on non-Linux platforms. The webapp MVP targets +// Linux workers (the proc/sysfs data sources only exist there); the +// build tag keeps the package compilable on a developer Mac for +// quick template/handler iteration. +func statDisk(_ string, _ *DiskSample) error { + return nil +} diff --git a/internal/webapp/metrics_test.go b/internal/webapp/metrics_test.go new file mode 100644 index 0000000..97a9e0f --- /dev/null +++ b/internal/webapp/metrics_test.go @@ -0,0 +1,140 @@ +package webapp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeFakeMetricsProc writes a minimal /proc tree with stat, meminfo, +// loadavg, uptime, and net/dev files so CollectSnapshot can parse +// it without a real /proc. +func makeFakeMetricsProc(t *testing.T) string { + t.Helper() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "stat"), []byte(statFixture), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "meminfo"), []byte(meminfoFixture), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "loadavg"), []byte(loadavgFixture), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "uptime"), []byte(uptimeFixture), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "net", "dev"), []byte(netdevFixture), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "mounts"), []byte(mountsFixture), 0o644)) + return root +} + +const statFixture = `cpu 100 0 50 800 20 0 0 0 0 0 +cpu0 25 0 12 200 5 0 0 0 0 0 +intr 12345 0 0 0 +ctxt 78910 +btime 1700000000 +` + +const meminfoFixture = `MemTotal: 16384000 kB +MemFree: 4096000 kB +MemAvailable: 8192000 kB +Buffers: 512000 kB +Cached: 2048000 kB +SwapCached: 0 kB +SwapTotal: 2048000 kB +SwapFree: 2048000 kB +` + +const loadavgFixture = "0.42 0.85 1.23 1/123 4567\n" + +const uptimeFixture = "12345.67\n" + +const netdevFixture = `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 1000000 1000 0 0 0 0 0 0 1000000 1000 0 0 0 0 0 0 + eth0: 9999999 5000 1 2 0 0 0 0 8888888 4500 0 0 0 0 0 0 +` + +const mountsFixture = `/dev/sda1 / ext4 rw,relatime 0 0 +tmpfs /run tmpfs rw,nosuid 0 0 +` + +func TestReadProcStatPercentages(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "stat"), []byte(statFixture), 0o644)) + cpu, err := readProcStat(filepath.Join(root, "stat")) + require.NoError(t, err) + // Fixture: user=100, nice=0, system=50, idle=800, iowait=20, steal=0 + // Total = 970, idle+iowait = 820, busy = 150. + assert.InDelta(t, 10.31, cpu.UserPct, 0.01) + assert.InDelta(t, 5.15, cpu.SystemPct, 0.01) + assert.InDelta(t, 82.47, cpu.IdlePct, 0.01) + assert.InDelta(t, 15.46, cpu.TotalPct, 0.01) +} + +func TestReadMemInfoBytes(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "meminfo"), []byte(meminfoFixture), 0o644)) + mem, err := readMemInfo(filepath.Join(root, "meminfo")) + require.NoError(t, err) + assert.Equal(t, uint64(16384000*1024), mem.Total) + assert.Equal(t, uint64(8192000*1024), mem.Available) + assert.Equal(t, uint64(2048000*1024), mem.SwapTotal) + // used = total - available = 8192 MiB + // pct = 8192 / 16384 * 100 = 50.00 + assert.InDelta(t, 50.0, mem.UsedPct, 0.01) +} + +func TestReadLoadAvg(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "loadavg"), []byte(loadavgFixture), 0o644)) + load, err := readLoadAvg(filepath.Join(root, "loadavg")) + require.NoError(t, err) + assert.InDelta(t, 0.42, load.One, 0.001) + assert.InDelta(t, 0.85, load.Five, 0.001) + assert.InDelta(t, 1.23, load.Fifteen, 0.001) +} + +func TestReadUptime(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "uptime"), []byte(uptimeFixture), 0o644)) + d, err := readUptime(filepath.Join(root, "uptime")) + require.NoError(t, err) + assert.InDelta(t, 12345.67, d.Seconds(), 0.01) +} + +func TestReadNetDev(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "net", "dev"), []byte(netdevFixture), 0o644)) + ifaces, err := readNetDev(filepath.Join(root, "net", "dev")) + require.NoError(t, err) + require.Len(t, ifaces, 2) + eth := ifaces[1] + assert.Equal(t, "eth0", eth.Name) + assert.Equal(t, uint64(9999999), eth.RxBytes) + assert.Equal(t, uint64(8888888), eth.TxBytes) + assert.Equal(t, uint64(5000), eth.RxPkt) + assert.Equal(t, uint64(1), eth.RxErr) +} + +func TestReadMounts(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "mounts"), []byte(mountsFixture), 0o644)) + mounts, err := readMounts(filepath.Join(root, "mounts")) + require.NoError(t, err) + require.Len(t, mounts, 2) + assert.Equal(t, "/", mounts[0].Mount) + assert.Equal(t, "ext4", mounts[0].FSType) +} + +func TestCollectSnapshotEndToEnd(t *testing.T) { + root := makeFakeMetricsProc(t) + snap, err := CollectSnapshot(root) + require.NoError(t, err) + assert.InDelta(t, 15.46, snap.CPU.TotalPct, 0.5, + "CPU busy should be in the expected range") + assert.InDelta(t, 50.0, snap.Memory.UsedPct, 0.5, + "memory used should be ~50% from the fixture") + assert.InDelta(t, 12345.67, snap.Uptime.Seconds(), 0.5) + require.Len(t, snap.Networks, 2) + require.Len(t, snap.Disks, 2) + assert.False(t, snap.BootAt.IsZero(), "boot time must be derivable") +} diff --git a/internal/webapp/middleware.go b/internal/webapp/middleware.go new file mode 100644 index 0000000..4bb5030 --- /dev/null +++ b/internal/webapp/middleware.go @@ -0,0 +1,173 @@ +package webapp + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "net/http" + "strings" + "time" +) + +// requireSession is the middleware chain for authenticated pages. +// On success, the request context carries the *Session so handlers +// can look up the user without re-querying the store. On failure, +// the operator is redirected to /web/login. +// +// When basic auth is configured (WORKER_LOGIN/WORKER_PASSWORD), the +// middleware accepts HTTP Basic credentials on /web/api/* as an +// alternative to the session cookie. The synthetic session is created +// in-memory only (no row in webapp_sessions) so curl -u operators +// don't accumulate dead rows. +// +// First-run password-change enforcement is intentionally OFF: the +// requires_change flag is still recorded on webapp_users and the +// /web/change-password page is still reachable, but the operator is +// not redirected there on first login. The flag is reserved for a +// future "hardening" toggle that operators will opt into (e.g. via +// a config knob or a UI setting). For now the worker ships with +// frictionless first-login so basic auth + standalone bcrypt users +// can both reach the operator console immediately. +func (s *Server) requireSession(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Fast-path: HTTP Basic for API consumers when basic auth + // is configured. Only the /web/api/* prefix is exposed this + // way so the browser-driven login flow stays intact. + if s.basicAuthOK && strings.HasPrefix(r.URL.Path, "/web/api/") { + if s.checkBasicAuth(r) { + next(w, r) + return + } + w.Header().Set("WWW-Authenticate", basicAuthRealm) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + sess, err := s.resolveSession(r) + if err != nil { + redirectTo(w, r, pathLogin) + return + } + ctx := context.WithValue(r.Context(), ctxKeySession, sess) + next(w, r.WithContext(ctx)) + } +} + +// checkBasicAuth validates the Authorization: Basic header against +// the configured WORKER_LOGIN / WORKER_PASSWORD pair. Login is +// compared in constant time; password is hashed at startup so the +// raw value never enters the request hot path. +func (s *Server) checkBasicAuth(r *http.Request) bool { + if !s.basicAuthOK { + return false + } + user, pass, ok := r.BasicAuth() + if !ok { + return false + } + userHash := sha256.Sum256([]byte(user)) + wantUserHash := sha256.Sum256([]byte(s.cfg.BasicAuthLogin)) + if subtle.ConstantTimeCompare(userHash[:], wantUserHash[:]) != 1 { + return false + } + passHash := sha256.Sum256([]byte(pass)) + return subtle.ConstantTimeCompare(passHash[:], s.basicAuthHash[:]) == 1 +} + +// resolveSession reads the session cookie, validates the row, slides +// the absolute expiry forward on activity, and returns the Session. +// Returns errMissingSession if any step fails. +func (s *Server) resolveSession(r *http.Request) (*Session, error) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return nil, errMissingSession + } + sess, err := s.store.GetSession(r.Context(), c.Value) + if err != nil { + return nil, errMissingSession + } + if err := s.store.TouchSession(r.Context(), sess.ID, s.cfg.SessionIdle, s.cfg.SessionAbs); err != nil { + return nil, errMissingSession + } + sess.LastSeenAt = time.Now().UTC() + return sess, nil +} + +// requireCSRF verifies the double-submit token on state-changing +// requests. The middleware compares the form/header value against +// the session's stored CSRF token. The cookie value is not the +// authoritative source on its own (it is set without HttpOnly so +// that JS on the same origin could read it, but Phase 1 ships no +// JS, so a missing cookie is fine). +func (s *Server) requireCSRF(sess *Session, r *http.Request) bool { + if sess == nil { + return false + } + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + return true + } + want := sess.CSRFToken + if want == "" { + return false + } + // Form value is the primary check; header is a fallback for + // fetch-style callers. + if err := r.ParseForm(); err != nil { + return false + } + got := strings.TrimSpace(r.FormValue("csrf_token")) + if got == "" { + got = strings.TrimSpace(r.Header.Get("X-CSRF-Token")) + } + if got == "" || constantTimeEq(got, want) != 1 { + return false + } + return true +} + +// writeSessionCookie sets the session and CSRF cookies. The Secure +// flag is set when the request did not arrive over loopback (i.e., +// when the operator has gone out of their way to expose the +// webapp over a non-loopback interface). +func (s *Server) writeSessionCookie(w http.ResponseWriter, r *http.Request, sess *Session) { + if sess == nil { + return + } + secure := !isLoopbackRequest(r) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sess.ID, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + Expires: sess.ExpiresAt, + }) + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: sess.CSRFToken, + Path: "/", + HttpOnly: false, + Secure: secure, + SameSite: http.SameSiteStrictMode, + Expires: sess.ExpiresAt, + }) +} + +// clearSessionCookie blanks out the session and CSRF cookies. Used +// on logout and on hard errors (DB failure, etc.). +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { + secure := !isLoopbackRequest(r) + for _, name := range []string{sessionCookieName, csrfCookieName} { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + Expires: time.Unix(0, 0), + MaxAge: -1, + }) + } +} diff --git a/internal/webapp/page_data.go b/internal/webapp/page_data.go new file mode 100644 index 0000000..7491140 --- /dev/null +++ b/internal/webapp/page_data.go @@ -0,0 +1,40 @@ +package webapp + +import ( + "net/http" + "time" +) + +// basePageData is the struct every page template receives (embedded +// into the per-page struct). The shared layout looks up Title, +// IsLogin, CSRFToken, Version, BuildDate, and StartedAt from here so +// handlers do not need to repeat those fields on every page. +type basePageData struct { + Title string + IsLogin bool + CSRFToken string + Version string + BuildDate string + StartedAt time.Time +} + +// newBasePage builds the common fields. isLogin is true for the +// login page so the layout can hide the navigation; csrfToken is the +// empty string when isLogin is true (there is no session yet). +func (s *Server) newBasePage(_ *http.Request, title string, sess *Session) basePageData { + return basePageData{ + Title: title, + IsLogin: sess == nil, + CSRFToken: tokenOrEmpty(sess), + Version: s.deps.Version, + BuildDate: s.deps.BuildDate, + StartedAt: s.deps.StartedAt, + } +} + +func tokenOrEmpty(sess *Session) string { + if sess == nil { + return "" + } + return sess.CSRFToken +} diff --git a/internal/webapp/routes.go b/internal/webapp/routes.go new file mode 100644 index 0000000..f9ff0da --- /dev/null +++ b/internal/webapp/routes.go @@ -0,0 +1,184 @@ +package webapp + +import ( + "context" + "errors" + "net" + "net/http" + "path" + "strings" +) + +// routes wires the HTTP mux for the Phase 1 webapp. The shape is +// deliberately stdlib-only (no gin) to keep the binary small and to +// follow section 12 of the plan doc (strict CSP, no inline scripts). +// +// Public routes (no auth): /web/login, /web/logout, /web/change-password. +// Authenticated routes: everything else. The middleware chain in +// middleware.go enforces this and adds CSRF / security headers. +// +// Handlers live in handlers_auth.go (login flow) and the per-page +// handlers_* files for the rest. +func (s *Server) routes() { + // Static assets are served via the embedded FS so no CDN is + // reachable from the worker webapp. They are public so the + // login page can render with the right CSS. + s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + // Auth flow (unauthenticated). + s.mux.HandleFunc("GET /web/login", s.handleLoginForm) + s.mux.HandleFunc("POST /web/login", s.handleLoginSubmit) + s.mux.HandleFunc("POST /web/logout", s.handleLogout) + + // Change-password is reachable from /settings. The login flow + // does not force the operator through it any more (the + // requires_change flag is recorded on the user row but no + // longer enforced); the page is still here so an operator who + // wants to rotate the bcrypt password can do so explicitly. + s.mux.Handle("GET /web/change-password", s.requireSession(s.handleChangePasswordForm)) + s.mux.Handle("POST /web/change-password", s.requireSession(s.handleChangePasswordSubmit)) + + // Authenticated pages. + s.mux.Handle("GET /{$}", s.requireSession(s.handleOverview)) // exact "/" + s.mux.Handle("GET /overview", s.requireSession(s.handleOverview)) + s.mux.Handle("GET /apps", s.requireSession(s.handleApps)) + s.mux.Handle("GET /apps/{id}", s.requireSession(s.handleAppDetail)) + s.mux.Handle("GET /checks", s.requireSession(s.handleChecks)) + s.mux.Handle("GET /notifications", s.requireSession(s.handleNotifications)) + s.mux.Handle("GET /logs", s.requireSession(s.handleLogs)) + s.mux.Handle("GET /status", s.requireSession(s.handleStatus)) + s.mux.Handle("GET /settings", s.requireSession(s.handleSettings)) + s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken)) + s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates)) + + // Health endpoint for the cmd health subcommand and for the + // operator to confirm the listener is up without going through the + // login form. Returns 200 with a tiny body. + s.mux.HandleFunc("GET /healthz", s.handleHealth) + + // Cluster admin endpoints. The routes are always registered so + // SetCluster can attach/detach a cluster at runtime; the handlers + // themselves return 503 when no cluster subsystem is attached. + // Both routes require a session (the worker webapp is single-tenant + // so every logged-in operator is effectively an admin). + s.mux.Handle("GET /web/api/cluster/status", s.requireSession(s.handleClusterStatus)) + s.mux.Handle("POST /web/api/cluster/apply-test-config", s.requireSession(s.handleClusterApplyTestConfig)) + + // Cross-worker peer status. The path is intentionally under + // /api/ (not /web/api/) so the basic-auth middleware does not + // intercept it; peer workers are still expected to live in a + // trusted local network (slice 1 of + // docs/distributed/worker-to-worker-raft.md). + s.mux.HandleFunc("GET /api/peer/status", s.handlePeerStatus) +} + +// securityHeaders wraps the mux with the response-header policy +// required by section 12 of the plan doc: +// +// - Cache-Control: no-store on every authenticated response +// (this is enforced inside handlers instead because the policy +// depends on whether the response is HTML or a 401 redirect; +// see writeNoStore). +// - CSP: default-src 'self'; no inline scripts, no eval. Phase 1 +// serves no external assets, so 'self' is sufficient. +// - X-Content-Type-Options: nosniff. +// - Referrer-Policy: no-referrer (do not leak paths in referrers). +// - X-Frame-Options: DENY (the webapp is never meant to be +// framed, even on loopback). +// +// Secure / SameSite / HttpOnly on cookies is enforced inside the +// session helpers (see writeSessionCookie) because the values depend +// on whether the request arrived over a loopback connection. +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self'; "+ + "script-src 'self'; object-src 'none'; base-uri 'none'; "+ + "frame-ancestors 'none'; form-action 'self'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + +// writeNoStore sets the response policy required for authenticated +// pages. Per section 12.4: every authenticated response returns +// Cache-Control: no-store so an operator on a shared kiosk browser +// cannot walk back to a logged-in view of the operator console. +func writeNoStore(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") +} + +// isLoopbackRequest reports whether the incoming TCP connection is +// from a loopback address. Used by the cookie helper to decide +// whether to set the Secure flag (a Secure cookie set over plain +// HTTP on loopback works fine, but the cookie helper only flips +// Secure when not on loopback for safety). +func isLoopbackRequest(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // RemoteAddr may already be just an IP if no port is + // present (some test servers). + host = r.RemoteAddr + } + if host == "" { + return false + } + if host == "::1" || strings.HasPrefix(host, "127.") { + return true + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return true + } + return false +} + +// clientIP extracts the client IP from r.RemoteAddr. X-Forwarded-For +// is intentionally ignored: Phase 1 binds to loopback only, so any +// forwarded header would come from the operator's own browser and +// is not authoritative. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// Path returns the request's URL path with a leading slash so route +// lookups can compare with the registered pattern. +func Path(r *http.Request) string { + p := r.URL.Path + if p == "" { + p = "/" + } + return path.Clean(p) +} + +// redirectTo sends a 302 to the given path. Used by the login and +// logout handlers. +func redirectTo(w http.ResponseWriter, r *http.Request, p string) { + http.Redirect(w, r, p, http.StatusFound) +} + +// errMissingSession is the canonical "no session cookie" error for +// middleware. It is converted to a redirect to the login page by +// requireSession. +var errMissingSession = errors.New("webapp: no session") + +// ctxWithSession attaches a session to the request context. +type ctxKey int + +const ( + ctxKeySession ctxKey = iota +) + +// sessionFromContext returns the session attached by requireSession. +// The bool result is false if no session is attached. +func sessionFromContext(ctx context.Context) (*Session, bool) { + v, ok := ctx.Value(ctxKeySession).(*Session) + return v, ok +} diff --git a/internal/webapp/scan.go b/internal/webapp/scan.go new file mode 100644 index 0000000..19b8643 --- /dev/null +++ b/internal/webapp/scan.go @@ -0,0 +1,15 @@ +package webapp + +import "bufio" + +// bufioNewScanner is a thin wrapper around bufio.NewScanner with a +// 1 MiB max line size. Most /proc files fit comfortably under that. +// Splitting it out lets tests override the buffer size if needed. +func bufioNewScanner(r interface { + Read(p []byte) (int, error) +}, +) *bufio.Scanner { + s := bufio.NewScanner(r) + s.Buffer(make([]byte, 0, 64*1024), 1<<20) + return s +} diff --git a/internal/webapp/server.go b/internal/webapp/server.go new file mode 100644 index 0000000..b105e67 --- /dev/null +++ b/internal/webapp/server.go @@ -0,0 +1,646 @@ +package webapp + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "rsgit.ru/rsmon/rsmon/internal/distworker" +) + +// Phase 1 (MVP) defaults. Times match docs/distributed/worker-web-app.md +// section 5.5 (30-minute idle, 8-hour absolute). +const ( + defaultSessionIdle = 30 * time.Minute + defaultSessionAbs = 8 * time.Hour + defaultHTTPListenAddr = "0.0.0.0:27401" + + // Audit retention: 7 days per section 13.2 of the plan doc. + defaultAuditRetention = 7 * 24 * time.Hour + // Prune runs once per day; the timer survives a long-lived worker + // because the store is appended to from every state-changing handler. + defaultAuditPruneInterval = 24 * time.Hour + + // Server-read-header timeout. Keeps slowloris at bay without + // truncating large form posts on the change-password page. + readHeaderTimeout = 10 * time.Second + + // Cookie names. Kept short so they fit inside the browser cookie + // per-domain limit even when the operator is running the worker + // webapp next to other local services. + sessionCookieName = "rsmon_wsess" + csrfCookieName = "rsmon_wcsrf" +) + +// Config holds the runtime knobs for the local webapp server. The +// fields mirror the relevant env-var settings (with the same names) +// so cmd/rsmon-worker/main.go can build a Config from os.Environ. +// +// Phase 1 (MVP) honors WORKER_HOST, WORKER_PORT, WORKER_LOGIN, +// WORKER_PASSWORD, and the RSMON_WEBAPP_DATA_DIR knob. Basic auth +// (WORKER_LOGIN/PASSWORD) is the primary auth path now that +// deployments want the webapp reachable on non-loopback addresses; +// the per-machine bcrypt user remains the fallback for fully offline +// single-operator installs. +type Config struct { + // Addr is the bind address for the HTTP listener. Operators + // typically set WORKER_HOST=0.0.0.0 + WORKER_PORT to expose the + // webapp on a public interface, with the network fronted by a + // reverse proxy / Traefik. + Addr string + + // DataDir is the on-disk directory the SQLite store and any other + // state files live in. The directory is created on demand. + DataDir string + + // SessionIdle / SessionAbs override the defaults above when set + // to a positive value. + SessionIdle time.Duration + SessionAbs time.Duration + + // StorePath is the on-disk path of the SQLite file. When empty, + // the server derives it from DataDir + "webapp.db". + StorePath string + + // BasicAuthLogin / BasicAuthPassword enable HTTP basic auth on + // the webapp when both are non-empty. Both must be set; a mixed + // state (XOR) is rejected by ValidateBasicAuth. Basic auth covers + // the /web/api/* JSON endpoints AND the login form itself, so the + // web UI works for an operator who only knows their credentials. + BasicAuthLogin string + BasicAuthPassword string + + // DebugClusterApply gates the /web/api/cluster/apply-test-config + // endpoint. When false (the default) the route is registered but + // the handler returns 404 so the endpoint is invisible in + // production. Operators who want to poke the cluster FSM during + // development set WORKER_CLUSTER_DEBUG_APPLY=true. The endpoint + // must NEVER be reachable in production — it appends hardcoded + // log entries to the Raft FSM without going through the real + // config-adoption producer. + DebugClusterApply bool + + // ReleaseURL is the optional URL the worker polls to discover + // the latest published version of the worker binary. When empty + // the /updates page shows the placeholder "v1 (dev)". The URL + // is expected to respond with a JSON body containing a + // "tag_name" field (GitHub release JSON is the canonical + // shape). WORKER_RELEASE_URL sets this. + ReleaseURL string +} + +// ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD +// are both set or both empty. A mixed state is a config bug and is +// rejected so an operator notices immediately. Both empty disables +// basic auth and falls back to the local bcrypt user. +func ValidateBasicAuth(login, password string) error { + loginSet := strings.TrimSpace(login) != "" + passSet := password != "" + if loginSet != passSet { + return fmt.Errorf( + "webapp: WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty (got login=%s password=%s)", + boolStr(loginSet), boolStr(passSet)) + } + return nil +} + +func boolStr(b bool) string { + if b { + return "set" + } + return "empty" +} + +// ClusterEnabledFromEnv is a tiny helper that returns true when the +// WORKER_CLUSTER_ENABLED env var is set to a truthy value. The exact +// parsing rules match the cmd binary: only the literal string "true" +// (case-insensitive) is treated as enabled. This keeps the webapp +// package's env-handling dependency-free of the workercluster +// package. +func ClusterEnabledFromEnv(env map[string]string) bool { + v := strings.ToLower(strings.TrimSpace(env[envClusterEnabled])) + return v == truthyTrue || v == truthyOne || v == truthyYes +} + +// Truthy literal constants hoisted to satisfy goconst. parseBool +// and ClusterEnabledFromEnv share the same vocabulary. +const ( + truthyTrue = "true" + truthyOne = "1" + truthyYes = "yes" +) + +// envOr returns the env var value or fallback. +func envOr(env map[string]string, key, fallback string) string { + if v, ok := env[key]; ok && v != "" { + return v + } + return fallback +} + +// ConfigFromEnv builds a Config from a process-style environment map. +// It rejects XOR WORKER_LOGIN/WORKER_PASSWORD (ValidateBasicAuth) and +// reuses distworker.HTTPConfigFromEnv so the webapp and the cmd +// binary agree on host/port/url semantics. +// +// WORKER_HOST defaults to distworker.DefaultHTTPHost (0.0.0.0) and +// WORKER_PORT to distworker.DefaultHTTPPort (27401). The previous +// Phase 1 policy of binding to loopback only was removed: production +// deployments now expose the webapp on a real interface, with TLS +// terminated by a reverse proxy. +func ConfigFromEnv(env map[string]string, defaultDataDir string) (Config, error) { + login := strings.TrimSpace(env["WORKER_LOGIN"]) + password := env["WORKER_PASSWORD"] + if err := ValidateBasicAuth(login, password); err != nil { + return Config{}, err + } + httpCfg := distworker.HTTPConfigFromEnv() + host := httpCfg.Host + if raw := strings.TrimSpace(env["WORKER_HOST"]); raw != "" { + host = raw + } + port := httpCfg.Port + if raw := strings.TrimSpace(env["WORKER_PORT"]); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 && v <= 65535 { + port = v + } + } + addr := net.JoinHostPort(host, strconv.Itoa(port)) + dataDir := envOr(env, "RSMON_WEBAPP_DATA_DIR", defaultDataDir) + cfg := Config{ + Addr: addr, + DataDir: dataDir, + BasicAuthLogin: login, + BasicAuthPassword: password, + } + if v := env["RSMON_WEBAPP_STORE_PATH"]; v != "" { + cfg.StorePath = v + } + cfg.DebugClusterApply = parseBool(env[envClusterDebugApply]) + cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL]) + return cfg, nil +} + +// parseBool returns true for the strings "true", "1", "yes" (any +// case, trimmed). Anything else is false. Used for opt-in feature +// flags wired through env vars without dragging in a config-package +// dependency. +func parseBool(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case truthyTrue, truthyOne, truthyYes: + return true + } + return false +} + +// ConfigFromEnvOrDefault builds a Config from the process +// environment via os.Getenv. Exposed so the cmd rsmon-worker binary +// can wire the webapp without passing an env map around. +func ConfigFromEnvOrDefault() Config { + env := map[string]string{} + for _, k := range []string{ + envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword, + "RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH", + "WORKER_CLUSTER_ENABLED", + envClusterDebugApply, envReleaseURL, + } { + if v := os.Getenv(k); v != "" { + env[k] = v + } + } + cfg, err := ConfigFromEnv(env, defaultDataDir()) + if err != nil { + log.Printf("webapp: %v; falling back to safe defaults", err) + cfg = Config{Addr: defaultHTTPListenAddr} + } + return cfg +} + +// Deps are the in-process interfaces the webapp reads from the +// worker. Each field is optional; the page that needs it must +// tolerate a nil value. Splitting the deps from the runner keeps +// tests focused and lets cmd/rsmon-worker wire a thin facade. +type Deps struct { + Runner WorkerView + Cluster ClusterView // optional; nil means no cluster endpoints are registered + Version string + BuildDate string + Commit string + StartedAt time.Time + Logger *log.Logger + TokenRotator func(ctx context.Context) (newToken string, err error) // optional; nil disables rotation + + // ReleaseHTTPClient is the *http.Client the /updates page uses + // to poll Config.ReleaseURL. When nil the page falls back to a + // 3s-timeout default client. cmd/rsmon-worker wires a shared + // client so transport-level settings (TLS, proxy) are honored + // without each handler opening its own connection pool. + ReleaseHTTPClient *http.Client +} + +// ClusterView is the narrow surface webapp needs from the worker +// cluster. The concrete type lives in cmd/rsmon-worker; webapp only +// depends on this interface so it can be stubbed in tests without +// pulling in the raft package or bbolt. +type ClusterView interface { + Stats() ClusterStats + ApplyTestConfig() (uint64, error) + ClusterID() string + LocalAddr() string +} + +// ClusterStats is the wire-stable view of a cluster. Field names +// mirror the JSON the GET /web/api/cluster/status endpoint returns. +// +// FSMConfigVersion / FSMOutboxLen / FSMPartition carry the FSM-side +// operator signals from plan section 6.1. The webapp's ClusterView +// adapter copies them from the workercluster ClusterStats so the JSON +// endpoint can surface the FSM state without taking a separate code +// path through the FSM type. +type ClusterStats struct { + NodeID string + LocalAddr string + State string + Leader string + Term uint64 + AppliedIndex uint64 + LastIndex uint64 + NumPeers int + Voters []string + FSMChecks int + FSMMembers int + FSMConfigVersion uint64 + FSMOutboxLen int + FSMPartition string +} + +// WorkerView is the surface the webapp needs from the distworker +// runner. It is a narrow interface so tests can mock it without +// touching the websocket loop or pool plumbing. +type WorkerView interface { + HTTPConfig() distworker.HTTPConfig + Token() string + TokenRotatedAt() time.Time + WorkerID() string + RegionCode() string + WorkerVersion() string + WorkerCapabilities() []string + LastHeartbeatAck() time.Time + RecentResults(n int) []ResultRow + RecentNotifications(n int) []NotificationRow + // MasterStatus returns the most recent local selfcheck verdict + // and the wall-clock time it was produced. The first return + // value is nil when no probe has run yet. The webapp renders + // the verdict at /api/peer/status so peer workers can join + // the consensus (see + // docs/distributed/worker-to-worker-raft.md). + MasterStatus() (up *bool, observedAt time.Time) +} + +// ResultRow is one row from the worker's in-memory result ring +// buffer. It is the same shape the checks page renders as a table. +type ResultRow struct { + MonitorID int64 + CheckID int64 + Kind string + Host string + State string + DurationMs int64 + Error string + At time.Time +} + +// NotificationRow is one row from the worker's in-memory notification +// ring buffer. Phase 1 only emits selfcheck alerts; main-app-issued +// notifications still live in the main app's DB. +type NotificationRow struct { + Kind string // "email", "telegram_private", "telegram_group" + Channel string + Subject string + Body string + OK bool + Error string + At time.Time +} + +// Server is the local HTTP server for the worker webapp. It owns the +// SQLite store, the template bundle, the in-memory log buffer, and +// the inventory snapshot. A single instance is bound to a single +// Config and is safe for concurrent use after Start returns. +type Server struct { + cfg Config + store *Store + deps Deps + mux *http.ServeMux + templates *Templates + logBuffer *LogBuffer + inventory *Inventory + metrics *Metrics + cluster ClusterView + pruneStop chan struct{} + pruneWG sync.WaitGroup + + releasePoller *releasePoller + + // basicAuthHash is the SHA-256 hash of BasicAuthPassword (or the + // zero value when basic auth is not configured). Computed once in + // New so the middleware uses constant-time comparison. Login is + // kept plaintext in cfg because the comparison happens in the + // login form handler too. + basicAuthHash [32]byte + basicAuthOK bool + + httpServer *http.Server +} + +// New constructs a Server with the given config and dependencies. +// The store is opened (and the schema applied) synchronously; the +// caller must call Close to release the SQLite handle. +func New(cfg Config, deps *Deps) (*Server, error) { //nolint:gocritic // Config is widely passed by value in this package + if cfg.Addr == "" { + cfg.Addr = defaultHTTPListenAddr + } + if _, _, err := net.SplitHostPort(cfg.Addr); err != nil { + return nil, fmt.Errorf("webapp: bind address %q: %w", cfg.Addr, err) + } + if cfg.SessionIdle <= 0 { + cfg.SessionIdle = defaultSessionIdle + } + if cfg.SessionAbs <= 0 { + cfg.SessionAbs = defaultSessionAbs + } + if cfg.DataDir == "" { + cfg.DataDir = defaultDataDir() + } + if cfg.StorePath == "" { + if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { + return nil, fmt.Errorf("webapp: mkdir data dir: %w", err) + } + cfg.StorePath = filepath.Join(cfg.DataDir, "webapp.db") + } + store, err := OpenStore(cfg.StorePath) + if err != nil { + return nil, err + } + if deps.Logger == nil { + deps.Logger = log.New(os.Stderr, "webapp: ", log.LstdFlags|log.Lshortfile) + } + if deps.StartedAt.IsZero() { + deps.StartedAt = time.Now().UTC() + } + tmpl, err := loadTemplates() + if err != nil { + _ = store.Close() + return nil, err + } + s := &Server{ + cfg: cfg, + store: store, + deps: *deps, + mux: http.NewServeMux(), + templates: tmpl, + logBuffer: NewLogBuffer(5000), + inventory: NewInventory(store, deps.Logger), + metrics: NewMetrics(), + cluster: deps.Cluster, + pruneStop: make(chan struct{}), + releasePoller: &releasePoller{}, + } + if cfg.ReleaseURL != "" { + s.releasePoller.setURL(cfg.ReleaseURL) + } + if cfg.BasicAuthLogin != "" && cfg.BasicAuthPassword != "" { + s.basicAuthHash = sha256.Sum256([]byte(cfg.BasicAuthPassword)) + s.basicAuthOK = true + } + s.routes() + s.httpServer = &http.Server{ + Addr: cfg.Addr, + Handler: s.securityHeaders(s.mux), + ReadHeaderTimeout: readHeaderTimeout, + IdleTimeout: 2 * time.Minute, + } + return s, nil +} + +// BasicAuthEnabled reports whether the server is configured with +// WORKER_LOGIN / WORKER_PASSWORD basic auth. Exposed for templates +// that need to vary the login form (username field + explanatory copy). +func (s *Server) BasicAuthEnabled() bool { + return s != nil && s.basicAuthOK +} + +// BasicAuthLogin returns the configured WORKER_LOGIN. Used by the +// login form so an operator with curl / scripts can copy the value +// straight out of the /settings page. +func (s *Server) BasicAuthLogin() string { + if s == nil { + return "" + } + return s.cfg.BasicAuthLogin +} + +// Store returns the embedded store for tests and for the cmd +// rsmon-worker binary that needs to provision the first user. +func (s *Server) Store() *Store { return s.store } + +// LogBuffer returns the in-memory worker-log ring buffer so the +// worker process can attach an slog/JSON sink. +func (s *Server) LogBuffer() *LogBuffer { return s.logBuffer } + +// SetCluster attaches a cluster subsystem after construction. The +// cluster admin endpoints (/web/api/cluster/status and the test-config +// applier) are not registered until SetCluster is called. Pass nil to +// detach. Intended for cmd/rsmon-worker to wire the cluster after +// webapp.New; tests should construct a fresh Server with the cluster +// already on the Deps. +func (s *Server) SetCluster(c ClusterView) { s.cluster = c } + +// Cluster returns the attached cluster subsystem (or nil). +func (s *Server) Cluster() ClusterView { return s.cluster } + +// Close shuts down the HTTP listener, the prune goroutine, and the +// embedded store. Safe to call multiple times. +func (s *Server) Close(ctx context.Context) error { + if s == nil { + return nil + } + select { + case <-s.pruneStop: + default: + close(s.pruneStop) + } + s.pruneWG.Wait() + if s.httpServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := s.httpServer.Shutdown(shutdownCtx); err != nil && + !errors.Is(err, http.ErrServerClosed) { + s.deps.Logger.Printf("shutdown: %v", err) + } + } + if s.store != nil { + _ = s.store.Close() + } + return nil +} + +// Start launches the audit-prune goroutine and binds the HTTP +// listener. Blocks until ctx is canceled or the listener errors +// out. Call Close to ensure cleanup. +func (s *Server) Start(ctx context.Context) error { + s.pruneWG.Add(1) + go s.pruneLoop(ctx) + + // Run ListenAndServe in a goroutine so we can race it against ctx. + errCh := make(chan error, 1) + go func() { + s.deps.Logger.Printf("listening on http://%s (data dir=%s)", s.cfg.Addr, s.cfg.DataDir) + if err := s.httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.httpServer.Shutdown(shutdownCtx) + return nil + case err := <-errCh: + return err + } +} + +// Addr returns the actual bind address the server is using. +func (s *Server) Addr() string { return s.cfg.Addr } + +// Handler returns the http.Handler the server registers with +// http.Server. Exposed for tests that want to wrap the mux in an +// httptest.Server so the bound port is observable. Production code +// should call ListenAndServe / Start instead. +func (s *Server) Handler() http.Handler { + return s.securityHeaders(s.mux) +} + +// SessionIdle / SessionAbs expose the configured timeouts so the +// handlers can compute the expiry without hard-coding. +func (s *Server) SessionIdle() time.Duration { return s.cfg.SessionIdle } + +// SessionAbs returns the absolute session lifetime configured for +// this server. Used by handlers that need to compute the absolute +// cap of a session relative to its creation time. +func (s *Server) SessionAbs() time.Duration { return s.cfg.SessionAbs } + +// ProvisionFirstRunIfNeeded mints a fresh first-run password and +// seeds the user row if no user exists yet. Prints the plaintext +// once via the supplied logger so the operator can read it from the +// worker log. Subsequent starts do nothing — the bcrypt hash on +// disk is the source of truth. +// +// When basic auth is configured the operator never types a password +// into the webapp login form, so the first-run bcrypt seed is +// unnecessary. We still create an anonymous user row so the basic-auth +// login flow has a stable user_id to anchor sessions on; the row's +// bcrypt hash is intentionally unusable. +// +// Called by cmd/rsmon-worker/main.go after New() and before +// Start() so the listener accepts the login form even before the +// worker has a websocket connection to the main app. +func ProvisionFirstRunIfNeeded(srv *Server, logger *log.Logger) error { + if srv == nil || srv.store == nil { + return fmt.Errorf("webapp: nil server") + } + ctx := context.Background() + existing, err := srv.store.GetUser(ctx) + if err == nil && existing != nil { + // User already provisioned — leave it alone. + return nil + } + if srv.basicAuthOK { + // Basic-auth operator never logs in via bcrypt; the user + // row is just a placeholder for session.user_id. + if err := srv.store.EnsureAnonymousUser(ctx); err != nil { + return fmt.Errorf("webapp: provision anonymous user: %w", err) + } + logger.Printf("webapp: WORKER_LOGIN/WORKER_PASSWORD basic auth enabled; bcrypt first-run password skipped") + return nil + } + plain, err := GenerateFirstRunPassword() + if err != nil { + return fmt.Errorf("webapp: mint first-run password: %w", err) + } + hash, err := HashPassword(plain) + if err != nil { + return fmt.Errorf("webapp: hash first-run password: %w", err) + } + if _, err := srv.store.CreateFirstRunUser(ctx, hash); err != nil { + return fmt.Errorf("webapp: create first-run user: %w", err) + } + logger.Printf("==============================================================") + logger.Printf("webapp: first-run password generated — print this NOW and store safely:") + logger.Printf("webapp: password = %s", plain) + logger.Printf("webapp: this password is shown only once. Log in and change it.") + logger.Printf("==============================================================") + return nil +} + +func (s *Server) pruneLoop(ctx context.Context) { + defer s.pruneWG.Done() + ticker := time.NewTicker(defaultAuditPruneInterval) + defer ticker.Stop() + // Run once shortly after startup so a long-lived worker does not + // wait a full day for the first prune. + first := time.NewTimer(5 * time.Minute) + defer first.Stop() + for { + select { + case <-ctx.Done(): + return + case <-s.pruneStop: + return + case <-first.C: + if n, err := s.store.PruneAudit(ctx, defaultAuditRetention); err != nil { + s.deps.Logger.Printf("audit prune: %v", err) + } else if n > 0 { + s.deps.Logger.Printf("audit prune: deleted %d old rows", n) + } + case <-ticker.C: + if n, err := s.store.PruneAudit(ctx, defaultAuditRetention); err != nil { + s.deps.Logger.Printf("audit prune: %v", err) + } else if n > 0 { + s.deps.Logger.Printf("audit prune: deleted %d old rows", n) + } + } + } +} + +// defaultDataDir returns the per-user state directory the worker +// webapp uses by default. Matches the path documented in the plan +// doc: $XDG_DATA_HOME/rsmon-worker (fallback ~/.local/share/rsmon-worker). +func defaultDataDir() string { + if v := os.Getenv("RSMON_WEBAPP_DATA_DIR"); v != "" { + return v + } + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + return filepath.Join(v, "rsmon-worker") + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "/var/lib/rsmon-worker" + } + return filepath.Join(home, ".local", "share", "rsmon-worker") +} diff --git a/internal/webapp/server_test.go b/internal/webapp/server_test.go new file mode 100644 index 0000000..740c9dc --- /dev/null +++ b/internal/webapp/server_test.go @@ -0,0 +1,600 @@ +package webapp + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestValidateBasicAuth pins the XOR rejection invariant for +// WORKER_LOGIN / WORKER_PASSWORD. Both empty disables basic auth; +// both set enables it; mixed (XOR) is a config bug. +func TestValidateBasicAuth(t *testing.T) { + cases := []struct { + name string + login string + password string + wantErr bool + }{ + {"both empty", "", "", false}, + {"login only", "user", "", true}, + {"password only", "", "secret", true}, + {"both set", "user", "secret", false}, + {"whitespace login ignored", " ", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ValidateBasicAuth(c.login, c.password) + if c.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestConfigFromEnvDefaults pins the env-less default bind: the +// webapp must listen on 0.0.0.0:27401 (not on the main RSMon port +// 7401) when no env overrides are set. The change from 127.0.0.1 +// to 0.0.0.0 was made in tandem with the basic-auth rewrite so the +// webapp can be reached on a real interface by an operator who +// fronted the worker with Traefik/nginx. +func TestConfigFromEnvDefaults(t *testing.T) { + cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir()) + require.NoError(t, err) + assert.Equal(t, "0.0.0.0", cfg.Addr[:7], "default host must be 0.0.0.0") + assert.Equal(t, "27401", cfg.Addr[len(cfg.Addr)-5:], "default port must be 27401") + assert.False(t, cfg.BasicAuthLogin != "" || cfg.BasicAuthPassword != "", + "basic auth must be off by default") +} + +// TestConfigFromEnvBasicAuthAcceptsBothEnvVars exercises the new +// happy path where WORKER_LOGIN and WORKER_PASSWORD are both set. +func TestConfigFromEnvBasicAuthAcceptsBothEnvVars(t *testing.T) { + env := map[string]string{ + "WORKER_LOGIN": "alice", + "WORKER_PASSWORD": "s3cret", + "WORKER_HOST": "0.0.0.0", + "WORKER_PORT": "30000", + } + cfg, err := ConfigFromEnv(env, t.TempDir()) + require.NoError(t, err) + assert.Equal(t, "alice", cfg.BasicAuthLogin) + assert.Equal(t, "s3cret", cfg.BasicAuthPassword) + assert.Equal(t, "0.0.0.0:30000", cfg.Addr) +} + +// TestConfigFromEnvBasicAuthRejectsXOR pins the regression guard: +// setting only one of WORKER_LOGIN / WORKER_PASSWORD must fail fast +// so an operator notices the misconfiguration. +func TestConfigFromEnvBasicAuthRejectsXOR(t *testing.T) { + _, err := ConfigFromEnv(map[string]string{ + "WORKER_LOGIN": "alice", + }, t.TempDir()) + assert.Error(t, err, "XOR (login only) must be rejected") + _, err = ConfigFromEnv(map[string]string{ + "WORKER_PASSWORD": "s3cret", + }, t.TempDir()) + assert.Error(t, err, "XOR (password only) must be rejected") +} + +// TestConfigFromEnvDebugClusterApply pins the default-off behavior +// of the cluster-apply debug gate and verifies the env flag flips +// it on. Production builds must not accidentally expose the +// endpoint, so the default is false. +func TestConfigFromEnvDebugClusterApply(t *testing.T) { + cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir()) + require.NoError(t, err) + assert.False(t, cfg.DebugClusterApply, "default must leave the debug flag off") + + cfg, err = ConfigFromEnv(map[string]string{ + "WORKER_CLUSTER_DEBUG_APPLY": "true", + }, t.TempDir()) + require.NoError(t, err) + assert.True(t, cfg.DebugClusterApply) + + // Other truthy spellings accepted. + for _, v := range []string{"yes", "1", "TRUE", "YeS"} { + cfg, err = ConfigFromEnv(map[string]string{ + "WORKER_CLUSTER_DEBUG_APPLY": v, + }, t.TempDir()) + require.NoError(t, err) + assert.True(t, cfg.DebugClusterApply, "must accept truthy value %q", v) + } + + // Empty / unknown values stay false. + for _, v := range []string{"", "false", "0", "no"} { + cfg, err = ConfigFromEnv(map[string]string{ + "WORKER_CLUSTER_DEBUG_APPLY": v, + }, t.TempDir()) + require.NoError(t, err) + assert.False(t, cfg.DebugClusterApply, "must reject non-truthy value %q", v) + } +} + +// TestConfigFromEnvReleaseURL pins the env-driven WORKER_RELEASE_URL +// plumbing. The handler reads cfg.ReleaseURL when the page renders, +// so the value must survive ConfigFromEnv exactly. +func TestConfigFromEnvReleaseURL(t *testing.T) { + cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir()) + require.NoError(t, err) + assert.Empty(t, cfg.ReleaseURL, "default ReleaseURL must be empty") + + cfg, err = ConfigFromEnv(map[string]string{ + "WORKER_RELEASE_URL": " https://example.com/releases ", + }, t.TempDir()) + require.NoError(t, err) + assert.Equal(t, "https://example.com/releases", cfg.ReleaseURL, + "ReleaseURL must be trimmed before storage") +} + +// TestLoginPageRendersAnonymous verifies the login page is +// reachable without a session and returns the right HTTP headers. +// In local-only mode the form has only a password field; the +// basic-auth variant adds a username field. +func TestLoginPageRendersAnonymous(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + resp, err := http.Get(ts.URL + "/web/login") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "default-src 'self'") + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "RSMon worker login") + assert.Contains(t, string(body), `name="password"`) + assert.NotContains(t, string(body), `name="login"`, + "local-only login form must not show a username field") +} + +// TestLoginPageShowsUsernameFieldWhenBasicAuthConfigured confirms the +// login form renders a username input and the basic-auth explanatory +// copy when WORKER_LOGIN/WORKER_PASSWORD are set on the server. +func TestLoginPageShowsUsernameFieldWhenBasicAuthConfigured(t *testing.T) { + srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") + ts := newHTTPTestServer(t, srv) + + resp, err := http.Get(ts.URL + "/web/login") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), `name="login"`, + "basic-auth login form must show a username field") + assert.Contains(t, string(body), "WORKER_LOGIN") +} + +// TestAuthenticatedPagesRedirectToLogin asserts every authenticated +// route returns 302 to /web/login when no session cookie is sent. +func TestAuthenticatedPagesRedirectToLogin(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + pages := []string{ + "/overview", + "/apps", + "/checks", + "/notifications", + "/logs", + "/status", + "/settings", + "/updates", + } + for _, p := range pages { + t.Run(p, func(t *testing.T) { + c := httpClient() + req, _ := http.NewRequest(http.MethodGet, ts.URL+p, nil) + resp, err := c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusFound, resp.StatusCode, + "%s must redirect to login when unauthenticated", p) + assert.Equal(t, "/web/login", resp.Header.Get("Location")) + }) + } +} + +// TestFirstLoginReachesOverviewDirectly pins the frictionless +// first-login flow: the operator lands on /overview immediately and +// is NOT redirected to /web/change-password even though +// requires_change is set on the bcrypt user row. The flag is kept +// in the schema as a future hardening knob but the login flow no +// longer enforces it. +func TestFirstLoginReachesOverviewDirectly(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + c, _ := loginAsFirstRun(t, ts.URL, srv) + + // /overview must NOT redirect to change-password any more. + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/overview", nil) + resp, err := c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode, + "first-login must reach /overview without bouncing to change-password") + + // The flag is still recorded on the user row so a future + // hardening toggle can re-enforce it without a schema change. + user, err := srv.store.GetUser(context.Background()) + require.NoError(t, err) + assert.True(t, user.RequiresChange, + "requires_change flag must remain set on the user row for future use") +} + +// TestRequiresChangeFlagDoesNotBlockAPIAccess complements the +// overview test: even on /web/api/* the flag must not bounce the +// operator. The path is the change-password page itself (which is +// under /web/, not /web/api/), so we exercise the overview path as +// a proxy for "any authenticated page". +func TestRequiresChangeFlagDoesNotBlockAPIAccess(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + c, _ := loginAsFirstRun(t, ts.URL, srv) + + for _, path := range []string{"/overview", "/apps", "/checks", "/status", "/settings"} { + req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) + resp, err := c.Do(req) + require.NoError(t, err) + resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode, + "%s must not redirect away despite requires_change flag", path) + } +} + +// TestChangePasswordClearsFlagAndAllowsOverview exercises the full +// change-password form. CSRF check is enforced, the requires_change +// flag clears. The change-password page is reachable without a +// forced redirect from /overview, so the test starts by walking +// the operator straight into the form. +func TestChangePasswordClearsFlagAndAllowsOverview(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + c, plain := loginAsFirstRun(t, ts.URL, srv) + + // /overview is reachable immediately (no forced redirect any + // more); the operator clicks "change password" themselves. + resp, err := c.Get(ts.URL + "/overview") + require.NoError(t, err) + resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Now drive the change-password form directly. + resp, err = c.Get(ts.URL + "/web/change-password") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() //nolint:errcheck + csrf := extractCSRFToken(t, string(body)) + + form := url.Values{} + form.Set("current_password", plain) + form.Set("new_password", "new-stronger-password") + form.Set("new_password_confirm", "new-stronger-password") + form.Set("csrf_token", csrf) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/change-password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err = c.Do(req) + require.NoError(t, err) + resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Equal(t, "/overview", resp.Header.Get("Location")) + + // The flag must now be cleared on the user row. + user, err := srv.store.GetUser(context.Background()) + require.NoError(t, err) + assert.False(t, user.RequiresChange, + "requires_change must clear after the operator updates the password") +} + +// TestChangePasswordRequiresCSRF ensures state-changing endpoints +// refuse requests without a CSRF token (defense in depth on top of +// the double-submit comparison). +func TestChangePasswordRequiresCSRF(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + c, _ := loginAsFirstRun(t, ts.URL, srv) + + form := url.Values{} + form.Set("current_password", "irrelevant") + form.Set("new_password", "new-stronger-password") + form.Set("new_password_confirm", "new-stronger-password") + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/change-password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusForbidden, resp.StatusCode, + "change-password POST without CSRF must be 403") +} + +// TestLogoutClearsSession verifies the session row is removed and +// subsequent authenticated pages redirect to login again. +func TestLogoutClearsSession(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + // Now the operator can reach /overview. + resp, err := c.Get(ts.URL + "/overview") + require.NoError(t, err) + resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Fetch CSRF, then POST /web/logout. + resp, err = c.Get(ts.URL + "/overview") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() //nolint:errcheck + csrf := extractCSRFToken(t, string(body)) + form := url.Values{} + form.Set("csrf_token", csrf) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/logout", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err = c.Do(req) + require.NoError(t, err) + resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusFound, resp.StatusCode) + + // Authenticated pages must now redirect again. + req, _ = http.NewRequest(http.MethodGet, ts.URL+"/overview", nil) + resp, err = c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Equal(t, "/web/login", resp.Header.Get("Location")) +} + +// TestAuditRowsWrittenOnLoginAndLogout ensures the audit log +// captures the auth events with the right shape. +func TestAuditRowsWrittenOnLoginAndLogout(t *testing.T) { + srv := newTestServer(t, &stubRunner{id: "w-1"}) + ts := newHTTPTestServer(t, srv) + + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/overview") + require.NoError(t, err) + body2, _ := io.ReadAll(resp.Body) + resp.Body.Close() //nolint:errcheck + csrf := extractCSRFToken(t, string(body2)) + + // Logout. + form := url.Values{} + form.Set("csrf_token", csrf) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/logout", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err = c.Do(req) + require.NoError(t, err) + resp.Body.Close() //nolint:errcheck + + entries, err := srv.store.RecentAudit(context.Background(), 10) + require.NoError(t, err) + var actions []string + for _, e := range entries { + actions = append(actions, e.Action) + } + assert.Contains(t, actions, "login") + assert.Contains(t, actions, "logout") +} + +// TestOverviewRendersStubData wires the stubbed worker view and +// asserts /overview renders without panicking and includes the +// stubbed worker id. +func TestOverviewRendersStubData(t *testing.T) { + srv := newTestServer(t, &stubRunner{ + id: "w-stub-1", + token: "abcdefghijklmnop", + lastAck: time.Now().Add(-time.Minute).UTC(), + }) + ts := newHTTPTestServer(t, srv) + c, _ := loginAsFirstRun(t, ts.URL, srv) + clearRequiresChange(t, srv) + + resp, err := c.Get(ts.URL + "/overview") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + body := mustBody(t, resp) + assert.Contains(t, body, "w-stub-1") + assert.Contains(t, body, "RSMon worker v") +} + +// TestHealthzPublic verifies the health endpoint is reachable +// without a session and returns 200 + "ok". +func TestHealthzPublic(t *testing.T) { + srv := newTestServer(t, &stubRunner{}) + ts := newHTTPTestServer(t, srv) + resp, err := http.Get(ts.URL + "/healthz") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, "ok\n", string(body)) +} + +// TestStaticAssetsServedWithoutAuth confirms /static/ is served +// publicly so the login page can render its CSS without a session. +func TestStaticAssetsServedWithoutAuth(t *testing.T) { + srv := newTestServer(t, &stubRunner{}) + ts := newHTTPTestServer(t, srv) + resp, err := http.Get(ts.URL + "/static/style.css") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), ".topbar") +} + +// TestFirstRunPasswordStableAcrossRestart ensures the first-run +// password survives a process restart (the same bcrypt hash is +// re-loaded). +func TestFirstRunPasswordStableAcrossRestart(t *testing.T) { + dir := t.TempDir() + hash, err := HashPassword("seed-password-for-test") + require.NoError(t, err) + _, err = openWithSeed(dir, hash) + require.NoError(t, err) + _, err = openWithSeed(dir, hash) + require.NoError(t, err) + s3, err := openWithSeed(dir, hash) + require.NoError(t, err) + user, err := s3.GetUser(context.Background()) + require.NoError(t, err) + assert.True(t, passwordMatches(user.BcryptHash, "seed-password-for-test")) +} + +func openWithSeed(dir, hash string) (*Store, error) { + store, err := OpenStore(filepathJoin(dir, "webapp.db")) + if err != nil { + return nil, err + } + if _, err := store.GetUser(context.Background()); err != nil { + // No user yet, seed one. + _, err := store.CreateUser(context.Background(), hash, false) + if err != nil { + return nil, err + } + } + return store, nil +} + +func passwordMatches(hash, plain string) bool { + return VerifyPassword(hash, plain) == nil +} + +// helpers (kept local to avoid leaking test-only helpers into prod). + +func filepathJoin(a, b string) string { + // tiny re-impl to avoid importing path/filepath at the top of + // every test case (the import is pulled in via test_helpers.go). + return a + "/" + b +} + +func extractCSRFToken(t *testing.T, body string) string { + t.Helper() + const marker = `name="csrf_token" value="` + idx := strings.Index(body, marker) + require.GreaterOrEqual(t, idx, 0, "no csrf token found in body") + rest := body[idx+len(marker):] + end := strings.Index(rest, `"`) + require.GreaterOrEqual(t, end, 0, "csrf token not terminated") + return rest[:end] +} + +func mustBody(t *testing.T, resp *http.Response) string { + t.Helper() + defer resp.Body.Close() //nolint:errcheck + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(b) +} + +// TestBasicAuthMiddlewareAcceptsCredentials exercises the HTTP Basic +// auth fast-path on /web/api/*: a correctly-configured client gets +// 200 from the cluster status endpoint without a session cookie. +func TestBasicAuthMiddlewareAcceptsCredentials(t *testing.T) { + srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") + ts := newHTTPTestServer(t, srv) + + c := httpClient() + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/cluster/status", nil) + req.SetBasicAuth("alice", "s3cret") + resp, err := c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + // The stub server has no cluster attached, so the handler + // returns 503 "no cluster subsystem". What matters here is that + // the basic-auth check passed (no 401 with WWW-Authenticate). + assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode) +} + +// TestBasicAuthMiddlewareRejectsBadPassword confirms an incorrect +// password returns 401 + WWW-Authenticate header so curl prompts the +// operator to retry. +func TestBasicAuthMiddlewareRejectsBadPassword(t *testing.T) { + srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") + ts := newHTTPTestServer(t, srv) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/cluster/status", nil) + req.SetBasicAuth("alice", "wrong") + c := http.DefaultClient + resp, err := c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Contains(t, resp.Header.Get("WWW-Authenticate"), `Basic realm=`) +} + +// TestBasicAuthLoginFormHappyPath drives the form-submission path: +// operator types username+password matching WORKER_LOGIN/WORKER_PASSWORD +// and lands on /overview. +func TestBasicAuthLoginFormHappyPath(t *testing.T) { + srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") + ts := newHTTPTestServer(t, srv) + + c := httpClient() + form := url.Values{} + form.Set("login", "alice") + form.Set("password", "s3cret") + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusFound, resp.StatusCode, + "basic-auth login should redirect on success") + assert.Equal(t, pathOverview, resp.Header.Get("Location")) +} + +// TestBasicAuthLoginFormRejectsWrongPassword ensures a wrong password +// keeps the operator on the login page (302 NOT issued) and writes an +// audit row tagged basic_auth. +func TestBasicAuthLoginFormRejectsWrongPassword(t *testing.T) { + srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") + ts := newHTTPTestServer(t, srv) + + c := httpClient() + form := url.Values{} + form.Set("login", "alice") + form.Set("password", "wrong") + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.Do(req) + require.NoError(t, err) + resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode, + "wrong password re-renders the login page (no 302)") + + entries, err := srv.store.RecentAudit(context.Background(), 5) + require.NoError(t, err) + var sawFail bool + for _, e := range entries { + if e.Action == "login_failed" && e.AuthMode == "basic_auth" { + sawFail = true + } + } + assert.True(t, sawFail, "audit log must capture basic-auth login_failed") +} + +var ( + _ = json.Marshal + _ = os.Getenv +) diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css new file mode 100644 index 0000000..bc2485a --- /dev/null +++ b/internal/webapp/static/style.css @@ -0,0 +1,63 @@ +:root { + --bg: #f6f7f9; + --card: #ffffff; + --fg: #1f2937; + --muted: #6b7280; + --accent: #2563eb; + --accent-fg: #ffffff; + --border: #e5e7eb; + --error: #b91c1c; + --ok: #047857; + --warn: #b45309; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.4; } + +.topbar { + display: flex; align-items: center; gap: 24px; + padding: 12px 20px; background: var(--card); border-bottom: 1px solid var(--border); +} +.topbar .brand { font-weight: 700; } +.topbar nav { display: flex; gap: 12px; flex: 1; flex-wrap: wrap; } +.topbar nav a { color: var(--fg); text-decoration: none; padding: 4px 8px; border-radius: 4px; } +.topbar nav a:hover { background: var(--bg); } +.topbar .who { font-size: 0.9em; color: var(--muted); } + +main { padding: 20px; max-width: 1100px; margin: 0 auto; } +.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); } +.card h1 { margin-top: 0; } + +.login { max-width: 480px; margin: 60px auto; } +.login form label { display: block; margin: 12px 0; } +.login form input { width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; } +.login form button { background: var(--accent); color: var(--accent-fg); border: 0; padding: 8px 16px; border-radius: 4px; cursor: pointer; } +.login .muted { font-size: 0.85em; color: var(--muted); margin-top: 16px; } + +form label { display: block; margin: 8px 0; } +form input, form select, form textarea { padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; } +form button { background: var(--accent); color: var(--accent-fg); border: 0; padding: 6px 12px; border-radius: 4px; cursor: pointer; } +button.link { background: transparent; color: var(--accent); padding: 0; border: 0; cursor: pointer; } +button.danger { background: var(--error); } + +.error { color: var(--error); } +.muted { color: var(--muted); } +.ok { color: var(--ok); } +.warn { color: var(--warn); } + +table { border-collapse: collapse; width: 100%; } +table th, table td { padding: 6px 8px; text-align: left; border-bottom: 1px solid var(--border); } +table th { background: var(--bg); font-weight: 600; } +table tr:hover td { background: var(--bg); } + +.logout-form { display: inline; } +.logout-form button { font-size: 0.9em; } + +.footer { text-align: center; padding: 12px; color: var(--muted); font-size: 0.85em; } + +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; } +.metric { background: var(--bg); padding: 12px; border-radius: 6px; border: 1px solid var(--border); } +.metric .label { color: var(--muted); font-size: 0.85em; } +.metric .value { font-size: 1.4em; font-weight: 600; margin-top: 4px; } + +pre.log { background: #111827; color: #e5e7eb; padding: 12px; border-radius: 6px; overflow: auto; max-height: 600px; font-size: 0.85em; white-space: pre; } diff --git a/internal/webapp/store.go b/internal/webapp/store.go new file mode 100644 index 0000000..b283609 --- /dev/null +++ b/internal/webapp/store.go @@ -0,0 +1,492 @@ +package webapp + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + // Pure-Go SQLite driver. Imported with the blank identifier so the + // driver registers itself with database/sql under the name "sqlite". + // modernc.org/sqlite is CGO-free which keeps the worker binary + // portable (no glibc/musl split) and matches the constraint in + // docs/distributed/worker-web-app.md section 13.1. + _ "modernc.org/sqlite" +) + +// schemaSQL defines the embedded store tables for the Phase 1 MVP. +// The store keeps: +// +// - first-run webapp user with bcrypt hash and requires_change flag +// - session cookies (server-side copy + idle/absolute expiry) +// - audit log rows (actor, role, auth_mode, ip, ua, action, target, +// before_hash, after_hash, ts) +// - discovered-app inventory cache (json blob per app) +// +// All tables use INTEGER PRIMARY KEY so GORM-style IDs work; the +// store itself is hand-written SQL because the scope is tiny and we +// want zero coupling to GORM. +const schemaSQL = ` +CREATE TABLE IF NOT EXISTS webapp_users ( + id INTEGER PRIMARY KEY, + bcrypt_hash TEXT NOT NULL, + requires_change INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_change_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS webapp_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + csrf_token TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + ua TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_webapp_sessions_user + ON webapp_sessions(user_id); + +CREATE TABLE IF NOT EXISTS webapp_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + auth_mode TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + ua TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + before_hash TEXT NOT NULL DEFAULT '', + after_hash TEXT NOT NULL DEFAULT '', + ts INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_webapp_audit_ts + ON webapp_audit(ts); + +CREATE TABLE IF NOT EXISTS webapp_apps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + source TEXT NOT NULL, + pid INTEGER NOT NULL DEFAULT 0, + ports TEXT NOT NULL DEFAULT '', + start_ts INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL, + json_blob TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_webapp_apps_name + ON webapp_apps(name); +` + +// Store is the embedded webapp database. It is safe for concurrent +// use because the underlying *sql.DB is. +// +// All exported methods take a context for cancellation; passing +// context.Background() is fine for the Phase 1 callers (background +// pruning timer + per-request handlers). +type Store struct { + db *sql.DB +} + +// OpenStore opens (or creates) the embedded SQLite database at the +// given path. For tests, pass "file::memory:?cache=shared" or just +// ":memory:". The returned Store must be closed via Close(). +func OpenStore(path string) (*Store, error) { + if path == "" { + return nil, errors.New("webapp: empty store path") + } + // _pragma options: enforce foreign keys is irrelevant for this + // single-table-per-feature design; we do however turn on WAL for + // on-disk files because the audit write path is concurrent with + // the login handler and we do not want readers to block writers. + dsn := path + if !strings.HasPrefix(path, "file:") && path != ":memory:" { + dsn = path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" + } + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("webapp: open sqlite: %w", err) + } + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("webapp: ping sqlite: %w", err) + } + if _, err := db.Exec(schemaSQL); err != nil { + _ = db.Close() + return nil, fmt.Errorf("webapp: apply schema: %w", err) + } + return &Store{db: db}, nil +} + +// Close releases the underlying database handle. Safe to call once. +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +// User is the single-user model for the Phase 1 webapp. The worker +// webapp is single-tenant (one operator per worker) so a single +// row is the whole table. +type User struct { + ID int64 + BcryptHash string + RequiresChange bool + CreatedAt time.Time + LastChangeAt time.Time +} + +// GetUser returns the single webapp user, or sql.ErrNoRows if no +// user has been provisioned yet (first run). +func (s *Store) GetUser(ctx context.Context) (*User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, bcrypt_hash, requires_change, created_at, last_change_at + FROM webapp_users ORDER BY id ASC LIMIT 1`) + u := &User{} + var requiresChange int + var created, changed int64 + if err := row.Scan(&u.ID, &u.BcryptHash, &requiresChange, &created, &changed); err != nil { + return nil, err + } + u.RequiresChange = requiresChange != 0 + u.CreatedAt = time.Unix(created, 0).UTC() + u.LastChangeAt = time.Unix(changed, 0).UTC() + return u, nil +} + +// CreateUser provisions the first (and only) webapp user with the +// requires_change flag set to the given value. Returns the new user +// row on success. +// +// Callers should pick the flag value: +// - false: subsequent-start path; the operator knows the password +// and does not need to rotate it again. +// - true: first-run path; the operator must update the random +// initial password before reaching any other page. +func (s *Store) CreateUser(ctx context.Context, bcryptHash string, requiresChange bool) (*User, error) { + flagVal := 0 + if requiresChange { + flagVal = 1 + } + now := time.Now().UTC() + res, err := s.db.ExecContext(ctx, + `INSERT INTO webapp_users (bcrypt_hash, requires_change, created_at, last_change_at) + VALUES (?, ?, ?, ?)`, + bcryptHash, flagVal, now.Unix(), now.Unix()) + if err != nil { + return nil, fmt.Errorf("webapp: insert user: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("webapp: last insert id: %w", err) + } + return &User{ + ID: id, + BcryptHash: bcryptHash, + RequiresChange: requiresChange, + CreatedAt: now, + LastChangeAt: now, + }, nil +} + +// CreateFirstRunUser provisions the single webapp user with the +// requires_change flag set so the operator is forced to update the +// password before any other page is reachable. Used by the cmd +// binary on first start after minting the random initial password. +func (s *Store) CreateFirstRunUser(ctx context.Context, bcryptHash string) (*User, error) { + return s.CreateUser(ctx, bcryptHash, true) +} + +// EnsureAnonymousUser provisions a placeholder user row with an +// unusable bcrypt hash when no user exists yet. Used by the basic-auth +// login flow: the session row needs a user_id foreign key target, but +// the operator authenticates with WORKER_LOGIN/WORKER_PASSWORD, not a +// bcrypt password. Idempotent — returns nil if a row already exists. +// +// The hash is a fixed bcrypt of a random unguessable value so even an +// attacker who reads the SQLite file cannot derive a usable password. +func (s *Store) EnsureAnonymousUser(ctx context.Context) error { + existing, err := s.GetUser(ctx) + if err == nil && existing != nil { + return nil + } + unusable, err := HashPassword("!" + newAnonymousSecret() + "!") + if err != nil { + return fmt.Errorf("webapp: hash anonymous password: %w", err) + } + _, err = s.CreateUser(ctx, unusable, false) + if err != nil { + return fmt.Errorf("webapp: create anonymous user: %w", err) + } + return nil +} + +func newAnonymousSecret() string { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + // Fall back to a time-derived secret — still unguessable + // from outside the worker process because we only need the + // hash to be non-recoverable, not the plaintext. + return fmt.Sprintf("anon-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} + +// UpdatePassword rotates the user's bcrypt hash and clears the +// requires_change flag. beforeHash is recorded for the audit row. +func (s *Store) UpdatePassword(ctx context.Context, userID int64, newHash string) error { + now := time.Now().UTC() + res, err := s.db.ExecContext(ctx, + `UPDATE webapp_users SET bcrypt_hash = ?, requires_change = 0, last_change_at = ? WHERE id = ?`, + newHash, now.Unix(), userID) + if err != nil { + return fmt.Errorf("webapp: update password: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("webapp: rows affected: %w", err) + } + if n == 0 { + return fmt.Errorf("webapp: user %d not found", userID) + } + return nil +} + +// MarkRequiresChange flips the requires_change flag back on. Used by +// the password-change handler when the new password fails the +// minimal-strength check (so the operator is forced to retry). +func (s *Store) MarkRequiresChange(ctx context.Context, userID int64) error { + _, err := s.db.ExecContext(ctx, + `UPDATE webapp_users SET requires_change = 1 WHERE id = ?`, userID) + return err +} + +// Session is a row from webapp_sessions. +type Session struct { + ID string + UserID int64 + CSRFToken string + IP string + UA string + CreatedAt time.Time + LastSeenAt time.Time + ExpiresAt time.Time +} + +// CreateSession inserts a new session row and returns it. +func (s *Store) CreateSession(ctx context.Context, sess *Session) error { + if sess.ID == "" || sess.CSRFToken == "" { + return errors.New("webapp: session id and csrf token are required") + } + if sess.CreatedAt.IsZero() { + sess.CreatedAt = time.Now().UTC() + } + if sess.LastSeenAt.IsZero() { + sess.LastSeenAt = sess.CreatedAt + } + if sess.ExpiresAt.IsZero() { + return errors.New("webapp: session expires_at is required") + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO webapp_sessions + (id, user_id, csrf_token, ip, ua, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + sess.ID, sess.UserID, sess.CSRFToken, sess.IP, sess.UA, + sess.CreatedAt.Unix(), sess.LastSeenAt.Unix(), sess.ExpiresAt.Unix()) + if err != nil { + return fmt.Errorf("webapp: insert session: %w", err) + } + return nil +} + +// GetSession fetches a session by id. Returns sql.ErrNoRows if the +// session is unknown or expired. +func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, user_id, csrf_token, ip, ua, created_at, last_seen_at, expires_at + FROM webapp_sessions WHERE id = ?`, id) + sess := &Session{} + var created, lastSeen, expires int64 + if err := row.Scan(&sess.ID, &sess.UserID, &sess.CSRFToken, &sess.IP, &sess.UA, + &created, &lastSeen, &expires); err != nil { + return nil, err + } + sess.CreatedAt = time.Unix(created, 0).UTC() + sess.LastSeenAt = time.Unix(lastSeen, 0).UTC() + sess.ExpiresAt = time.Unix(expires, 0).UTC() + if time.Now().UTC().After(sess.ExpiresAt) { + return nil, sql.ErrNoRows + } + return sess, nil +} + +// TouchSession updates last_seen_at and slides the absolute expiry +// forward by the idle window, if and only if the session is still +// inside its absolute cap. +func (s *Store) TouchSession(ctx context.Context, id string, idle, absolute time.Duration) error { + now := time.Now().UTC() + row := s.db.QueryRowContext(ctx, + `SELECT last_seen_at FROM webapp_sessions WHERE id = ?`, id) + var lastSeen int64 + if err := row.Scan(&lastSeen); err != nil { + return err + } + created := time.Unix(lastSeen, 0).UTC() + newLast := now + newExp := now.Add(idle) + absCap := created.Add(absolute) + if newExp.After(absCap) { + newExp = absCap + } + _, err := s.db.ExecContext(ctx, + `UPDATE webapp_sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`, + newLast.Unix(), newExp.Unix(), id) + return err +} + +// DeleteSession removes the session by id. Used on logout. +func (s *Store) DeleteSession(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM webapp_sessions WHERE id = ?`, id) + return err +} + +// AuditEntry is a single audit-log row. +type AuditEntry struct { + ID int64 + Actor string + Role string + AuthMode string + IP string + UA string + Action string + Target string + BeforeHash string + AfterHash string + TS time.Time +} + +// WriteAudit appends a row to webapp_audit. +func (s *Store) WriteAudit(ctx context.Context, e *AuditEntry) error { + if e.Action == "" { + return errors.New("webapp: audit action is required") + } + if e.TS.IsZero() { + e.TS = time.Now().UTC() + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO webapp_audit + (actor, role, auth_mode, ip, ua, action, target, before_hash, after_hash, ts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.Actor, e.Role, e.AuthMode, e.IP, e.UA, e.Action, e.Target, + e.BeforeHash, e.AfterHash, e.TS.Unix()) + if err != nil { + return fmt.Errorf("webapp: insert audit: %w", err) + } + return nil +} + +// RecentAudit returns the most recent n audit rows in reverse-chron +// order. Used by tests; the UI does not need this view in Phase 1. +func (s *Store) RecentAudit(ctx context.Context, limit int) ([]AuditEntry, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, actor, role, auth_mode, ip, ua, action, target, + before_hash, after_hash, ts + FROM webapp_audit ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() //nolint:errcheck + var out []AuditEntry + for rows.Next() { + e := AuditEntry{} + var ts int64 + if err := rows.Scan(&e.ID, &e.Actor, &e.Role, &e.AuthMode, &e.IP, &e.UA, + &e.Action, &e.Target, &e.BeforeHash, &e.AfterHash, &ts); err != nil { + return nil, err + } + e.TS = time.Unix(ts, 0).UTC() + out = append(out, e) + } + return out, rows.Err() +} + +// PruneAudit deletes audit rows older than retention. Called from a +// daily timer in the server. +func (s *Store) PruneAudit(ctx context.Context, retention time.Duration) (int64, error) { + cutoff := time.Now().UTC().Add(-retention).Unix() + res, err := s.db.ExecContext(ctx, + `DELETE FROM webapp_audit WHERE ts < ?`, cutoff) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// App is the inventory cache row. +type App struct { + ID int64 + Name string + Source string + PID int + Ports string + StartTS int64 + LastSeen time.Time + JSONBlob string +} + +// ReplaceApps deletes the entire inventory and re-inserts the given +// snapshot. Called from the inventory refresh loop. Wrapped in a +// transaction so the page never reads a half-replaced list. +func (s *Store) ReplaceApps(ctx context.Context, apps []App) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM webapp_apps`); err != nil { + _ = tx.Rollback() + return err + } + for _, a := range apps { + if _, err := tx.ExecContext(ctx, + `INSERT INTO webapp_apps (name, source, pid, ports, start_ts, last_seen, json_blob) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.Name, a.Source, a.PID, a.Ports, a.StartTS, a.LastSeen.Unix(), a.JSONBlob); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() +} + +// ListApps returns the inventory rows in insertion order. Used by the +// discovered-apps handler. +func (s *Store) ListApps(ctx context.Context) ([]App, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, source, pid, ports, start_ts, last_seen, json_blob + FROM webapp_apps ORDER BY id ASC`) + if err != nil { + return nil, err + } + defer rows.Close() //nolint:errcheck + var out []App + for rows.Next() { + a := App{} + var lastSeen int64 + if err := rows.Scan(&a.ID, &a.Name, &a.Source, &a.PID, &a.Ports, + &a.StartTS, &lastSeen, &a.JSONBlob); err != nil { + return nil, err + } + a.LastSeen = time.Unix(lastSeen, 0).UTC() + out = append(out, a) + } + return out, rows.Err() +} diff --git a/internal/webapp/store_test.go b/internal/webapp/store_test.go new file mode 100644 index 0000000..337a716 --- /dev/null +++ b/internal/webapp/store_test.go @@ -0,0 +1,233 @@ +package webapp + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + // Use a per-test in-memory DSN so the schema is fresh and there + // are no goroutine-leak concerns from shared cache. + store, err := OpenStore(":memory:") + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + return store +} + +func TestStoreCreateAndGetUser(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + _, err := s.GetUser(ctx) + require.ErrorIs(t, err, sql.ErrNoRows, "fresh store must have no user") + + hash, err := HashPassword("hello world") + require.NoError(t, err) + u, err := s.CreateUser(ctx, hash, false) + require.NoError(t, err) + require.NotNil(t, u) + assert.NotZero(t, u.ID) + assert.False(t, u.RequiresChange, "fresh user must not require change") + + got, err := s.GetUser(ctx) + require.NoError(t, err) + assert.Equal(t, u.ID, got.ID) + assert.Equal(t, hash, got.BcryptHash) +} + +func TestStoreUpdatePasswordMarksRequiresChange(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + hash, err := HashPassword("first") + require.NoError(t, err) + u, err := s.CreateUser(ctx, hash, false) + require.NoError(t, err) + + newHash, err := HashPassword("second") + require.NoError(t, err) + require.NoError(t, s.UpdatePassword(ctx, u.ID, newHash)) + + got, err := s.GetUser(ctx) + require.NoError(t, err) + assert.Equal(t, newHash, got.BcryptHash) + assert.False(t, got.RequiresChange) + + require.NoError(t, s.MarkRequiresChange(ctx, u.ID)) + got, err = s.GetUser(ctx) + require.NoError(t, err) + assert.True(t, got.RequiresChange) +} + +func TestStoreSessionLifecycle(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + hash, err := HashPassword("p") + require.NoError(t, err) + u, err := s.CreateUser(ctx, hash, false) + require.NoError(t, err) + + sess := Session{ + ID: "sess-1", + UserID: u.ID, + CSRFToken: "csrf-1", + IP: "127.0.0.1", + UA: "ua", + ExpiresAt: time.Now().Add(time.Minute).UTC(), + } + require.NoError(t, s.CreateSession(ctx, &sess)) + + got, err := s.GetSession(ctx, "sess-1") + require.NoError(t, err) + assert.Equal(t, sess.CSRFToken, got.CSRFToken) + assert.Equal(t, u.ID, got.UserID) + + require.NoError(t, s.TouchSession(ctx, "sess-1", 5*time.Minute, 8*time.Hour)) + require.NoError(t, s.DeleteSession(ctx, "sess-1")) + + _, err = s.GetSession(ctx, "sess-1") + assert.ErrorIs(t, err, sql.ErrNoRows) +} + +func TestStoreGetSessionExpired(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + hash, err := HashPassword("p") + require.NoError(t, err) + u, err := s.CreateUser(ctx, hash, false) + require.NoError(t, err) + + sess := Session{ + ID: "sess-2", + UserID: u.ID, + CSRFToken: "csrf-2", + ExpiresAt: time.Now().Add(-time.Second).UTC(), + } + require.NoError(t, s.CreateSession(ctx, &sess)) + + _, err = s.GetSession(ctx, "sess-2") + assert.ErrorIs(t, err, sql.ErrNoRows, "expired session must surface as no-rows") +} + +func TestStoreCreateSessionValidation(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + cases := []struct { + name string + sess Session + }{ + { + "empty id", + Session{CSRFToken: "c", UserID: 1, ExpiresAt: time.Now().Add(time.Minute)}, + }, + { + "empty csrf", + Session{ID: "x", UserID: 1, ExpiresAt: time.Now().Add(time.Minute)}, + }, + { + "empty expiry", + Session{ID: "x", UserID: 1, CSRFToken: "c"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := s.CreateSession(ctx, &tc.sess) + assert.Error(t, err) + }) + } +} + +func TestStoreAuditWriteAndRecent(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 5; i++ { + require.NoError(t, s.WriteAudit(ctx, &AuditEntry{ + Actor: "operator", + Role: "admin", + AuthMode: "local", + Action: "login", + Target: "self", + BeforeHash: "", + AfterHash: "", + })) + } + got, err := s.RecentAudit(ctx, 3) + require.NoError(t, err) + assert.Len(t, got, 3, "RecentAudit must respect limit") + + // Empty action must be rejected. + err = s.WriteAudit(ctx, &AuditEntry{Action: ""}) + assert.Error(t, err) +} + +func TestStoreAuditPrune(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + now := time.Now().UTC() + // 10 rows older than 1h and 5 rows from "now". + for i := 0; i < 10; i++ { + require.NoError(t, s.WriteAudit(ctx, &AuditEntry{ + Action: "old", + TS: now.Add(-2 * time.Hour), + })) + } + for i := 0; i < 5; i++ { + require.NoError(t, s.WriteAudit(ctx, &AuditEntry{ + Action: "fresh", + TS: now, + })) + } + + n, err := s.PruneAudit(ctx, time.Hour) + require.NoError(t, err) + assert.Equal(t, int64(10), n, "expected to prune exactly the 10 old rows") + + got, err := s.RecentAudit(ctx, 100) + require.NoError(t, err) + require.Len(t, got, 5) + for _, e := range got { + assert.Equal(t, "fresh", e.Action, "only fresh rows must remain") + } +} + +func TestStoreReplaceAndListApps(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + first := []App{ + {Name: "rsmon-worker", Source: "process", PID: 100, Ports: "7401/tcp", StartTS: 1, LastSeen: time.Now().UTC(), JSONBlob: `{"foo":"bar"}`}, + {Name: "postgres", Source: "process", PID: 200, Ports: "5432/tcp", StartTS: 2, LastSeen: time.Now().UTC(), JSONBlob: ""}, + } + require.NoError(t, s.ReplaceApps(ctx, first)) + + list, err := s.ListApps(ctx) + require.NoError(t, err) + require.Len(t, list, 2) + assert.Equal(t, "rsmon-worker", list[0].Name) + assert.True(t, strings.HasPrefix(list[0].JSONBlob, "{"), "JSON blob must round-trip") + + second := []App{ + {Name: "redis", Source: "process", PID: 300, Ports: "6379/tcp", StartTS: 3, LastSeen: time.Now().UTC()}, + } + require.NoError(t, s.ReplaceApps(ctx, second)) + + list, err = s.ListApps(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, "redis", list[0].Name) +} + +func TestStoreOpenInvalidPath(t *testing.T) { + _, err := OpenStore("") + assert.True(t, errors.Is(err, err) || err != nil) +} diff --git a/internal/webapp/template_helpers.go b/internal/webapp/template_helpers.go new file mode 100644 index 0000000..dc9437d --- /dev/null +++ b/internal/webapp/template_helpers.go @@ -0,0 +1,84 @@ +package webapp + +import ( + "fmt" + "strings" + "time" +) + +// Template helpers. Kept in their own file so templates.go stays +// focused on parsing/wiring. + +// fmtBytes renders a byte count as a human-readable string. The +// unit is picked automatically (B / KiB / MiB / GiB / TiB). +func fmtBytes(n uint64) string { + const k = 1024 + if n < k { + return fmt.Sprintf("%d B", n) + } + div, exp := uint64(k), 1 + for n2 := n / k; n2 >= k; n2 /= k { + div *= k + exp++ + } + suffix := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}[exp-1] + return fmt.Sprintf("%.2f %s", float64(n)/float64(div), suffix) +} + +// fmtPercent renders a 0..100 number as "N.NN%". +func fmtPercent(p float64) string { + return fmt.Sprintf("%.2f%%", p) +} + +// fmtDuration renders a duration as "1d 2h 3m 4s". The format is +// stable across short and long uptimes. +func fmtDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + days := int(d / (24 * time.Hour)) + d -= time.Duration(days) * 24 * time.Hour + hours := int(d / time.Hour) + d -= time.Duration(hours) * time.Hour + minutes := int(d / time.Minute) + d -= time.Duration(minutes) * time.Minute + seconds := int(d / time.Second) + switch { + case days > 0: + return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds) + case hours > 0: + return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) + case minutes > 0: + return fmt.Sprintf("%dm %ds", minutes, seconds) + default: + return fmt.Sprintf("%ds", seconds) + } +} + +// fmtTime renders a timestamp as "2006-01-02 15:04:05 UTC". +func fmtTime(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.UTC().Format("2006-01-02 15:04:05 UTC") +} + +// fmtBool renders a bool as "yes" / "no". +func fmtBool(b bool) string { + if b { + return "yes" + } + return "no" +} + +// sanitizeName strips characters that could break out of an HTML +// attribute when interpolated by a template. Template auto-escapes +// by default, but a paranoid layer here keeps audit targets safe +// when they are rendered into other contexts. +func sanitizeName(s string) string { + s = strings.ReplaceAll(s, "\x00", "") + if len(s) > 128 { + s = s[:128] + } + return s +} diff --git a/internal/webapp/templates.go b/internal/webapp/templates.go new file mode 100644 index 0000000..f2d2019 --- /dev/null +++ b/internal/webapp/templates.go @@ -0,0 +1,98 @@ +package webapp + +import ( + "embed" + "fmt" + "html/template" + "io" + "io/fs" + "strings" + "sync" +) + +//go:embed templates/*.html static/* +var contentFS embed.FS + +// staticFS exposes the embedded static assets (CSS, JS, favicon) so +// they can be served by http.FileServer. There is no CDN dependency; +// everything ships inside the worker binary. +var staticFS = mustSubFS("static") + +// mustSubFS returns the named sub-filesystem inside contentFS, or +// panics if it is missing. The embed directive above guarantees the +// dir exists at build time. +func mustSubFS(name string) fs.FS { + sub, err := fs.Sub(contentFS, name) + if err != nil { + panic(fmt.Sprintf("webapp: embedded sub %q: %v", name, err)) + } + return sub +} + +// Templates is a thin wrapper around html/template that lazily +// parses the embedded *.html files on first Execute. The FuncMap is +// intentionally minimal so adding global helpers does not blow up +// the per-page test surface. +type Templates struct { + mu sync.RWMutex + cache map[string]*template.Template + funcs template.FuncMap +} + +// loadTemplates parses every embedded template into the cache. The +// shared layout (templates/layout.html) is parsed into every page +// so each {{template "content" .}} substitution resolves cleanly. +func loadTemplates() (*Templates, error) { + t := &Templates{ + cache: map[string]*template.Template{}, + funcs: template.FuncMap{ + "fmtBytes": fmtBytes, + "fmtPercent": fmtPercent, + "fmtDuration": fmtDuration, + "fmtTime": fmtTime, + "fmtBool": fmtBool, + "hasPrefix": strings.HasPrefix, + "sanitizeName": sanitizeName, + }, + } + pages, err := fs.ReadDir(contentFS, "templates") + if err != nil { + return nil, fmt.Errorf("webapp: read templates: %w", err) + } + for _, p := range pages { + if p.IsDir() || !strings.HasSuffix(p.Name(), ".html") { + continue + } + if _, err := t.parse(p.Name()); err != nil { + return nil, fmt.Errorf("webapp: parse %s: %w", p.Name(), err) + } + } + return t, nil +} + +// parse loads the named template plus the shared layout. Page +// templates "{{define" body"}}" themselves. +func (t *Templates) parse(name string) (*template.Template, error) { + tmpl := template.New(name).Funcs(t.funcs) + if _, err := tmpl.ParseFS(contentFS, "templates/layout.html"); err != nil { + return nil, err + } + if _, err := tmpl.ParseFS(contentFS, "templates/"+name); err != nil { + return nil, err + } + t.cache[name] = tmpl + return tmpl, nil +} + +// Execute renders the named page template into w. The page is +// wrapped by the shared layout block via the "layout" template +// defined in templates/layout.html. +func (t *Templates) Execute(w io.Writer, name string, data interface{}) error { + t.mu.RLock() + tmpl, ok := t.cache[name] + t.mu.RUnlock() + if !ok { + return fmt.Errorf("webapp: no such template %q", name) + } + return tmpl.ExecuteTemplate(w, "layout", data) +} diff --git a/internal/webapp/templates/app_detail.html b/internal/webapp/templates/app_detail.html new file mode 100644 index 0000000..6c8c57c --- /dev/null +++ b/internal/webapp/templates/app_detail.html @@ -0,0 +1,20 @@ +{{define "body"}}
+

{{.App.Name}}

+
+
Source
{{.App.Source}}
+
PID
{{.App.PID}}
+
Open ports
+
+ {{if .App.Ports}} +
    + {{range .App.Ports}}
  • {{.}}
  • {{end}} +
+ {{else}}none{{end}} +
+
Cmdline
{{.App.Cmdline}}
+
Cwd
{{.App.CWD}}
+
Last seen
{{fmtTime .App.LastSeen}}
+
Last check
(Phase 1: no per-app probes)
+
+

← All apps

+
{{end}} diff --git a/internal/webapp/templates/apps.html b/internal/webapp/templates/apps.html new file mode 100644 index 0000000..5c5f2af --- /dev/null +++ b/internal/webapp/templates/apps.html @@ -0,0 +1,32 @@ +{{define "body"}}
+

Discovered apps

+

Refreshed every 60s from /proc. Docker Compose / nginx / systemd discoveries land with the deploymentd integration (see docs/plans/inventory-management.md §7.2); once the host runs deploymentd and posts to RSMon, the matching Site + Deployment rows appear here side-by-side with the per-process list.

+ + + + + + + + + + + + + + {{range .Apps}} + + + + + + + + + + {{else}} + + {{end}} + +
NameSourcePIDPortsUptimeLast check
{{.Name}}{{.Source}}{{.PID}}{{range .Ports}}{{.}}
{{end}}
{{fmtTime .LastSeen}}details
No apps discovered yet — first refresh runs within 60s of start.
+
{{end}} diff --git a/internal/webapp/templates/change_password.html b/internal/webapp/templates/change_password.html new file mode 100644 index 0000000..657219a --- /dev/null +++ b/internal/webapp/templates/change_password.html @@ -0,0 +1,21 @@ +{{define "body"}}
+

Change password

+

The operator must change the first-run password before they can use the worker web app.

+ {{if .Error}}

{{.Error}}

{{end}} +
+ + + + + +
+
{{end}} diff --git a/internal/webapp/templates/checks.html b/internal/webapp/templates/checks.html new file mode 100644 index 0000000..ae94cd8 --- /dev/null +++ b/internal/webapp/templates/checks.html @@ -0,0 +1,34 @@ +{{define "body"}}
+

Recent checks

+

Last 50 results produced by this worker.

+

+ {{.RunNowTooltip}}

+ + + + + + + + + + + + + + {{range .Rows}} + + + + + + + + + + {{else}} + + {{end}} + +
MonitorCheckKindHostStateDuration (ms)Error
{{.MonitorID}}{{.CheckID}}{{.Kind}}{{.Host}}{{.State}}{{.DurationMs}}{{.Error}}
No checks yet. Once the main app assigns jobs to this worker, results will appear here.
+
{{end}} diff --git a/internal/webapp/templates/layout.html b/internal/webapp/templates/layout.html new file mode 100644 index 0000000..12accc5 --- /dev/null +++ b/internal/webapp/templates/layout.html @@ -0,0 +1,39 @@ +{{define "layout"}} + + + + + + {{.Title}} + + + +
+
RSMon worker
+ {{if not .IsLogin}} + +
+
+ + +
+
+ {{end}} +
+
+ {{template "body" .}} +
+
+ RSMon worker v{{.Version}} · built {{.BuildDate}} +
+ +{{end}} diff --git a/internal/webapp/templates/login.html b/internal/webapp/templates/login.html new file mode 100644 index 0000000..862361d --- /dev/null +++ b/internal/webapp/templates/login.html @@ -0,0 +1,33 @@ +{{define "body"}}{{end}} diff --git a/internal/webapp/templates/logs.html b/internal/webapp/templates/logs.html new file mode 100644 index 0000000..24fc408 --- /dev/null +++ b/internal/webapp/templates/logs.html @@ -0,0 +1,17 @@ +{{define "body"}}
+

Worker logs

+

In-memory ring buffer (capacity {{.BufferCap}}, holding {{.BufferSize}} lines). Phase 1 shows worker-binary logs only; stack and host logs land in later phases.

+
+ + +
+
{{range .Lines}}{{.}}
+{{end}}
+
{{end}} diff --git a/internal/webapp/templates/notifications.html b/internal/webapp/templates/notifications.html new file mode 100644 index 0000000..9297ca3 --- /dev/null +++ b/internal/webapp/templates/notifications.html @@ -0,0 +1,32 @@ +{{define "body"}}
+

Recent notifications

+

Last 50 notifications emitted by this worker (Phase 1: selfcheck alerts only).

+

+ {{.ResendTooltip}}

+ + + + + + + + + + + + + {{range .Rows}} + + + + + + + + + {{else}} + + {{end}} + +
ChannelSubjectBodyStatusErrorWhen
{{.Channel}} ({{.Kind}}){{.Subject}}{{.Body}}{{if .OK}}delivered{{else}}failed{{end}}{{.Error}}{{fmtTime .At}}
No notifications yet.
+
{{end}} diff --git a/internal/webapp/templates/overview.html b/internal/webapp/templates/overview.html new file mode 100644 index 0000000..ed0d0f9 --- /dev/null +++ b/internal/webapp/templates/overview.html @@ -0,0 +1,56 @@ +{{define "body"}}
+

Overview

+
+
+
Worker ID
+
{{.WorkerID}}
+
+
+
Region
+
{{.RegionCode}}
+
+
+
State
+
{{.WorkerState}}
+
+
+
Last heartbeat ack
+
{{fmtTime .LastAckAt}}
+
+
+
Discovered apps
+
{{.DiscoveredCount}}
+
+
+
Checks (last 1000)
+
{{.ResultCount}}
+
+
+
Notifications (last 1000)
+
{{.NotifCount}}
+
+
+
CPU
+
{{fmtPercent .Snapshot.CPU.TotalPct}}
+
+
+
Memory used
+
{{fmtPercent .Snapshot.Memory.UsedPct}}
+
+
+
Uptime
+
{{fmtDuration .Snapshot.Uptime}}
+
+
+
Load (1/5/15)
+
{{printf "%.2f / %.2f / %.2f" .Snapshot.Load.One .Snapshot.Load.Five .Snapshot.Load.Fifteen}}
+
+
+
+ +
+

Recent log lines

+
{{range .LogTail}}{{.}}
+{{end}}
+

View all logs

+
{{end}} diff --git a/internal/webapp/templates/settings.html b/internal/webapp/templates/settings.html new file mode 100644 index 0000000..87b03ea --- /dev/null +++ b/internal/webapp/templates/settings.html @@ -0,0 +1,31 @@ +{{define "body"}}
+

Settings

+

Phase 1 exposes only the worker fields; web-app settings (theme, session timeout, basic-auth password rotate) land in later phases.

+ +

Worker

+
+
Worker ID
{{.WorkerID}}
+
Region
{{.RegionCode}}
+
Version
{{.WorkerVersion}}
+
Capabilities
+ {{if .Capabilities}} +
    {{range .Capabilities}}
  • {{.}}
  • {{end}}
+ {{else}}{{end}} +
+
Last heartbeat ack
{{fmtTime .LastAckAt}}
+
+ +

Token

+

+ Current token: {{.TokenMasked}}
+ Last rotated: {{fmtTime .TokenRotatedAt}} +

+
+ + + Issues a fresh token via the main app API and updates the in-memory runner config. On failure the old token is kept. +
+
{{end}} diff --git a/internal/webapp/templates/status.html b/internal/webapp/templates/status.html new file mode 100644 index 0000000..312cc2e --- /dev/null +++ b/internal/webapp/templates/status.html @@ -0,0 +1,82 @@ +{{define "body"}}
+

Server status

+

Sampled every 5s from /proc and statfs. CLI tools (lsblk, smartctl, sensors) are not bundled with the worker; install them on the host separately if you want richer hardware data. SMART self-tests are out of scope.

+ +

CPU

+
+
Total busy
{{fmtPercent .Snapshot.CPU.TotalPct}}
+
User
{{fmtPercent .Snapshot.CPU.UserPct}}
+
System
{{fmtPercent .Snapshot.CPU.SystemPct}}
+
Idle
{{fmtPercent .Snapshot.CPU.IdlePct}}
+
I/O wait
{{fmtPercent .Snapshot.CPU.IOWaitPct}}
+
+ +

Load

+
+
1 min
{{printf "%.2f" .Snapshot.Load.One}}
+
5 min
{{printf "%.2f" .Snapshot.Load.Five}}
+
15 min
{{printf "%.2f" .Snapshot.Load.Fifteen}}
+
+ +

Memory

+ + + + + + + + + + +
Total{{fmtBytes .Snapshot.Memory.Total}}
Available{{fmtBytes .Snapshot.Memory.Available}} ({{fmtPercent .Snapshot.Memory.AvailablePct}})
Free{{fmtBytes .Snapshot.Memory.Free}}
Buffers{{fmtBytes .Snapshot.Memory.Buffers}}
Cached{{fmtBytes .Snapshot.Memory.Cached}}
Swap total{{fmtBytes .Snapshot.Memory.SwapTotal}}
Swap free{{fmtBytes .Snapshot.Memory.SwapFree}}
+ +

Uptime

+
+
Uptime
{{fmtDuration .Snapshot.Uptime}}
+
Boot at
{{fmtTime .Snapshot.BootAt}}
+
Last sample
{{fmtTime .SnapshotAt}}
+
+ +

Network

+ + + + {{range .Snapshot.Networks}} + + + + + + + + + + + + {{else}} + + {{end}} + +
IfaceRX bytesTX bytesRX pktsTX pktsRX errsTX errsRX dropsTX drops
{{.Name}}{{fmtBytes .RxBytes}}{{fmtBytes .TxBytes}}{{.RxPkt}}{{.TxPkt}}{{.RxErr}}{{.TxErr}}{{.RxDrop}}{{.TxDrop}}
No network interfaces detected.
+ +

Mount points

+ + + + {{range .Snapshot.Disks}} + + + + + + + + + + {{else}} + + {{end}} + +
MountDeviceFSTotalFreeUsedUse%
{{.Mount}}{{.Device}}{{.FSType}}{{fmtBytes .Total}}{{fmtBytes .Free}}{{fmtBytes .Used}}{{fmtPercent .UsedPct}}
No mount points parsed from /proc/mounts.
+
{{end}} diff --git a/internal/webapp/templates/updates.html b/internal/webapp/templates/updates.html new file mode 100644 index 0000000..cb0b5b7 --- /dev/null +++ b/internal/webapp/templates/updates.html @@ -0,0 +1,17 @@ +{{define "body"}}
+

Updates

+

Current binary version compared against the latest release. When WORKER_RELEASE_URL is unset the "latest known" cell shows the placeholder v1 (dev). Pull and restart stays disabled until Docker management lands (requires sudo / docker socket access).

+
+
Current version
{{.CurrentVersion}}
+
Latest known
{{.LatestKnown}}
+
In sync?
+ {{if eq .CurrentVersion .LatestKnown}} + yes + {{else}} + update available + {{end}} +
+
+ +

{{.PullTooltip}}

+
{{end}} diff --git a/internal/webapp/test_helpers.go b/internal/webapp/test_helpers.go new file mode 100644 index 0000000..ec1e9b6 --- /dev/null +++ b/internal/webapp/test_helpers.go @@ -0,0 +1,189 @@ +package webapp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rsgit.ru/rsmon/rsmon/internal/distworker" +) + +// _ = assert is a guard so test_helpers.go stays import-clean when +// only the helpers below are needed. +var _ = assert.New + +// distworkerHTTPConfig is a local alias for distworker.HTTPConfig so +// the test stub matches the WorkerView interface signature. +type _ = distworker.HTTPConfig + +// stubRunner is a minimal WorkerView used by tests. RecentResults +// and RecentNotifications return the slices we configured; the rest +// are simple getters. +type stubRunner struct { + id string + region string + version string + caps []string + lastAck time.Time + token string + rotated time.Time + results []ResultRow + notifs []NotificationRow + + // masterUp / masterAt let tests pin a specific + // /api/peer/status response. The default is the zero value + // ("no probe yet" / nil up), matching the runner's pre-Start + // behavior. + masterUp *bool + masterAt time.Time +} + +// distworkerHTTPConfig is a local alias for distworker.HTTPConfig so +// the test stub does not pull in the worker package's full surface. +type distworkerHTTPConfig = distworker.HTTPConfig + +func (s *stubRunner) HTTPConfig() distworker.HTTPConfig { return distworker.HTTPConfig{} } +func (s *stubRunner) Token() string { return s.token } +func (s *stubRunner) TokenRotatedAt() time.Time { return s.rotated } +func (s *stubRunner) WorkerID() string { return s.id } +func (s *stubRunner) RegionCode() string { return s.region } +func (s *stubRunner) WorkerVersion() string { return s.version } +func (s *stubRunner) WorkerCapabilities() []string { return s.caps } +func (s *stubRunner) LastHeartbeatAck() time.Time { return s.lastAck } +func (s *stubRunner) RecentResults(_ int) []ResultRow { return append([]ResultRow{}, s.results...) } +func (s *stubRunner) RecentNotifications(_ int) []NotificationRow { + return append([]NotificationRow{}, s.notifs...) +} +func (s *stubRunner) MasterStatus() (*bool, time.Time) { return s.masterUp, s.masterAt } + +// newTestServer builds an in-memory Server with a stubbed worker +// view and no DB persistence (dataDir is a t.TempDir()). The +// inventory and metrics goroutines are NOT started so tests stay +// hermetic. +func newTestServer(t *testing.T, runner WorkerView) *Server { + t.Helper() + return newTestServerWithBasicAuth(t, runner, "", "") +} + +// newTestServerWithBasicAuth is the basic-auth-aware variant of +// newTestServer. login == "" or password == "" disables basic auth. +func newTestServerWithBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server { + t.Helper() + dir := t.TempDir() + cfg := Config{ + Addr: "127.0.0.1:0", + DataDir: dir, + BasicAuthLogin: login, + BasicAuthPassword: password, + } + srv, err := New(cfg, &Deps{ + Runner: runner, + Version: "test", + BuildDate: "test-build", + StartedAt: time.Now().UTC(), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = srv.Close(context.Background()) + }) + return srv +} + +// newHTTPTestServer wraps srv.Handler in an httptest.Server so tests +// can dial a real loopback port and let cookie jar + redirects work +// naturally. +func newHTTPTestServer(t *testing.T, srv *Server) *httptest.Server { + t.Helper() + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return ts +} + +// httpClient returns a cookie-aware *http.Client that does not +// follow redirects automatically (tests want to see the 302). +func httpClient() *http.Client { + jar, _ := cookiejar.New(nil) + return &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} +} + +// loginAsFirstRun provisions a fresh password via the same path the +// cmd binary uses (first-run hash written to the store, plaintext +// returned), then logs the operator in by POSTing the credentials. +// The cookie jar is returned so callers can re-use the session. +// +// The first-run flow is frictionless: the login lands on /overview +// without bouncing through /web/change-password. The flag is still +// recorded on the user row for a future hardening knob. +func loginAsFirstRun(t *testing.T, baseURL string, srv *Server) (*http.Client, string) { + t.Helper() + plain, err := GenerateFirstRunPassword() + require.NoError(t, err) + hash, err := HashPassword(plain) + require.NoError(t, err) + _, err = srv.store.CreateFirstRunUser(context.Background(), hash) + require.NoError(t, err) + + c := httpClient() + form := url.Values{} + form.Set("password", plain) + req, _ := http.NewRequest(http.MethodPost, baseURL+"/web/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusFound, resp.StatusCode, + "login should redirect on success") + require.Equal(t, "/overview", resp.Header.Get("Location")) + return c, plain +} + +// provisionFirstRunUser seeds the store with a user whose bcrypt hash +// matches the supplied plaintext and whose requires_change flag is +// set. loginAsFirstRun wraps the round trip that consumes the seed. +func provisionFirstRunUser(t *testing.T, srv *Server, plain string) { + t.Helper() + hash, err := HashPassword(plain) + require.NoError(t, err) + _, err = srv.store.CreateFirstRunUser(context.Background(), hash) + require.NoError(t, err) +} + +// clearRequiresChange flips the requires_change flag off after the +// operator has changed their password (in tests we skip the change +// form to assert the regular page flow). +func clearRequiresChange(t *testing.T, srv *Server) { + t.Helper() + user, err := srv.store.GetUser(context.Background()) + require.NoError(t, err) + require.NoError(t, srv.store.MarkRequiresChange(context.Background(), user.ID)) + // MarkRequiresChange sets it to 1; we want it off. Use a direct + // helper. + _, err = srv.store.db.ExecContext(context.Background(), + `UPDATE webapp_users SET requires_change = 0 WHERE id = ?`, user.ID) + require.NoError(t, err) +} + +var ( + _ = os.Getenv + _ = filepath.Join + _ = json.Marshal + _ = bytes.NewReader + _ = io.Copy + _ = fmt.Sprintf +) diff --git a/internal/wire/types.go b/internal/wire/types.go new file mode 100644 index 0000000..88e0190 --- /dev/null +++ b/internal/wire/types.go @@ -0,0 +1,319 @@ +// Package wire contains shared wire format types for control plane <-> worker communication +package wire + +import "encoding/json" + +// RegisterRequest is sent to the control plane registration API. +type RegisterRequest struct { + WorkerID string `json:"worker_id" binding:"required"` + RegionCode string `json:"region_code" binding:"required"` + Version string `json:"version"` + URL string `json:"url,omitempty"` + Capabilities []string `json:"capabilities"` + TaskEnvelope bool `json:"task_envelope"` + Concurrency int `json:"concurrency"` +} + +// RegisterResponse contains the generated auth token. +type RegisterResponse struct { + AuthToken string `json:"auth_token"` + WorkerID string `json:"worker_id"` +} + +// HeartbeatRequest is sent periodically by workers to indicate liveness. +type HeartbeatRequest struct { + ActiveChecks int `json:"active_checks"` + QueueDepth int `json:"queue_depth"` + ActiveNotifications int `json:"active_notifications,omitempty"` + NotificationQueueDepth int `json:"notification_queue_depth,omitempty"` +} + +// CheckJob represents a check assigned to a worker +type CheckJob struct { + JobID string `json:"job_id"` // unique job identifier + LeaseToken string `json:"lease_token,omitempty"` + CheckID int64 `json:"check_id"` + MonitorID int64 `json:"monitor_id"` + Kind string `json:"kind"` // "http", "ssl", etc. + Host string `json:"host"` + URL *string `json:"url"` + Interval int `json:"interval"` + Settings json.RawMessage `json:"settings"` // CheckSettings JSON +} + +// JobsResponse contains a batch of check jobs for a worker +type JobsResponse struct { + Jobs []CheckJob `json:"jobs"` +} + +// CheckResultReport is sent by workers to report check results +type CheckResultReport struct { + JobID string `json:"job_id"` + LeaseToken string `json:"lease_token,omitempty"` + CheckID int64 `json:"check_id"` + MonitorID int64 `json:"monitor_id"` + State string `json:"state"` // "OK", "ERR", "WARN", "FAIL" + Error *string `json:"error"` + Warnings []string `json:"warnings"` + Infos []string `json:"infos"` + DurationMs int64 `json:"duration_ms"` + ExpiresAt *string `json:"expires_at"` // RFC3339 if set + Metrics []MetricPoint `json:"metrics,omitempty"` +} + +// MetricPoint is a TSDB point reported by a worker for control-plane persistence. +type MetricPoint struct { + Metric string `json:"metric"` + Tags map[string]string `json:"tags"` + Fields map[string]interface{} `json:"fields"` +} + +// ServerMetricReport is a worker-local host snapshot. Workers never receive +// database credentials: the control plane validates worker/server ownership +// then persists this cache and its TSDB points. +type ServerMetricReport struct { + ServerID int64 `json:"server_id"` + CPUPercent *float64 `json:"cpu_percent,omitempty"` + MemUsed *int64 `json:"mem_used,omitempty"` + MemTotal *int64 `json:"mem_total,omitempty"` + DiskUsed *int64 `json:"disk_used,omitempty"` + DiskTotal *int64 `json:"disk_total,omitempty"` + NetRx *int64 `json:"net_rx,omitempty"` + NetTx *int64 `json:"net_tx,omitempty"` + HostUptimeSec *int64 `json:"host_uptime_sec,omitempty"` + Load1 *float64 `json:"load1,omitempty"` + Load5 *float64 `json:"load5,omitempty"` + Load15 *float64 `json:"load15,omitempty"` + ProcessCount *int `json:"process_count,omitempty"` + Processes []ProcessMetric `json:"processes,omitempty"` + Networks []NetworkMetric `json:"networks,omitempty"` +} + +// ProcessMetric and NetworkMetric are bounded diagnostic snapshots, not a +// second time series. The worker enforces their limits before sending them. +type ProcessMetric struct { + PID int `json:"pid"` + Name string `json:"name"` + CPUPercent float64 `json:"cpu_percent"` + MemoryRSS int64 `json:"memory_rss"` +} + +type NetworkMetric struct { + Interface string `json:"interface"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` +} + +// ResultsRequest contains multiple check results being reported +type ResultsRequest struct { + Results []CheckResultReport `json:"results"` +} + +// LLMConfig is an LLM endpoint configuration sent to workers. +type LLMConfig struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Model string `json:"model"` + APIKey string `json:"api_key,omitempty"` + Kind string `json:"kind"` +} + +// SMTPCredential is the wire form of an SMTP notification credential pushed +// to workers via the init/config refresh. See docs/worker-protocol.md +// "Credentials Push". +type SMTPCredential struct { + ID int64 `json:"id"` + Name string `json:"name"` + Server string `json:"server"` + Port int `json:"port"` + Login string `json:"login"` + Password string `json:"password"` + FromName string `json:"from_name"` + FromAddress string `json:"from_address"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` +} + +// TelegramCredential is the wire form of a Telegram bot credential pushed +// to workers via the init/config refresh. +type TelegramCredential struct { + ID int64 `json:"id"` + Name string `json:"name"` + BotName string `json:"bot_name"` + Token string `json:"token"` + APIURL string `json:"api_url"` +} + +// WebhookCredential is the wire form of the shared webhook signing secret +// (one global secret per environment; the contact URL is per-task). Phase 4 +// will move to per-account secrets. +type WebhookCredential struct { + SigningSecret string `json:"signing_secret"` +} + +// MattermostCredential is the wire form of the shared Mattermost default +// webhook. Per-account credentials (Phase 4) override this when present. +type MattermostCredential struct { + DefaultUsername string `json:"default_username,omitempty"` + DefaultIconURL string `json:"default_icon_url,omitempty"` +} + +// NotificationCredentials groups all notification credentials the worker +// is allowed to use, pushed via init.credentials (and every 5m config refresh). +// The shape matches the per-method arrays shown in docs/worker-protocol.md +// "Credentials Push" so the worker can resolve a credential by method directly. +type NotificationCredentials struct { + SMTP []SMTPCredential `json:"smtp,omitempty"` + Telegram []TelegramCredential `json:"telegram,omitempty"` + Webhook *WebhookCredential `json:"webhook,omitempty"` + Mattermost *MattermostCredential `json:"mattermost,omitempty"` +} + +// SystemContact is the wire form of a Contact flagged is_system=true. +// Workers notify these contacts directly when the main API is +// unreachable, bypassing the central tasks queue. See +// docs/distributed/notifications-from-worker.md "System Selfcheck". +type SystemContact struct { + ID int64 `json:"id"` + Kind string `json:"kind"` // "email", "telegram_private", "telegram_group" + Value string `json:"value"` // email address or numeric telegram chat id + Name string `json:"name"` +} + +// PeerInfo is the wire form of a peer worker this worker can reach +// over HTTP for cross-worker confirmation (see +// docs/distributed/worker-to-worker-raft.md and +// docs/plans/network-diagnostics-partial.md §4.1). The control plane +// pushes one entry per other active worker via WorkerInit.Peers; the +// current worker is excluded so a worker never dials itself. +// +// Login/Password are populated only when the peer has basic auth +// configured (WORKER_LOGIN / WORKER_PASSWORD). They are the same +// shared secret every worker is configured with locally, so pushing +// them per-peer is safe inside a trusted control-plane / worker fleet +// and avoids the per-peer registration ceremony. The fields are +// omitted from JSON when empty so a legacy control plane that does +// not yet fill them in still produces a wire-compatible payload. +type PeerInfo struct { + WorkerID string `json:"worker_id"` + URL string `json:"url"` + RegionCode string `json:"region_code,omitempty"` + Login string `json:"login,omitempty"` + Password string `json:"password,omitempty"` +} + +// WorkerInit is sent by the control plane after websocket authentication. +type WorkerInit struct { + WorkerID string `json:"worker_id"` + RegionCode string `json:"region_code"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + NotificationMethods []string `json:"notification_methods,omitempty"` + NotificationAccounts []int64 `json:"notification_accounts,omitempty"` + Concurrency int `json:"concurrency"` + URL string `json:"url,omitempty"` + ServerID *int64 `json:"server_id,omitempty"` + LLMs []LLMConfig `json:"llms,omitempty"` + Credentials *NotificationCredentials `json:"credentials,omitempty"` + SystemContacts []SystemContact `json:"system_contacts,omitempty"` + // Peers lists the other active workers this node can reach over + // HTTP for cross-worker confirmation / Raft-style consensus on + // the master API selfcheck. The current worker is excluded by + // the control plane; a worker that receives an empty list treats + // the selfcheck as a single-node decision (no peer polling). + Peers []PeerInfo `json:"peers,omitempty"` +} + +// TaskEnvelope is the task frame the control plane sends on the worker +// websocket. It is a tagged union over (check | notification); the existing +// check shape is preserved so workers built against the original CheckJob +// envelope keep working until they are rebuilt. +// +// Phase 1 of docs/plans/worker-notifier-mvp.md only emits NotificationTask +// envelopes; the check kind remains the legacy Task CheckJob path. +type TaskEnvelope struct { + Type string `json:"type"` // "check" | "notification" + JobID string `json:"job_id"` // mirrors the inner Job.ID for dispatch + Job *CheckJob `json:"check,omitempty"` // check payload (existing path) + Notify *NotificationTask `json:"notification,omitempty"` // notification payload (new path) +} + +const ( + TaskTypeCheck = "check" + TaskTypeNotification = "notification" +) + +// NotificationTask is the wire shape of one notification delivery attempt. +// It carries everything the worker needs to render and send one message +// without going back to the database: pre-rendered subject/body, the contact +// endpoint, and the notification/monitor context for audit logging. +type NotificationTask struct { + JobID string `json:"job_id"` + LeaseToken string `json:"lease_token,omitempty"` + AccountID int64 `json:"account_id"` + MessageID int64 `json:"message_id"` + NotificationID int64 `json:"notification_id"` + EventIDs []int64 `json:"event_ids"` + CheckID *int64 `json:"check_id,omitempty"` + MonitorID *int64 `json:"monitor_id,omitempty"` + CredentialID *int64 `json:"credential_id,omitempty"` + Method string `json:"method"` // "email" | "telegram" | "webhook" | "mattermost" | "sms" | "voice" + Contact NotificationContact `json:"contact"` + Subject string `json:"subject"` + BodyText string `json:"body_text"` + BodyMarkdown string `json:"body_markdown"` + BodyHTML string `json:"body_html"` + Language string `json:"language,omitempty"` + MessageKind string `json:"message_kind"` // "down" | "up" | "exp" | "test" + Deadline *string `json:"deadline,omitempty"` +} + +// NotificationContact is the wire form of Contact. Kept minimal so the +// worker can deliver without pulling additional DB rows. +type NotificationContact struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Value string `json:"value"` + Name string `json:"name"` +} + +// NotificationResultReport is the wire shape returned by the worker after +// attempting one NotificationTask. Status drives the control-plane result +// handler (see docs/plans/worker-notifier-mvp.md section 5.3). +type NotificationResultReport struct { + JobID string `json:"job_id"` + LeaseToken string `json:"lease_token,omitempty"` + MessageID int64 `json:"message_id"` + Status string `json:"status"` // "delivered" | "retryable" | "permanent" | "partial" + ProviderResponse *string `json:"provider_response,omitempty"` + DurationMs int `json:"duration_ms"` + Error *string `json:"error,omitempty"` + RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"` +} + +// Notification result status enums. Mirror the wire JSON values so the +// control-plane handler and the worker executor can speak the same language +// without translating strings. +const ( + NotificationResultDelivered = "delivered" + NotificationResultRetryable = "retryable" + NotificationResultPermanent = "permanent" + NotificationResultPartial = "partial" +) + +// WorkerMessage is the websocket envelope used by control plane and workers. +// TaskEnvelope carries normal generic tasks. The legacy Task and +// NotificationTask fields remain accepted by workers during rollout. +type WorkerMessage struct { + Kind string `json:"kind"` + Init *WorkerInit `json:"init,omitempty"` + Task *CheckJob `json:"task,omitempty"` + TaskEnvelope *TaskEnvelope `json:"task_envelope,omitempty"` + NotificationTask *NotificationTask `json:"notification_task,omitempty"` + Event *json.RawMessage `json:"event,omitempty"` + Result *CheckResultReport `json:"result,omitempty"` + NotificationResult *NotificationResultReport `json:"notification_result,omitempty"` + ServerMetric *ServerMetricReport `json:"server_metric,omitempty"` + Heartbeat *HeartbeatRequest `json:"heartbeat,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/internal/wire/types_test.go b/internal/wire/types_test.go new file mode 100644 index 0000000..914e883 --- /dev/null +++ b/internal/wire/types_test.go @@ -0,0 +1,305 @@ +package wire + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWorkerInit_CredentialsJSON verifies that WorkerInit with Credentials +// round-trips through JSON with the expected nested structure. +func TestWorkerInit_CredentialsJSON(t *testing.T) { + init := WorkerInit{ + WorkerID: "worker-1", + RegionCode: "ru", + Version: "v1", + Concurrency: 4, + Credentials: &NotificationCredentials{ + SMTP: []SMTPCredential{{ + ID: 11, + Name: "primary", + Server: "smtp.example.com", + Port: 587, + Login: "alerts@example.com", + Password: "smtp-password-xyz", + FromName: "RSMon Alerts", + FromAddress: "alerts@example.com", + }}, + Telegram: []TelegramCredential{{ + ID: 22, + Name: "main-bot", + BotName: "rsmon_alerts_bot", + Token: "bot-token-9876543210:ABCDEFG", + APIURL: "https://api.telegram.org", + }}, + }, + } + + data, err := json.Marshal(init) + require.NoError(t, err) + + out := string(data) + assert.Contains(t, out, `"credentials":{`, + "credentials must serialize as a top-level object, got %s", out) + assert.Contains(t, out, `"smtp":[`, + "credentials.smtp must be an array, got %s", out) + assert.Contains(t, out, `"telegram":[`, + "credentials.telegram must be an array, got %s", out) + assert.Contains(t, out, `"smtp-password-xyz"`, + "smtp password must round-trip, got %s", out) + assert.Contains(t, out, `"bot-token-9876543210:ABCDEFG"`, + "telegram token must round-trip, got %s", out) + + var decoded WorkerInit + require.NoError(t, json.Unmarshal(data, &decoded)) + require.NotNil(t, decoded.Credentials, + "Credentials must unmarshal back into a non-nil pointer") + require.Len(t, decoded.Credentials.SMTP, 1) + require.Len(t, decoded.Credentials.Telegram, 1) + assert.Equal(t, "primary", decoded.Credentials.SMTP[0].Name) + assert.Equal(t, "smtp-password-xyz", decoded.Credentials.SMTP[0].Password) + assert.Equal(t, 587, decoded.Credentials.SMTP[0].Port) + assert.Equal(t, "main-bot", decoded.Credentials.Telegram[0].Name) + assert.Equal(t, "bot-token-9876543210:ABCDEFG", decoded.Credentials.Telegram[0].Token) +} + +// TestWorkerInit_CredentialsOmittedWhenNil verifies the omitempty contract: +// a WorkerInit with no credentials must not serialize the credentials key. +func TestWorkerInit_CredentialsOmittedWhenNil(t *testing.T) { + init := WorkerInit{WorkerID: "w-1", Concurrency: 1} + data, err := json.Marshal(init) + require.NoError(t, err) + assert.False(t, strings.Contains(string(data), `"credentials"`), + "credentials key must be omitted when nil, got %s", string(data)) +} + +// TestNotificationCredentials_EmptySlicesOmitted ensures both inner slices +// honor omitempty so an empty config is a compact object. +func TestNotificationCredentials_EmptySlicesOmitted(t *testing.T) { + creds := NotificationCredentials{} + data, err := json.Marshal(creds) + require.NoError(t, err) + out := string(data) + assert.False(t, strings.Contains(out, `"smtp"`), + "empty smtp slice must be omitted, got %s", out) + assert.False(t, strings.Contains(out, `"telegram"`), + "empty telegram slice must be omitted, got %s", out) +} + +// TestWorkerInit_URLRoundTrip verifies that the URL field added in Task 2 +// round-trips through JSON in both directions and honors omitempty when +// empty so legacy workers that do not push a URL stay wire-compatible. +func TestWorkerInit_URLRoundTrip(t *testing.T) { + init := WorkerInit{ + WorkerID: "worker-1", + RegionCode: "ru", + Version: "v1", + Concurrency: 4, + URL: "https://worker-eu.example.com", + } + + data, err := json.Marshal(init) + require.NoError(t, err) + assert.Contains(t, string(data), `"url":"https://worker-eu.example.com"`, + "URL must serialize as a top-level url field, got %s", string(data)) + + var decoded WorkerInit + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, "https://worker-eu.example.com", decoded.URL, + "URL must round-trip back into the WorkerInit struct") +} + +// TestWorkerInit_URLOmittedWhenEmpty ensures the omitempty contract for +// the URL field: a worker that does not yet advertise a URL must not +// push an empty url key to the control plane. +func TestWorkerInit_URLOmittedWhenEmpty(t *testing.T) { + init := WorkerInit{WorkerID: "w-1", Concurrency: 1} + data, err := json.Marshal(init) + require.NoError(t, err) + assert.False(t, strings.Contains(string(data), `"url"`), + "empty URL must be omitted from the JSON envelope, got %s", string(data)) +} + +// TestRegisterRequest_URLRoundTrip mirrors the init test for the +// registration payload sent at POST /api/internal/workers/register. +func TestRegisterRequest_URLRoundTrip(t *testing.T) { + req := RegisterRequest{ + WorkerID: "worker-eu-1", + RegionCode: "eu", + Version: "v1", + URL: "https://worker-eu.example.com", + } + + data, err := json.Marshal(req) + require.NoError(t, err) + assert.Contains(t, string(data), `"url":"https://worker-eu.example.com"`, + "URL must serialize into the register payload, got %s", string(data)) + + var decoded RegisterRequest + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, "https://worker-eu.example.com", decoded.URL) +} + +// TestRegisterRequest_URLOmittedWhenEmpty guards the same wire +// compatibility for the register payload. +func TestRegisterRequest_URLOmittedWhenEmpty(t *testing.T) { + req := RegisterRequest{WorkerID: "w-1", RegionCode: "ru"} + data, err := json.Marshal(req) + require.NoError(t, err) + assert.False(t, strings.Contains(string(data), `"url"`), + "empty URL must be omitted from the register payload, got %s", string(data)) +} + +// TestWorkerInit_NotificationCapabilitiesRoundTrip ensures the +// notification_methods + notification_accounts fields added by +// docs/plans/worker-notifier-mvp.md section 5.4 round-trip through JSON so +// the worker can read them after the init push. +func TestWorkerInit_NotificationCapabilitiesRoundTrip(t *testing.T) { + init := WorkerInit{ + WorkerID: "w-1", + RegionCode: "production", + Concurrency: 8, + NotificationMethods: []string{"email", "telegram", "webhook", "mattermost"}, + NotificationAccounts: []int64{7, 8, 9}, + } + data, err := json.Marshal(init) + require.NoError(t, err) + body := string(data) + assert.Contains(t, body, `"notification_methods":["email","telegram","webhook","mattermost"]`) + assert.Contains(t, body, `"notification_accounts":[7,8,9]`) + + var decoded WorkerInit + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, init.NotificationMethods, decoded.NotificationMethods) + assert.Equal(t, init.NotificationAccounts, decoded.NotificationAccounts) +} + +// TestWorkerInit_NotificationCapabilitiesOmittedWhenEmpty guards the +// omitempty contract for the new fields so a worker that does not advertise +// notification capabilities stays wire-compatible. +func TestWorkerInit_NotificationCapabilitiesOmittedWhenEmpty(t *testing.T) { + init := WorkerInit{WorkerID: "w-1", RegionCode: "ru", Concurrency: 1} + data, err := json.Marshal(init) + require.NoError(t, err) + assert.False(t, strings.Contains(string(data), `"notification_methods"`)) + assert.False(t, strings.Contains(string(data), `"notification_accounts"`)) +} + +// TestNotificationTask_RoundTrip verifies the new notification task payload +// carries the full envelope (contact, method, pre-rendered body) so the +// worker can deliver without touching the database. +func TestNotificationTask_RoundTrip(t *testing.T) { + task := NotificationTask{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + AccountID: 42, + MessageID: 12345, + NotificationID: 678, + EventIDs: []int64{987, 988}, + CredentialID: int64Ptr(77), + Method: "email", + Contact: NotificationContact{ + ID: 1, + Kind: "email", + Value: "ops@example.com", + Name: "Ops on-call", + }, + Subject: "[rsmon] example.com is down", + BodyText: "Down since 2026-06-24T12:00Z.", + BodyMarkdown: "**Down** since 2026-06-24T12:00Z.", + BodyHTML: "

Down since 2026-06-24T12:00Z.

", + Language: "en", + MessageKind: "down", + } + + data, err := json.Marshal(task) + require.NoError(t, err) + + var decoded NotificationTask + require.NoError(t, json.Unmarshal(data, &decoded)) + + assert.Equal(t, task.JobID, decoded.JobID) + assert.Equal(t, task.AccountID, decoded.AccountID) + assert.Equal(t, task.MessageID, decoded.MessageID) + assert.Equal(t, task.Method, decoded.Method) + assert.Equal(t, task.CredentialID, decoded.CredentialID) + assert.Equal(t, task.Contact, decoded.Contact) + assert.Equal(t, task.Subject, decoded.Subject) + assert.Equal(t, task.BodyHTML, decoded.BodyHTML) + assert.Equal(t, task.EventIDs, decoded.EventIDs) + assert.Equal(t, task.MessageKind, decoded.MessageKind) +} + +func int64Ptr(v int64) *int64 { + return &v +} + +// TestNotificationResultReport_RoundTrip ensures the result report carries +// every field the control-plane result handler relies on (status, +// retry_after, provider_response). +func TestNotificationResultReport_RoundTrip(t *testing.T) { + respStr := "250 OK id=1234" + errStr := "smtp 421 retry-after: 60" + retry := 60 + report := NotificationResultReport{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + MessageID: 12345, + Status: "retryable", + ProviderResponse: &respStr, + DurationMs: 240, + Error: &errStr, + RetryAfterSeconds: &retry, + } + + data, err := json.Marshal(report) + require.NoError(t, err) + body := string(data) + assert.Contains(t, body, `"status":"retryable"`) + assert.Contains(t, body, `"retry_after_seconds":60`) + assert.Contains(t, body, `"duration_ms":240`) + + var decoded NotificationResultReport + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, report.JobID, decoded.JobID) + assert.Equal(t, report.Status, decoded.Status) + assert.Equal(t, "250 OK id=1234", *decoded.ProviderResponse) + require.NotNil(t, decoded.RetryAfterSeconds) + assert.Equal(t, 60, *decoded.RetryAfterSeconds) + require.NotNil(t, decoded.Error) + assert.Equal(t, errStr, *decoded.Error) +} + +// TestWorkerMessage_TaskEnvelopeBranches confirms both the rollout-compatible +// legacy CheckJob frame and the generic task envelope. +func TestWorkerMessage_TaskEnvelopeBranches(t *testing.T) { + legacy := WorkerMessage{ + Kind: "task", + Task: &CheckJob{JobID: "job-1", CheckID: 11, Kind: "http", Host: "example.com"}, + } + data, err := json.Marshal(legacy) + require.NoError(t, err) + body := string(data) + assert.Contains(t, body, `"task":{"job_id":"job-1","check_id":11`, + "legacy frame must carry the task payload, got %s", body) + assert.False(t, strings.Contains(body, `"notification_task"`), + "legacy check frame must not surface the notification fields") + assert.False(t, strings.Contains(body, `"notification_result"`), + "legacy check frame must not surface the notification fields") + + notification := WorkerMessage{ + Kind: "task", + TaskEnvelope: &TaskEnvelope{ + Type: TaskTypeNotification, JobID: "job-2", Notify: &NotificationTask{ + JobID: "job-2", Method: "email", MessageKind: "down", Subject: "down", + Contact: NotificationContact{ID: 1, Kind: "email", Value: "ops@example.com"}, + }, + }, + } + data2, err := json.Marshal(notification) + require.NoError(t, err) + assert.Contains(t, string(data2), `"task_envelope":{"type":"notification","job_id":"job-2"`) + assert.False(t, strings.Contains(string(data2), `"check":`), + "notification frame must not surface the legacy check field") +} diff --git a/internal/workdays/workdays.go b/internal/workdays/workdays.go new file mode 100644 index 0000000..8f028df --- /dev/null +++ b/internal/workdays/workdays.go @@ -0,0 +1,54 @@ +// Package workdays provides functionality. +package workdays + +import ( + "strconv" + "time" + + "github.com/rickar/cal/v2" +) + +// NewHoliday provides functionality. +func NewHoliday(month time.Month, day int) *cal.Holiday { + return &cal.Holiday{ + Name: month.String() + " " + strconv.Itoa(day), + Month: month, + Day: day, + Func: cal.CalcDayOfMonth, + } +} + +// GetCalendar provides functionality. +func GetCalendar() *cal.BusinessCalendar { + c := cal.NewBusinessCalendar() + + // add holidays for the business + c.AddHoliday( + NewHoliday(time.January, 1), + NewHoliday(time.January, 2), + NewHoliday(time.January, 3), + NewHoliday(time.January, 4), + NewHoliday(time.January, 5), + NewHoliday(time.January, 6), + NewHoliday(time.January, 7), + NewHoliday(time.January, 8), + NewHoliday(time.February, 23), + NewHoliday(time.March, 8), + NewHoliday(time.May, 1), + NewHoliday(time.May, 9), + NewHoliday(time.June, 12), + NewHoliday(time.November, 4), + NewHoliday(time.December, 30), + NewHoliday(time.December, 31), + ) + + // optionally change the holiday calculation behavior + // (the default is US-style where weekend holidays are + // observed on the closest weekday) + // c.Observed = cal.ObservedExact + + c.SetWorkday(time.Saturday, true) + c.SetWorkday(time.Sunday, true) + + return c +} diff --git a/internal/workercluster/admin_test.go b/internal/workercluster/admin_test.go new file mode 100644 index 0000000..90facc3 --- /dev/null +++ b/internal/workercluster/admin_test.go @@ -0,0 +1,256 @@ +package workercluster + +import ( + "context" + "io" + "log" + "net" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/hashicorp/raft" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClusterSmokeStats is a minimal smoke test that constructs a +// Cluster, calls Stats() before and after bootstrap, and confirms the +// reported values match what we expect. It is intentionally narrow +// (no Apply, no leader election) so it can run on every CI worker +// without the rafthttp transport overhead. +func TestClusterSmokeStats(t *testing.T) { + addr := pickPort(t) + dataDir := t.TempDir() + creds := HTTPCreds{Login: "alice", Password: "secret"} + + c, err := New(&Options{ + NodeID: "smoke-1", + LocalAddr: addr, + DataDir: dataDir, + Creds: creds, + Bootstrap: true, + // Tight timeouts so the test stays sub-second even on slow CI. + HeartbeatTimeout: 200 * time.Millisecond, + ElectionTimeout: 600 * time.Millisecond, + LogOutput: io.Discard, + Logger: log.New(io.Discard, "", 0), + }) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, c.Start(ctx)) + t.Cleanup(func() { + shut, cancelShut := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShut() + _ = c.Shutdown(shut) + }) + + require.NoError(t, waitForLeader(ctx, c, 5*time.Second)) + + stats := c.Stats() + assert.Equal(t, "smoke-1", stats.NodeID) + assert.Equal(t, addr, stats.LocalAddr) + // Stats().Leader is the raft ServerAddress (host:port); the + // webapp-friendly NodeID lookup happens in ClusterStats. + assert.Equal(t, addr, stats.Leader, + "Stats().Leader is the raft address of the leader") + assert.Equal(t, raft.Leader.String(), stats.State) + assert.NotZero(t, stats.Term, "term should advance after the first election") + + clusterStats := c.ClusterStats() + assert.Equal(t, "smoke-1", clusterStats.NodeID) + assert.Equal(t, "smoke-1", clusterStats.Leader, + "ClusterStats translates the leader address back to the worker NodeID") + assert.Contains(t, clusterStats.Voters, "smoke-1", + "voter list must include the local node") + assert.Equal(t, 0, clusterStats.FSMChecks, "no config.adopt applied yet") + + assert.Equal(t, "smoke-1", c.ClusterID()) + assert.Equal(t, addr, c.LocalAddr()) +} + +// TestClusterApplyTestConfig_Smoke verifies the ApplyTestConfig helper +// commits a config.adopt entry and the FSM reflects the new version. +// This is the function the webapp admin endpoint and the CLI flag +// both go through. +func TestClusterApplyTestConfig_Smoke(t *testing.T) { + addr := pickPort(t) + dataDir := t.TempDir() + creds := HTTPCreds{Login: "alice", Password: "secret"} + + c, err := New(&Options{ + NodeID: "smoke-2", + LocalAddr: addr, + DataDir: dataDir, + Creds: creds, + Bootstrap: true, + // Tight timeouts so the test stays sub-second. + HeartbeatTimeout: 200 * time.Millisecond, + ElectionTimeout: 600 * time.Millisecond, + LogOutput: io.Discard, + Logger: log.New(io.Discard, "", 0), + }) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, c.Start(ctx)) + t.Cleanup(func() { + shut, cancelShut := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShut() + _ = c.Shutdown(shut) + }) + + require.NoError(t, waitForLeader(ctx, c, 5*time.Second)) + require.Equal(t, raft.Leader, c.Raft().State(), + "smoke test requires the local node to be leader") + + check := DefaultDebugCriticalCheck() + idx, err := c.ApplyTestConfig(&check) + require.NoError(t, err) + assert.NotZero(t, idx, "applied index must be non-zero") + + fsmStats := c.FSM().Stats() + assert.EqualValues(t, 1, fsmStats.ConfigVersion, "fsm should have adopted one config version") + assert.Equal(t, 1, fsmStats.ConfigCount, "fsm should hold exactly one critical check") + + clusterStats := c.ClusterStats() + assert.Equal(t, 1, clusterStats.FSMChecks, + "ClusterStats().FSMChecks must mirror FSM().Stats().ConfigCount") + assert.EqualValues(t, 1, clusterStats.FSMConfigVersion, + "ClusterStats().FSMConfigVersion must mirror FSM().Stats().ConfigVersion") + assert.Equal(t, 0, clusterStats.FSMOutboxLen, + "ClusterStats().FSMOutboxLen must mirror FSM().Stats().OutboxLen (empty here)") + assert.Equal(t, "steady", clusterStats.FSMPartition, + "ClusterStats().FSMPartition must mirror FSM().Stats().Partition") +} + +// TestClusterStats_FSMFieldsOnFreshCluster pins the FSM-side fields +// on a fresh, never-applied cluster to the documented zero values +// (ConfigVersion=0, OutboxLen=0, Partition=steady). The JSON endpoint +// relies on these being deterministic so a freshly bootstrapped cluster +// does not surprise operators with stale defaults. +func TestClusterStats_FSMFieldsOnFreshCluster(t *testing.T) { + addr := pickPort(t) + c, err := New(&Options{ + NodeID: "fresh-fsm", + LocalAddr: addr, + DataDir: t.TempDir(), + Creds: HTTPCreds{Login: "alice", Password: "secret"}, + Bootstrap: true, + HeartbeatTimeout: 200 * time.Millisecond, + ElectionTimeout: 600 * time.Millisecond, + LogOutput: io.Discard, + Logger: log.New(io.Discard, "", 0), + }) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, c.Start(ctx)) + t.Cleanup(func() { + shut, cancelShut := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShut() + _ = c.Shutdown(shut) + }) + require.NoError(t, waitForLeader(ctx, c, 5*time.Second)) + + cs := c.ClusterStats() + assert.EqualValues(t, 0, cs.FSMConfigVersion, "no config.adopt applied yet") + assert.Equal(t, 0, cs.FSMOutboxLen, "no outbox entries yet") + assert.Equal(t, "steady", cs.FSMPartition, + "newly created FSM defaults Partition.State to 'steady'") +} + +// TestApplyTestConfig_NonLeaderErrors pins the precondition that the +// helper refuses to submit an entry on a non-leader (the raft library +// would reject the apply anyway, but we want the failure to be +// deterministic and informative). +func TestApplyTestConfig_NonLeaderErrors(t *testing.T) { + // Three-node fixture so we have a clear "not the leader" node. + if testing.Short() { + t.Skip("3-node smoke skipped in -short mode") + } + f := newClusterFixture(t, 3) + require.NoError(t, waitForLeader(context.Background(), f.Nodes[0], 5*time.Second)) + + var leader, follower *Cluster + for _, n := range f.Nodes { + if n.Raft().State() == raft.Leader { + leader = n + } else { + follower = n + } + } + require.NotNil(t, leader) + require.NotNil(t, follower) + + check := DefaultDebugCriticalCheck() + _, err := follower.ApplyTestConfig(&check) + require.Error(t, err, "non-leader must refuse ApplyTestConfig") + assert.Contains(t, strings.ToLower(err.Error()), "not leader") +} + +// TestClusterStats_VotersIsFreshEachCall is a tiny regression guard: +// the Voters slice returned by ClusterStats must be a fresh slice on +// every call (the Stats.Members backing store is mutated by FSM +// Apply calls). If we accidentally return the backing slice directly, +// the JSON handler would race with raft. +func TestClusterStats_VotersIsFreshEachCall(t *testing.T) { + addr := pickPort(t) + c, err := New(&Options{ + NodeID: "fresh-1", + LocalAddr: addr, + DataDir: t.TempDir(), + Creds: HTTPCreds{Login: "alice", Password: "secret"}, + Bootstrap: true, + HeartbeatTimeout: 200 * time.Millisecond, + ElectionTimeout: 600 * time.Millisecond, + LogOutput: io.Discard, + Logger: log.New(io.Discard, "", 0), + }) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, c.Start(ctx)) + t.Cleanup(func() { + shut, cancelShut := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShut() + _ = c.Shutdown(shut) + }) + require.NoError(t, waitForLeader(ctx, c, 5*time.Second)) + + s1 := c.ClusterStats() + s2 := c.ClusterStats() + require.NotEmpty(t, s1.Voters) + require.NotEmpty(t, s2.Voters) + + // Mutating one must not affect the other. + original := s1.Voters[0] + s1.Voters[0] = "MUTATED" + assert.Equal(t, original, s2.Voters[0], + "Voters slices must not share backing storage") +} + +// keep atomic referenced so the import isn't flagged on minimal edits. +var _ atomic.Int32 + +// ensureLocalAddrIsLoopback is a build-tag helper that the demo and +// other helpers can call to confirm we never accidentally bind a +// public address. +func ensureLocalAddrIsLoopback(addr string) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return err + } + if !strings.HasPrefix(host, "127.") && host != "::1" { + return &loopbackErr{host: host} + } + return nil +} + +type loopbackErr struct{ host string } + +func (e *loopbackErr) Error() string { return "non-loopback host: " + e.host } diff --git a/internal/workercluster/bootstrap.go b/internal/workercluster/bootstrap.go new file mode 100644 index 0000000..cfe1b6b --- /dev/null +++ b/internal/workercluster/bootstrap.go @@ -0,0 +1,119 @@ +package workercluster + +import ( + "context" + "fmt" + "sync" + "time" +) + +// BootstrapResult is the outcome of a Bootstrap call. +type BootstrapResult struct { + // Leader is the node id that ended up holding leadership when + // bootstrap finished. For a 1-node bootstrap this is always the + // local node. + Leader string + + // Voters is the final voter set as observed on the local node. + Voters []string +} + +// Bootstrap starts a fresh cluster from the first node. It is a +// convenience wrapper around New + Start + BootstrapCluster for the +// common single-node bootstrap case. Production code that needs +// custom timeouts should call New / Start directly. +func Bootstrap(ctx context.Context, opts *Options) (*Cluster, BootstrapResult, error) { + opts.Bootstrap = true + opts.Seed = Peer{} + c, err := New(opts) + if err != nil { + return nil, BootstrapResult{}, err + } + if err := c.Start(ctx); err != nil { + return nil, BootstrapResult{}, err + } + + if err := waitForLeader(ctx, c, 10*time.Second); err != nil { + _ = c.Shutdown(ctx) + return nil, BootstrapResult{}, err + } + + stats := c.Stats() + return c, BootstrapResult{Leader: stats.Leader, Voters: votersFromStats(&stats)}, nil +} + +// JoinCluster brings up a new node that joins an existing cluster via +// the given seed peer. It blocks until the local node is a voter in +// the raft configuration. +func JoinCluster(ctx context.Context, opts *Options, seed Peer) (*Cluster, error) { + opts.Bootstrap = false + opts.Seed = seed + c, err := New(opts) + if err != nil { + return nil, err + } + if err := c.Start(ctx); err != nil { + return nil, err + } + if err := waitForLeader(ctx, c, 15*time.Second); err != nil { + _ = c.Shutdown(ctx) + return nil, err + } + return c, nil +} + +// waitForLeader blocks until the cluster has a leader (or ctx is +// canceled, or timeout elapses). +func waitForLeader(ctx context.Context, c *Cluster, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + stats := c.Stats() + if stats.Leader != "" { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("workercluster: no leader after %s (state=%s)", timeout, stats.State) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +// votersFromStats returns the voter WorkerIDs from the cluster stats. +// It is best-effort: the FSM membership cache is the source of truth. +func votersFromStats(s *Stats) []string { + out := make([]string, 0, len(s.Members)) + for _, m := range s.Members { + if m.Role == RoleVoter { + out = append(out, m.WorkerID) + } + } + return out +} + +// Parallel starts the given cluster starts concurrently and returns +// once all of them have completed (or the first one errors). +func Parallel(starts ...func() error) error { + var wg sync.WaitGroup + errs := make(chan error, len(starts)) + for _, fn := range starts { + wg.Add(1) + go func(fn func() error) { + defer wg.Done() + if err := fn(); err != nil { + errs <- err + } + }(fn) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + return err + } + } + return nil +} diff --git a/internal/workercluster/cluster.go b/internal/workercluster/cluster.go new file mode 100644 index 0000000..1a7fe7f --- /dev/null +++ b/internal/workercluster/cluster.go @@ -0,0 +1,636 @@ +package workercluster + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "sync" + "time" + + rafthttp "github.com/CanonicalLtd/raft-http" + raftmembership "github.com/CanonicalLtd/raft-membership" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/raft" +) + +// Peer is one other worker this node knows about at bootstrap time. It +// is also the shape used by callers that drive membership changes +// outside the bootstrap path. +type Peer struct { + WorkerID string + Address string // host:port of the rafthttp endpoint +} + +// Options configures a single Cluster. Defaults are applied for any +// zero-valued field; see applyDefaults for the rules. +type Options struct { + // NodeID is the Raft ServerID this node uses for itself. Required. + NodeID string + + // LocalAddr is the host:port this node binds for the rafthttp + // endpoint. Required. + LocalAddr string + + // DataDir is the directory the bbolt store and snapshot files live + // in. Created if missing. Required. + DataDir string + + // Creds are the basic-auth credentials shared by all voters. They + // gate the inbound rafthttp endpoint and are required on outbound + // dials once a future rafthttp auth hook lands. Required. + Creds HTTPCreds + + // RaftPath is the URL path the rafthttp handler mounts on. Default + // "/raft". + RaftPath string + + // HeartbeatTimeout / ElectionTimeout control the raft timing. + // Defaults are 1s / 3s which keeps the e2e tests snappy. + HeartbeatTimeout time.Duration + ElectionTimeout time.Duration + + // Logger is the destination for cluster log lines. Defaults to + // os.Stderr. + Logger *log.Logger + + // Bootstrap seeds a one-voter cluster on first start when no + // existing state is present. Set Bootstrap=true on the first node + // and false on every subsequent node that joins an existing + // cluster via Seed. + Bootstrap bool + + // Seed is the address (host:port of the rafthttp endpoint) of an + // existing voter this node should join before it can become a + // voter itself. Leave empty for the bootstrap node. + Seed Peer + + // LogOutput is the destination for rafthttp log lines. Defaults to + // io.Discard so the test runs stay quiet. + LogOutput io.Writer +} + +// Stats is a small read-only view of the cluster's runtime state. +type Stats struct { + NodeID string + LocalAddr string + State string + Leader string + Term uint64 + AppliedIx uint64 + LastIx uint64 + NumPeers int + Members []Member +} + +// Cluster is the Raft cluster wrapper for one node. It owns the +// bbolt store, the FSM, the rafthttp transport, the raft.Raft instance, +// and the HTTP server. +type Cluster struct { + opts Options + log *log.Logger + + store *BoltStore + fsm *FSM + raft *raft.Raft + layer *rafthttp.Layer + handler *rafthttp.Handler + + listener net.Listener + server *http.Server + + membershipWG sync.WaitGroup + + mu sync.Mutex + closed bool +} + +// New constructs a Cluster but does not start it. Call Start to bind +// the listener and bootstrap / join the Raft group. +func New(opts *Options) (*Cluster, error) { + if err := opts.validate(); err != nil { + return nil, err + } + opts.applyDefaults() + + return &Cluster{ + opts: *opts, + log: opts.Logger, + store: nil, // opened in Start + fsm: NewFSM(), + handler: nil, // built in Start + }, nil +} + +// Start binds the listener, constructs the transport, opens the store, +// and either bootstraps a fresh cluster or joins the seed peer. +func (c *Cluster) Start(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return errors.New("workercluster: cluster is shut down") + } + + listener, err := net.Listen("tcp", c.opts.LocalAddr) + if err != nil { + return fmt.Errorf("workercluster: listen %q: %w", c.opts.LocalAddr, err) + } + c.listener = listener + + store, err := NewBoltStore(c.opts.DataDir) + if err != nil { + _ = listener.Close() + return fmt.Errorf("workercluster: open store: %w", err) + } + c.store = store + + c.handler = rafthttp.NewHandler() + dial := AuthDial(rafthttp.NewDialTCP(), c.opts.Creds) + + authHandler := NewAuthHandler(c.handler, c.opts.Creds, c.log) + + layer, server, err := NewTransport( + c.opts.RaftPath, + listener, + authHandler, + dial, + c.opts.LogOutput, + ) + if err != nil { + _ = store.Close() + _ = listener.Close() + return err + } + c.layer = layer + c.server = server + + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + c.log.Printf("workercluster: http server: %v", err) + } + }() + + transport := raft.NewNetworkTransport( + layer, + 2, + 10*time.Second, + c.opts.LogOutput, + ) + + config := raft.DefaultConfig() + config.LocalID = raft.ServerID(c.opts.NodeID) + config.HeartbeatTimeout = c.opts.HeartbeatTimeout + config.ElectionTimeout = c.opts.ElectionTimeout + // LeaderLeaseTimeout must be < HeartbeatTimeout; if the caller + // overrode the heartbeat to a small value, default the lease to a + // safely smaller value. + if config.LeaderLeaseTimeout == 0 || config.LeaderLeaseTimeout >= config.HeartbeatTimeout { + config.LeaderLeaseTimeout = config.HeartbeatTimeout / 2 + } + config.Logger = hclog.New(&hclog.LoggerOptions{ + Name: "raft", + Output: c.opts.LogOutput, + Level: hclog.DefaultLevel, + }) + + r, err := raft.NewRaft(config, c.fsm, store.LogStore(), store.StableStore(), store.SnapshotStore(), transport) + if err != nil { + return c.shutdownLocked(fmt.Errorf("workercluster: new raft: %w", err)) + } + c.raft = r + + c.membershipWG.Add(1) + go func() { + defer c.membershipWG.Done() + raftmembership.HandleChangeRequests(r, c.handler.Requests()) + }() + + if c.opts.Bootstrap { + if err := c.bootstrapLocked(); err != nil { + return c.shutdownLocked(err) + } + } else if (c.opts.Seed != Peer{}) { + if err := c.joinSeedLocked(ctx); err != nil { + return c.shutdownLocked(err) + } + } + return nil +} + +// Raft returns the underlying *raft.Raft. Exposed for callers that need +// access to Apply, GetConfiguration, Stats, Barrier, Snapshot, etc. +func (c *Cluster) Raft() *raft.Raft { return c.raft } + +// FSM returns the cluster's FSM. Read-only: callers must not mutate the +// FSM directly outside of Apply. +func (c *Cluster) FSM() *FSM { return c.fsm } + +// Stats returns a snapshot of the cluster's runtime state. Safe for +// concurrent callers. +func (c *Cluster) Stats() Stats { + c.mu.Lock() + defer c.mu.Unlock() + + out := Stats{ + NodeID: c.opts.NodeID, + LocalAddr: c.opts.LocalAddr, + Members: membersAsSlice(c.fsm.Membership), + } + if c.raft == nil { + out.State = "uninitialized" + return out + } + out.State = c.raft.State().String() + // Leader is reported as the raft.ServerAddress the library + // returns (a host:port). Callers that want a worker NodeID + // should go through ClusterStats, which does the address→ID + // lookup with a guard against stale leader caches (a follower + // that has not yet observed a new term still reports the old + // leader's address, and we don't want to surface that as the + // canonical leader_id). + out.Leader = string(c.raft.Leader()) + out.AppliedIx = c.raft.AppliedIndex() + out.LastIx = c.raft.LastIndex() + out.Term = parseTerm(c.raft.Stats()["term"]) + if cfgFuture := c.raft.GetConfiguration(); cfgFuture.Error() == nil { + out.NumPeers = len(cfgFuture.Configuration().Servers) + } + + return out +} + +func parseTerm(raw string) uint64 { + var v uint64 + for i := 0; i < len(raw); i++ { + if raw[i] < '0' || raw[i] > '9' { + break + } + v = v*10 + uint64(raw[i]-'0') + } + return v +} + +// Apply submits a log entry to the cluster. Blocks until the entry has +// been applied (or ctx is canceled). +func (c *Cluster) Apply(_ context.Context, payload []byte, timeout time.Duration) error { + c.mu.Lock() + r := c.raft + c.mu.Unlock() + if r == nil { + return errors.New("workercluster: not started") + } + + future := r.Apply(payload, timeout) + return future.Error() +} + +// Join asks the seed peer (or the leader it redirects to) to add this +// node as a voter. +func (c *Cluster) Join(_ context.Context, _ Peer, id raft.ServerID, addr raft.ServerAddress, timeout time.Duration) error { + c.mu.Lock() + layer := c.layer + c.mu.Unlock() + if layer == nil { + return errors.New("workercluster: not started") + } + return layer.Join(id, addr, timeout) +} + +// Leave asks the seed peer (or the leader it redirects to) to remove +// this node from the cluster. +func (c *Cluster) Leave(_ context.Context, peer Peer, id raft.ServerID, timeout time.Duration) error { + c.mu.Lock() + layer := c.layer + c.mu.Unlock() + if layer == nil { + return errors.New("workercluster: not started") + } + return layer.Leave(id, raft.ServerAddress(peer.Address), timeout) +} + +// Snapshot asks the leader to take a snapshot now. The returned error +// is the snapshot future's error. +func (c *Cluster) Snapshot() error { + c.mu.Lock() + r := c.raft + c.mu.Unlock() + if r == nil { + return errors.New("workercluster: not started") + } + return r.Snapshot().Error() +} + +// DefaultDebugCriticalCheck returns the hardcoded CriticalCheckConfig +// the cluster admin debug endpoint and the +// --cluster-debug-apply-test-config CLI flag apply. A fresh Epoch is +// stamped on every call so repeated applies produce distinct entries +// (handy for verifying replication timing). +func DefaultDebugCriticalCheck() CriticalCheckConfig { + return CriticalCheckConfig{ + ID: 9999, + Kind: "distributed_critical", + IntervalS: 30, + Target: "http://example.com", + Epoch: time.Now().UTC().UnixNano(), + } +} + +// ApplyTestConfig submits a hardcoded config.adopt log entry with the +// supplied CriticalCheckConfig. Returns the applied log index. This +// is a debug convenience used by the e2e script and the +// --cluster-debug-apply-test-config CLI flag; production code should +// build entries from the real signed-config-adoption producer +// (Phase-N work). +// +// DEBUG: this exists only so the e2e shell script can verify FSM +// replication without a real producer wired in. +// +// TODO(phase-N): remove once the real producer lands. +func (c *Cluster) ApplyTestConfig(check *CriticalCheckConfig) (uint64, error) { + c.mu.Lock() + r := c.raft + c.mu.Unlock() + if r == nil { + return 0, errors.New("workercluster: not started") + } + if r.State() != raft.Leader { + return 0, errors.New("workercluster: not leader; submit on the leader") + } + + payload := ConfigAdoptPayload{ + Version: 1, + Actor: c.opts.NodeID, + Checks: []CriticalCheckConfig{*check}, + } + raw, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("workercluster: encode payload: %w", err) + } + entry, err := EncodeEntry(&Entry{Type: EntryConfigAdopt, Adopted: raw}) + if err != nil { + return 0, fmt.Errorf("workercluster: encode entry: %w", err) + } + fut := r.Apply(entry, 10*time.Second) + if err := fut.Error(); err != nil { + return 0, fmt.Errorf("workercluster: apply test config: %w", err) + } + return fut.Index(), nil +} + +// ClusterID returns the NodeID this cluster was constructed with. It +// is exposed so HTTP handlers can label status responses with a +// stable identifier even when the raft library's own State() reports +// "uninitialized" (e.g. before bootstrap completes). +func (c *Cluster) ClusterID() string { return c.opts.NodeID } + +// LocalAddr returns the rafthttp bind address this cluster is using. +// Exposed for the cluster status endpoint. +func (c *Cluster) LocalAddr() string { return c.opts.LocalAddr } + +// ClusterStats is the cluster stats shape used by the webapp admin +// handlers. It mirrors the JSON the GET /web/api/cluster/status +// endpoint returns; keeping the mapping in one place stops drift +// between Stats() and the wire format. +// +// FSMConfigVersion / FSMOutboxLen / FSMPartition carry the FSM-side +// operator signals from plan section 6.1 (config_version, outbox +// length, partition_state). They let the JSON endpoint surface +// "what config the cluster has adopted", "how many notifications +// are still pending in the outbox" and "what the cluster thinks of +// network partition state" without the operator having to scrape a +// raft log directly. +type ClusterStats struct { + NodeID string + LocalAddr string + State string + Leader string + Term uint64 + AppliedIndex uint64 + LastIndex uint64 + NumPeers int + Voters []string + FSMChecks int + FSMMembers int + FSMConfigVersion uint64 + FSMOutboxLen int + FSMPartition string +} + +// ClusterStats returns a view of the cluster suitable for the webapp +// admin endpoint. It calls Stats() internally and folds in the FSM +// counters so the handler does not need to know about the FSM type. +func (c *Cluster) ClusterStats() ClusterStats { + s := c.Stats() + fsmStats := c.FSM().Stats() + return ClusterStats{ + NodeID: s.NodeID, + LocalAddr: s.LocalAddr, + State: s.State, + Leader: leaderIDForAddr(c, s.Leader), + Term: s.Term, + AppliedIndex: s.AppliedIx, + LastIndex: s.LastIx, + NumPeers: s.NumPeers, + Voters: votersFromClusterStats(c, &s), + FSMChecks: fsmStats.ConfigCount, + FSMMembers: fsmStats.Members, + FSMConfigVersion: fsmStats.ConfigVersion, + FSMOutboxLen: fsmStats.OutboxLen, + FSMPartition: fsmStats.Partition, + } +} + +// leaderIDForAddr translates a raft.ServerAddress (host:port) the +// library returns for raft.Leader() into a worker NodeID by matching +// against the current raft configuration. The empty string is +// returned unchanged so callers can detect "no leader yet". +// +// IMPORTANT: raft.Leader() on a follower can return the address of +// the previous leader until the follower observes the new term via +// an AppendEntries. We rely on the raft configuration to do the +// translation; the configuration is updated on every membership +// change but lags the Leader() field slightly. Callers that need a +// strict guarantee should treat the result as "best-effort" — it is +// good enough for an operator-facing status endpoint, not for safety +// decisions. +func leaderIDForAddr(c *Cluster, addr string) string { + if addr == "" { + return "" + } + c.mu.Lock() + r := c.raft + c.mu.Unlock() + if r == nil { + return addr + } + cfgFuture := r.GetConfiguration() + if cfgFuture.Error() != nil { + return addr + } + for _, srv := range cfgFuture.Configuration().Servers { + if string(srv.Address) == addr { + return string(srv.ID) + } + } + return addr +} + +// votersFromClusterStats returns the voter WorkerIDs currently +// registered with the raft library. The FSM membership cache is the +// primary source (it mirrors what the library stores, with a denorm +// for fast reads); when the cache is empty (e.g. right after a fresh +// bootstrap, before any membership.propose_add entry has been +// committed) we fall back to the raft configuration directly so the +// endpoint never reports an empty voter list on a healthy cluster. +func votersFromClusterStats(c *Cluster, s *Stats) []string { + out := make([]string, 0, len(s.Members)) + for _, m := range s.Members { + if m.Role == RoleVoter { + out = append(out, m.WorkerID) + } + } + if len(out) > 0 { + return out + } + c.mu.Lock() + r := c.raft + c.mu.Unlock() + if r == nil { + return out + } + cfgFuture := r.GetConfiguration() + if cfgFuture.Error() != nil { + return out + } + for _, srv := range cfgFuture.Configuration().Servers { + if srv.Suffrage == raft.Voter { + out = append(out, string(srv.ID)) + } + } + return out +} + +// Shutdown closes the HTTP server, the rafthttp handler, and the +// underlying raft instance. After Shutdown returns the Cluster cannot be +// reused. +func (c *Cluster) Shutdown(_ context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil + } + return c.shutdownLocked(nil) +} + +// shutdownLocked tears the cluster down. err is returned to the caller. +func (c *Cluster) shutdownLocked(retErr error) error { + c.closed = true + if c.raft != nil { + _ = c.raft.Shutdown().Error() + } + c.membershipWG.Wait() + if c.server != nil { + shutdownErr := c.server.Shutdown(context.Background()) + if shutdownErr != nil && retErr == nil { + retErr = shutdownErr + } + } + if c.store != nil { + _ = c.store.Close() + } + if c.listener != nil { + _ = c.listener.Close() + } + return retErr +} + +func (o *Options) validate() error { + if o.NodeID == "" { + return errors.New("workercluster: NodeID is required") + } + if o.LocalAddr == "" { + return errors.New("workercluster: LocalAddr is required") + } + if o.DataDir == "" { + return errors.New("workercluster: DataDir is required") + } + if !o.Creds.IsConfigured() { + return errors.New("workercluster: Creds must have both login and password set") + } + return nil +} + +func (o *Options) applyDefaults() { + if o.RaftPath == "" { + o.RaftPath = "/raft" + } + if o.HeartbeatTimeout == 0 { + o.HeartbeatTimeout = 1000 * time.Millisecond + } + if o.ElectionTimeout == 0 { + o.ElectionTimeout = 3000 * time.Millisecond + } + if o.Logger == nil { + o.Logger = log.New(os.Stderr, "[workercluster] ", log.LstdFlags) + } + if o.LogOutput == nil { + o.LogOutput = io.Discard + } +} + +// bootstrapLocked creates a one-voter configuration on this node. It is +// only called when opts.Bootstrap is true. +// +//nolint:unparam // signature reserves error return for future pre-bootstrap checks. +func (c *Cluster) bootstrapLocked() error { + cfg := raft.Configuration{ + Servers: []raft.Server{ + { + ID: raft.ServerID(c.opts.NodeID), + Address: raft.ServerAddress(c.opts.LocalAddr), + }, + }, + } + f := c.raft.BootstrapCluster(cfg) + if err := f.Error(); err != nil { + // BootstrapCluster returns an error when the cluster has + // already been bootstrapped in a previous run. That's fine + // for a restart: just keep going and let the existing state + // take over. + c.log.Printf("workercluster: bootstrap: %v (continuing with existing state)", err) + } + return nil +} + +// joinSeedLocked asks the seed peer to add this node as a voter. +// rafthttp.Join(id, addr, timeout): id is OUR ServerID and addr is the +// peer's address we dial. The seed's address is what we want to +// contact; our own address is sent in the URL's address= param. +func (c *Cluster) joinSeedLocked(_ context.Context) error { + if err := c.layer.Join( + raft.ServerID(c.opts.NodeID), + raft.ServerAddress(c.opts.Seed.Address), + 10*time.Second, + ); err != nil { + return fmt.Errorf("workercluster: join seed %s: %w", c.opts.Seed.Address, err) + } + return nil +} + +func membersAsSlice(m map[string]Member) []Member { + out := make([]Member, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} + +// EnsureDataDir creates dir and its parent directories. +func EnsureDataDir(dir string) error { + return os.MkdirAll(dir, 0o750) +} diff --git a/internal/workercluster/demo_test.go b/internal/workercluster/demo_test.go new file mode 100644 index 0000000..0f23441 --- /dev/null +++ b/internal/workercluster/demo_test.go @@ -0,0 +1,212 @@ +package workercluster + +import ( + "context" + "fmt" + "io" + "log" + "net" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/hashicorp/raft" +) + +// TestDemo_Transcript is a focused, single-goroutine end-to-end that +// prints a human-readable transcript of the cluster lifecycle: bootstrap +// node-1, join node-2 and node-3, kill the leader, observe the new +// leader. Used to capture the demo transcript reported back from this +// task. +// +// The transcript is written to stdout when -v is passed or always when +// the demo env var is set, to keep CI logs clean. +func TestDemo_Transcript(t *testing.T) { + if testing.Short() { + t.Skip("skipping demo transcript in -short mode") + } + if os.Getenv("WORKERCLUSTER_DEMO") == "" { + t.Skip("set WORKERCLUSTER_DEMO=1 to run the demo transcript") + } + + var ( + mu sync.Mutex + lines []string + logf = func(format string, args ...interface{}) { + mu.Lock() + defer mu.Unlock() + line := fmt.Sprintf(format, args...) + lines = append(lines, line) + fmt.Println(line) + } + traceOn = true + _ = traceOn + ) + + quietLogger := log.New(io.Discard, "", 0) + + reservePort := func() string { + l, err := net.Listen("tcp", "127.0.0.1:0") + requireNoErr(t, err) + addr := l.Addr().String() + requireNoErr(t, l.Close()) + _, port, err := net.SplitHostPort(addr) + requireNoErr(t, err) + return port + } + + mkOpts := func(nodeID, port string, bootstrap bool, seed Peer) *Options { + return &Options{ + NodeID: nodeID, + LocalAddr: "127.0.0.1:" + port, + DataDir: t.TempDir(), + Creds: HTTPCreds{Login: "alice", Password: "secret"}, + Bootstrap: bootstrap, + Seed: seed, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + Logger: quietLogger, + LogOutput: io.Discard, + } + } + + ctx := context.Background() + + // Bootstrap node-1. + port1 := reservePort() + c1, res, err := Bootstrap(ctx, mkOpts("node-1", port1, true, Peer{})) + requireNoErr(t, err) + t.Cleanup(func() { + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = c1.Shutdown(shut) + }) + leader1ID := leaderIDFromAddress([]*Cluster{c1}, res.Leader) + logf("[t+0.0s] node-1 bootstrapped as voter (addr=127.0.0.1:%s, leader=%s)", port1, leader1ID) + logf("[t+0.0s] initial voters (1): %s", strings.Join(res.Voters, ", ")) + + // Join node-2. + port2 := reservePort() + c2, err := JoinCluster(ctx, mkOpts("node-2", port2, false, Peer{WorkerID: "node-1", Address: "127.0.0.1:" + port1}), Peer{WorkerID: "node-1", Address: "127.0.0.1:" + port1}) + requireNoErr(t, err) + t.Cleanup(func() { + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = c2.Shutdown(shut) + }) + logf("[t+0.5s] node-2 joined via node-1 (addr=127.0.0.1:%s, voters=%d)", port2, c2.Stats().NumPeers+1) + + // Join node-3. + port3 := reservePort() + c3, err := JoinCluster(ctx, mkOpts("node-3", port3, false, Peer{WorkerID: "node-1", Address: "127.0.0.1:" + port1}), Peer{WorkerID: "node-1", Address: "127.0.0.1:" + port1}) + requireNoErr(t, err) + t.Cleanup(func() { + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = c3.Shutdown(shut) + }) + logf("[t+1.0s] node-3 joined via node-1 (addr=127.0.0.1:%s, voters=%d)", port3, c3.Stats().NumPeers+1) + logf("[t+1.0s] current leader (by raft config): %s", leaderIDFromAddress([]*Cluster{c1, c2, c3}, c3.Stats().Leader)) + + // Apply a config.adopt entry to verify FSM replication. + checks := []CriticalCheckConfig{ + {ID: 1, MonitorID: 11, Kind: "http", Target: "https://pay.example/health", IntervalS: 30}, + } + raw, err := jsonMarshal(ConfigAdoptPayload{Version: 1, Actor: "control-plane", Checks: checks}) + requireNoErr(t, err) + entry, err := EncodeEntry(&Entry{Type: EntryConfigAdopt, Adopted: raw}) + requireNoErr(t, err) + + var leader *Cluster + for _, n := range []*Cluster{c1, c2, c3} { + if n.Raft().State() == raft.Leader { + leader = n + break + } + } + requireNotNil(t, leader) + logf("[t+1.5s] leader=%s; applying config.adopt (1 check)", leader.opts.NodeID) + requireNoErr(t, leader.Apply(ctx, entry, 5*time.Second)) + + // Wait for replication. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if c1.FSM().Stats().ConfigVersion == 1 && c2.FSM().Stats().ConfigVersion == 1 && c3.FSM().Stats().ConfigVersion == 1 { + break + } + time.Sleep(50 * time.Millisecond) + } + logf("[t+2.0s] replicated config_version=1 to all 3 voters (config_count=%d)", c1.FSM().Stats().ConfigCount) + + // Kill the leader. + oldID := leader.opts.NodeID + oldAddr := leader.opts.LocalAddr + logf("[t+2.5s] killing leader %s (addr=%s)", oldID, oldAddr) + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + requireNoErr(t, leader.Shutdown(shut)) + cancel() + + // Wait for a new leader. + deadline = time.Now().Add(5 * time.Second) + var newLeaderAddr string + for time.Now().Before(deadline) { + for _, n := range []*Cluster{c1, c2, c3} { + if n == leader { + continue + } + s := n.Stats() + if s.Leader != "" && s.Leader != oldAddr { + newLeaderAddr = s.Leader + break + } + } + if newLeaderAddr != "" { + break + } + time.Sleep(50 * time.Millisecond) + } + requireNotEmpty(t, newLeaderAddr) + newLeaderID := leaderIDFromAddress([]*Cluster{c1, c2, c3}, newLeaderAddr) + logf("[t+5.5s] new leader=%s (addr=%s) (failed over from %s)", newLeaderID, newLeaderAddr, oldID) + + logf("[t+6.0s] FSM readable on remaining nodes: %d/3", c1.FSM().Stats().ConfigVersion+c2.FSM().Stats().ConfigVersion+c3.FSM().Stats().ConfigVersion) +} + +// leaderIDFromAddress translates a raft ServerAddress (host:port) back +// to the local WorkerID by matching it against known clusters' bind +// addresses. Returns the input as-is if no match is found. +func leaderIDFromAddress(nodes []*Cluster, addr string) string { + if addr == "" { + return "(unknown)" + } + for _, n := range nodes { + if n.opts.LocalAddr == addr { + return n.opts.NodeID + } + } + return addr +} + +// requireNoErr is a tiny assert helper for the demo. +func requireNoErr(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func requireNotNil(t *testing.T, v interface{}) { + t.Helper() + if v == nil { + t.Fatal("expected non-nil") + } +} + +func requireNotEmpty(t *testing.T, s string) { + t.Helper() + if s == "" { + t.Fatal("expected non-empty") + } +} diff --git a/internal/workercluster/e2e_test.go b/internal/workercluster/e2e_test.go new file mode 100644 index 0000000..cf40e9b --- /dev/null +++ b/internal/workercluster/e2e_test.go @@ -0,0 +1,515 @@ +package workercluster + +import ( + "context" + "fmt" + "io" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/hashicorp/raft" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// clusterFixture is a 3-node in-process cluster used by the e2e tests. +// It opens three bbolt stores, three HTTP listeners, three rafthttp +// handlers, and starts each raft.Raft. The first node bootstraps as a +// 1-voter cluster; the other two join via the raft-membership handler. +type clusterFixture struct { + Nodes []*Cluster + + creds HTTPCreds + logOut io.Writer + stopOnce sync.Once +} + +func newClusterFixture(t *testing.T, n int) *clusterFixture { + t.Helper() + require.GreaterOrEqual(t, n, 1, "cluster fixture requires at least one node") + + creds := HTTPCreds{Login: "alice", Password: "secret"} + f := &clusterFixture{ + creds: creds, + logOut: io.Discard, + } + + // Pre-reserve three ports so the bootstrap node has stable + // addresses for the other nodes to dial into. + addrs := make([]string, n) + dataDirs := make([]string, n) + for i := 0; i < n; i++ { + l := newLocalListener(t) + addrs[i] = l.Addr().String() + require.NoError(t, l.Close()) + dataDirs[i] = t.TempDir() + } + + ctx := context.Background() + + // Bootstrap the first node. + bootstrapOpts := Options{ + NodeID: "node-1", + LocalAddr: addrs[0], + DataDir: dataDirs[0], + Creds: creds, + Bootstrap: true, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + LogOutput: f.logOut, + } + c1, err := New(&bootstrapOpts) + require.NoError(t, err) + require.NoError(t, c1.Start(ctx)) + require.NoError(t, waitForLeader(ctx, c1, 5*time.Second)) + f.Nodes = append(f.Nodes, c1) + + // Bring up the remaining nodes sequentially, joining via node-1 + // until each becomes a voter. + for i := 1; i < n; i++ { + opts := Options{ + NodeID: fmt.Sprintf("node-%d", i+1), + LocalAddr: addrs[i], + DataDir: dataDirs[i], + Creds: creds, + Bootstrap: false, + Seed: Peer{WorkerID: "node-1", Address: addrs[0]}, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + LogOutput: f.logOut, + } + c, err := New(&opts) + require.NoError(t, err) + require.NoError(t, c.Start(ctx)) + // Wait for the join to be reflected in the cluster config. + require.NoError(t, waitForVoterCount(ctx, c, i+1, 15*time.Second)) + f.Nodes = append(f.Nodes, c) + } + + t.Cleanup(f.Close) + return f +} + +func waitForVoterCount(ctx context.Context, c *Cluster, n int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + s := c.Stats() + if s.NumPeers+1 >= n { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("workercluster: only %d voters after %s (need %d)", s.NumPeers+1, timeout, n) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +func (f *clusterFixture) Close() { + f.stopOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, n := range f.Nodes { + if n != nil { + _ = n.Shutdown(ctx) + } + } + }) +} + +// TestThreeNodeCluster_LeaderElection starts a 3-node cluster and +// verifies exactly one leader is elected within the timeout, and all +// nodes see the same leader. +func TestThreeNodeCluster_LeaderElection(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + f := newClusterFixture(t, 3) + + // Wait until every node reports the same leader. + deadline := time.Now().Add(5 * time.Second) + var lastLeader string + for { + leaders := make(map[string]int) + for _, n := range f.Nodes { + s := n.Stats() + if s.Leader == "" { + leaders[""]++ + continue + } + leaders[s.Leader]++ + } + if len(leaders) == 1 { + for leader, count := range leaders { + if leader != "" && count == 3 { + lastLeader = leader + break + } + } + if lastLeader != "" { + break + } + } + require.False(t, time.Now().After(deadline), "no consensus on leader within deadline, last seen: %v", leaders) + time.Sleep(50 * time.Millisecond) + } + assert.NotEmpty(t, lastLeader) +} + +// TestThreeNodeCluster_FSMApply verifies a config.adopt entry proposed +// on one node ends up in the FSM of all three. +func TestThreeNodeCluster_FSMApply(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + f := newClusterFixture(t, 3) + require.NoError(t, waitForLeader(context.Background(), f.Nodes[0], 5*time.Second)) + + leader := leaderCluster(f) + require.NotNil(t, leader, "expected a leader in the fixture") + + checks := []CriticalCheckConfig{ + {ID: 1, MonitorID: 11, Kind: "http", Target: "https://a", IntervalS: 30}, + {ID: 2, MonitorID: 12, Kind: "ssl", Target: "b", IntervalS: 60}, + } + raw, err := jsonMarshal(ConfigAdoptPayload{Version: 11, Actor: leader.opts.NodeID, Checks: checks}) + require.NoError(t, err) + + entry, err := EncodeEntry(&Entry{Type: EntryConfigAdopt, Adopted: raw}) + require.NoError(t, err) + require.NoError(t, leader.Apply(context.Background(), entry, 5*time.Second)) + + // Wait until every node has applied the entry. + deadline := time.Now().Add(5 * time.Second) + for { + ok := true + for _, n := range f.Nodes { + if n.FSM().Stats().ConfigVersion != 11 { + ok = false + break + } + } + if ok { + break + } + require.False(t, time.Now().After(deadline), "config did not replicate within deadline") + time.Sleep(50 * time.Millisecond) + } + + for _, n := range f.Nodes { + s := n.FSM().Stats() + assert.EqualValues(t, 11, s.ConfigVersion, "node %s", n.opts.NodeID) + assert.Equal(t, 2, s.ConfigCount, "node %s", n.opts.NodeID) + } +} + +// TestThreeNodeCluster_Failover shuts down the leader and verifies a +// new leader is elected within the election-timeout window. +func TestThreeNodeCluster_Failover(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + f := newClusterFixture(t, 3) + require.NoError(t, waitForLeader(context.Background(), f.Nodes[0], 5*time.Second)) + + leader := leaderCluster(f) + require.NotNil(t, leader) + oldLeaderID := leader.opts.NodeID + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, leader.Shutdown(ctx)) + + // Wait until one of the remaining two nodes holds leadership. + require.NoError(t, waitForLeaderOnAny(f.Nodes[1:], 10*time.Second)) + + leaders := make(map[string]bool) + for _, n := range f.Nodes[1:] { + s := n.Stats() + require.NotEmpty(t, s.Leader, "node %s has no leader after failover", s.NodeID) + leaders[s.Leader] = true + } + require.True(t, leaders[oldLeaderID] == false, "old leader should not be elected after shutdown") + require.Len(t, leaders, 1, "exactly one leader expected after failover, got %v", leaders) +} + +func waitForLeaderOnAny(nodes []*Cluster, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + for _, n := range nodes { + if n.Stats().Leader != "" { + return nil + } + } + if time.Now().After(deadline) { + return fmt.Errorf("no leader after %s", timeout) + } + time.Sleep(50 * time.Millisecond) + } +} + +// TestThreeNodeCluster_JoinAndRemove boots a 1-node cluster, adds a +// second and a third voter, then removes the third. We assert the FSM +// membership cache reflects the change. +func TestThreeNodeCluster_JoinAndRemove(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + // 1-node bootstrap. + creds := HTTPCreds{Login: "alice", Password: "secret"} + addr1 := pickPort(t) + c1, err := New(&Options{ + NodeID: "node-1", + LocalAddr: addr1, + DataDir: t.TempDir(), + Creds: creds, + Bootstrap: true, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + LogOutput: io.Discard, + }) + require.NoError(t, err) + require.NoError(t, c1.Start(ctx)) + require.NoError(t, waitForLeader(ctx, c1, 5*time.Second)) + t.Cleanup(func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, n := range []*Cluster{c1} { + if n != nil { + _ = n.Shutdown(shutCtx) + } + } + }) + + // Join node-2. + addr2 := pickPort(t) + c2, err := JoinCluster(ctx, &Options{ + NodeID: "node-2", + LocalAddr: addr2, + DataDir: t.TempDir(), + Creds: creds, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + LogOutput: io.Discard, + }, Peer{WorkerID: "node-1", Address: addr1}) + require.NoError(t, err) + t.Cleanup(func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = c2.Shutdown(shutCtx) + }) + require.NoError(t, waitForVoterCount(ctx, c2, 2, 10*time.Second)) + + // Join node-3. + addr3 := pickPort(t) + c3, err := JoinCluster(ctx, &Options{ + NodeID: "node-3", + LocalAddr: addr3, + DataDir: t.TempDir(), + Creds: creds, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + LogOutput: io.Discard, + }, Peer{WorkerID: "node-1", Address: addr1}) + require.NoError(t, err) + t.Cleanup(func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = c3.Shutdown(shutCtx) + }) + require.NoError(t, waitForVoterCount(ctx, c3, 3, 10*time.Second)) + + // All three nodes should see 3 voters. + for _, n := range []*Cluster{c1, c2, c3} { + cfgFuture := n.Raft().GetConfiguration() + require.NoError(t, cfgFuture.Error()) + ids := make([]string, 0, 3) + for _, srv := range cfgFuture.Configuration().Servers { + ids = append(ids, string(srv.ID)) + } + require.Equal(t, 3, len(ids), "node %s should have 3 voters, got %v", n.opts.NodeID, ids) + } + + // Remove node-3 by proposing a membership change via its local + // raft. (rafthttp only ships Join + Leave; we use raft.RemoveServer + // directly here to keep the test driver straightforward.) + leader := pickLeader(t, []*Cluster{c1, c2, c3}) + require.NotNil(t, leader) + require.NoError(t, leader.Raft().RemoveServer(raft.ServerID("node-3"), 0, 5*time.Second).Error()) + + require.NoError(t, waitForVoterCount(ctx, c1, 2, 10*time.Second)) +} + +// TestThreeNodeCluster_SnapshotRestore populates the FSM with a few +// entries, takes a snapshot on the leader, then restarts the leader +// from the same data dir to verify the snapshot+log restore cycle. +func TestThreeNodeCluster_SnapshotRestore(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + f := newClusterFixture(t, 3) + require.NoError(t, waitForLeader(context.Background(), f.Nodes[0], 5*time.Second)) + + leader := leaderCluster(f) + require.NotNil(t, leader) + + // Push a few entries so the FSM has something worth snapshotting. + for i := 0; i < 5; i++ { + checks := []CriticalCheckConfig{ + {ID: int64(i + 1), MonitorID: int64(i + 1), Kind: "http", Target: fmt.Sprintf("https://a/%d", i), IntervalS: 30}, + } + raw, err := jsonMarshal(ConfigAdoptPayload{Version: uint64(i + 1), Checks: checks}) + require.NoError(t, err) + entry, err := EncodeEntry(&Entry{Type: EntryConfigAdopt, Adopted: raw}) + require.NoError(t, err) + require.NoError(t, leader.Apply(context.Background(), entry, 5*time.Second)) + } + + // Force a snapshot. + require.NoError(t, leader.Snapshot()) + + // All followers should see the snapshot effect. + deadline := time.Now().Add(5 * time.Second) + for { + ok := true + for _, n := range f.Nodes { + if n.FSM().Stats().ConfigVersion != 5 { + ok = false + break + } + } + if ok { + break + } + require.False(t, time.Now().After(deadline), "snapshot did not replicate within deadline") + time.Sleep(50 * time.Millisecond) + } + + // Snapshot file must exist on disk for at least the leader. + store := leader.store + snaps, err := store.SnapshotStore().List() + require.NoError(t, err) + require.NotEmpty(t, snaps, "expected at least one snapshot on disk after Snapshot()") +} + +// TestBootstrapSafety_TwoClusters confirms that starting two +// independent clusters with the same bootstrap token creates two +// distinct raft groups, not a single merged one. +func TestBootstrapSafety_TwoClusters(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + creds := HTTPCreds{Login: "alice", Password: "secret"} + a1, err := New(&Options{ + NodeID: "a-1", + LocalAddr: pickPort(t), + DataDir: t.TempDir(), + Creds: creds, + Bootstrap: true, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + LogOutput: io.Discard, + }) + require.NoError(t, err) + require.NoError(t, a1.Start(ctx)) + t.Cleanup(func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = a1.Shutdown(shutCtx) + }) + + b1, err := New(&Options{ + NodeID: "b-1", + LocalAddr: pickPort(t), + DataDir: t.TempDir(), + Creds: creds, + Bootstrap: true, + HeartbeatTimeout: 300 * time.Millisecond, + ElectionTimeout: 1000 * time.Millisecond, + LogOutput: io.Discard, + }) + require.NoError(t, err) + require.NoError(t, b1.Start(ctx)) + t.Cleanup(func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = b1.Shutdown(shutCtx) + }) + + require.NoError(t, waitForLeader(ctx, a1, 5*time.Second)) + require.NoError(t, waitForLeader(ctx, b1, 5*time.Second)) + + require.NoError(t, waitForLeaderOnAny([]*Cluster{a1}, 1*time.Second)) + + // Each cluster must have exactly one voter: itself. + aConfig := a1.Raft().GetConfiguration() + require.NoError(t, aConfig.Error()) + require.Len(t, aConfig.Configuration().Servers, 1) + assert.Equal(t, raft.ServerID("a-1"), aConfig.Configuration().Servers[0].ID) + + bConfig := b1.Raft().GetConfiguration() + require.NoError(t, bConfig.Error()) + require.Len(t, bConfig.Configuration().Servers, 1) + assert.Equal(t, raft.ServerID("b-1"), bConfig.Configuration().Servers[0].ID) +} + +// pickPort reserves a free port on the loopback interface and returns +// its address. We close the listener immediately; the actual raft +// listener will rebind to the same port because no other process has +// claimed it. +func pickPort(t *testing.T) string { + t.Helper() + l := newLocalListener(t) + addr := l.Addr().String() + require.NoError(t, l.Close()) + return addr +} + +func leaderCluster(f *clusterFixture) *Cluster { + for _, n := range f.Nodes { + if n.Raft().State() == raft.Leader { + return n + } + } + return nil +} + +func leaderFromFixture(f *clusterFixture) string { + for _, n := range f.Nodes { + if n.Raft().State() == raft.Leader { + return n.opts.NodeID + } + } + return "" +} + +func pickLeader(t *testing.T, nodes []*Cluster) *Cluster { + t.Helper() + for _, n := range nodes { + if n.Raft().State() == raft.Leader { + return n + } + } + t.Fatal("no leader in cluster fixture") + return nil +} + +// ensureLocalAddrsAreLoopback sanity-checks the helper above produces +// 127.0.0.1 addresses so the e2e suite never accidentally opens a +// non-loopback port. +func TestPickPortIsLoopback(t *testing.T) { + addr := pickPort(t) + host, _, err := net.SplitHostPort(addr) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(host, "127.") || host == "::1", "expected loopback, got %q", host) +} diff --git a/internal/workercluster/entries.go b/internal/workercluster/entries.go new file mode 100644 index 0000000..cf1f5fa --- /dev/null +++ b/internal/workercluster/entries.go @@ -0,0 +1,152 @@ +package workercluster + +import "encoding/json" + +// EntryType enumerates the kinds of replicated log entries the FSM +// understands. Every log entry committed through the Raft Apply path +// carries an Entry of one of these kinds. +// +// The set is the subset from plan section 6.2 that this implementation +// actually exercises today. The remaining kinds are defined in +// distworker/entries.go placeholders (ObserverSetUpdate, IncidentObserve, +// ...) so later phases can add Apply branches without a wire-format +// churn. +type EntryType string + +const ( + // EntryConfigAdopt installs a new adopted critical-check list + // into the FSM. Replaces any previously adopted list. + EntryConfigAdopt EntryType = "config.adopt" + + // EntryObserverSetUpdate installs a new observer set with a + // version stamp. + EntryObserverSetUpdate EntryType = "observer_set.update" + + // EntryMembershipProposeAdd appends a Member to the membership + // cache. The Raft library itself handles the voter promotion; the + // FSM just keeps a denormalized mirror. + EntryMembershipProposeAdd EntryType = "membership.propose_add" + + // EntryMembershipDemote flips a voter's role to observer. + EntryMembershipDemote EntryType = "membership.demote" + + // EntryMembershipRemove drops a Member from the membership cache. + EntryMembershipRemove EntryType = "membership.remove" + + // EntryIncidentObserve appends one observer vote for a check. + EntryIncidentObserve EntryType = "incident.observe" + + // EntryIncidentTransition records a state transition produced by + // the FSM from committed observations. + EntryIncidentTransition EntryType = "incident.transition" + + // EntryOutboxEnqueue appends notification outbox metadata. + EntryOutboxEnqueue EntryType = "outbox.enqueue" + + // EntryOutboxDelivered marks an outbox entry as delivered. + EntryOutboxDelivered EntryType = "outbox.delivered" + + // EntryOutboxAck records a delivery ack / duplicate marker. + EntryOutboxAck EntryType = "outbox.ack" + + // EntryPartitionReport stores the cluster's view of network + // partition state. + EntryPartitionReport EntryType = "partition.report" + + // EntryDiagnosticsUpdate stores per-worker health metadata. + EntryDiagnosticsUpdate EntryType = "diagnostics.update" +) + +// Entry is the wire format the FSM expects on every Apply. The Data +// field holds the JSON-encoded payload of the matching struct. +type Entry struct { + Type EntryType `json:"type"` + Term uint64 `json:"term,omitempty"` + Index uint64 `json:"index,omitempty"` + ActorID string `json:"actor_id,omitempty"` + Version uint64 `json:"version,omitempty"` + Adopted json.RawMessage `json:"adopted,omitempty"` + Set json.RawMessage `json:"set,omitempty"` + Member json.RawMessage `json:"member,omitempty"` + State json.RawMessage `json:"state,omitempty"` + Observe json.RawMessage `json:"observe,omitempty"` + Outbox json.RawMessage `json:"outbox,omitempty"` + OutboxID uint64 `json:"outbox_seq,omitempty"` + Report json.RawMessage `json:"report,omitempty"` + Diag json.RawMessage `json:"diag,omitempty"` +} + +// ConfigAdoptPayload is the body of an EntryConfigAdopt entry. +type ConfigAdoptPayload struct { + Version uint64 `json:"version"` + Actor string `json:"actor"` + Checks []CriticalCheckConfig `json:"checks"` +} + +// ObserverSetPayload is the body of an EntryObserverSetUpdate entry. +type ObserverSetPayload struct { + Set ObserverSet `json:"set"` +} + +// MemberPayload is the body of an EntryMembershipProposeAdd, +// EntryMembershipDemote, and EntryMembershipRemove entries. +type MemberPayload struct { + Member Member `json:"member"` + Reason string `json:"reason,omitempty"` + Prev *Member `json:"prev,omitempty"` // populated on demote/remove +} + +// IncidentObservePayload is one observer vote for a check. +type IncidentObservePayload struct { + CheckID int64 `json:"check_id"` + WorkerID string `json:"worker_id"` + Label string `json:"label"` // ok | warn | down | unknown + ObservedIx uint64 `json:"observed_at_index"` +} + +// IncidentTransitionPayload is the FSM-produced state transition +// recorded after a quorum rule fires. +type IncidentTransitionPayload struct { + CheckID int64 `json:"check_id"` + State IncidentState `json:"state"` + Reason string `json:"reason,omitempty"` +} + +// OutboxPayload is the body of an EntryOutboxEnqueue entry. +type OutboxPayload struct { + Entry OutboxMeta `json:"entry"` +} + +// OutboxUpdatePayload is the body of EntryOutboxDelivered and +// EntryOutboxAck entries. +type OutboxUpdatePayload struct { + Seq uint64 `json:"seq"` + State string `json:"state"` + Attempts int `json:"attempts"` + LastError string `json:"last_error,omitempty"` +} + +// PartitionReportPayload is the body of an EntryPartitionReport entry. +type PartitionReportPayload struct { + State PartitionState `json:"state"` +} + +// DiagnosticsPayload is the body of an EntryDiagnosticsUpdate entry. +type DiagnosticsPayload struct { + Diag WorkerDiagnostics `json:"diag"` +} + +// EncodeEntry marshals a fully-populated Entry to JSON bytes ready to +// hand to raft.Apply. +func EncodeEntry(e *Entry) ([]byte, error) { + return json.Marshal(e) +} + +// DecodeEntry parses Entry bytes back into the wire struct. +func DecodeEntry(b []byte) (Entry, error) { + var e Entry + if err := json.Unmarshal(b, &e); err != nil { + return e, err + } + return e, nil +} diff --git a/internal/workercluster/entries_test.go b/internal/workercluster/entries_test.go new file mode 100644 index 0000000..02aad66 --- /dev/null +++ b/internal/workercluster/entries_test.go @@ -0,0 +1,80 @@ +package workercluster + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEntries_RoundTrip encodes an Entry with each payload populated, +// decodes it, and verifies the wire shape is stable. +func TestEntries_RoundTrip(t *testing.T) { + adopted, err := json.Marshal(ConfigAdoptPayload{Version: 7, Checks: []CriticalCheckConfig{{ID: 1}}}) + require.NoError(t, err) + member, err := json.Marshal(MemberPayload{Member: Member{WorkerID: "w-1", Role: "voter"}}) + require.NoError(t, err) + outbox, err := json.Marshal(OutboxPayload{Entry: OutboxMeta{IncidentID: 1, Channel: "telegram"}}) + require.NoError(t, err) + observe, err := json.Marshal(IncidentObservePayload{CheckID: 1, WorkerID: "w-1", Label: "ok"}) + require.NoError(t, err) + state, err := json.Marshal(IncidentTransitionPayload{CheckID: 1, State: IncidentState{CheckID: 1, State: "open"}}) + require.NoError(t, err) + report, err := json.Marshal(PartitionReportPayload{State: PartitionState{State: "steady"}}) + require.NoError(t, err) + diag, err := json.Marshal(DiagnosticsPayload{Diag: WorkerDiagnostics{WorkerID: "w-1"}}) + require.NoError(t, err) + set, err := json.Marshal(ObserverSetPayload{Set: ObserverSet{Version: 3, Voters: []string{"w-1", "w-2", "w-3"}}}) + require.NoError(t, err) + + entry := Entry{ + Type: EntryConfigAdopt, + Version: 7, + Adopted: adopted, + Member: member, + Outbox: outbox, + Observe: observe, + State: state, + Report: report, + Diag: diag, + Set: set, + } + raw, err := EncodeEntry(&entry) + require.NoError(t, err) + + got, err := DecodeEntry(raw) + require.NoError(t, err) + assert.Equal(t, entry.Type, got.Type) + assert.JSONEq(t, string(adopted), string(got.Adopted)) + assert.JSONEq(t, string(member), string(got.Member)) + assert.JSONEq(t, string(outbox), string(got.Outbox)) + assert.JSONEq(t, string(observe), string(got.Observe)) + assert.JSONEq(t, string(state), string(got.State)) + assert.JSONEq(t, string(report), string(got.Report)) + assert.JSONEq(t, string(diag), string(got.Diag)) + assert.JSONEq(t, string(set), string(got.Set)) +} + +// TestEntries_StableTypeStrings locks down the wire-format strings so +// future phases can extend the enum without breaking on-the-wire +// compatibility for the kinds we already ship. +func TestEntries_StableTypeStrings(t *testing.T) { + expected := map[EntryType]string{ + EntryConfigAdopt: "config.adopt", + EntryObserverSetUpdate: "observer_set.update", + EntryMembershipProposeAdd: "membership.propose_add", + EntryMembershipDemote: "membership.demote", + EntryMembershipRemove: "membership.remove", + EntryIncidentObserve: "incident.observe", + EntryIncidentTransition: "incident.transition", + EntryOutboxEnqueue: "outbox.enqueue", + EntryOutboxDelivered: "outbox.delivered", + EntryOutboxAck: "outbox.ack", + EntryPartitionReport: "partition.report", + EntryDiagnosticsUpdate: "diagnostics.update", + } + for k, v := range expected { + assert.Equal(t, v, string(k), "entry type wire string") + } +} diff --git a/internal/workercluster/fsm.go b/internal/workercluster/fsm.go new file mode 100644 index 0000000..687261f --- /dev/null +++ b/internal/workercluster/fsm.go @@ -0,0 +1,394 @@ +package workercluster + +import ( + "encoding/json" + "fmt" + "io" + "sync" + + "github.com/hashicorp/raft" +) + +// FSM is the in-memory replicated state machine for the worker Raft +// cluster. It holds: +// +// - ConfigVersion + the adopted critical-check list. +// - ObserverSet with a version stamp. +// - One IncidentState per CheckID. +// - Outbox metadata (capped at outboxCap). +// - Membership cache mirrored from raft's own configuration so readers +// can iterate without going through raft.GetConfiguration. +// - One WorkerDiagnostics per WorkerID. +// - The last PartitionState and CentralWitnessReport. +// +// Concurrency: Apply is invoked serially by the Raft library, so the +// FSM does not lock inside Apply. External readers (Stats, snapshot +// Persist) acquire fsm.mu as readers. The mutex is never held while a +// disk write is in progress. +// +// Snapshot layout: the entire FSM state is JSON-encoded into a single +// snapshot record. This is fine for the small, append-mostly state the +// plan describes (section 6.1). +type FSM struct { + mu sync.RWMutex + + ConfigVersion uint64 + Config []CriticalCheckConfig + ObserverSet ObserverSet + Incidents map[int64]IncidentState + Outbox []OutboxMeta + Membership map[string]Member + Diagnostics map[string]WorkerDiagnostics + Partition PartitionState + Witness CentralWitnessReport + + // outboxSeq is a monotonically increasing counter handed out to + // each appended OutboxMeta entry. + outboxSeq uint64 + + // lastIndex tracks the highest applied log index, used by the + // metadata fields on outbox/diagnostics entries. + lastIndex uint64 +} + +const outboxCap = 1024 + +// NewFSM returns an empty FSM ready to receive Apply calls or a Restore +// snapshot. +func NewFSM() *FSM { + return &FSM{ + Incidents: make(map[int64]IncidentState), + Membership: make(map[string]Member), + Diagnostics: make(map[string]WorkerDiagnostics), + ObserverSet: ObserverSet{}, + Partition: PartitionState{State: "steady"}, + } +} + +// Apply runs one committed log entry through the FSM. The raft +// library serializes Apply calls, but external readers (Stats, +// Snapshot) can run concurrently, so we still acquire the write lock. +func (f *FSM) Apply(log *raft.Log) interface{} { + f.mu.Lock() + defer f.mu.Unlock() + + if log.Type != raft.LogCommand { + // Configuration changes are managed by the raft library and + // do not flow through our entry types. + return nil + } + + entry, err := DecodeEntry(log.Data) + if err != nil { + // An undecodable entry is a bug; raft expects Apply to be + // deterministic and the FSM to never fail. We log nothing + // here (the library forwards the response back to the caller + // via ApplyFuture) and just leave the FSM untouched. + return fmt.Errorf("fsm: decode entry: %w", err) + } + f.lastIndex = log.Index + + switch entry.Type { + case EntryConfigAdopt: + var p ConfigAdoptPayload + if err := json.Unmarshal(entry.Adopted, &p); err != nil { + return fmt.Errorf("fsm: decode config.adopt: %w", err) + } + f.ConfigVersion = p.Version + f.Config = p.Checks + return nil + + case EntryObserverSetUpdate: + var p ObserverSetPayload + if err := json.Unmarshal(entry.Set, &p); err != nil { + return fmt.Errorf("fsm: decode observer_set.update: %w", err) + } + f.ObserverSet = p.Set + f.ObserverSet.AdoptedAtIx = log.Index + return nil + + case EntryMembershipProposeAdd: + var p MemberPayload + if err := json.Unmarshal(entry.Member, &p); err != nil { + return fmt.Errorf("fsm: decode membership.propose_add: %w", err) + } + p.Member.JoinedAtIx = log.Index + f.Membership[p.Member.WorkerID] = p.Member + return nil + + case EntryMembershipDemote: + var p MemberPayload + if err := json.Unmarshal(entry.Member, &p); err != nil { + return fmt.Errorf("fsm: decode membership.demote: %w", err) + } + if cur, ok := f.Membership[p.Member.WorkerID]; ok { + prev := cur + p.Prev = &prev + cur.Role = "observer" + cur.LastSeenIx = log.Index + f.Membership[p.Member.WorkerID] = cur + } + return nil + + case EntryMembershipRemove: + var p MemberPayload + if err := json.Unmarshal(entry.Member, &p); err != nil { + return fmt.Errorf("fsm: decode membership.remove: %w", err) + } + if cur, ok := f.Membership[p.Member.WorkerID]; ok { + prev := cur + p.Prev = &prev + f.Membership[p.Member.WorkerID] = cur + delete(f.Membership, p.Member.WorkerID) + } + return nil + + case EntryIncidentObserve: + // TODO(phase-1): feed observations into the incident FSM + // rule. For now we just record the latest observation + // against the check's IncidentState so callers can read it. + var p IncidentObservePayload + if err := json.Unmarshal(entry.Observe, &p); err != nil { + return fmt.Errorf("fsm: decode incident.observe: %w", err) + } + inc := f.Incidents[p.CheckID] + inc.CheckID = p.CheckID + if p.Label == "ok" { + inc.LastOKAtIx = log.Index + } + inc.LastConfirmAtIx = log.Index + f.Incidents[p.CheckID] = inc + return nil + + case EntryIncidentTransition: + var p IncidentTransitionPayload + if err := json.Unmarshal(entry.State, &p); err != nil { + return fmt.Errorf("fsm: decode incident.transition: %w", err) + } + f.Incidents[p.CheckID] = p.State + return nil + + case EntryOutboxEnqueue: + var p OutboxPayload + if err := json.Unmarshal(entry.Outbox, &p); err != nil { + return fmt.Errorf("fsm: decode outbox.enqueue: %w", err) + } + f.outboxSeq++ + p.Entry.Seq = f.outboxSeq + if p.Entry.State == "" { + p.Entry.State = "pending" + } + f.Outbox = append(f.Outbox, p.Entry) + if len(f.Outbox) > outboxCap { + // Drop oldest. The cap is small and metadata-only. + f.Outbox = f.Outbox[len(f.Outbox)-outboxCap:] + } + return nil + + case EntryOutboxDelivered, EntryOutboxAck: + var p OutboxUpdatePayload + raw := entry.Outbox + if len(raw) == 0 { + raw = entry.State + } + if err := json.Unmarshal(raw, &p); err != nil { + return fmt.Errorf("fsm: decode outbox.update: %w", err) + } + for i := range f.Outbox { + if f.Outbox[i].Seq == p.Seq { + f.Outbox[i].State = p.State + f.Outbox[i].Attempts = p.Attempts + f.Outbox[i].LastError = p.LastError + break + } + } + return nil + + case EntryPartitionReport: + var p PartitionReportPayload + if err := json.Unmarshal(entry.Report, &p); err != nil { + return fmt.Errorf("fsm: decode partition.report: %w", err) + } + p.State.UpdatedIx = log.Index + f.Partition = p.State + return nil + + case EntryDiagnosticsUpdate: + var p DiagnosticsPayload + if err := json.Unmarshal(entry.Diag, &p); err != nil { + return fmt.Errorf("fsm: decode diagnostics.update: %w", err) + } + p.Diag.LastBeatIx = log.Index + f.Diagnostics[p.Diag.WorkerID] = p.Diag + return nil + + default: + return fmt.Errorf("fsm: unknown entry type %q", entry.Type) + } +} + +// Snapshot returns a snapshot of the current FSM state. The library +// expects Snapshot() to return quickly; the actual encoding happens in +// Persist. +// +// We deep-copy the maps and slices so subsequent Apply calls do not +// mutate the captured state. +func (f *FSM) Snapshot() (raft.FSMSnapshot, error) { + f.mu.RLock() + defer f.mu.RUnlock() + + state := fsmState{ + ConfigVersion: f.ConfigVersion, + Config: append([]CriticalCheckConfig(nil), f.Config...), + ObserverSet: f.ObserverSet, + Incidents: cloneIncidents(f.Incidents), + Outbox: append([]OutboxMeta(nil), f.Outbox...), + Membership: cloneMembers(f.Membership), + Diagnostics: cloneDiag(f.Diagnostics), + Partition: f.Partition, + Witness: f.Witness, + OutboxSeq: f.outboxSeq, + LastIndex: f.lastIndex, + } + return &fsmSnapshot{state: state}, nil +} + +// Restore replaces the FSM state with the snapshot read from sink. +// Called on startup if a snapshot is present, before any Apply. +func (f *FSM) Restore(sink io.ReadCloser) error { + defer sink.Close() //nolint:errcheck // library contract + + var state fsmState + if err := json.NewDecoder(sink).Decode(&state); err != nil { + return fmt.Errorf("fsm: restore decode: %w", err) + } + + f.mu.Lock() + defer f.mu.Unlock() + + f.ConfigVersion = state.ConfigVersion + f.Config = state.Config + f.ObserverSet = state.ObserverSet + f.Incidents = state.Incidents + f.Outbox = state.Outbox + f.Membership = state.Membership + f.Diagnostics = state.Diagnostics + f.Partition = state.Partition + f.Witness = state.Witness + f.outboxSeq = state.OutboxSeq + f.lastIndex = state.LastIndex + + if f.Incidents == nil { + f.Incidents = make(map[int64]IncidentState) + } + if f.Membership == nil { + f.Membership = make(map[string]Member) + } + if f.Diagnostics == nil { + f.Diagnostics = make(map[string]WorkerDiagnostics) + } + return nil +} + +// fsmState is the on-the-wire snapshot representation. Versioned so we +// can evolve the FSM shape without breaking older snapshots. +type fsmState struct { + SchemaVersion int `json:"schema_version"` + ConfigVersion uint64 `json:"config_version"` + Config []CriticalCheckConfig `json:"config"` + ObserverSet ObserverSet `json:"observer_set"` + Incidents map[int64]IncidentState `json:"incidents"` + Outbox []OutboxMeta `json:"outbox"` + Membership map[string]Member `json:"membership"` + Diagnostics map[string]WorkerDiagnostics `json:"diagnostics"` + Partition PartitionState `json:"partition"` + Witness CentralWitnessReport `json:"witness"` + OutboxSeq uint64 `json:"outbox_seq"` + LastIndex uint64 `json:"last_index"` +} + +// fsmSnapshot wraps fsmState so Persist can stream it to a sink while +// the library holds the FSM lock. +type fsmSnapshot struct { + state fsmState +} + +const fsmSnapshotSchemaVersion = 1 + +func (s *fsmSnapshot) Persist(sink raft.SnapshotSink) error { + s.state.SchemaVersion = fsmSnapshotSchemaVersion + if err := json.NewEncoder(sink).Encode(s.state); err != nil { + _ = sink.Cancel() + return fmt.Errorf("fsm: persist encode: %w", err) + } + return sink.Close() +} + +// Release is a no-op; the snapshot holds no external resources. +func (s *fsmSnapshot) Release() {} + +// LastIndex returns the highest applied log index the FSM has seen. +// Exposed for callers that need to compute trigger conditions. +func (f *FSM) LastIndex() uint64 { + f.mu.RLock() + defer f.mu.RUnlock() + return f.lastIndex +} + +// SnapshotStats is a small read-only view used by tests and metrics. +type SnapshotStats struct { + ConfigVersion uint64 `json:"config_version"` + ConfigCount int `json:"config_count"` + Incidents int `json:"incidents"` + OutboxLen int `json:"outbox_len"` + Members int `json:"members"` + Diagnostics int `json:"diagnostics"` + Partition string `json:"partition"` + ObserverSet ObserverSet `json:"observer_set"` + Membership map[string]Member `json:"membership"` + DiagnosticsMap map[string]WorkerDiagnostics `json:"diagnostics_map"` +} + +// Stats returns a point-in-time view of the FSM state. Safe for +// concurrent callers; takes the read lock. +func (f *FSM) Stats() SnapshotStats { + f.mu.RLock() + defer f.mu.RUnlock() + + return SnapshotStats{ + ConfigVersion: f.ConfigVersion, + ConfigCount: len(f.Config), + Incidents: len(f.Incidents), + OutboxLen: len(f.Outbox), + Members: len(f.Membership), + Diagnostics: len(f.Diagnostics), + Partition: f.Partition.State, + ObserverSet: f.ObserverSet, + Membership: cloneMembers(f.Membership), + DiagnosticsMap: cloneDiag(f.Diagnostics), + } +} + +func cloneMembers(in map[string]Member) map[string]Member { + out := make(map[string]Member, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneDiag(in map[string]WorkerDiagnostics) map[string]WorkerDiagnostics { + out := make(map[string]WorkerDiagnostics, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneIncidents(in map[int64]IncidentState) map[int64]IncidentState { + out := make(map[int64]IncidentState, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/workercluster/fsm_test.go b/internal/workercluster/fsm_test.go new file mode 100644 index 0000000..3098c58 --- /dev/null +++ b/internal/workercluster/fsm_test.go @@ -0,0 +1,219 @@ +package workercluster + +import ( + "testing" + + "github.com/hashicorp/raft" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFSMApply_ConfigAdopt verifies the FSM accepts a config.adopt entry +// and replaces the Config + ConfigVersion. +func TestFSMApply_ConfigAdopt(t *testing.T) { + fsm := NewFSM() + + checks := []CriticalCheckConfig{ + {ID: 1, MonitorID: 11, Kind: "http", Target: "https://a", IntervalS: 30}, + {ID: 2, MonitorID: 12, Kind: "ssl", Target: "b", IntervalS: 60}, + } + raw, err := jsonMarshal(ConfigAdoptPayload{Version: 7, Actor: "ctrl", Checks: checks}) + require.NoError(t, err) + + resp := fsm.Apply(&raft.Log{Index: 1, Term: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryConfigAdopt, nil, nil, nil, raw, nil, nil, nil)}) + require.Nil(t, resp) + + stats := fsm.Stats() + assert.EqualValues(t, 7, stats.ConfigVersion) + assert.Equal(t, 2, stats.ConfigCount) +} + +// TestFSMApply_ObserverSetUpdate verifies the observer-set update path. +func TestFSMApply_ObserverSetUpdate(t *testing.T) { + fsm := NewFSM() + + set := ObserverSet{Version: 3, ConfigVersion: 7, Voters: []string{"w1", "w2", "w3"}, Observers: []string{"w4"}} + raw, err := jsonMarshal(ObserverSetPayload{Set: set}) + require.NoError(t, err) + + resp := fsm.Apply(&raft.Log{Index: 2, Term: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryObserverSetUpdate, raw, nil, nil, nil, nil, nil, nil)}) + require.Nil(t, resp) + + stats := fsm.Stats() + assert.EqualValues(t, 3, stats.ObserverSet.Version) + assert.Equal(t, []string{"w1", "w2", "w3"}, stats.ObserverSet.Voters) + assert.EqualValues(t, 2, stats.ObserverSet.AdoptedAtIx) +} + +// TestFSMApply_MembershipProposeAdd verifies propose_add populates the +// membership cache with the worker's joined-at index. +func TestFSMApply_MembershipProposeAdd(t *testing.T) { + fsm := NewFSM() + + m := Member{WorkerID: "w-1", WorkerURL: "http://w1", RaftAddress: "127.0.0.1:8001", Role: "voter"} + raw, err := jsonMarshal(MemberPayload{Member: m}) + require.NoError(t, err) + + resp := fsm.Apply(&raft.Log{Index: 3, Term: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipProposeAdd, nil, raw, nil, nil, nil, nil, nil)}) + require.Nil(t, resp) + + stats := fsm.Stats() + require.Contains(t, stats.Membership, "w-1") + got := stats.Membership["w-1"] + assert.Equal(t, "voter", got.Role) + assert.EqualValues(t, 3, got.JoinedAtIx) +} + +// TestFSMApply_MembershipDemote flips a voter to observer. +func TestFSMApply_MembershipDemote(t *testing.T) { + fsm := NewFSM() + + addRaw, err := jsonMarshal(MemberPayload{Member: Member{WorkerID: "w-1", Role: "voter"}}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipProposeAdd, nil, addRaw, nil, nil, nil, nil, nil)})) + + demRaw, err := jsonMarshal(MemberPayload{Member: Member{WorkerID: "w-1", Role: "observer"}, Reason: "drain"}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 2, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipDemote, nil, demRaw, nil, nil, nil, nil, nil)})) + + got := fsm.Membership["w-1"] + assert.Equal(t, "observer", got.Role) + assert.EqualValues(t, 2, got.LastSeenIx) +} + +// TestFSMApply_MembershipRemove drops a member from the cache. +func TestFSMApply_MembershipRemove(t *testing.T) { + fsm := NewFSM() + + addRaw, err := jsonMarshal(MemberPayload{Member: Member{WorkerID: "w-1", Role: "voter"}}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipProposeAdd, nil, addRaw, nil, nil, nil, nil, nil)})) + + rmRaw, err := jsonMarshal(MemberPayload{Member: Member{WorkerID: "w-1"}, Reason: "gone"}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 2, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipRemove, nil, rmRaw, nil, nil, nil, nil, nil)})) + + _, ok := fsm.Membership["w-1"] + assert.False(t, ok, "member should be removed") +} + +// TestFSMApply_OutboxEnqueue verifies the outbox grows and the seq is +// stamped automatically. +func TestFSMApply_OutboxEnqueue(t *testing.T) { + fsm := NewFSM() + + raw, err := jsonMarshal(OutboxPayload{Entry: OutboxMeta{IncidentID: 1, TenantID: 9, Channel: "telegram", DedupKey: "notif:1:telegram:c"}}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryOutboxEnqueue, nil, nil, nil, nil, nil, nil, raw)})) + + require.Len(t, fsm.Outbox, 1) + assert.EqualValues(t, 1, fsm.Outbox[0].Seq) + assert.Equal(t, "pending", fsm.Outbox[0].State) +} + +// TestFSMApply_OutboxDelivered verifies the delivered update path. +func TestFSMApply_OutboxDelivered(t *testing.T) { + fsm := NewFSM() + + raw, err := jsonMarshal(OutboxPayload{Entry: OutboxMeta{IncidentID: 1, Channel: "telegram"}}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryOutboxEnqueue, nil, nil, nil, nil, nil, nil, raw)})) + + upd, err := jsonMarshal(OutboxUpdatePayload{Seq: 1, State: "sent", Attempts: 1}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 2, Type: raft.LogCommand, Data: mustEntry(t, EntryOutboxDelivered, nil, nil, nil, nil, nil, nil, upd)})) + + assert.Equal(t, "sent", fsm.Outbox[0].State) + assert.Equal(t, 1, fsm.Outbox[0].Attempts) +} + +// TestFSMSnapshot_Restore exercises the snapshot+restore cycle. We +// populate the FSM, snapshot it, restore into a fresh FSM, and verify +// the state matches exactly. +func TestFSMSnapshot_Restore(t *testing.T) { + src := NewFSM() + + checks := []CriticalCheckConfig{ + {ID: 1, MonitorID: 11, Kind: "http", Target: "https://a", IntervalS: 30}, + } + rawC, err := jsonMarshal(ConfigAdoptPayload{Version: 9, Checks: checks}) + require.NoError(t, err) + require.Nil(t, src.Apply(&raft.Log{Index: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryConfigAdopt, nil, nil, nil, rawC, nil, nil, nil)})) + + memberRaw, err := jsonMarshal(MemberPayload{Member: Member{WorkerID: "w-1", Role: "voter", WorkerURL: "http://w1"}}) + require.NoError(t, err) + require.Nil(t, src.Apply(&raft.Log{Index: 2, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipProposeAdd, nil, memberRaw, nil, nil, nil, nil, nil)})) + + setRaw, err := jsonMarshal(ObserverSetPayload{Set: ObserverSet{Version: 4, Voters: []string{"w-1"}}}) + require.NoError(t, err) + require.Nil(t, src.Apply(&raft.Log{Index: 3, Type: raft.LogCommand, Data: mustEntry(t, EntryObserverSetUpdate, setRaw, nil, nil, nil, nil, nil, nil)})) + + outRaw, err := jsonMarshal(OutboxPayload{Entry: OutboxMeta{IncidentID: 1, Channel: "telegram"}}) + require.NoError(t, err) + require.Nil(t, src.Apply(&raft.Log{Index: 4, Type: raft.LogCommand, Data: mustEntry(t, EntryOutboxEnqueue, nil, nil, nil, nil, nil, nil, outRaw)})) + + snap, err := src.Snapshot() + require.NoError(t, err) + + var buf bytesBuffer + require.NoError(t, snap.Persist(&buf)) + + dst := NewFSM() + require.NoError(t, dst.Restore(&buf)) + + s := dst.Stats() + assert.EqualValues(t, 9, s.ConfigVersion) + assert.Equal(t, 1, s.ConfigCount) + assert.Equal(t, 1, s.Members) + assert.Equal(t, 1, s.OutboxLen) + assert.EqualValues(t, 4, s.ObserverSet.Version) + assert.Equal(t, "w-1", s.Membership["w-1"].WorkerID) +} + +// TestFSMSnapshot_SnapshotIsIsolatedFromLive confirms that a snapshot +// taken from one FSM does not see updates made after the snapshot is +// captured. +func TestFSMSnapshot_SnapshotIsIsolatedFromLive(t *testing.T) { + fsm := NewFSM() + + snap, err := fsm.Snapshot() + require.NoError(t, err) + + raw, err := jsonMarshal(MemberPayload{Member: Member{WorkerID: "w-1", Role: "voter"}}) + require.NoError(t, err) + require.Nil(t, fsm.Apply(&raft.Log{Index: 1, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipProposeAdd, nil, raw, nil, nil, nil, nil, nil)})) + + var buf bytesBuffer + require.NoError(t, snap.Persist(&buf)) + + dst := NewFSM() + require.NoError(t, dst.Restore(&buf)) + assert.Equal(t, 0, dst.Stats().Members, "snapshot taken before Apply must not contain the new member") +} + +// TestFSMApply_UnknownEntryTypeReturnsError verifies Apply returns an +// error for unknown entry kinds. +func TestFSMApply_UnknownEntryTypeReturnsError(t *testing.T) { + fsm := NewFSM() + resp := fsm.Apply(&raft.Log{Index: 1, Type: raft.LogCommand, Data: mustEntry(t, "weird.thing", nil, nil, nil, nil, nil, nil, nil)}) + err, _ := resp.(error) + require.Error(t, err) +} + +// TestFSMSnapshot_StatsIsConsistent confirms Stats() returns a coherent +// view after a batch of Applies. The raft library calls Apply +// serially, so we do not need to lock inside Apply; this test +// exercises the same pattern at the application level. +func TestFSMSnapshot_StatsIsConsistent(t *testing.T) { + fsm := NewFSM() + + raw, err := jsonMarshal(MemberPayload{Member: Member{WorkerID: "w-1", Role: "voter"}}) + require.NoError(t, err) + + for i := uint64(1); i <= 50; i++ { + _ = fsm.Apply(&raft.Log{Index: i, Type: raft.LogCommand, Data: mustEntry(t, EntryMembershipProposeAdd, nil, raw, nil, nil, nil, nil, nil)}) + } + s := fsm.Stats() + require.Equal(t, 1, s.Members, "single member") + require.Equal(t, "w-1", s.Membership["w-1"].WorkerID) +} diff --git a/internal/workercluster/snapshot.go b/internal/workercluster/snapshot.go new file mode 100644 index 0000000..de8931f --- /dev/null +++ b/internal/workercluster/snapshot.go @@ -0,0 +1,190 @@ +package workercluster + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "hash/fnv" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/hashicorp/raft" +) + +// boltSnapshotStore stores raft snapshots as JSON files on disk under +// /snapshots/.json. The store is concurrency-safe via +// the OS filesystem; no per-call mutex is needed. +type boltSnapshotStore struct { + store *BoltStore +} + +// snapshotRecord is the on-disk shape of a single snapshot. +type snapshotRecord struct { + Meta raft.SnapshotMeta `json:"meta"` + State []byte `json:"state"` +} + +// Create opens a new snapshot sink at index/term. The library will write +// data to the sink; we close it by atomically renaming a temp file. +func (s *boltSnapshotStore) Create(version raft.SnapshotVersion, index, term uint64, configuration raft.Configuration, + configurationIndex uint64, _ raft.Transport, +) (raft.SnapshotSink, error) { + if version != raft.SnapshotVersionMax { + return nil, fmt.Errorf("workercluster: unsupported snapshot version %d", version) + } + + id := snapshotID(index, term) + dir := s.store.snapshotDir + tmp := filepath.Join(dir, id+".json.tmp") + final := filepath.Join(dir, id+".json") + + f, err := os.Create(tmp) + if err != nil { + return nil, fmt.Errorf("workercluster: create snapshot tmp: %w", err) + } + + sink := &boltSnapshotSink{ + file: f, + tmp: tmp, + final: final, + meta: raft.SnapshotMeta{ + Version: version, + ID: id, + Index: index, + Term: term, + Configuration: configuration, + ConfigurationIndex: configurationIndex, + }, + } + return sink, nil +} + +// List returns all stored snapshots in descending index order. +func (s *boltSnapshotStore) List() ([]*raft.SnapshotMeta, error) { + dir := s.store.snapshotDir + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + out := make([]*raft.SnapshotMeta, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + rec, err := readSnapshotFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + m := rec.Meta + out = append(out, &m) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Index > out[j].Index }) + return out, nil +} + +// Open returns a ReadCloser for the snapshot with the given id. +func (s *boltSnapshotStore) Open(id string) (*raft.SnapshotMeta, io.ReadCloser, error) { + dir := s.store.snapshotDir + rec, err := readSnapshotFile(filepath.Join(dir, id+".json")) + if err != nil { + return nil, nil, fmt.Errorf("workercluster: open snapshot %q: %w", id, err) + } + m := rec.Meta + return &m, io.NopCloser(bytes.NewReader(rec.State)), nil +} + +// boltSnapshotSink accumulates bytes written to it and renames the +// temporary file into place on Close. On Cancel the temp file is +// removed. +type boltSnapshotSink struct { + file *os.File + tmp string + final string + meta raft.SnapshotMeta + + mu sync.Mutex + state bytes.Buffer +} + +func (s *boltSnapshotSink) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.state.Write(p) +} + +func (s *boltSnapshotSink) ID() string { return s.meta.ID } + +func (s *boltSnapshotSink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + rec := snapshotRecord{Meta: s.meta, State: s.state.Bytes()} + raw, err := json.Marshal(rec) + if err != nil { + _ = s.file.Close() + _ = os.Remove(s.tmp) + return fmt.Errorf("workercluster: encode snapshot: %w", err) + } + if err := os.WriteFile(s.final, raw, 0o600); err != nil { + _ = s.file.Close() + _ = os.Remove(s.tmp) + return fmt.Errorf("workercluster: write snapshot: %w", err) + } + _ = os.Remove(s.tmp) + _ = s.file.Close() + return nil +} + +func (s *boltSnapshotSink) Cancel() error { + s.mu.Lock() + defer s.mu.Unlock() + _ = s.file.Close() + _ = os.Remove(s.tmp) + return nil +} + +func readSnapshotFile(path string) (*snapshotRecord, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var rec snapshotRecord + if err := json.Unmarshal(raw, &rec); err != nil { + return nil, err + } + return &rec, nil +} + +// snapshotID returns a deterministic, sortable snapshot identifier. +// FNV hash of the term+index keeps the file name short while staying +// unique per (index, term). +func snapshotID(index, term uint64) string { + h := fnv.New64a() + buf := make([]byte, 16) + binary.BigEndian.PutUint64(buf[:8], index) + binary.BigEndian.PutUint64(buf[8:], term) + _, _ = h.Write(buf) + return strconv.FormatUint(index, 10) + "-" + strconv.FormatUint(term, 10) + "-" + strconv.FormatUint(h.Sum64(), 16) +} + +// encodeLog / decodeLog round-trip a raft.Log entry as msgpack-style +// JSON. The LogStore requires byte-stable encoding so a StoreLog + +// GetLog cycle returns the same values. +func encodeLog(log *raft.Log) ([]byte, error) { + return json.Marshal(log) +} + +func decodeLog(raw []byte, log *raft.Log) error { + return json.Unmarshal(raw, log) +} diff --git a/internal/workercluster/store.go b/internal/workercluster/store.go new file mode 100644 index 0000000..fca36ed --- /dev/null +++ b/internal/workercluster/store.go @@ -0,0 +1,235 @@ +package workercluster + +import ( + "encoding/binary" + "fmt" + "os" + "path/filepath" + + bolt "go.etcd.io/bbolt" + + "github.com/hashicorp/raft" +) + +// BoltStore bundles a single bbolt DB used as both the Raft LogStore and +// the StableStore, plus a directory of snapshot files on disk. +// +// One DB for both stores keeps the on-disk layout simple: the bbolt file +// holds two buckets, "logs" and "stable", and the snapshot store is just +// `/snapshots/.json` files alongside it. The library's API +// surfaces LogStore / StableStore / SnapshotStore separately so the +// application code does not have to care. +type BoltStore struct { + db *bolt.DB + + logBucket []byte + stableBucket []byte + + snapshotDir string +} + +const ( + defaultLogBucket = "logs" + defaultStableBucket = "stable" +) + +// NewBoltStore opens or creates the bbolt-backed store rooted at dir. +// The directory is created if missing. The DB file lives at +// /raft.db; snapshots live in /snapshots/. +func NewBoltStore(dir string) (*BoltStore, error) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("workercluster: mkdir %q: %w", dir, err) + } + if err := os.MkdirAll(filepath.Join(dir, "snapshots"), 0o750); err != nil { + return nil, fmt.Errorf("workercluster: mkdir snapshots: %w", err) + } + + db, err := bolt.Open(filepath.Join(dir, "raft.db"), 0o600, &bolt.Options{}) + if err != nil { + return nil, fmt.Errorf("workercluster: open bolt: %w", err) + } + + bs := &BoltStore{ + db: db, + logBucket: []byte(defaultLogBucket), + stableBucket: []byte(defaultStableBucket), + snapshotDir: filepath.Join(dir, "snapshots"), + } + + if err := db.Update(func(tx *bolt.Tx) error { + for _, b := range [][]byte{bs.logBucket, bs.stableBucket} { + if _, err := tx.CreateBucketIfNotExists(b); err != nil { + return err + } + } + return nil + }); err != nil { + _ = db.Close() + return nil, fmt.Errorf("workercluster: init buckets: %w", err) + } + + return bs, nil +} + +// Close releases the underlying bbolt handle. +func (s *BoltStore) Close() error { + if s.db == nil { + return nil + } + return s.db.Close() +} + +// StableStore returns the StableStore half of the backing store. +func (s *BoltStore) StableStore() raft.StableStore { + return &boltStableStore{store: s} +} + +// LogStore returns the LogStore half of the backing store. +func (s *BoltStore) LogStore() raft.LogStore { + return &boltLogStore{store: s} +} + +// SnapshotStore returns the SnapshotStore half of the backing store. +func (s *BoltStore) SnapshotStore() raft.SnapshotStore { + return &boltSnapshotStore{store: s} +} + +// DB exposes the underlying bbolt handle for tests that want to poke +// at it directly. Production code should never need this. +func (s *BoltStore) DB() *bolt.DB { + return s.db +} + +// boltStableStore implements raft.StableStore on top of a BoltStore. +type boltStableStore struct { + store *BoltStore +} + +func (s *boltStableStore) Set(key, val []byte) error { + return s.store.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(s.store.stableBucket).Put(key, val) + }) +} + +func (s *boltStableStore) Get(key []byte) ([]byte, error) { + var out []byte + err := s.store.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(s.store.stableBucket).Get(key) + if v != nil { + // Copy out of the mmap region; bbolt may reuse the slice. + out = append([]byte(nil), v...) + } + return nil + }) + return out, err +} + +func (s *boltStableStore) SetUint64(key []byte, val uint64) error { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, val) + return s.Set(key, buf) +} + +func (s *boltStableStore) GetUint64(key []byte) (uint64, error) { + v, err := s.Get(key) + if err != nil { + return 0, err + } + if len(v) == 0 { + return 0, nil + } + if len(v) < 8 { + return 0, nil + } + return binary.BigEndian.Uint64(v), nil +} + +// boltLogStore implements raft.LogStore on top of a BoltStore. +// +// Layout: each log entry is stored under the 8-byte big-endian index +// key. FirstIndex scans for the lowest key, LastIndex for the highest. +type boltLogStore struct { + store *BoltStore +} + +func logKey(index uint64) []byte { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, index) + return buf +} + +func (s *boltLogStore) FirstIndex() (uint64, error) { + var idx uint64 + err := s.store.db.View(func(tx *bolt.Tx) error { + c := tx.Bucket(s.store.logBucket).Cursor() + k, _ := c.First() + if k == nil { + idx = 0 + return nil + } + idx = binary.BigEndian.Uint64(k) + return nil + }) + return idx, err +} + +func (s *boltLogStore) LastIndex() (uint64, error) { + var idx uint64 + err := s.store.db.View(func(tx *bolt.Tx) error { + c := tx.Bucket(s.store.logBucket).Cursor() + k, _ := c.Last() + if k == nil { + idx = 0 + return nil + } + idx = binary.BigEndian.Uint64(k) + return nil + }) + return idx, err +} + +func (s *boltLogStore) GetLog(index uint64, log *raft.Log) error { + err := s.store.db.View(func(tx *bolt.Tx) error { + raw := tx.Bucket(s.store.logBucket).Get(logKey(index)) + if raw == nil { + return raft.ErrLogNotFound + } + return decodeLog(raw, log) + }) + return err +} + +func (s *boltLogStore) StoreLog(log *raft.Log) error { + return s.StoreLogs([]*raft.Log{log}) +} + +func (s *boltLogStore) StoreLogs(logs []*raft.Log) error { + if len(logs) == 0 { + return nil + } + return s.store.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(s.store.logBucket) + for _, log := range logs { + raw, err := encodeLog(log) + if err != nil { + return err + } + if err := b.Put(logKey(log.Index), raw); err != nil { + return err + } + } + return nil + }) +} + +func (s *boltLogStore) DeleteRange(lo, hi uint64) error { + return s.store.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(s.store.logBucket) + for i := lo; i <= hi; i++ { + if err := b.Delete(logKey(i)); err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/workercluster/store_test.go b/internal/workercluster/store_test.go new file mode 100644 index 0000000..2043232 --- /dev/null +++ b/internal/workercluster/store_test.go @@ -0,0 +1,173 @@ +package workercluster + +import ( + "testing" + "time" + + "github.com/hashicorp/raft" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStoreRoundTrip_Log writes a single log entry, reads it back, and +// verifies every field matches. This is the contract the raft library +// relies on. +func TestStoreRoundTrip_Log(t *testing.T) { + s, err := NewBoltStore(t.TempDir()) + require.NoError(t, err) + defer s.Close() //nolint:errcheck + + store := s.LogStore() + + in := &raft.Log{ + Index: 5, + Term: 3, + Type: raft.LogCommand, + Data: []byte(`{"type":"config.adopt","adopted":{"version":1}}`), + AppendedAt: nowRFC3339(), + } + require.NoError(t, store.StoreLog(in)) + + got := &raft.Log{} + require.NoError(t, store.GetLog(5, got)) + assert.Equal(t, in.Index, got.Index) + assert.Equal(t, in.Term, got.Term) + assert.Equal(t, in.Type, got.Type) + assert.Equal(t, in.Data, got.Data) + assert.True(t, in.AppendedAt.Equal(got.AppendedAt)) +} + +// TestStoreRoundTrip_FirstLastIndex exercises the index range after +// StoreLog and DeleteRange. +func TestStoreRoundTrip_FirstLastIndex(t *testing.T) { + s, err := NewBoltStore(t.TempDir()) + require.NoError(t, err) + defer s.Close() //nolint:errcheck + + store := s.LogStore() + + first, err := store.FirstIndex() + require.NoError(t, err) + assert.EqualValues(t, 0, first) + + last, err := store.LastIndex() + require.NoError(t, err) + assert.EqualValues(t, 0, last) + + for i := uint64(10); i <= 20; i++ { + require.NoError(t, store.StoreLog(&raft.Log{Index: i, Term: 1, Type: raft.LogCommand, Data: []byte{byte(i)}})) + } + + first, err = store.FirstIndex() + require.NoError(t, err) + assert.EqualValues(t, 10, first) + last, err = store.LastIndex() + require.NoError(t, err) + assert.EqualValues(t, 20, last) + + // Delete a middle range. + require.NoError(t, store.DeleteRange(12, 15)) + + first, err = store.FirstIndex() + require.NoError(t, err) + assert.EqualValues(t, 10, first) + last, err = store.LastIndex() + require.NoError(t, err) + assert.EqualValues(t, 20, last, "LastIndex should still be the highest stored") + + // The deleted slot should now report ErrLogNotFound. + err = store.GetLog(13, &raft.Log{}) + assert.ErrorIs(t, err, raft.ErrLogNotFound) + + // Surviving slot still readable. + got := &raft.Log{} + require.NoError(t, store.GetLog(11, got)) + assert.Equal(t, byte(11), got.Data[0]) +} + +// TestStoreRoundTrip_StableStore checks the StableStore contract: Set / +// Get / SetUint64 / GetUint64. +func TestStoreRoundTrip_StableStore(t *testing.T) { + s, err := NewBoltStore(t.TempDir()) + require.NoError(t, err) + defer s.Close() //nolint:errcheck + + ss := s.StableStore() + + require.NoError(t, ss.SetUint64([]byte("term"), 7)) + require.NoError(t, ss.Set([]byte("voted_for"), []byte("w-1"))) + + term, err := ss.GetUint64([]byte("term")) + require.NoError(t, err) + assert.EqualValues(t, 7, term) + + voted, err := ss.Get([]byte("voted_for")) + require.NoError(t, err) + assert.Equal(t, []byte("w-1"), voted) + + // Unknown key returns zero value, not error. + missing, err := ss.GetUint64([]byte("nope")) + require.NoError(t, err) + assert.EqualValues(t, 0, missing) +} + +// TestStoreRoundTrip_SnapshotStore_CreateOpenList exercises the +// SnapshotStore lifecycle: Create, write, Close, then List + Open. +func TestStoreRoundTrip_SnapshotStore_CreateOpenList(t *testing.T) { + s, err := NewBoltStore(t.TempDir()) + require.NoError(t, err) + defer s.Close() //nolint:errcheck + + ss := s.SnapshotStore() + + cfg := raft.Configuration{Servers: []raft.Server{{ID: "w-1", Address: "127.0.0.1:1", Suffrage: raft.Voter}}} + sink, err := ss.Create(raft.SnapshotVersionMax, 100, 4, cfg, 50, nil) + require.NoError(t, err) + + body := []byte(`{"hello":"world","config_count":3}`) + _, err = sink.Write(body) + require.NoError(t, err) + require.NoError(t, sink.Close()) + + list, err := ss.List() + require.NoError(t, err) + require.Len(t, list, 1) + assert.EqualValues(t, 100, list[0].Index) + assert.EqualValues(t, 4, list[0].Term) + + meta, r, err := ss.Open(list[0].ID) + require.NoError(t, err) + defer r.Close() //nolint:errcheck + + buf := make([]byte, len(body)) + n, err := r.Read(buf) + require.NoError(t, err) + assert.Equal(t, len(body), n) + assert.Equal(t, body, buf[:n]) + + assert.EqualValues(t, 100, meta.Index) +} + +// TestStoreRoundTrip_SnapshotStore_DeleteRangeAfterSnapshot ensures the +// log store + snapshot store coexist on disk under the same bbolt file. +func TestStoreRoundTrip_SnapshotStore_DeleteRangeAfterSnapshot(t *testing.T) { + s, err := NewBoltStore(t.TempDir()) + require.NoError(t, err) + defer s.Close() //nolint:errcheck + + ls := s.LogStore() + for i := uint64(1); i <= 50; i++ { + require.NoError(t, ls.StoreLog(&raft.Log{Index: i, Term: 1, Type: raft.LogCommand})) + } + require.NoError(t, ls.DeleteRange(1, 25)) + + first, err := ls.FirstIndex() + require.NoError(t, err) + assert.EqualValues(t, 26, first) +} + +// nowRFC3339 returns a fixed-format RFC3339 timestamp for tests so +// append log entries have a deterministic AppendedAt. +func nowRFC3339() time.Time { + return time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC) +} diff --git a/internal/workercluster/testhelpers_test.go b/internal/workercluster/testhelpers_test.go new file mode 100644 index 0000000..911306b --- /dev/null +++ b/internal/workercluster/testhelpers_test.go @@ -0,0 +1,56 @@ +package workercluster + +import ( + "bytes" + "encoding/json" + "testing" +) + +// jsonMarshal marshals v to JSON or fails the test. +func jsonMarshal(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +// bytesBuffer is a minimal in-memory raft.SnapshotSink that captures +// the bytes written to it. Used by snapshot Persist() in tests. +type bytesBuffer struct { + bytes.Buffer +} + +func (b *bytesBuffer) Close() error { return nil } +func (b *bytesBuffer) Cancel() error { return nil } +func (b *bytesBuffer) ID() string { return "test-snapshot" } + +// mustEntry encodes an Entry struct with the given field values into +// JSON. It exists so the test bodies stay readable. All payload fields +// are []byte; pass nil to leave them unset. +func mustEntry( + t *testing.T, + typ EntryType, + setJSON, memberJSON, observeJSON, adoptedJSON, configJSON, stateJSON, outboxJSON []byte, +) []byte { + t.Helper() + + entry := Entry{ + Type: typ, + Set: rawOrEmpty(setJSON), + Member: rawOrEmpty(memberJSON), + Observe: rawOrEmpty(observeJSON), + Adopted: rawOrEmpty(adoptedJSON), + Report: rawOrEmpty(configJSON), + State: rawOrEmpty(stateJSON), + Outbox: rawOrEmpty(outboxJSON), + } + raw, err := json.Marshal(entry) + if err != nil { + t.Fatalf("encode entry: %v", err) + } + return raw +} + +func rawOrEmpty(b []byte) json.RawMessage { + if len(b) == 0 { + return nil + } + return json.RawMessage(b) +} diff --git a/internal/workercluster/transport.go b/internal/workercluster/transport.go new file mode 100644 index 0000000..1c948dc --- /dev/null +++ b/internal/workercluster/transport.go @@ -0,0 +1,287 @@ +package workercluster + +import ( + "bytes" + "crypto/subtle" + "encoding/base64" + "fmt" + "io" + "log" + "net" + "net/http" + "sync" + "time" + + rafthttp "github.com/CanonicalLtd/raft-http" +) + +// HTTPCreds is the basic-auth credential pair workers use to gate the +// rafthttp endpoint. The values come from distworker.HTTPConfig.Login +// and distworker.HTTPConfig.Password (env vars WORKER_LOGIN and +// WORKER_PASSWORD). +type HTTPCreds struct { + Login string + Password string +} + +// IsConfigured returns true when both login and password are set. +// NewTransport and NewHandler refuse to start otherwise. +func (c HTTPCreds) IsConfigured() bool { + return c.Login != "" && c.Password != "" +} + +// AuthDial returns a rafthttp.Dial function that wraps the inner +// connection so the Authorization header is injected on every HTTP +// request the rafthttp library writes over it. +// +// rafthttp builds its own http.Request without Authorization (GET for +// the stream upgrade, POST/DELETE for membership changes). It then +// writes the request to the net.Conn returned by Dial. We can't inject +// at the request layer; we have to do it at the connection layer. +// +// Implementation: the wrapper buffers the first Write, looks for the +// end-of-headers marker (\r\n\r\n), inserts an Authorization header +// just before it, then forwards the augmented buffer plus any further +// writes to the inner conn. +// +// TODO(security): replace with a rafthttp fork that supports an +// outbound Authorization header or use NewDialTLS with mTLS client +// certs once the worker identity model in plan section 7.1 lands. +func AuthDial(inner rafthttp.Dial, creds HTTPCreds) rafthttp.Dial { + if !creds.IsConfigured() { + panic("workercluster: AuthDial requires both login and password") + } + return func(addr string, timeout time.Duration) (net.Conn, error) { + conn, err := inner(addr, timeout) + if err != nil { + return nil, err + } + return &authInjectingConn{ + Conn: conn, + auth: "Basic " + base64.StdEncoding.EncodeToString([]byte(creds.Login+":"+creds.Password)), + }, nil + } +} + +// authInjectingConn wraps a net.Conn and rewrites the first HTTP +// request written to it so it carries an Authorization header. +// +// State machine: +// +// - injected = false: incoming bytes are appended to buf until we see +// the end-of-headers marker (\r\n\r\n). +// - once we see \r\n\r\n, we insert the Authorization header just +// before it, drain the buffer to the inner conn, and switch to +// passthrough. +// - if too much data arrives without a header terminator (e.g. a +// very large POST body), we forward as-is; the auth handler will +// reject the request. +// - subsequent writes pass through unchanged. +// +// This is sufficient for rafthttp: every HTTP request it writes is a +// self-contained, single-shot request over a fresh connection. +type authInjectingConn struct { + net.Conn + + auth string + + mu sync.Mutex + buf []byte + injected bool +} + +func (a *authInjectingConn) Write(p []byte) (int, error) { + a.mu.Lock() + defer a.mu.Unlock() + + if !a.injected { + a.buf = append(a.buf, p...) + if len(a.buf) > maxAuthHeaderBuffer { + // Too much data before we saw the header terminator; + // bail out and forward as-is. The request will be + // rejected by the auth handler on the other side, + // which is the correct failure mode. + a.injected = true + if _, err := a.Conn.Write(a.buf); err != nil { + return 0, err + } + a.buf = nil + return len(p), nil + } + if idx := bytes.Index(a.buf, []byte("\r\n\r\n")); idx >= 0 { + // Split around the header terminator. + head := a.buf[:idx] + rest := a.buf[idx:] + newBuf := make([]byte, 0, len(a.buf)+len(a.auth)+32) + newBuf = append(newBuf, head...) + newBuf = append(newBuf, []byte("\r\nAuthorization: ")...) + newBuf = append(newBuf, []byte(a.auth)...) + newBuf = append(newBuf, rest...) + a.buf = newBuf + a.injected = true + n, err := a.Conn.Write(a.buf) + a.buf = nil + return n, err + } + return len(p), nil + } + return a.Conn.Write(p) +} + +// maxAuthHeaderBuffer caps the bytes we'll buffer waiting for the +// header terminator. 64 KiB is well past any reasonable rafthttp +// request and large enough to absorb the headers + a small body. +const maxAuthHeaderBuffer = 64 * 1024 + +// NewAuthHandler wraps an inner rafthttp.Handler with an HTTP basic-auth +// check. Requests without matching credentials are rejected with 401 +// before the rafthttp path runs. +// +// The auth check uses crypto/subtle.ConstantTimeCompare to avoid timing +// leaks on the credential comparison. +func NewAuthHandler(inner *rafthttp.Handler, creds HTTPCreds, logger *log.Logger) http.Handler { + if logger == nil { + logger = log.Default() + } + if !creds.IsConfigured() { + // We panic on construction rather than at request time so a + // misconfigured worker fails fast at startup. + panic("workercluster: NewAuthHandler requires both login and password") + } + + expectedUser := []byte(creds.Login) + expectedPass := []byte(creds.Password) + + return &authHandler{inner: inner, expectedUser: expectedUser, expectedPass: expectedPass, logger: logger} +} + +type authHandler struct { + inner *rafthttp.Handler + expectedUser []byte + expectedPass []byte + logger *log.Logger +} + +func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || + subtle.ConstantTimeCompare([]byte(user), a.expectedUser) != 1 || + subtle.ConstantTimeCompare([]byte(pass), a.expectedPass) != 1 { + a.logger.Printf("[WARN] raft-http: rejected %s %s from %s: bad credentials", + r.Method, r.URL.Path, r.RemoteAddr) + w.Header().Set("WWW-Authenticate", `Basic realm="raft"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + a.inner.ServeHTTP(w, r) +} + +// Unwrap exposes the inner rafthttp.Handler so callers like +// unwrapRafthttpHandler can find it. +func (a *authHandler) Unwrap() http.Handler { return a.inner } + +// NewTransport builds a rafthttp Layer + NetworkTransport pair bound to +// the given listener. The returned Layer is ready to hand to +// raft.NewNetworkTransport. Close must be called on shutdown to drain +// the Layer's HTTP handler. +// +// dial is the rafthttp.Dial used to connect to peers; if nil, the +// rafthttp.NewDialTCP default is used. handler is an http.Handler that +// owns the rafthttp endpoint; production callers pass the auth wrapper +// from NewAuthHandler. The inbound listener is started by the caller +// because the listener needs to be running before peers can dial in. +func NewTransport( + raftPath string, + listener net.Listener, + handler http.Handler, + dial rafthttp.Dial, + logOutput io.Writer, +) (*rafthttp.Layer, *http.Server, error) { + if raftPath == "" { + raftPath = "/raft" + } + if listener == nil { + return nil, nil, fmt.Errorf("workercluster: listener is required") + } + if handler == nil { + return nil, nil, fmt.Errorf("workercluster: handler is required") + } + if dial == nil { + dial = rafthttp.NewDialTCP() + } + logger := log.New(logOutput, "[raft-http] ", log.LstdFlags) + + realHandler, ok := unwrapRafthttpHandler(handler) + if !ok { + return nil, nil, fmt.Errorf("workercluster: handler must wrap a *rafthttp.Handler") + } + layer := rafthttp.NewLayerWithLogger(raftPath, listener.Addr(), realHandler, dial, logger) + + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + + return layer, server, nil +} + +// unwrapRafthttpHandler walks a chain of http.Handler wrappers and +// returns the *rafthttp.Handler at the bottom. Production wraps it in +// NewAuthHandler; tests can wrap it in additional middleware. +func unwrapRafthttpHandler(h http.Handler) (*rafthttp.Handler, bool) { + for { + switch v := h.(type) { + case *rafthttp.Handler: + return v, true + case interface{ Unwrap() http.Handler }: + h = v.Unwrap() + default: + return nil, false + } + } +} + +// CheckBasicAuth is a small helper used by transport_test.go to confirm +// the auth wrapper rejects bad creds and accepts good ones without +// needing the rafthttp Library state. +func CheckBasicAuth(h http.Handler, r *http.Request, login, password string) bool { + r.Header.Set("Authorization", basicAuthHeader(login, password)) + rec := &recordingResponseWriter{header: http.Header{}} + h.ServeHTTP(rec, r) + return rec.status == http.StatusOK +} + +// basicAuthHeader returns the value of an HTTP Basic Authorization +// header for the given user/password pair. Exported for tests; production +// code uses Go's r.BasicAuth() helper. +func basicAuthHeader(user, pass string) string { + const prefix = "Basic " + value := user + ":" + pass + return prefix + base64Encode(value) +} + +// base64Encode is a tiny indirection so tests do not import encoding/base64 +// directly; the production call sites use Go's standard library. +func base64Encode(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +// recordingResponseWriter is a minimal http.ResponseWriter for tests. +type recordingResponseWriter struct { + header http.Header + body []byte + status int +} + +func (w *recordingResponseWriter) Header() http.Header { return w.header } +func (w *recordingResponseWriter) Write(b []byte) (int, error) { + w.body = append(w.body, b...) + if w.status == 0 { + w.status = http.StatusOK + } + return len(b), nil +} + +func (w *recordingResponseWriter) WriteHeader(status int) { + w.status = status +} diff --git a/internal/workercluster/transport_test.go b/internal/workercluster/transport_test.go new file mode 100644 index 0000000..ebed3e9 --- /dev/null +++ b/internal/workercluster/transport_test.go @@ -0,0 +1,133 @@ +package workercluster + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" + + rafthttp "github.com/CanonicalLtd/raft-http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newLocalListener opens a TCP listener on the loopback interface +// using an ephemeral port. Used by transport and e2e tests. +func newLocalListener(t *testing.T) net.Listener { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + return l +} + +// TestTransport_Auth_RejectsMissingCreds verifies that a request +// without an Authorization header is rejected with 401. +func TestTransport_Auth_RejectsMissingCreds(t *testing.T) { + h := rafthttp.NewHandler() + wrapped := NewAuthHandler(h, HTTPCreds{Login: "alice", Password: "secret"}, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/raft", nil) + wrapped.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, `Basic realm="raft"`, rec.Header().Get("WWW-Authenticate")) +} + +// TestTransport_Auth_RejectsWrongCreds verifies that a request with +// incorrect credentials is rejected with 401. +func TestTransport_Auth_RejectsWrongCreds(t *testing.T) { + h := rafthttp.NewHandler() + wrapped := NewAuthHandler(h, HTTPCreds{Login: "alice", Password: "secret"}, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/raft", nil) + req.SetBasicAuth("alice", "wrong") + wrapped.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +// TestTransport_Auth_AcceptsCorrectCreds verifies that a request with +// matching credentials is forwarded to the inner rafthttp handler. The +// rafthttp GET path expects an Upgrade header; without one it returns +// 400, but the important point is that the auth wrapper does not block +// the request before reaching the inner handler. +func TestTransport_Auth_AcceptsCorrectCreds(t *testing.T) { + h := rafthttp.NewHandler() + wrapped := NewAuthHandler(h, HTTPCreds{Login: "alice", Password: "secret"}, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/raft", nil) + req.SetBasicAuth("alice", "secret") + wrapped.ServeHTTP(rec, req) + + assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "auth wrapper should not block good credentials") + assert.Equal(t, http.StatusBadRequest, rec.Code, "rafthttp expects Upgrade:raft header; with auth, 400 confirms we reached the inner handler") +} + +// TestTransport_Auth_PanicsOnEmptyCreds verifies the wrapper refuses +// to construct without both login and password. +func TestTransport_Auth_PanicsOnEmptyCreds(t *testing.T) { + assert.Panics(t, func() { + NewAuthHandler(rafthttp.NewHandler(), HTTPCreds{Login: "", Password: ""}, nil) + }) +} + +// TestTransport_Auth_TimingSafe verifies that the wrapper uses +// crypto/subtle.ConstantTimeCompare rather than ==. We assert this +// indirectly: with wrong creds the response is always 401 regardless of +// how close the password is to the real one. +func TestTransport_Auth_TimingSafe(t *testing.T) { + h := rafthttp.NewHandler() + wrapped := NewAuthHandler(h, HTTPCreds{Login: "alice", Password: "secret"}, nil) + + for _, pw := range []string{"s", "se", "sec", "secr", "secre", "secret", "secretX"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/raft", nil) + req.SetBasicAuth("alice", pw) + wrapped.ServeHTTP(rec, req) + if pw == "secret" { + assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "exact password should pass auth") + } else { + assert.Equal(t, http.StatusUnauthorized, rec.Code, "password %q should fail auth", pw) + } + } +} + +// TestTransport_HTTPCreds_IsConfigured verifies the IsConfigured +// contract: both set, or neither. +func TestTransport_HTTPCreds_IsConfigured(t *testing.T) { + cases := []struct { + creds HTTPCreds + want bool + }{ + {HTTPCreds{Login: "u", Password: "p"}, true}, + {HTTPCreds{Login: "u"}, false}, + {HTTPCreds{Password: "p"}, false}, + {HTTPCreds{}, false}, + } + for _, tc := range cases { + assert.Equal(t, tc.want, tc.creds.IsConfigured(), "%+v", tc.creds) + } +} + +// TestTransport_NewTransportRequiresArgs guards the constructor's +// invariants. +func TestTransport_NewTransportRequiresArgs(t *testing.T) { + h := rafthttp.NewHandler() + l := newLocalListener(t) + + _, _, err := NewTransport("", l, nil, nil, nil) + require.Error(t, err) + + _, _, err = NewTransport("", nil, h, nil, nil) + require.Error(t, err) + + layer, srv, err := NewTransport("/raft", l, h, nil, nil) + require.NoError(t, err) + require.NotNil(t, layer) + require.NotNil(t, srv) + require.NoError(t, srv.Close()) + require.NoError(t, l.Close()) +} diff --git a/internal/workercluster/types.go b/internal/workercluster/types.go new file mode 100644 index 0000000..96d6f01 --- /dev/null +++ b/internal/workercluster/types.go @@ -0,0 +1,138 @@ +// Package workercluster implements a worker-to-worker Raft consensus +// cluster for the RSMon distributed worker subsystem. +// +// The package is an early subset of the plan in +// docs/distributed/worker-to-worker-raft.md. It provides: +// +// - A small in-memory FSM (CriticalCheckConfig list, membership cache, +// incident/outbox placeholders). +// - A bbolt-backed LogStore / StableStore / SnapshotStore. +// - An HTTP/WebSocket transport (rafthttp) with HTTP basic auth on +// both inbound and outbound connections. +// - A 3-voter cluster bootstrap / join / leave flow. +// +// Out of scope for this package (see // TODO(phase-N): comments in the +// relevant files): the distributed_critical check kind itself, signed +// config adoption, envelope-encrypted credentials, snapshot encryption, +// and the external central witness. +package workercluster + +// Worker role strings used in Member.Role and ObserverSet.Voters / +// Observers. Centralized so lints (goconst) and callers agree on the +// canonical spelling. +const ( + RoleVoter = "voter" + RoleObserver = "observer" + RoleVoterAndObserver = "voter+observer" +) + +// CriticalCheckConfig is one adopted critical check entry. The cluster +// runs every entry from this list on every voter+observer worker. +// +// TODO(phase-1): wire this into the checks/c* dispatcher once the +// distributed_critical kind lands. +type CriticalCheckConfig struct { + ID int64 `json:"id"` + MonitorID int64 `json:"monitor_id"` + Kind string `json:"kind"` + Target string `json:"target"` + IntervalS int `json:"interval_seconds"` + Epoch int64 `json:"epoch"` // monotonic per config_version + ConfigHash string `json:"config_hash"` +} + +// IncidentState is the per-check incident lifecycle. The values are the +// names used by the plan section 9.1. +// +// TODO(phase-1): implement the hysteresis + flap_suppression rules +// described in section 8.5 of the plan. +type IncidentState struct { + CheckID int64 `json:"check_id"` + State string `json:"state"` // clear | observing | open | resolving + OpenedAtIndex uint64 `json:"opened_at_index,omitempty"` + LastConfirmAtIx uint64 `json:"last_confirm_at_index,omitempty"` + LastOKAtIx uint64 `json:"last_ok_at_index,omitempty"` + Confirmations int `json:"confirmations"` +} + +// OutboxMeta is the metadata-only notification outbox entry. The FSM +// keeps only metadata; provider delivery happens outside Raft safety. +// +// TODO(phase-1): wire the outbox entries to sender/ once the commit- +// before-notify flow in section 9.4 lands. +type OutboxMeta struct { + Seq uint64 `json:"seq"` + IncidentID int64 `json:"incident_id"` + TenantID int64 `json:"tenant_id"` + Channel string `json:"channel"` + ContactRef string `json:"contact_ref"` + DedupKey string `json:"dedup_key"` + State string `json:"state"` // pending | sent | failed | dead + Attempts int `json:"attempts"` + LastError string `json:"last_error,omitempty"` +} + +// Member is one worker in the cluster. The Membership cache mirrors what +// the raft library itself stores, but is denormalized here so callers can +// read the current set without going through raft.GetConfiguration. +// +// TODO(phase-1): fold in region_code + build_version + drain_state. +type Member struct { + WorkerID string `json:"worker_id"` + WorkerURL string `json:"worker_url"` + RaftAddress string `json:"raft_address"` + Role string `json:"role"` // voter | observer | voter+observer + JoinedAtIx uint64 `json:"joined_at_index"` + LastSeenIx uint64 `json:"last_seen_index,omitempty"` + BuildVer string `json:"build_version,omitempty"` +} + +// ObserverSet is the versioned observer-set object described in plan +// section 6.5. The hash is a placeholder for the adoption check; the +// real signed-config adoption flow lands in phase 1. +// +// TODO(phase-1): replace Hash with a real content hash + Ed25519 +// signature once section 11.3 lands. +type ObserverSet struct { + Version uint64 `json:"version"` + ConfigVersion uint64 `json:"config_version"` + AdoptedAtIx uint64 `json:"adopted_at_index"` + Voters []string `json:"voters"` + Observers []string `json:"observers"` + Hash string `json:"hash"` +} + +// WorkerDiagnostics is a compact per-worker health snapshot. The FSM +// stores the most recent report for each worker. +// +// TODO(phase-1): publish these to VictoriaMetrics via internal/influx +// once section 15 of the plan lands. +type WorkerDiagnostics struct { + WorkerID string `json:"worker_id"` + DiskFreePct int `json:"disk_free_pct"` + ClockSkewMs int64 `json:"clock_skew_ms"` + LastBeatIx uint64 `json:"last_beat_index,omitempty"` +} + +// PartitionState mirrors section 10.2 of the plan. The cluster writes +// its current view via partition.report entries. +// +// TODO(phase-1): wire the external central witness (section 10.3) into +// this object. +type PartitionState struct { + State string `json:"state"` // steady | degraded | partitioned | healing | ... + UpdatedIx uint64 `json:"updated_at_index"` + Reason string `json:"reason,omitempty"` +} + +// CentralWitnessReport is the last external witness view. Empty until +// the control plane starts pushing reports. +// +// TODO(phase-1): accept incoming witness reports from the control plane +// over the existing worker websocket; section 10.3 of the plan. +type CentralWitnessReport struct { + ReachableVoters []string `json:"reachable_voters"` + ReachableObservers []string `json:"reachable_observers"` + SplitBrain bool `json:"split_brain_detected"` + ReportedAtIx uint64 `json:"reported_at_index"` +} diff --git a/packaging/systemd/rsmon-worker.service b/packaging/systemd/rsmon-worker.service new file mode 100644 index 0000000..559d910 --- /dev/null +++ b/packaging/systemd/rsmon-worker.service @@ -0,0 +1,28 @@ +[Unit] +Description=RSMon distributed monitoring worker +Documentation=https://rocketgit.ru/rsmon/worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=rsmon-worker +Group=rsmon-worker +Environment=HOME=/var/lib/rsmon-worker +Environment=RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker/webapp +EnvironmentFile=/etc/rsmon-worker/worker.env +WorkingDirectory=/var/lib/rsmon-worker +ExecStart=/usr/local/bin/rsmon-worker +Restart=always +RestartSec=5s +TimeoutStopSec=20s +AmbientCapabilities=CAP_NET_RAW +CapabilityBoundingSet=CAP_NET_RAW +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=full +ReadWritePaths=/var/lib/rsmon-worker + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/worker.env.example b/packaging/systemd/worker.env.example new file mode 100644 index 0000000..66ffcc7 --- /dev/null +++ b/packaging/systemd/worker.env.example @@ -0,0 +1,16 @@ +RSMON_URL=https://rsmon.ru +RSMON_TOKEN=replace-with-worker-token +WORKER_HOST=127.0.0.1 +WORKER_PORT=27401 +WORKER_URL= +WORKER_LOGIN=admin +WORKER_PASSWORD=replace-with-a-long-random-password +RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker/webapp +WORKER_CLUSTER_ENABLED=false +WORKER_CLUSTER_ID= +WORKER_CLUSTER_HOST=127.0.0.1 +WORKER_CLUSTER_PORT=37401 +WORKER_CLUSTER_PEERS= +WORKER_CLUSTER_DATA_DIR=/var/lib/rsmon-worker/cluster +WORKER_CLUSTER_BOOTSTRAP=false +WORKER_RELEASE_URL= diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh new file mode 100755 index 0000000..708f7dd --- /dev/null +++ b/scripts/install-systemd.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BINARY="${RSMON_WORKER_BINARY:-${ROOT_DIR}/bin/rsmon-worker}" +ENV_FILE="${RSMON_WORKER_ENV_FILE:-}" +START_SERVICE=1 + +usage() { + cat <<'USAGE' +Usage: sudo ./scripts/install-systemd.sh [--binary PATH] [--env PATH] [--no-start] + +Installs rsmon-worker as /usr/local/bin/rsmon-worker and configures systemd. +If --env is omitted on a first install, an example file is installed and the +service is not started until its token and password are configured. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --binary) + BINARY="${2:?--binary requires a path}" + shift 2 + ;; + --env) + ENV_FILE="${2:?--env requires a path}" + shift 2 + ;; + --no-start) + START_SERVICE=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'Unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + printf 'Run this installer as root.\n' >&2 + exit 1 +fi + +if [ ! -x "$BINARY" ]; then + if command -v go >/dev/null 2>&1; then + make -C "$ROOT_DIR" build + else + printf 'Worker binary not found at %s and Go is unavailable.\n' "$BINARY" >&2 + exit 1 + fi +fi + +if ! command -v chromium >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates chromium libcap2-bin tzdata + else + printf 'Warning: Chromium is not installed; browser-backed HTTP checks will fail.\n' >&2 + fi +fi + +if ! getent group rsmon-worker >/dev/null; then + groupadd --system rsmon-worker +fi +if ! id rsmon-worker >/dev/null 2>&1; then + useradd --system --gid rsmon-worker --home-dir /var/lib/rsmon-worker --create-home --shell /usr/sbin/nologin rsmon-worker +fi + +install -d -m 0755 /etc/rsmon-worker +install -d -o rsmon-worker -g rsmon-worker -m 0750 /var/lib/rsmon-worker /var/lib/rsmon-worker/webapp /var/lib/rsmon-worker/cluster +install -m 0755 "$BINARY" /usr/local/bin/rsmon-worker +install -m 0644 "$ROOT_DIR/packaging/systemd/rsmon-worker.service" /etc/systemd/system/rsmon-worker.service + +if [ -n "$ENV_FILE" ]; then + install -m 0600 "$ENV_FILE" /etc/rsmon-worker/worker.env +elif [ ! -f /etc/rsmon-worker/worker.env ]; then + install -m 0600 "$ROOT_DIR/packaging/systemd/worker.env.example" /etc/rsmon-worker/worker.env + START_SERVICE=0 + printf 'Installed /etc/rsmon-worker/worker.env; configure it before starting the service.\n' +fi + +if command -v setcap >/dev/null 2>&1; then + setcap cap_net_raw=+ep /usr/local/bin/rsmon-worker || true +fi + +systemctl daemon-reload +systemctl enable rsmon-worker.service +if [ "$START_SERVICE" -eq 1 ]; then + systemctl restart rsmon-worker.service + systemctl --no-pager --full status rsmon-worker.service +else + printf 'Start after configuration with: systemctl start rsmon-worker\n' +fi diff --git a/scripts/uninstall-systemd.sh b/scripts/uninstall-systemd.sh new file mode 100755 index 0000000..5d350cc --- /dev/null +++ b/scripts/uninstall-systemd.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + printf 'Run this uninstaller as root.\n' >&2 + exit 1 +fi + +systemctl disable --now rsmon-worker.service 2>/dev/null || true +rm -f /etc/systemd/system/rsmon-worker.service /usr/local/bin/rsmon-worker +systemctl daemon-reload + +printf 'Binary and service removed. Configuration and state were preserved in:\n' +printf ' /etc/rsmon-worker\n /var/lib/rsmon-worker\n' diff --git a/spec/factories/accounts.go b/spec/factories/accounts.go new file mode 100644 index 0000000..d3fef63 --- /dev/null +++ b/spec/factories/accounts.go @@ -0,0 +1,26 @@ +// Package factories provides functionality. +package factories + +import ( + "github.com/icrowley/fake" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// AccountFactory provides functionality. +func AccountFactory() models.Account { + account := models.Account{ + Name: fake.Company(), + } + return account +} + +// PersistedAccount provides functionality. +func PersistedAccount() models.Account { + account := AccountFactory() + err := models.DB().Save(&account).Error + if err != nil { + panic(err) + } + return account +} diff --git a/spec/factories/checks.go b/spec/factories/checks.go new file mode 100644 index 0000000..b257a0e --- /dev/null +++ b/spec/factories/checks.go @@ -0,0 +1,35 @@ +package factories + +import ( + "gorm.io/datatypes" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// CheckFactory provides functionality. +func CheckFactory(monitor *models.Monitor, kind string) models.Check { + if monitor == nil { + panic("CheckFactory requires monitor") + } + + checkName := "test check" + check := models.Check{ + Name: &checkName, + Kind: kind, + Monitor: monitor, + Settings: datatypes.JSON("{}"), + } + + return check +} + +// PersistedCheck provides functionality. +func PersistedCheck(monitor *models.Monitor, kind string) models.Check { + check := CheckFactory(monitor, kind) + + err := models.DB().Save(&check).Error + if err != nil { + panic(err) + } + return check +} diff --git a/spec/factories/contacts.go b/spec/factories/contacts.go new file mode 100644 index 0000000..69aff94 --- /dev/null +++ b/spec/factories/contacts.go @@ -0,0 +1,56 @@ +package factories + +import ( + "github.com/icrowley/fake" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// ContactFactory provides functionality. +func ContactFactory(account *models.Account, user *models.User) models.Contact { + contact := models.Contact{ + Name: fake.Company(), + } + if account == nil { + c := AccountFactory() + account = &c + } + contact.Account = account + contact.AccountID = &account.ID + + if user == nil { + c := UserFactory() + user = &c + } + contact.User = user + contact.UserID = &user.ID + + return contact +} + +// PersistedContact provides functionality. +func PersistedContact(account *models.Account, user *models.User) models.Contact { + contact := ContactFactory(account, user) + + if contact.Account.ID == 0 { + err := models.DB().Save(&contact.Account).Error + if err != nil { + panic(err) + } + contact.AccountID = &contact.Account.ID + } + + if contact.User.ID == 0 { + err := models.DB().Save(&contact.User).Error + if err != nil { + panic(err) + } + contact.UserID = &contact.User.ID + } + + err := models.DB().Save(&contact).Error + if err != nil { + panic(err) + } + return contact +} diff --git a/spec/factories/events.go b/spec/factories/events.go new file mode 100644 index 0000000..ed35544 --- /dev/null +++ b/spec/factories/events.go @@ -0,0 +1,38 @@ +package factories + +import ( + "rsgit.ru/rsmon/rsmon/app/models" +) + +// EventFactory provides functionality. +func EventFactory(monitor *models.Monitor, state, reason string) models.Event { + event := models.Event{ + Monitor: monitor, + Checks: monitor.Checks, + State: state, + Errors: 10, + Oks: 0, + Reason: reason, + } + + return event +} + +// PersistedEvent provides functionality. +func PersistedEvent(monitor *models.Monitor, state, reason string) models.Event { + event := EventFactory(monitor, state, reason) + + if event.Monitor.ID == 0 { + err := models.DB().Save(event.Monitor).Error + if err != nil { + panic(err) + } + event.MonitorID = event.Monitor.ID + } + err := models.DB().Save(&event).Error + if err != nil { + panic(err) + } + + return event +} diff --git a/spec/factories/groups.go b/spec/factories/groups.go new file mode 100644 index 0000000..8d2c6cd --- /dev/null +++ b/spec/factories/groups.go @@ -0,0 +1,40 @@ +package factories + +import ( + "github.com/icrowley/fake" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// GroupFactory provides functionality. +func GroupFactory(account *models.Account) models.Group { + group := models.Group{ + Name: fake.Company(), + } + if account == nil { + c := AccountFactory() + account = &c + } + group.Account = account + group.AccountID = account.ID + + return group +} + +// PersistedGroup provides functionality. +func PersistedGroup(account *models.Account) models.Group { + group := GroupFactory(account) + + if group.Account.ID == 0 { + err := models.DB().Save(&group.Account).Error + if err != nil { + panic(err) + } + group.AccountID = group.Account.ID + } + err := models.DB().Save(&group).Error + if err != nil { + panic(err) + } + return group +} diff --git a/spec/factories/init.go b/spec/factories/init.go new file mode 100644 index 0000000..95e5e7d --- /dev/null +++ b/spec/factories/init.go @@ -0,0 +1 @@ +package factories diff --git a/spec/factories/messages.go b/spec/factories/messages.go new file mode 100644 index 0000000..c72f332 --- /dev/null +++ b/spec/factories/messages.go @@ -0,0 +1,63 @@ +package factories + +import ( + "time" + + "github.com/lib/pq" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// MessageFactory provides functionality. +func MessageFactory() models.Message { + ts := time.Date(2019, time.January, 3, 3, 4, 4, 0, time.UTC) + + monitor := MonitorFactory(nil, "test monitor") + check := CheckFactory(&monitor, "http") + e := "testerr" + check.Error = &e + + event := models.Event{ + Monitor: &monitor, + StartTime: &ts, + EndTime: nil, + Duration: 60, + Errors: 3, + Oks: 0, + State: "current", + Reason: "test error", + + ChecksDown: pq.StringArray{check.Kind}, + Checks: []models.Check{ + check, + }, + } + + message := models.Message{ + Contact: &models.Contact{ + Kind: "email", + }, + Kind: "down", + Events: []models.Event{ + event, + }, + } + + return message +} + +// ExpMessageFactory provides functionality. +func ExpMessageFactory() models.Message { + monitor := MonitorFactory(nil, "test monitor") + check := CheckFactory(&monitor, "http") + + message := models.Message{ + Contact: &models.Contact{ + Kind: "email", + }, + Kind: "exp", + Check: &check, + } + + return message +} diff --git a/spec/factories/monitors.go b/spec/factories/monitors.go new file mode 100644 index 0000000..3a5cd70 --- /dev/null +++ b/spec/factories/monitors.go @@ -0,0 +1,60 @@ +package factories + +import ( + "github.com/icrowley/fake" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// MonitorFactory provides functionality. +func MonitorFactory(group *models.Group, name string) models.Monitor { + if name == "" { + name = fake.Company() + } + host := fake.DomainName() + monitor := models.Monitor{ + Enabled: true, + Name: &name, + Host: host, + } + if group == nil { + g := GroupFactory(nil) + group = &g + } + monitor.Group = group + monitor.GroupID = group.ID + + return monitor +} + +// PersistedMonitor provides functionality. +func PersistedMonitor(group *models.Group) models.Monitor { + monitor := MonitorFactory(group, "") + + if monitor.Group.ID == 0 { + err := models.DB().Save(&monitor.Group).Error + if err != nil { + panic(err) + } + monitor.GroupID = monitor.Group.ID + } + err := models.DB().Save(&monitor).Error + if err != nil { + panic(err) + } + return monitor +} + +// MonitorWithNotification provides functionality. +func MonitorWithNotification() (models.Contact, models.Notification, models.Monitor) { + user := PersistedUser("test@test.ru", "123") + account, err := models.CreateAccountForUser(fake.Company(), &user) + if err != nil { + panic(err) + } + contact := PersistedContact(account, &user) + group := PersistedGroup(account) + notification := PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false) + monitor := PersistedMonitor(&group) + return contact, notification, monitor +} diff --git a/spec/factories/notifications.go b/spec/factories/notifications.go new file mode 100644 index 0000000..8920037 --- /dev/null +++ b/spec/factories/notifications.go @@ -0,0 +1,53 @@ +package factories + +import ( + "github.com/icrowley/fake" + + "rsgit.ru/rsmon/rsmon/app/models" +) + +// NotificationFactory provides functionality. +func NotificationFactory(account *models.Account, contactIDs, groupIDs []int64, alertDelay int64, notifyRestore bool) models.Notification { + BeforeExpiration := int64(3600) + notification := models.Notification{ + Name: fake.Company(), + ContactIDs: contactIDs, + GroupIDs: groupIDs, + AlertDelay: &alertDelay, + NotifyDown: true, + NotifyRestore: notifyRestore, + BeforeExpiration: &BeforeExpiration, + } + if account == nil { + c := AccountFactory() + account = &c + } + notification.Account = account + notification.AccountID = account.ID + + return notification +} + +// PersistedNotification provides functionality. +func PersistedNotification(account *models.Account, contactIDs, groupIDs []int64, alertDelay int64, notifyRestore bool) models.Notification { //nolint:lll + notification := NotificationFactory(account, contactIDs, groupIDs, alertDelay, notifyRestore) + + if notification.Account.ID == 0 { + err := models.DB().Save(¬ification.Account).Error + if err != nil { + panic(err) + } + notification.AccountID = notification.Account.ID + } + err := models.DB().Save(¬ification).Error + if err != nil { + panic(err) + } + + err = notification.PersistRelations() + if err != nil { + panic(err) + } + + return notification +} diff --git a/spec/factories/users.go b/spec/factories/users.go new file mode 100644 index 0000000..7a66153 --- /dev/null +++ b/spec/factories/users.go @@ -0,0 +1,72 @@ +package factories + +import ( + "fmt" + "os" + "strings" + "sync/atomic" + "time" + + "github.com/icrowley/fake" + + "rsgit.ru/rsmon/rsmon/app/models" + "rsgit.ru/rsmon/rsmon/app/models/authidentity" +) + +var userCounter uint64 + +// processPrefix is unique per OS process so that parallel test binaries +// (e.g. notifier and sender running at the same time with go test ./...) +// do not generate duplicate email addresses when given the same base email. +var processPrefix = fmt.Sprintf("%d%d", os.Getpid(), time.Now().UnixNano()%1000000) + +// UserFactory creates a new User with a random email. +func UserFactory() models.User { + email := fake.EmailAddress() + + user := models.User{ + Email: &email, + } + + return user +} + +// AuthIdentity creates an AuthIdentity for the given user. +func AuthIdentity(user *models.User, _ string) authidentity.AuthIdentity { + t := time.Now() + ai := authidentity.AuthIdentity{ + Basic: authidentity.Basic{ + UserID: &user.ID, + Provider: "password", + UID: *user.Email, + ConfirmedAt: &t, + }, + } + + return ai +} + +// PersistedUser creates and persists a user with the given email and password. +func PersistedUser(email, password string) models.User { + user := UserFactory() + if email == "" { + email = fake.EmailAddress() + } else { + id := atomic.AddUint64(&userCounter, 1) + atIdx := len(email) - len(email[strings.Index(email, "@"):]) //nolint:gocritic // offBy1: strings.Index is always valid for emails + // Embed both the process prefix and counter so emails are unique across + // parallel test binaries AND across sequential calls within a binary. + email = email[:atIdx] + "+" + processPrefix + fmt.Sprintf("%d", id) + email[atIdx:] + } + user.Email = &email + err := models.DB().Save(&user).Error + if err != nil { + panic(err) + } + ai := AuthIdentity(&user, password) + err = models.DB().Save(&ai).Error + if err != nil { + panic(err) + } + return user +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..c27b43a --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,141 @@ +// Package storage provides S3-compatible object storage using RustFS/MinIO +package storage + +import ( + "context" + "errors" + "io" + "log" + "os" + "sync" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +var ( + // Client is the S3 client for storage operations + Client *minio.Client + // BucketName is the default bucket for storing objects + BucketName string + once sync.Once +) + +// Init initializes the S3 storage client from environment variables +// Required environment variables: +// - RUSTFS_ENDPOINT: S3 endpoint URL (e.g., "localhost:9000") +// - RUSTFS_ACCESS_KEY: Access key ID +// - RUSTFS_SECRET_KEY: Secret access key +// - RUSTFS_BUCKET: Bucket name (optional, defaults to "rsmon") +// - RUSTFS_REGION: Region (optional, defaults to "us-east-1") +// - RUSTFS_USE_SSL: Use SSL (optional, defaults to "false") +func Init() error { + var initErr error + once.Do(func() { + endpoint := os.Getenv("RUSTFS_ENDPOINT") + if endpoint == "" { + initErr = errors.New("RUSTFS_ENDPOINT not set") + return + } + + accessKey := os.Getenv("RUSTFS_ACCESS_KEY") + if accessKey == "" { + initErr = errors.New("RUSTFS_ACCESS_KEY not set") + return + } + + secretKey := os.Getenv("RUSTFS_SECRET_KEY") + if secretKey == "" { + initErr = errors.New("RUSTFS_SECRET_KEY not set") + return + } + + bucket := os.Getenv("RUSTFS_BUCKET") + if bucket == "" { + bucket = "rsmon" + } + BucketName = bucket + + region := os.Getenv("RUSTFS_REGION") + if region == "" { + region = "us-east-1" + } + + useSSL := os.Getenv("RUSTFS_USE_SSL") == "true" + + // Initialize minio client + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: useSSL, + Region: region, + }) + if err != nil { + initErr = err + return + } + + Client = client + log.Printf("[storage] Initialized S3 client: endpoint=%s, bucket=%s, ssl=%v", endpoint, bucket, useSSL) //nolint:lll // accepted security trade-off + + // Create bucket if it doesn't exist + ctx := context.Background() + exists, err := client.BucketExists(ctx, bucket) + if err != nil { + initErr = err + return + } + + if !exists { + err = client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{Region: region}) + if err != nil { + log.Printf("[storage] Warning: failed to create bucket: %v", err) + } else { + log.Printf("[storage] Created bucket: %s", bucket) + } + } + }) + + return initErr +} + +// IsAvailable returns true if the storage client is initialized +func IsAvailable() bool { + return Client != nil +} + +// PutObject stores an object in S3 +func PutObject(ctx context.Context, objectName string, reader io.Reader, size int64, opts minio.PutObjectOptions) (info minio.UploadInfo, err error) { //nolint:lll,gocritic // hugeParam: accepted for interface compatibility + if Client == nil { + return minio.UploadInfo{}, errors.New("storage client not initialized") + } + return Client.PutObject(ctx, BucketName, objectName, reader, size, opts) +} + +// GetObject retrieves an object from S3 +func GetObject(ctx context.Context, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) { //nolint:gocritic,lll // hugeParam: accepted for interface compatibility + if Client == nil { + return nil, errors.New("storage client not initialized") + } + return Client.GetObject(ctx, BucketName, objectName, opts) +} + +// RemoveObject removes an object from S3 +func RemoveObject(ctx context.Context, objectName string, opts minio.RemoveObjectOptions) error { //nolint:gocritic,lll // hugeParam: accepted for interface compatibility + if Client == nil { + return errors.New("storage client not initialized") + } + return Client.RemoveObject(ctx, BucketName, objectName, opts) +} + +// PresignedGetObject generates a presigned URL for getting an object +func PresignedGetObject(ctx context.Context, objectName string, expires time.Duration) (string, error) { + if Client == nil { + return "", errors.New("storage client not initialized") + } + presignedURL, err := Client.PresignedGetObject(ctx, BucketName, objectName, expires, nil) + if err != nil { + return "", err + } + return presignedURL.String(), nil +}