diff --git a/README.md b/README.md index 8f03658..328e407 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,12 @@ All modules from the original plan are built: troubleshooting connectivity issues (ported from Sloth Manager's provider-diagnostics log, generalized to cover every integration this app has, not just DNS) -- **Secrets** — expiry tracking for API tokens/certs/passwords +- **Secrets** — expiry tracking for API tokens/certs/passwords. An SSL + certificate can optionally be given a host:port to watch: the app opens a + real TLS connection (daily, and on demand via "Check now"), reads the + certificate's actual expiry, and keeps the date current — so a renewed cert + is picked up automatically and an unreachable host is flagged instead of + silently going stale - **IP Addresses (IPAM)** — inventory of IPs across vendors/locations, with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in tailnet device IPs and VM/LXC IPs (never overwrites a diff --git a/server/drizzle/0007_secret_madripoor.sql b/server/drizzle/0007_secret_madripoor.sql new file mode 100644 index 0000000..ca653fe --- /dev/null +++ b/server/drizzle/0007_secret_madripoor.sql @@ -0,0 +1,4 @@ +ALTER TABLE `secrets` ADD `check_host` text;--> statement-breakpoint +ALTER TABLE `secrets` ADD `check_port` integer;--> statement-breakpoint +ALTER TABLE `secrets` ADD `last_checked_at` text;--> statement-breakpoint +ALTER TABLE `secrets` ADD `last_check_error` text; \ No newline at end of file diff --git a/server/drizzle/meta/0007_snapshot.json b/server/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..9928b06 --- /dev/null +++ b/server/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1213 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b8c719f5-3dfc-43a1-892e-3eed11fa47c0", + "prevId": "ba20e414-e06c-43b0-823b-7fc6b11facc4", + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "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 946c2cd..fc6263f 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1790103102037, "tag": "0006_aberrant_darkhawk", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1790372043652, + "tag": "0007_secret_madripoor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e04678f..758fbf6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -82,6 +82,11 @@ export const secrets = sqliteTable("secrets", { expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01 warnDays: integer("warn_days").notNull().default(30), notes: text("notes"), + // ssl_certificate secrets only: when set, expiryDate is read from the live certificate on this host:port instead of typed in. + checkHost: text("check_host"), + checkPort: integer("check_port"), + lastCheckedAt: text("last_checked_at"), + lastCheckError: text("last_check_error"), createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`), diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 506e255..798ee49 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -6,6 +6,7 @@ import { secrets, secretTypes } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; import { computeSecretStatus } from "../services/secretStatus.js"; +import { fetchCertExpiry, refreshTlsSecret } from "../services/tlsCheck.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const secretsRouter = Router(); @@ -23,9 +24,12 @@ const secretInput = z.object({ name: z.string().min(1).max(200), type: z.enum(secretTypes), description: z.string().max(2000).optional(), - expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"), + // Optional only because a monitored certificate gets its date from the live cert; validated below. + expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD").optional(), warnDays: z.number().int().min(0).max(3650).default(30), notes: z.string().max(4000).optional(), + checkHost: z.string().min(1).max(253).regex(/^[A-Za-z0-9._:-]+$/, "Invalid host").nullable().optional(), + checkPort: z.number().int().min(1).max(65535).nullable().optional(), }); secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { @@ -33,8 +37,37 @@ secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) = if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } + const { checkHost, checkPort, expiryDate: manualExpiry, ...rest } = parsed.data; - const [created] = await db.insert(secrets).values(parsed.data).returning(); + if (checkHost && rest.type !== "ssl_certificate") { + return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." }); + } + + let expiryDate = manualExpiry; + let lastCheckedAt: string | null = null; + let lastCheckError: string | null = null; + if (checkHost) { + lastCheckedAt = new Date().toISOString(); + try { + expiryDate = await fetchCertExpiry(checkHost, checkPort ?? 443); + } catch (err) { + lastCheckError = err instanceof Error ? err.message : String(err); + if (!manualExpiry) { + return res.status(400).json({ + error: "tls_check_failed", + message: `Couldn't read the certificate from ${checkHost}:${checkPort ?? 443} (${lastCheckError}). Fix the host, or enter an expiry date manually.`, + }); + } + } + } + if (!expiryDate) { + return res.status(400).json({ error: "invalid_body", message: "An expiry date is required unless a host to check is given." }); + } + + const [created] = await db + .insert(secrets) + .values({ ...rest, expiryDate, checkHost: checkHost ?? null, checkPort: checkHost ? (checkPort ?? 443) : null, lastCheckedAt, lastCheckError }) + .returning(); await recordAudit({ actor: req.currentUser!, @@ -60,11 +93,38 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re return res.status(404).json({ error: "not_found" }); } - const [updated] = await db - .update(secrets) - .set({ ...parsed.data, updatedAt: new Date().toISOString() }) - .where(eq(secrets.id, id)) - .returning(); + const { checkHost, checkPort, ...rest } = parsed.data; + const nextType = rest.type ?? existing.type; + if (checkHost && nextType !== "ssl_certificate") { + return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." }); + } + // Changing a monitored secret to a non-certificate type quietly ends the monitoring rather than leaving a stale check behind. + const nextHost = nextType !== "ssl_certificate" ? null : checkHost !== undefined ? checkHost : existing.checkHost; + const nextPort = checkPort !== undefined ? checkPort : existing.checkPort; + + const now = new Date().toISOString(); + const changes: Partial = { ...rest, updatedAt: now }; + if (!nextHost) { + Object.assign(changes, { checkHost: null, checkPort: null, lastCheckedAt: null, lastCheckError: null }); + } else { + const port = nextPort ?? 443; + Object.assign(changes, { checkHost: nextHost, checkPort: port }); + if (nextHost !== existing.checkHost || port !== (existing.checkPort ?? 443)) { + changes.lastCheckedAt = now; + try { + changes.expiryDate = await fetchCertExpiry(nextHost, port); + changes.lastCheckError = null; + } catch (err) { + // Keep whatever date we already have (a manually supplied one, else the existing one) and record why the check failed. + changes.lastCheckError = err instanceof Error ? err.message : String(err); + } + } else { + // Same host as before: the live certificate stays the source of truth, so ignore a hand-typed date. + delete changes.expiryDate; + } + } + + const [updated] = await db.update(secrets).set(changes).where(eq(secrets.id, id)).returning(); await recordAudit({ actor: req.currentUser!, @@ -78,6 +138,26 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } }); })); +secretsRouter.post("/:id/check-tls", requireRole("operator"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const result = await refreshTlsSecret(id); + if (!result) { + return res.status(400).json({ error: "not_monitored", message: "This secret has no host to check." }); + } + const [updated] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1); + + await recordAudit({ + actor: req.currentUser!, + category: "secret", + action: "check_tls", + targetType: "secret", + targetId: id, + detail: { name: result.name, host: result.host, port: result.port, ok: result.ok, error: result.error }, + }); + + res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) }, result }); +})); + secretsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1); diff --git a/server/src/services/notify.ts b/server/src/services/notify.ts index 993f64d..70b0693 100644 --- a/server/src/services/notify.ts +++ b/server/src/services/notify.ts @@ -219,14 +219,22 @@ export async function notifyDnsRecordDeleted( export async function notifySecretExpiry( expiring: { name: string; status: "expired" | "expiring"; daysLeft: number }[], + checkFailures: { name: string; host: string; port: number; error?: string }[] = [], ): Promise { - if (expiring.length === 0) return; + if (expiring.length === 0 && checkFailures.length === 0) return; if (!(await eventEnabled("secretCheck"))) return; - const lines = expiring.map((s) => (s.status === "expired" ? `✕ EXPIRED — ${s.name}` : `⚠ ${s.daysLeft}d left — ${s.name}`)); - await notify( - "Homelab Manager — Secrets Alert", - `${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`, - ); + const sections: string[] = []; + if (expiring.length > 0) { + const lines = expiring.map((s) => (s.status === "expired" ? `✕ EXPIRED — ${s.name}` : `⚠ ${s.daysLeft}d left — ${s.name}`)); + sections.push(`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`); + } + if (checkFailures.length > 0) { + const lines = checkFailures.map((f) => `⚠ ${f.name} (${f.host}:${f.port}) — ${f.error ?? "check failed"}`); + sections.push( + `Couldn't read the live certificate for ${checkFailures.length} monitored secret${checkFailures.length !== 1 ? "s" : ""} — the expiry shown may be stale:\n\n${lines.join("\n")}`, + ); + } + await notify("Homelab Manager — Secrets Alert", sections.join("\n\n")); } export async function notifyDockerUpdates( diff --git a/server/src/services/secretExpiryScheduler.ts b/server/src/services/secretExpiryScheduler.ts index 4b18add..36e11a6 100644 --- a/server/src/services/secretExpiryScheduler.ts +++ b/server/src/services/secretExpiryScheduler.ts @@ -3,16 +3,31 @@ import { db } from "../db/client.js"; import { secrets } from "../db/schema.js"; import { computeSecretStatus } from "./secretStatus.js"; import { notifySecretExpiry } from "./notify.js"; +import { refreshTlsSecrets, type TlsCheckResult } from "./tlsCheck.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; const LAST_RUN_FLAG = "secretCheckLastRunDate"; +/** + * Refreshes every monitored certificate's expiry from the live server first, + * so the alert below is computed from what's actually being served rather + * than a stale date. This runs on every scheduled pass regardless of the + * "secret expiry reminder" toggle — that toggle only controls whether a + * notification is sent (notifySecretExpiry checks it itself). + */ async function checkSecretExpiry(): Promise { + let checkFailures: TlsCheckResult[] = []; + try { + checkFailures = (await refreshTlsSecrets()).filter((r) => !r.ok); + } catch (err) { + console.error("[secretExpiry] TLS refresh failed:", err); + } + const rows = await db.select().from(secrets); const expiring = rows .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); + await notifySecretExpiry(expiring, checkFailures); } async function checkSecretExpiryOnce(): Promise { @@ -39,9 +54,7 @@ export async function scheduleSecretExpiryCheck(): Promise { const { notifications } = await getSettings(); currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => { setInternalFlag(LAST_RUN_FLAG, "").catch(() => {}); - getSettings().then(({ notifications: n }) => { - if (n.secretCheck) checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err)); - }); + checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err)); }); console.log(`Secret expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`); } diff --git a/server/src/services/tlsCheck.ts b/server/src/services/tlsCheck.ts new file mode 100644 index 0000000..5b4fc77 --- /dev/null +++ b/server/src/services/tlsCheck.ts @@ -0,0 +1,89 @@ +import * as tls from "node:tls"; +import { isIP } from "node:net"; +import { eq, isNotNull } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { secrets } from "../db/schema.js"; + +/** + * Opens a real TLS connection and returns the server certificate's expiry as + * a UTC "YYYY-MM-DD" date. Certificate verification is deliberately off + * (rejectUnauthorized: false): the point is to read the expiry of whatever + * certificate the service is actually serving, and homelab services very + * commonly present self-signed or internal-CA certificates that a verifying + * connection would refuse before ever exposing the dates — including an + * already-expired one, which is exactly the case worth reporting. + */ +export function fetchCertExpiry(host: string, port: number, timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + const socket = tls.connect( + { + host, + port, + // SNI must be a hostname — Node warns (and some servers reject) when an IP is sent as the server name. + servername: isIP(host) === 0 ? host : undefined, + rejectUnauthorized: false, + }, + () => { + const cert = socket.getPeerCertificate(); + socket.end(); + if (!cert || !cert.valid_to) return reject(new Error("The server presented no certificate")); + const validTo = new Date(cert.valid_to); + if (Number.isNaN(validTo.getTime())) return reject(new Error(`Couldn't parse the certificate expiry "${cert.valid_to}"`)); + resolve(validTo.toISOString().slice(0, 10)); + }, + ); + socket.setTimeout(timeoutMs, () => { + socket.destroy(); + reject(new Error(`Timed out after ${timeoutMs / 1000}s connecting to ${host}:${port}`)); + }); + socket.on("error", (err) => { + // The most likely mistake is pointing at a plain-HTTP (or other non-TLS) port; OpenSSL's own message for that is opaque. + if (/wrong version number|packet length too long/i.test(err.message)) { + return reject(new Error(`${host}:${port} isn't speaking TLS — is that the right port?`)); + } + reject(err); + }); + }); +} + +export interface TlsCheckResult { + id: number; + name: string; + host: string; + port: number; + ok: boolean; + expiryDate?: string; + error?: string; +} + +/** Checks one monitored secret and records the outcome on its row — a successful check overwrites expiryDate, a failed one keeps the last known date and records why. */ +export async function refreshTlsSecret(id: number): Promise { + const [row] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1); + if (!row || !row.checkHost) return null; + const port = row.checkPort ?? 443; + const now = new Date().toISOString(); + try { + const expiryDate = await fetchCertExpiry(row.checkHost, port); + await db + .update(secrets) + .set({ + expiryDate, + lastCheckedAt: now, + lastCheckError: null, + ...(expiryDate !== row.expiryDate ? { updatedAt: now } : {}), + }) + .where(eq(secrets.id, id)); + return { id, name: row.name, host: row.checkHost, port, ok: true, expiryDate }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + await db.update(secrets).set({ lastCheckedAt: now, lastCheckError: error }).where(eq(secrets.id, id)); + return { id, name: row.name, host: row.checkHost, port, ok: false, error }; + } +} + +/** Checks every monitored secret (all at once — a homelab has a handful, and each is bounded by its own timeout). */ +export async function refreshTlsSecrets(): Promise { + const rows = await db.select({ id: secrets.id }).from(secrets).where(isNotNull(secrets.checkHost)); + const results = await Promise.all(rows.map((r) => refreshTlsSecret(r.id))); + return results.filter((r): r is TlsCheckResult => r !== null); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 150c6b6..b9fec51 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -72,15 +72,23 @@ export interface SecretRecord { updatedAt: string; status: SecretStatus; daysLeft: number; + /** ssl_certificate only — when set, expiryDate is read from the live certificate here rather than typed in. */ + checkHost: string | null; + checkPort: number | null; + lastCheckedAt: string | null; + lastCheckError: string | null; } export interface SecretInput { name: string; type: SecretType; description?: string; - expiryDate: string; + /** Omitted when a host to check is given — the date then comes from the live certificate. */ + expiryDate?: string; warnDays: number; notes?: string; + checkHost?: string | null; + checkPort?: number | null; } export interface IpamEntry { @@ -663,6 +671,11 @@ export const api = { body: JSON.stringify(data), }), remove: (id: number) => request(`/api/secrets/${id}`, { method: "DELETE" }), + checkTls: (id: number) => + request<{ secret: SecretRecord; result: { ok: boolean; expiryDate?: string; error?: string } }>( + `/api/secrets/${id}/check-tls`, + { method: "POST" }, + ), }, ipam: { list: () => request<{ entries: IpamEntry[] }>("/api/ipam"), diff --git a/web/src/pages/Secrets.tsx b/web/src/pages/Secrets.tsx index 8288892..4484696 100644 --- a/web/src/pages/Secrets.tsx +++ b/web/src/pages/Secrets.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client"; import { downloadCsv } from "../utils/csv"; +import { formatDateTime } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; @@ -28,8 +29,25 @@ const emptyForm: SecretInput = { expiryDate: "", warnDays: 30, notes: "", + checkHost: "", + checkPort: 443, }; +/** The API client throws `Request failed (400): {json}` — pull the server's own message out of that when there is one. */ +function readableError(err: unknown): string { + const text = err instanceof Error ? err.message : String(err); + const match = text.match(/^Request failed \(\d+\): (.*)$/s); + if (match) { + try { + const body = JSON.parse(match[1]); + if (typeof body.message === "string") return body.message; + } catch { + // not JSON — fall through to the raw text + } + } + return text; +} + export default function Secrets({ user }: { user: CurrentUser }) { const canEdit = user.role === "admin" || user.role === "operator"; const [secrets, setSecrets] = useState(null); @@ -42,6 +60,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { const [saving, setSaving] = useState(false); const selection = useSelection(); const [bulkDeleting, setBulkDeleting] = useState(false); + const [checkingId, setCheckingId] = useState(null); function load() { api.secrets @@ -77,6 +96,8 @@ export default function Secrets({ user }: { user: CurrentUser }) { expiryDate: s.expiryDate, warnDays: s.warnDays, notes: s.notes ?? "", + checkHost: s.checkHost ?? "", + checkPort: s.checkPort ?? 443, }); } @@ -90,20 +111,42 @@ export default function Secrets({ user }: { user: CurrentUser }) { setError(null); setSaving(true); try { + // A host only means something for a certificate; when one is set the date comes from the live cert, so it isn't sent. + const host = form.type === "ssl_certificate" ? (form.checkHost ?? "").trim() : ""; + const payload: SecretInput = { + ...form, + expiryDate: host ? undefined : form.expiryDate, + checkHost: host || null, + checkPort: host ? form.checkPort || 443 : null, + }; if (editingId) { - await api.secrets.update(editingId, form); + await api.secrets.update(editingId, payload); } else { - await api.secrets.create(form); + await api.secrets.create(payload); } cancelEdit(); load(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(readableError(err)); } finally { setSaving(false); } } + async function checkNow(s: SecretRecord) { + setError(null); + setCheckingId(s.id); + try { + const res = await api.secrets.checkTls(s.id); + if (!res.result.ok) setError(`Couldn't read the certificate for "${s.name}": ${res.result.error}`); + load(); + } catch (err) { + setError(readableError(err)); + } finally { + setCheckingId(null); + } + } + async function remove(s: SecretRecord) { if (!confirm(`Delete secret "${s.name}"?`)) return; setError(null); @@ -138,7 +181,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { function exportCsv() { downloadCsv( "secrets.csv", - ["Name", "Type", "Description", "Expiry Date", "Warn Days", "Status", "Days Left", "Notes"], + ["Name", "Type", "Description", "Expiry Date", "Warn Days", "Status", "Days Left", "Notes", "Monitored Host"], (sorted ?? []).map((s) => [ s.name, TYPE_LABELS[s.type], @@ -148,10 +191,13 @@ export default function Secrets({ user }: { user: CurrentUser }) { s.status, s.daysLeft, s.notes ?? "", + s.checkHost ? `${s.checkHost}:${s.checkPort ?? 443}` : "", ]), ); } + const monitoredInForm = form.type === "ssl_certificate" && !!(form.checkHost ?? "").trim(); + return ( <>

Secrets

@@ -192,11 +238,39 @@ export default function Secrets({ user }: { user: CurrentUser }) { setForm({ ...form, expiryDate: e.target.value })} /> + {monitoredInForm &&
Read from the live certificate.
} + {form.type === "ssl_certificate" && ( + <> +
+ + setForm({ ...form, checkHost: e.target.value })} + /> +
Connects over TLS and reads the certificate's real expiry, checked daily.
+
+
+ + setForm({ ...form, checkPort: Number(e.target.value) })} + /> +
+ + )}
{s.name} {TYPE_LABELS[s.type]} - {s.expiryDate} + + {s.expiryDate} + {s.checkHost && ( + + Auto + + )} + {s.lastCheckError && ( + + Check failed + + )} + {s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`} {s.status} @@ -340,6 +429,11 @@ export default function Secrets({ user }: { user: CurrentUser }) { {canEdit && (
+ {s.checkHost && ( + + )}