From ca0fa817f8c59cbf4509447ca5f0350208dee106 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 26 Sep 2026 03:31:02 +0200 Subject: [PATCH] Track domain registration expiry, with daily reminders New Domains page listing when each domain registration expires, read from the registry. Domains behind the DNS zones already synced are picked up automatically; others can be added by hand. You're reminded daily from N days before expiry (Settings > Notifications, default 30) until it's renewed, and told when an expiry date hasn't been refreshable for several days so a stale date isn't trusted silently. RDAP alone would not have covered this homelab: .se, .nu, .io, .eu and .de are not in IANA's RDAP bootstrap. Lookups therefore try RDAP where the TLD publishes a server and fall back to WHOIS on port 43, found via IANA's own referral, parsing the expiry line out of the free-text answer. Only the expiry date and registrar are read or stored. Verified live against the real registries: .se and .nu via WHOIS, .com/.org/.dev via RDAP. Behaviour worth knowing: - A DNS zone that is a subdomain (lab.example.se) resolves to the registration that actually expires by trying the name and then its parents, so no public-suffix list is needed. Zones already covered by a tracked domain are not looked up again. - "Couldn't ask" is never confused with "not registered": network errors, rate limits and garbled answers are errors, and a transient error at any level stops the walk from concluding the domain doesn't exist. - A failed refresh keeps the last known expiry and records why, rather than blanking a date that's still relied on. - Zones that don't resolve to a real registration (.lan, .local, unregistered names) simply get no row. Zone-derived rows disappear when their zone does; manual rows stay. Zone-derived rows can't be deleted by hand. - Registries that don't publish an expiry (.de, .eu) are tracked with a note instead of a date. - Input like "example.com/path" is refused rather than silently reduced to its host. - Runs on the daily secret-expiry schedule and reminder time, on demand (Check all now / per domain), and once at startup if nothing has been read in a day. Never blocks startup, one lookup at a time with a pause. The warning window lives with the other thresholds in settings. New table domains (migration 0011); two settings fields (toggle and warning days). Verified with 90 checks against fake RDAP/WHOIS backends (name normalization, date formats, WHOIS parsing including rate-limit and no-expiry answers, bootstrap and referral caching, stale-cache fallback, parent walking, add/sync/check/refresh, concurrency guard, alert selection and stale detection, the daily notification and its toggle, role rules) plus a live smoke test against real registries and a browser check of the page against the real router. Real dev database mtime untouched. Not checked: a screenshot of the finished page (the capture timed out); structure, sorting, errors and the viewer view were verified. Co-Authored-By: Claude Sonnet 5 --- README.md | 12 + server/drizzle/0011_strange_mongoose.sql | 14 + server/drizzle/meta/0011_snapshot.json | 1483 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 22 + server/src/index.ts | 2 + server/src/routes/domains.ts | 81 + server/src/routes/settings.ts | 3 +- server/src/services/domainLookup.ts | 235 +++ server/src/services/domainMonitor.ts | 200 +++ server/src/services/notify.ts | 25 + server/src/services/secretExpiryScheduler.ts | 7 + server/src/services/settingsStore.ts | 7 +- web/src/App.tsx | 2 + web/src/api/client.ts | 31 + web/src/layout/AppShell.tsx | 2 + web/src/pages/Domains.tsx | 246 +++ .../pages/settings/NotificationSettings.tsx | 26 +- 18 files changed, 2401 insertions(+), 4 deletions(-) create mode 100644 server/drizzle/0011_strange_mongoose.sql create mode 100644 server/drizzle/meta/0011_snapshot.json create mode 100644 server/src/routes/domains.ts create mode 100644 server/src/services/domainLookup.ts create mode 100644 server/src/services/domainMonitor.ts create mode 100644 web/src/pages/Domains.tsx 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 (