diff --git a/README.md b/README.md index 8850964..a12c3c7 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,18 @@ which can be filtered by one or several tags (the filter is in the URL, so a tag on a server's page links to everything sharing it), and they're searchable from the global search box. +**Domains** — when each domain registration expires, read from the registry +itself. The domains behind your DNS zones are picked up automatically (a zone +like `lab.example.se` resolves to the `example.se` registration that actually +expires); others can be added by hand. Each is looked up daily over RDAP where +the TLD offers it, and otherwise over WHOIS via IANA's referral — which is what +makes `.se`, `.nu` and `.io` work, since those aren't in the RDAP bootstrap. +You're reminded daily from N days before expiry (Settings → Notifications, +default 30) until it's renewed, and told when an expiry date couldn't be +refreshed for several days. Registries that don't publish an expiry (`.de`, +`.eu`) can be tracked but have no date to warn about. Private zones (`.lan`, +`.local`) are skipped. + **Automation failures** — every 15 minutes the app looks at the latest run of each Semaphore template and each Gitea repo's latest workflow run. A failed one raises a single notification (with the project/template or repo, run number, and diff --git a/server/drizzle/0011_strange_mongoose.sql b/server/drizzle/0011_strange_mongoose.sql new file mode 100644 index 0000000..878d3bd --- /dev/null +++ b/server/drizzle/0011_strange_mongoose.sql @@ -0,0 +1,14 @@ +CREATE TABLE `domains` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `origin` text DEFAULT 'manual' NOT NULL, + `expires_at` text, + `registrar` text, + `lookup_source` text, + `last_checked_at` text, + `last_checked_ok_at` text, + `last_check_error` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `domains_name_unique` ON `domains` (`name`); \ No newline at end of file diff --git a/server/drizzle/meta/0011_snapshot.json b/server/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..3e7c817 --- /dev/null +++ b/server/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1483 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7c19d681-222c-43c5-833a-07af390e3e8d", + "prevId": "d502ae04-16b8-472f-b5e0-23da93390b96", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "diag_log": { + "name": "diag_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_providers": { + "name": "dns_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "dns_providers_credential_id_integration_credentials_id_fk": { + "name": "dns_providers_credential_id_integration_credentials_id_fk", + "tableFrom": "dns_providers", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_records_cache": { + "name": "dns_records_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl": { + "name": "ttl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxied": { + "name": "proxied", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dns_records_cache_provider_id_dns_providers_id_fk": { + "name": "dns_records_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_records_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_zones_cache": { + "name": "dns_zones_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dns_zones_cache_provider_zone_idx": { + "name": "dns_zones_cache_provider_zone_idx", + "columns": [ + "provider_id", + "zone_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dns_zones_cache_provider_id_dns_providers_id_fk": { + "name": "dns_zones_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_zones_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domains": { + "name": "domains", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registrar": { + "name": "registrar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lookup_source": { + "name": "lookup_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_ok_at": { + "name": "last_checked_ok_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "domains_name_unique": { + "name": "domains_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_credentials": { + "name": "integration_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrations": { + "name": "integrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_credential_id_integration_credentials_id_fk": { + "name": "integrations_credential_id_integration_credentials_id_fk", + "tableFrom": "integrations", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ipam_entries": { + "name": "ipam_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "ipam_entries_ip_address_unique": { + "name": "ipam_entries_ip_address_unique", + "columns": [ + "ip_address" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_windows": { + "name": "maintenance_windows", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_queue": { + "name": "notification_queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_expression": { + "name": "schedule_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stale": { + "name": "is_stale", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "scheduled_tasks_server_id_servers_id_fk": { + "name": "scheduled_tasks_server_id_servers_id_fk", + "tableFrom": "scheduled_tasks", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secrets": { + "name": "secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warn_days": { + "name": "warn_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_host": { + "name": "check_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_port": { + "name": "check_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_links": { + "name": "server_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "server_links_server_id_servers_id_fk": { + "name": "server_links_server_id_servers_id_fk", + "tableFrom": "server_links", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_ports": { + "name": "server_ports", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tcp'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open": { + "name": "open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_seen_open_at": { + "name": "last_seen_open_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "server_ports_unique": { + "name": "server_ports_unique", + "columns": [ + "server_id", + "port", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "server_ports_server_id_servers_id_fk": { + "name": "server_ports_server_id_servers_id_fk", + "tableFrom": "server_ports", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os_type": { + "name": "os_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'linux'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_token_hash": { + "name": "api_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token_prefix": { + "name": "api_token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_model": { + "name": "cpu_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_cores": { + "name": "cpu_cores", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_load_percent": { + "name": "cpu_load_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_total_bytes": { + "name": "mem_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_used_bytes": { + "name": "mem_used_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disks": { + "name": "disks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "listening_ports": { + "name": "listening_ports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_port_scan": { + "name": "last_port_scan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_integration_id": { + "name": "proxmox_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_node": { + "name": "proxmox_node", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_guest_type": { + "name": "proxmox_guest_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_vmid": { + "name": "proxmox_vmid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_proxmox_link": { + "name": "hide_proxmox_link", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "servers_proxmox_integration_id_integrations_id_fk": { + "name": "servers_proxmox_integration_id_integrations_id_fk", + "tableFrom": "servers", + "tableTo": "integrations", + "columnsFrom": [ + "proxmox_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_login_at": { + "name": "last_login_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_oidc_sub_unique": { + "name": "users_oidc_sub_unique", + "columns": [ + "oidc_sub" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 252206f..a3d3b6d 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1790384497980, "tag": "0010_magenta_alice", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1790385846612, + "tag": "0011_strange_mongoose", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index d76c710..0d21a94 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -326,3 +326,25 @@ export const serverPorts = sqliteTable( }, (t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)], ); + +// ─── Domain registrations ─────────────────────────────────────────────────── + +export const domainOrigins = ["manual", "zone"] as const; +export type DomainOrigin = (typeof domainOrigins)[number]; + +// A registered domain whose expiry we track. "zone" rows are created from the DNS zones already synced from the +// providers (and removed again when the zone goes away); "manual" rows were typed in — for domains whose DNS lives elsewhere. +export const domains = sqliteTable("domains", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull().unique(), // the registrable domain, lowercase ASCII + origin: text("origin").$type().notNull().default("manual"), + expiresAt: text("expires_at"), // YYYY-MM-DD (UTC), as reported by the registry + registrar: text("registrar"), + lookupSource: text("lookup_source"), // "rdap" | "whois" + lastCheckedAt: text("last_checked_at"), + lastCheckedOkAt: text("last_checked_ok_at"), + lastCheckError: text("last_check_error"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); diff --git a/server/src/index.ts b/server/src/index.ts index 7e95b12..a614525 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,6 +24,7 @@ import { settingsRouter } from "./routes/settings.js"; import { searchRouter } from "./routes/search.js"; import { sessionsRouter } from "./routes/sessions.js"; import { maintenanceRouter } from "./routes/maintenance.js"; +import { domainsRouter } from "./routes/domains.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; @@ -92,6 +93,7 @@ app.use("/api/settings", settingsRouter); app.use("/api/search", searchRouter); app.use("/api/sessions", sessionsRouter); app.use("/api/maintenance", maintenanceRouter); +app.use("/api/domains", domainsRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/domains.ts b/server/src/routes/domains.ts new file mode 100644 index 0000000..1441f62 --- /dev/null +++ b/server/src/routes/domains.ts @@ -0,0 +1,81 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { domains } from "../db/schema.js"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { addManualDomain, checkDomain, domainStatus, isDomainCheckRunning, refreshAllDomains, type DomainRow } from "../services/domainMonitor.js"; +import { getSettings } from "../services/settingsStore.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const domainsRouter = Router(); +domainsRouter.use(requireAuth); + +function present(row: DomainRow, warnDays: number) { + return { ...row, ...domainStatus(row, warnDays) }; +} + +async function listPresented() { + const { healthChecks } = await getSettings(); + const rows = await db.select().from(domains).orderBy(domains.name); + return { domains: rows.map((r) => present(r, healthChecks.domainWarnDays)), warnDays: healthChecks.domainWarnDays, checking: isDomainCheckRunning() }; +} + +domainsRouter.get("/", asyncHandler(async (_req, res) => { + res.json(await listPresented()); +})); + +const addSchema = z.object({ name: z.string().min(1).max(253) }); + +domainsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { + const parsed = addSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Enter a domain name.", details: parsed.error.flatten() }); + + const result = await addManualDomain(parsed.data.name); + if (!result.ok) return res.status(result.status).json({ error: "cannot_add", message: result.message }); + + await recordAudit({ + actor: req.currentUser!, + category: "domain", + action: "add", + targetType: "domain", + targetId: result.row.id, + detail: { name: result.row.name, expiresAt: result.row.expiresAt }, + }); + const { healthChecks } = await getSettings(); + res.status(201).json({ domain: present(result.row, healthChecks.domainWarnDays), resolvedFrom: result.resolvedFrom }); +})); + +// Registered before "/:id/..." so "refresh" isn't taken for an id. +domainsRouter.post("/refresh", requireRole("operator"), asyncHandler(async (req, res) => { + const started = await refreshAllDomains(); + if (!started) return res.status(409).json({ error: "already_running", message: "A domain check is already running." }); + await recordAudit({ actor: req.currentUser!, category: "domain", action: "refresh_all", targetType: "domain", detail: {} }); + res.json(await listPresented()); +})); + +domainsRouter.post("/:id/check", requireRole("operator"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); + const row = await checkDomain(id); + if (!row) return res.status(404).json({ error: "not_found" }); + const { healthChecks } = await getSettings(); + res.json({ domain: present(row, healthChecks.domainWarnDays) }); +})); + +domainsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); + const [row] = await db.select().from(domains).where(eq(domains.id, id)).limit(1); + if (!row) return res.status(404).json({ error: "not_found" }); + if (row.origin === "zone") { + return res.status(409).json({ + error: "zone_domain", + message: `${row.name} is tracked because a DNS zone for it is configured. It goes away by itself when that zone is removed.`, + }); + } + await db.delete(domains).where(eq(domains.id, id)); + await recordAudit({ actor: req.currentUser!, category: "domain", action: "remove", targetType: "domain", targetId: id, detail: { name: row.name } }); + res.status(204).end(); +})); diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 7ceae38..798c864 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -70,6 +70,7 @@ const updateSchema = z.object({ proxmoxBackupCheck: z.boolean(), healthAlerts: z.boolean(), automationAlerts: z.boolean(), + domainExpiryCheck: z.boolean(), secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/), timezone: z.string(), integrationFailureAlerts: z.boolean(), @@ -88,7 +89,7 @@ const updateSchema = z.object({ .partial() .optional(), healthChecks: z - .object({ serverOfflineMinutes: z.number().int().min(15).max(10080), diskUsagePercent: z.number().int().min(50).max(99) }) + .object({ serverOfflineMinutes: z.number().int().min(15).max(10080), diskUsagePercent: z.number().int().min(50).max(99), domainWarnDays: z.number().int().min(1).max(365) }) .partial() .optional(), quietHours: z diff --git a/server/src/services/domainLookup.ts b/server/src/services/domainLookup.ts new file mode 100644 index 0000000..722d080 --- /dev/null +++ b/server/src/services/domainLookup.ts @@ -0,0 +1,235 @@ +import * as net from "node:net"; +import { domainToASCII } from "node:url"; + +/** + * Looks up when a domain registration expires. RDAP (the JSON successor to WHOIS) is used wherever the TLD + * publishes an RDAP server in IANA's bootstrap file. Plenty of TLDs don't — notably .se, .nu, .de, .io and .eu — + * so those fall back to classic WHOIS on port 43, found through IANA's own referral, and the expiry line is + * parsed out of the free-text answer. Only the expiry date and registrar are kept; nothing else about the + * registrant is read or stored. + */ + +export type LookupResult = + | { ok: true; domain: string; expiresAt: string | null; registrar: string | null; source: "rdap" | "whois" } + /** unsupported: no RDAP or WHOIS server for the TLD. not_found: the registry has no such domain. error: couldn't ask (network, rate limit, garbled answer) — says nothing about whether it exists. */ + | { ok: false; reason: "unsupported" | "not_found" | "error"; message: string }; + +export interface LookupDeps { + fetchJson: (url: string) => Promise<{ status: number; json: any }>; + whoisQuery: (server: string, query: string) => Promise; + now: () => number; +} + +const BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"; +const BOOTSTRAP_TTL_MS = 24 * 60 * 60 * 1000; +const USER_AGENT = "homelab-manager (domain expiry check)"; + +export const defaultDeps: LookupDeps = { + async fetchJson(url) { + const res = await fetch(url, { + headers: { Accept: "application/rdap+json, application/json", "User-Agent": USER_AGENT }, + redirect: "follow", + signal: AbortSignal.timeout(15_000), + }); + const text = await res.text(); + let json: any = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + // not JSON (an HTML error page, say) + } + return { status: res.status, json }; + }, + whoisQuery(server, query) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: server, port: 43 }); + let out = ""; + socket.setTimeout(12_000, () => { + socket.destroy(); + reject(new Error(`Timed out asking ${server}`)); + }); + socket.on("connect", () => socket.write(`${query}\r\n`)); + socket.on("data", (chunk) => { + out += chunk; + if (out.length > 200_000) socket.destroy(); // a WHOIS answer is a few KB; anything larger isn't one + }); + socket.on("end", () => resolve(out)); + socket.on("close", () => resolve(out)); + socket.on("error", reject); + }); + }, + now: () => Date.now(), +}; + +// ─── Names ────────────────────────────────────────────────────────────────── + +/** Lowercased ASCII (punycode) form of a domain, or null if it isn't a plausible registrable name. */ +export function normalizeDomain(input: string): string | null { + const trimmed = input.trim().toLowerCase().replace(/\.$/, ""); + if (!trimmed) return null; + // domainToASCII quietly drops anything after a "/" or "?" — a pasted URL would be accepted as its host. Refuse instead. + if (/[\s/\:@?#]/.test(trimmed)) return null; + const ascii = domainToASCII(trimmed); + if (!ascii || ascii.length > 253) return null; + const labels = ascii.split("."); + if (labels.length < 2) return null; + if (!labels.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return null; + return ascii; +} + +/** The name itself, then each parent with one fewer label, stopping at two labels: "a.b.example.se" → a.b.example.se, b.example.se, example.se. */ +export function candidateNames(domain: string): string[] { + const labels = domain.split("."); + const out: string[] = []; + for (let i = 0; i <= labels.length - 2; i++) out.push(labels.slice(i).join(".")); + return out; +} + +function tldOf(domain: string): string { + return domain.slice(domain.lastIndexOf(".") + 1); +} + +// ─── RDAP ─────────────────────────────────────────────────────────────────── + +let bootstrapCache: { at: number; byTld: Map } | null = null; + +async function rdapBase(tld: string, deps: LookupDeps): Promise { + if (!bootstrapCache || deps.now() - bootstrapCache.at > BOOTSTRAP_TTL_MS) { + try { + const { status, json } = await deps.fetchJson(BOOTSTRAP_URL); + if (status !== 200 || !Array.isArray(json?.services)) throw new Error(`HTTP ${status}`); + const byTld = new Map(); + for (const [tlds, urls] of json.services as [string[], string[]][]) { + const url = urls.find((u) => u.startsWith("https://")) ?? urls[0]; + if (url) for (const t of tlds) byTld.set(t.toLowerCase(), url.endsWith("/") ? url : `${url}/`); + } + bootstrapCache = { at: deps.now(), byTld }; + } catch (err) { + // A stale list is far better than none — it changes rarely. + if (!bootstrapCache) throw new Error(`Couldn't load IANA's RDAP server list: ${err instanceof Error ? err.message : err}`); + } + } + return bootstrapCache!.byTld.get(tld) ?? null; +} + +function rdapFromJson(json: any, domain: string): LookupResult { + const events: { eventAction?: string; eventDate?: string }[] = Array.isArray(json?.events) ? json.events : []; + const expiry = events.find((e) => /^expiration$|^expiry$/i.test(e.eventAction ?? ""))?.eventDate; + const time = expiry ? Date.parse(expiry) : NaN; + const registrarEntity = (Array.isArray(json?.entities) ? json.entities : []).find((e: any) => (e.roles ?? []).includes("registrar")); + const fn = registrarEntity?.vcardArray?.[1]?.find((v: unknown[]) => v[0] === "fn")?.[3]; + return { + ok: true, + domain, + expiresAt: Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : null, + registrar: typeof fn === "string" && fn ? fn : null, + source: "rdap", + }; +} + +// ─── WHOIS ────────────────────────────────────────────────────────────────── + +const whoisServerCache = new Map(); + +async function whoisServerFor(tld: string, deps: LookupDeps): Promise { + const cached = whoisServerCache.get(tld); + if (cached && deps.now() - cached.at < BOOTSTRAP_TTL_MS) return cached.server; + const answer = await deps.whoisQuery("whois.iana.org", tld); + const server = /^whois:\s*(\S+)/im.exec(answer)?.[1] ?? null; + whoisServerCache.set(tld, { at: deps.now(), server }); + return server; +} + +const MONTHS: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }; + +/** The date formats registries actually use, as a YYYY-MM-DD string (or null). */ +export function parseWhoisDate(raw: string): string | null { + const s = raw.trim(); + let m = /^(\d{4})[-./](\d{1,2})[-./](\d{1,2})/.exec(s); // 2031-03-09, 2031.03.09, 2027-08-13T04:00:00Z + if (m) return ymd(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + m = /^(\d{1,2})[-\s]([A-Za-z]{3})[a-z]*[-\s](\d{4})/.exec(s); // 13-Aug-2027, 13 August 2027 + if (m && MONTHS[m[2].toLowerCase()] !== undefined) return ymd(Number(m[3]), MONTHS[m[2].toLowerCase()], Number(m[1])); + m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})/.exec(s); // 13.08.2027 + if (m) return ymd(Number(m[3]), Number(m[2]) - 1, Number(m[1])); + return null; +} + +function ymd(y: number, mo: number, d: number): string | null { + const date = new Date(Date.UTC(y, mo, d)); + if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo || date.getUTCDate() !== d) return null; + return date.toISOString().slice(0, 10); +} + +const EXPIRY_LINE = /^\s*(?:registry\s+expiry\s+date|registrar\s+registration\s+expiration\s+date|expiration\s+date|expiry\s+date|expire\s+date|expires(?:\s+on)?|expire|paid-till|renewal\s+date)\s*:\s*(.+?)\s*$/i; +const NOT_FOUND = /no match|not found|no entries found|no data found|domain not found|status:\s*(?:free|available)|is free|no object found/i; + +export function parseWhois(text: string, domain: string): LookupResult { + let expiresAt: string | null = null; + for (const line of text.split(/\r?\n/)) { + const m = EXPIRY_LINE.exec(line); + if (m) { + expiresAt = parseWhoisDate(m[1]); + if (expiresAt) break; + } + } + const registrar = /^\s*registrar(?:\s+name)?\s*:\s*(.+?)\s*$/im.exec(text)?.[1] ?? null; + if (expiresAt) return { ok: true, domain, expiresAt, registrar, source: "whois" }; + if (NOT_FOUND.test(text)) return { ok: false, reason: "not_found", message: `${domain} isn't registered.` }; + // A server pushing back on us isn't the same as a registry that doesn't publish expiry dates. + if (/quota|rate.?limit|too many|exceeded|access denied|blocked|try again/i.test(text)) { + return { ok: false, reason: "error", message: `The WHOIS server for ${domain} refused the query (rate limited?). It will be retried.` }; + } + // Answered, but with no expiry we can read (e.g. .de and .eu don't publish one). + return { ok: true, domain, expiresAt: null, registrar, source: "whois" }; +} + +// ─── Lookup ───────────────────────────────────────────────────────────────── + +/** Looks up exactly this name (no parent walking). */ +export async function lookupRegistration(domain: string, deps: LookupDeps = defaultDeps): Promise { + const tld = tldOf(domain); + try { + const base = await rdapBase(tld, deps); + if (base) { + const { status, json } = await deps.fetchJson(`${base}domain/${encodeURIComponent(domain)}`); + if (status === 200 && json) return rdapFromJson(json, domain); + if (status === 404) return { ok: false, reason: "not_found", message: `${domain} isn't registered.` }; + return { ok: false, reason: "error", message: `The RDAP server for .${tld} answered HTTP ${status}.` }; + } + + const server = await whoisServerFor(tld, deps); + if (!server) return { ok: false, reason: "unsupported", message: `.${tld} has no public RDAP or WHOIS server to ask.` }; + return parseWhois(await deps.whoisQuery(server, domain), domain); + } catch (err) { + return { ok: false, reason: "error", message: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Finds the registration a name belongs to by trying it and then its parents ("lab.example.se" → "example.se"), + * so a DNS zone that's a subdomain still resolves to the domain that actually expires — without needing a + * public-suffix list. A transient error anywhere means we can't conclude "not registered". + */ +export async function resolveRegistration(name: string, deps: LookupDeps = defaultDeps): Promise { + let sawError: LookupResult | null = null; + let sawNotFound: LookupResult | null = null; + let firstUnsupported: LookupResult | null = null; + for (const candidate of candidateNames(name)) { + const result = await lookupRegistration(candidate, deps); + if (result.ok) return result; + if (result.reason === "unsupported") { + // Every candidate shares the TLD, so nothing further up can be answered either. + firstUnsupported = result; + break; + } + if (result.reason === "error") sawError = sawError ?? result; + else sawNotFound = sawNotFound ?? result; + } + return sawError ?? firstUnsupported ?? sawNotFound ?? { ok: false, reason: "not_found", message: `${name} isn't registered.` }; +} + +/** For tests: forget cached RDAP/WHOIS server lists. */ +export function resetLookupCaches(): void { + bootstrapCache = null; + whoisServerCache.clear(); +} diff --git a/server/src/services/domainMonitor.ts b/server/src/services/domainMonitor.ts new file mode 100644 index 0000000..64fac42 --- /dev/null +++ b/server/src/services/domainMonitor.ts @@ -0,0 +1,200 @@ +import { eq, inArray } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { dnsZonesCache, domains } from "../db/schema.js"; +import { defaultDeps, lookupRegistration, normalizeDomain, resolveRegistration, type LookupDeps, type LookupResult } from "./domainLookup.js"; +import { computeSecretStatus } from "./secretStatus.js"; +import { notifyDomainExpiry } from "./notify.js"; +import { getSettings } from "./settingsStore.js"; + +export type DomainRow = typeof domains.$inferSelect; + +/** An expiry date that couldn't be refreshed for this long is worth a mention alongside the reminders. */ +const STALE_AFTER_MS = 3 * 24 * 60 * 60 * 1000; +const REFRESH_AFTER_MS = 24 * 60 * 60 * 1000; +/** Registries rate-limit; one lookup at a time with a short pause is plenty for a handful of domains. */ +const PAUSE_MS = 250; + +const NO_EXPIRY_PUBLISHED = "The registry doesn't publish an expiry date for this domain."; + +let running = false; +export const isDomainCheckRunning = () => running; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ─── Applying a lookup result to a row ────────────────────────────────────── + +function outcomeFields(result: LookupResult, row: Pick, now: string) { + if (result.ok) { + return { + expiresAt: result.expiresAt ?? row.expiresAt, + registrar: result.registrar ?? row.registrar, + lookupSource: result.source, + lastCheckedAt: now, + lastCheckedOkAt: now, + // The registry answered — it just doesn't say when the domain expires (.de and .eu, for instance). + lastCheckError: result.expiresAt ? null : NO_EXPIRY_PUBLISHED, + }; + } + // Keep the last known expiry: a failed lookup must not blank out a date we still rely on. + return { + lastCheckedAt: now, + lastCheckError: result.reason === "not_found" ? `No longer found in the registry — has it expired and been deleted? (${result.message})` : result.message, + }; +} + +/** Re-reads one tracked domain from its registry and records the outcome on its row. */ +export async function checkDomain(id: number, deps: LookupDeps = defaultDeps): Promise { + const [row] = await db.select().from(domains).where(eq(domains.id, id)).limit(1); + if (!row) return null; + const result = await lookupRegistration(row.name, deps); + const [updated] = await db.update(domains).set(outcomeFields(result, row, new Date(deps.now()).toISOString())).where(eq(domains.id, id)).returning(); + return updated; +} + +/** Finds the registration a typed name belongs to and starts tracking it. */ +export async function addManualDomain( + input: string, + deps: LookupDeps = defaultDeps, +): Promise<{ ok: true; row: DomainRow; resolvedFrom: string | null } | { ok: false; status: 400 | 409 | 422 | 502; message: string }> { + const name = normalizeDomain(input); + if (!name) return { ok: false, status: 400, message: `"${input.trim()}" doesn't look like a domain name — use something like example.com.` }; + + const result = await resolveRegistration(name, deps); + if (!result.ok) { + if (result.reason === "error") return { ok: false, status: 502, message: `Couldn't look up ${name}: ${result.message}` }; + return { + ok: false, + status: 422, + message: result.reason === "unsupported" ? `Can't track ${name}: ${result.message}` : `${name} isn't registered (or its registry doesn't answer for it).`, + }; + } + + const [existing] = await db.select().from(domains).where(eq(domains.name, result.domain)).limit(1); + if (existing) return { ok: false, status: 409, message: `${result.domain} is already being tracked.` }; + + const now = new Date(deps.now()).toISOString(); + const [row] = await db + .insert(domains) + .values({ + name: result.domain, + origin: "manual", + expiresAt: result.expiresAt, + registrar: result.registrar, + lookupSource: result.source, + lastCheckedAt: now, + lastCheckedOkAt: now, + lastCheckError: result.expiresAt ? null : NO_EXPIRY_PUBLISHED, + }) + .returning(); + return { ok: true, row, resolvedFrom: result.domain !== name ? name : null }; +} + +// ─── Domains that come from DNS zones ─────────────────────────────────────── + +/** + * Makes sure every DNS zone we already know about is covered by a tracked domain, and drops zone-derived rows + * nothing covers any more. A zone like "lab.example.se" is covered by the row for "example.se". Zones that don't + * resolve to a real registration (".lan", ".local", a domain that isn't registered) simply don't get a row. + * Returns the ids of rows it just created, so the caller needn't look them up a second time. + */ +export async function syncZoneDomains(deps: LookupDeps = defaultDeps, pauseMs = PAUSE_MS): Promise> { + const zoneNames = [...new Set((await db.select({ name: dnsZonesCache.zoneName }).from(dnsZonesCache)).map((z) => z.name))] + .map((n) => normalizeDomain(n)) + .filter((n): n is string => n !== null); + + const covers = (rowName: string, zone: string) => zone === rowName || zone.endsWith(`.${rowName}`); + const created = new Set(); + + for (const zone of zoneNames) { + const existing = await db.select({ name: domains.name }).from(domains); + if (existing.some((r) => covers(r.name, zone))) continue; + + if (pauseMs) await sleep(pauseMs); + const result = await resolveRegistration(zone, deps); + if (!result.ok) { + if (result.reason === "error") console.error(`[domains] couldn't look up zone ${zone}: ${result.message}`); + continue; + } + if (existing.some((r) => r.name === result.domain)) continue; + const now = new Date(deps.now()).toISOString(); + const [row] = await db + .insert(domains) + .values({ + name: result.domain, + origin: "zone", + expiresAt: result.expiresAt, + registrar: result.registrar, + lookupSource: result.source, + lastCheckedAt: now, + lastCheckedOkAt: now, + lastCheckError: result.expiresAt ? null : NO_EXPIRY_PUBLISHED, + }) + .returning(); + created.add(row.id); + } + + const zoneRows = await db.select().from(domains).where(eq(domains.origin, "zone")); + const orphaned = zoneRows.filter((r) => !zoneNames.some((z) => covers(r.name, z))).map((r) => r.id); + if (orphaned.length > 0) await db.delete(domains).where(inArray(domains.id, orphaned)); + return created; +} + +// ─── Refreshing everything ────────────────────────────────────────────────── + +/** Syncs zone-derived domains, then re-reads every tracked domain. One run at a time. Returns false if one was already running. */ +export async function refreshAllDomains(deps: LookupDeps = defaultDeps, pauseMs = PAUSE_MS): Promise { + if (running) return false; + running = true; + try { + const fresh = await syncZoneDomains(deps, pauseMs); + for (const row of await db.select({ id: domains.id }).from(domains)) { + if (fresh.has(row.id)) continue; + if (pauseMs) await sleep(pauseMs); + await checkDomain(row.id, deps); + } + return true; + } finally { + running = false; + } +} + +/** At startup: catch up if nothing has been read in the last day (a fresh upgrade, or the app was off over the daily check). Not awaited by the caller. */ +export async function refreshDomainsIfStale(now: number = Date.now()): Promise { + const rows = await db.select({ checkedAt: domains.lastCheckedAt }).from(domains); + const stale = rows.length === 0 || rows.some((r) => !r.checkedAt || now - Date.parse(r.checkedAt) > REFRESH_AFTER_MS); + if (stale) await refreshAllDomains(); +} + +// ─── Alerts ───────────────────────────────────────────────────────────────── + +export function domainStatus(row: Pick, warnDays: number, now: Date = new Date()) { + if (!row.expiresAt) return { status: "unknown" as const, daysLeft: null }; + return computeSecretStatus(row.expiresAt, warnDays, now); +} + +export async function collectDomainAlerts(now: Date = new Date()) { + const { healthChecks } = await getSettings(); + const rows = await db.select().from(domains); + + const expiring = rows.flatMap((r) => { + const s = domainStatus(r, healthChecks.domainWarnDays, now); + return s.status === "expired" || s.status === "expiring" + ? [{ name: r.name, status: s.status, daysLeft: s.daysLeft!, expiresAt: r.expiresAt!, registrar: r.registrar }] + : []; + }); + + // Only failures that have gone on a while: one bad night at the registry isn't news, several days of a possibly stale date is. + const staleChecks = rows.flatMap((r) => { + if (!r.lastCheckError || r.lastCheckError === NO_EXPIRY_PUBLISHED) return []; + const since = Date.parse(r.lastCheckedOkAt ?? r.createdAt.replace(" ", "T") + "Z"); + return now.getTime() - since > STALE_AFTER_MS ? [{ name: r.name, error: r.lastCheckError, lastOkAt: r.lastCheckedOkAt }] : []; + }); + return { expiring, staleChecks }; +} + +/** The daily pass: re-read every domain, then remind about the ones that need renewing. */ +export async function runDomainCheck(deps: LookupDeps = defaultDeps, pauseMs = PAUSE_MS): Promise { + await refreshAllDomains(deps, pauseMs); + const { expiring, staleChecks } = await collectDomainAlerts(); + await notifyDomainExpiry(expiring, staleChecks); +} diff --git a/server/src/services/notify.ts b/server/src/services/notify.ts index 5f9c739..a02269c 100644 --- a/server/src/services/notify.ts +++ b/server/src/services/notify.ts @@ -313,6 +313,31 @@ export async function notifyAutomationRecovered(resolved: { message: string }[]) ); } +export async function notifyDomainExpiry( + expiring: { name: string; status: "expired" | "expiring"; daysLeft: number; expiresAt: string; registrar: string | null }[], + staleChecks: { name: string; error: string; lastOkAt: string | null }[] = [], +): Promise { + if (expiring.length === 0 && staleChecks.length === 0) return; + if (!(await eventEnabled("domainExpiryCheck"))) return; + const sections: string[] = []; + if (expiring.length > 0) { + const lines = expiring.map((d) => { + const registrar = d.registrar ? ` [${d.registrar}]` : ""; + return d.status === "expired" + ? `✕ EXPIRED ${d.expiresAt} — ${d.name}${registrar}` + : `⚠ ${d.daysLeft}d left (${d.expiresAt}) — ${d.name}${registrar}`; + }); + sections.push(`${expiring.length} domain${expiring.length !== 1 ? "s" : ""} need renewing:\n\n${lines.join("\n")}`); + } + if (staleChecks.length > 0) { + const lines = staleChecks.map((s) => `⚠ ${s.name} — ${s.error}${s.lastOkAt ? ` (last read ${s.lastOkAt.slice(0, 10)})` : " (never read)"}`); + sections.push( + `Couldn't refresh the registration for ${staleChecks.length} domain${staleChecks.length !== 1 ? "s" : ""} — the expiry shown may be stale:\n\n${lines.join("\n")}`, + ); + } + await notify("Homelab Manager — Domain Expiry", sections.join("\n\n")); +} + export async function notifyTailscaleKeyExpiry( expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[], ): Promise { diff --git a/server/src/services/secretExpiryScheduler.ts b/server/src/services/secretExpiryScheduler.ts index 36e11a6..a0920c4 100644 --- a/server/src/services/secretExpiryScheduler.ts +++ b/server/src/services/secretExpiryScheduler.ts @@ -5,6 +5,7 @@ import { computeSecretStatus } from "./secretStatus.js"; import { notifySecretExpiry } from "./notify.js"; import { refreshTlsSecrets, type TlsCheckResult } from "./tlsCheck.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; +import { refreshDomainsIfStale, runDomainCheck } from "./domainMonitor.js"; const LAST_RUN_FLAG = "secretCheckLastRunDate"; @@ -28,6 +29,10 @@ async function checkSecretExpiry(): Promise { .map((s) => ({ name: s.name, ...computeSecretStatus(s.expiryDate, s.warnDays) })) .filter((s): s is typeof s & { status: "expired" | "expiring" } => s.status === "expired" || s.status === "expiring"); await notifySecretExpiry(expiring, checkFailures); + + // Domain registrations ride on the same daily schedule and reminder time. Not awaited: this runs at startup + // too, and talking to registries (one at a time, politely) shouldn't hold the server up. + runDomainCheck().catch((err) => console.error("[domains] check failed:", err)); } async function checkSecretExpiryOnce(): Promise { @@ -63,4 +68,6 @@ export async function scheduleSecretExpiryCheck(): Promise { export async function initSecretExpiryScheduler(): Promise { await checkSecretExpiryOnce(); await scheduleSecretExpiryCheck(); + // Not awaited: it talks to registries and must not delay startup. Catches up after an upgrade or downtime; the daily job does the reminding. + refreshDomainsIfStale().catch((err) => console.error("[domains] startup refresh failed:", err)); } diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index 0acd9b9..31d2d1d 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -45,6 +45,8 @@ export interface NotificationEvents { healthAlerts: boolean; /** A Semaphore template or Gitea repo whose latest run failed, and when it succeeds again. */ automationAlerts: boolean; + /** Daily reminder while a domain registration is close to expiring or has expired. */ + domainExpiryCheck: boolean; secretCheckTime: string; // "HH:MM" — shared by the secret-expiry, Tailscale key-expiry, Docker update, and Proxmox backup checks timezone: string; integrationFailureAlerts: boolean; @@ -86,6 +88,8 @@ export interface HealthCheckSettings { serverOfflineMinutes: number; /** Disk / storage / volume usage at or above this percentage is reported. */ diskUsagePercent: number; + /** A domain registration expiring within this many days is reminded about daily (same schedule as secret expiry). */ + domainWarnDays: number; } export interface AppSettings { @@ -117,6 +121,7 @@ const DEFAULTS: AppSettings = { proxmoxBackupCheck: true, healthAlerts: true, automationAlerts: true, + domainExpiryCheck: true, secretCheckTime: "08:00", timezone: "UTC", integrationFailureAlerts: true, @@ -127,7 +132,7 @@ const DEFAULTS: AppSettings = { display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 }, logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 }, quietHours: { enabled: false, start: "22:00", end: "07:00" }, - healthChecks: { serverOfflineMinutes: 60, diskUsagePercent: 90 }, + healthChecks: { serverOfflineMinutes: 60, diskUsagePercent: 90, domainWarnDays: 30 }, }; const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[]; diff --git a/web/src/App.tsx b/web/src/App.tsx index 5bdaf0b..ab2c997 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,6 +21,7 @@ import Proxmox from "./pages/Proxmox"; import Synology from "./pages/Synology"; import Generator from "./pages/Generator"; import Maintenance from "./pages/Maintenance"; +import Domains from "./pages/Domains"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; @@ -89,6 +90,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 574f9de..bbc07c0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -184,6 +184,7 @@ export interface NotificationEvents { proxmoxBackupCheck: boolean; healthAlerts: boolean; automationAlerts: boolean; + domainExpiryCheck: boolean; secretCheckTime: string; timezone: string; integrationFailureAlerts: boolean; @@ -208,6 +209,7 @@ export interface LogRetentionSettings { export interface HealthCheckSettings { serverOfflineMinutes: number; diskUsagePercent: number; + domainWarnDays: number; } export interface QuietHoursSettings { @@ -490,6 +492,27 @@ export interface GiteaRepo { latestRun: GiteaWorkflowRun | null; } +export interface DomainRecord { + id: number; + name: string; + origin: "manual" | "zone"; + expiresAt: string | null; + registrar: string | null; + lookupSource: "rdap" | "whois" | null; + lastCheckedAt: string | null; + lastCheckedOkAt: string | null; + lastCheckError: string | null; + createdAt: string; + status: "ok" | "expiring" | "expired" | "unknown"; + daysLeft: number | null; +} + +export interface DomainList { + domains: DomainRecord[]; + warnDays: number; + checking: boolean; +} + export interface DockhandContainer { id: string; name: string; @@ -854,6 +877,14 @@ export const api = { remove: (id: number, portId: number) => request(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }), }, }, + domains: { + list: () => request("/api/domains"), + add: (name: string) => + request<{ domain: DomainRecord; resolvedFrom: string | null }>("/api/domains", { method: "POST", body: JSON.stringify({ name }) }), + refresh: () => request("/api/domains/refresh", { method: "POST" }), + check: (id: number) => request<{ domain: DomainRecord }>(`/api/domains/${id}/check`, { method: "POST" }), + remove: (id: number) => request(`/api/domains/${id}`, { method: "DELETE" }), + }, tasks: { list: ( params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {}, diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index afbf1d7..e4519e8 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -23,6 +23,7 @@ import { IconMoon, IconWand, IconTool, + IconWorldWww, } from "@tabler/icons-react"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { formatRemaining } from "../utils/duration"; @@ -44,6 +45,7 @@ const NAV_ITEMS: NavItem[] = [ { to: "/synology", label: "Synology", icon: }, { to: "/secrets", label: "Secrets", icon: }, { to: "/dns", label: "DNS", icon: }, + { to: "/domains", label: "Domains", icon: }, { to: "/ipam", label: "IP Addresses", icon: }, { to: "/tailscale", label: "Tailscale", icon: }, { to: "/semaphore", label: "Semaphore", icon: }, diff --git a/web/src/pages/Domains.tsx b/web/src/pages/Domains.tsx new file mode 100644 index 0000000..94e6cb6 --- /dev/null +++ b/web/src/pages/Domains.tsx @@ -0,0 +1,246 @@ +import { useEffect, useState } from "react"; +import { api, type CurrentUser, type DomainList, type DomainRecord } from "../api/client"; +import { downloadCsv } from "../utils/csv"; +import { formatDateTime } from "../utils/date"; +import { formatAgo } from "../utils/duration"; +import { readableError } from "../utils/errors"; +import { useSortable } from "../hooks/useSortable"; +import { usePagination } from "../hooks/usePagination"; +import SortableTh from "../components/SortableTh"; +import Pagination from "../components/Pagination"; + +function statusBadge(d: DomainRecord) { + switch (d.status) { + case "expired": + return Expired {Math.abs(d.daysLeft ?? 0)} d ago; + case "expiring": + return {d.daysLeft} d left; + case "ok": + return {d.daysLeft} d left; + default: + return Unknown; + } +} + +export default function Domains({ user }: { user: CurrentUser }) { + const canEdit = user.role === "admin" || user.role === "operator"; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [name, setName] = useState(""); + const [adding, setAdding] = useState(false); + const [refreshingAll, setRefreshingAll] = useState(false); + const [checkingId, setCheckingId] = useState(null); + + useEffect(() => { + api.domains + .list() + .then(setData) + .catch((err) => setError(readableError(err))); + }, []); + + const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.domains, "expiresAt"); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); + + async function add(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setNotice(null); + setAdding(true); + try { + const res = await api.domains.add(name.trim()); + setName(""); + if (res.resolvedFrom) setNotice(`${res.resolvedFrom} is part of ${res.domain.name}, so that's the registration being tracked.`); + setData(await api.domains.list()); + } catch (err) { + setError(readableError(err)); + } finally { + setAdding(false); + } + } + + async function refreshAll() { + setError(null); + setNotice(null); + setRefreshingAll(true); + try { + setData(await api.domains.refresh()); + } catch (err) { + setError(readableError(err)); + } finally { + setRefreshingAll(false); + } + } + + async function check(d: DomainRecord) { + setError(null); + setNotice(null); + setCheckingId(d.id); + try { + await api.domains.check(d.id); + setData(await api.domains.list()); + } catch (err) { + setError(readableError(err)); + } finally { + setCheckingId(null); + } + } + + async function remove(d: DomainRecord) { + if (!confirm(`Stop tracking ${d.name}?`)) return; + setError(null); + try { + await api.domains.remove(d.id); + setData(await api.domains.list()); + } catch (err) { + setError(readableError(err)); + } + } + + function exportCsv() { + if (!sorted) return; + downloadCsv( + "domains.csv", + ["Domain", "Expires", "Days left", "Registrar", "Source", "Origin", "Last checked", "Problem"], + sorted.map((d) => [ + d.name, + d.expiresAt ?? "", + d.daysLeft ?? "", + d.registrar ?? "", + d.lookupSource ?? "", + d.origin === "zone" ? "DNS zone" : "manual", + d.lastCheckedAt ? formatDateTime(new Date(d.lastCheckedAt)) : "", + d.lastCheckError ?? "", + ]), + ); + } + + return ( + <> +
+

Domains

+ {canEdit && ( + + )} +
+
+ When each domain registration expires, read from the registry itself. Domains for the DNS zones you've added show up + here automatically; add any others by hand. +
+ {error &&
{error}
} + {notice &&
{notice}
} + + {canEdit && ( +
+
+
+ + setName(e.target.value)} + /> +
+
+ +
+
+
+ )} + +
+
+

Registrations

+
+ +
+
+
+ + + + label="Domain" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Expires" sortKeyName="expiresAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Registrar" sortKeyName="registrar" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Last checked" sortKeyName="lastCheckedAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + {canEdit && } + + + + {pageItems?.map((d) => ( + + + + + + {canEdit && ( + + )} + + ))} + {data && data.domains.length === 0 && ( + + + + )} + +
Actions
+ {d.name} + {d.origin === "zone" ? "DNS zone" : "Manual"} + + {d.expiresAt ? ( + <> + {statusBadge(d)} {d.expiresAt} + + ) : ( + — + )} + + {d.registrar ?? "—"} + {d.lookupSource && · {d.lookupSource.toUpperCase()}} + + {d.lastCheckedAt ? ( + + {formatAgo(d.lastCheckedAt)} + + ) : ( + not yet + )} + {d.lastCheckError &&
{d.lastCheckError}
} +
+
+ + {d.origin === "manual" && ( + + )} +
+
+ No domains tracked yet. Ones for your DNS zones appear after the next daily check + {canEdit ? " — or use “Check all now”" : ""}. +
+
+ +
+ + {data && ( +
+ Expiry is read daily from the registry — over RDAP where the TLD offers it, otherwise from its WHOIS server (.se and + .nu, for example). You're reminded every day from {data.warnDays} days before a domain expires until it's renewed. A + registry that doesn't publish expiry dates (.de, .eu) can be tracked but has no date to warn about. +
+ )} + + ); +} diff --git a/web/src/pages/settings/NotificationSettings.tsx b/web/src/pages/settings/NotificationSettings.tsx index 78ecdc5..c748eb1 100644 --- a/web/src/pages/settings/NotificationSettings.tsx +++ b/web/src/pages/settings/NotificationSettings.tsx @@ -48,13 +48,14 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = { proxmoxBackupCheck: true, healthAlerts: true, automationAlerts: true, + domainExpiryCheck: true, secretCheckTime: "08:00", timezone: "UTC", integrationFailureAlerts: true, integrationFailureThreshold: 3, }; const DEFAULT_QUIET_HOURS: QuietHoursSettings = { enabled: false, start: "22:00", end: "07:00" }; -const DEFAULT_HEALTH_CHECKS: HealthCheckSettings = { serverOfflineMinutes: 60, diskUsagePercent: 90 }; +const DEFAULT_HEALTH_CHECKS: HealthCheckSettings = { serverOfflineMinutes: 60, diskUsagePercent: 90, domainWarnDays: 30 }; type TestResult = { ok: boolean; message: string } | null; @@ -527,6 +528,7 @@ export default function NotificationSettings() { { key: "dockerUpdateCheck" as const, label: "Docker image update available" }, { key: "proxmoxBackupCheck" as const, label: "Proxmox backup failed or a guest has no coverage" }, { key: "healthAlerts" as const, label: "Server offline, disk nearly full, or Synology volume/disk problem" }, + { key: "domainExpiryCheck" as const, label: "Domain registration expiring or expired (daily reminder)" }, { key: "automationAlerts" as const, label: "Semaphore template or Gitea workflow run failed" }, { key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" }, ].map(({ key, label }) => ( @@ -593,10 +595,30 @@ export default function NotificationSettings() {
Servers, Proxmox storage, Synology volumes. Checked every 15 min; a recovery notice follows.
+
+ +
+ setHealthChecks((h) => ({ ...h, domainWarnDays: Number(e.target.value) }))} + /> + days before +
+
Reminded daily from then until it's renewed.
+
{(() => { const dailyChecksEnabled = - notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck || notifications.proxmoxBackupCheck; + notifications.secretCheck || + notifications.tailscaleKeyCheck || + notifications.dockerUpdateCheck || + notifications.proxmoxBackupCheck || + notifications.domainExpiryCheck; return (