diff --git a/README.md b/README.md index a12c3c7..266cdd0 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,16 @@ 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. +**Consistency** — a report of where IPAM, DNS and your servers disagree about +an address: the same address on two servers, a DNS record named after a server +that points somewhere it isn't, an IPAM entry labelled with a server's name at +the wrong address, addresses in use that IPAM doesn't list (with an "Add to +IPAM" button), and server addresses no DNS record points at. It only compares +data the app already holds — nothing is fetched when you open it — so it says how +many servers reported addresses and how fresh the synced DNS zones are. Only +private addresses are compared; Docker bridge networks are left out; anything +that's fine on purpose can be ignored with a reason, and stays ignored. + **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 diff --git a/server/drizzle/0012_cool_harry_osborn.sql b/server/drizzle/0012_cool_harry_osborn.sql new file mode 100644 index 0000000..7e0edf6 --- /dev/null +++ b/server/drizzle/0012_cool_harry_osborn.sql @@ -0,0 +1,10 @@ +CREATE TABLE `consistency_ignores` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `key` text NOT NULL, + `title` text NOT NULL, + `reason` text, + `created_by` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `consistency_ignores_key_unique` ON `consistency_ignores` (`key`); \ No newline at end of file diff --git a/server/drizzle/meta/0012_snapshot.json b/server/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..1e8d16d --- /dev/null +++ b/server/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1544 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b16a94c8-1cfa-489a-a64f-0cb0c578bafb", + "prevId": "7c19d681-222c-43c5-833a-07af390e3e8d", + "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": {} + }, + "consistency_ignores": { + "name": "consistency_ignores", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "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": { + "consistency_ignores_key_unique": { + "name": "consistency_ignores_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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 a3d3b6d..8270ead 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1790385846612, "tag": "0011_strange_mongoose", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1790388232273, + "tag": "0012_cool_harry_osborn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 0d21a94..222c881 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -348,3 +348,18 @@ export const domains = sqliteTable("domains", { .notNull() .default(sql`(current_timestamp)`), }); + +// ─── Consistency report ───────────────────────────────────────────────────── + +// Findings someone has looked at and decided are fine (a DNS record that intentionally points elsewhere, a Docker bridge +// address, ...). Keyed by the finding's stable key so it stays ignored across runs. +export const consistencyIgnores = sqliteTable("consistency_ignores", { + id: integer("id").primaryKey({ autoIncrement: true }), + key: text("key").notNull().unique(), + title: text("title").notNull(), // what the finding said when it was ignored, so the list still makes sense once it's gone + reason: text("reason"), + createdBy: text("created_by"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); diff --git a/server/src/index.ts b/server/src/index.ts index a614525..4ff9f51 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,6 +25,7 @@ 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 { consistencyRouter } from "./routes/consistency.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; @@ -94,6 +95,7 @@ app.use("/api/search", searchRouter); app.use("/api/sessions", sessionsRouter); app.use("/api/maintenance", maintenanceRouter); app.use("/api/domains", domainsRouter); +app.use("/api/consistency", consistencyRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/consistency.ts b/server/src/routes/consistency.ts new file mode 100644 index 0000000..44651e6 --- /dev/null +++ b/server/src/routes/consistency.ts @@ -0,0 +1,117 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { consistencyIgnores, dnsProviders, dnsRecordsCache, dnsZonesCache, ipamEntries, servers } from "../db/schema.js"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { buildFindings, type Finding } from "../services/consistency.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const consistencyRouter = Router(); +consistencyRouter.use(requireAuth); + +function parseIps(stored: string | null): string[] { + if (!stored) return []; + try { + const value = JSON.parse(stored); + return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; + } catch { + return []; + } +} + +async function computeFindings(): Promise<{ findings: Finding[]; sources: Record }> { + const [serverRows, ipamRows, dnsRows, zoneRows] = await Promise.all([ + db.select({ id: servers.id, name: servers.name, hostname: servers.hostname, ips: servers.ipAddresses }).from(servers), + db.select({ id: ipamEntries.id, ip: ipamEntries.ipAddress, label: ipamEntries.label, source: ipamEntries.source }).from(ipamEntries), + db + .select({ name: dnsRecordsCache.name, type: dnsRecordsCache.type, content: dnsRecordsCache.content, providerName: dnsProviders.name }) + .from(dnsRecordsCache) + .innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id)), + db.select({ syncedAt: dnsZonesCache.syncedAt }).from(dnsZonesCache), + ]); + + const serverInputs = serverRows.map((s) => ({ id: s.id, name: s.name, hostname: s.hostname, ips: parseIps(s.ips) })); + const findings = buildFindings({ servers: serverInputs, ipam: ipamRows, dns: dnsRows }); + + const synced = zoneRows.map((z) => z.syncedAt).filter((t): t is string => !!t).sort(); + const sources = { + servers: { total: serverInputs.length, withAddresses: serverInputs.filter((s) => s.ips.length > 0).length }, + ipam: ipamRows.length, + dns: { + zones: zoneRows.length, + syncedZones: synced.length, + records: dnsRows.length, + oldestSyncedAt: synced[0] ?? null, + newestSyncedAt: synced[synced.length - 1] ?? null, + }, + }; + return { findings, sources }; +} + +consistencyRouter.get("/", asyncHandler(async (_req, res) => { + const { findings, sources } = await computeFindings(); + const ignores = await db.select().from(consistencyIgnores).orderBy(consistencyIgnores.createdAt); + const ignoredKeys = new Set(ignores.map((i) => i.key)); + const present = new Set(findings.map((f) => f.key)); + + const active = findings.filter((f) => !ignoredKeys.has(f.key)); + const counts = { error: 0, warning: 0, info: 0 }; + for (const f of active) counts[f.severity]++; + + res.json({ + findings: active, + counts, + ignored: ignores.map((i) => ({ ...i, stillPresent: present.has(i.key) })), + sources, + generatedAt: new Date().toISOString(), + }); +})); + +const ignoreSchema = z.object({ key: z.string().min(1).max(500), reason: z.string().trim().max(300).optional() }); + +// The key must belong to a finding that exists right now, and the stored title comes from that finding rather than the +// request — so the ignore list can't be filled with arbitrary text. +consistencyRouter.post("/ignore", requireRole("operator"), asyncHandler(async (req, res) => { + const parsed = ignoreSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Missing finding.", details: parsed.error.flatten() }); + + const { findings } = await computeFindings(); + const finding = findings.find((f) => f.key === parsed.data.key); + if (!finding) return res.status(404).json({ error: "not_found", message: "That finding no longer exists — refresh the report." }); + + const [existing] = await db.select({ id: consistencyIgnores.id }).from(consistencyIgnores).where(eq(consistencyIgnores.key, finding.key)).limit(1); + if (existing) return res.status(409).json({ error: "already_ignored", message: "That finding is already ignored." }); + + const [row] = await db + .insert(consistencyIgnores) + .values({ key: finding.key, title: finding.title, reason: parsed.data.reason || null, createdBy: req.currentUser!.email ?? req.currentUser!.name ?? req.currentUser!.oidcSub }) + .returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "consistency", + action: "ignore", + targetType: "consistency_finding", + targetId: row.id, + detail: { key: finding.key, title: finding.title, reason: row.reason }, + }); + res.status(201).json({ ignore: row }); +})); + +consistencyRouter.delete("/ignore/: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 deleted = await db.delete(consistencyIgnores).where(eq(consistencyIgnores.id, id)).returning(); + if (deleted.length === 0) return res.status(404).json({ error: "not_found" }); + await recordAudit({ + actor: req.currentUser!, + category: "consistency", + action: "unignore", + targetType: "consistency_finding", + targetId: id, + detail: { key: deleted[0].key, title: deleted[0].title }, + }); + res.status(204).end(); +})); diff --git a/server/src/services/consistency.ts b/server/src/services/consistency.ts new file mode 100644 index 0000000..6c51e11 --- /dev/null +++ b/server/src/services/consistency.ts @@ -0,0 +1,200 @@ +import * as net from "node:net"; +import { isPrivateAddress } from "./portScan.js"; + +/** + * Cross-checks the three places this app records what lives at an IP address — the IPAM inventory, the DNS records + * synced from the providers, and what each server's agent reports — and lists where they disagree. Pure: it takes + * plain data and returns findings, so every rule can be tested exactly. + */ + +export type FindingKind = "ip_conflict" | "dns_stale" | "ipam_stale" | "not_in_ipam" | "no_dns"; +export type Severity = "error" | "warning" | "info"; + +export interface ServerInput { + id: number; + name: string; + hostname: string | null; + /** What the agent last reported. Empty for a server with no agent or no report — such a server is never judged. */ + ips: string[]; +} +export interface IpamInput { + id: number; + ip: string; + label: string | null; + /** null = entered by hand; "tailscale"/"proxmox" = kept up to date by a sync. */ + source: string | null; +} +export interface DnsInput { + name: string; + type: string; + content: string; + providerName: string; +} + +export interface Finding { + /** Stable identity, so an ignored finding stays ignored across runs. */ + key: string; + kind: FindingKind; + severity: Severity; + title: string; + detail: string; + ip: string | null; + servers: { id: number; name: string }[]; + dnsNames: string[]; + /** For not_in_ipam: a label to pre-fill when adding the address to IPAM. */ + suggestedLabel: string | null; +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +const norm = (ip: string) => ip.trim().toLowerCase(); +const cleanName = (n: string) => n.trim().toLowerCase().replace(/\.$/, ""); +const firstLabel = (n: string) => cleanName(n).split(".")[0]; + +function inRange172(ip: string): boolean { + if (net.isIP(ip) !== 4) return false; + const [a, b] = ip.split(".").map(Number); + return a === 172 && b >= 16 && b <= 31; +} + +/** 100.64.0.0/10 — where Tailscale addresses live. MagicDNS names them, so a missing DNS record isn't a gap. */ +function isCgnat(ip: string): boolean { + if (net.isIP(ip) !== 4) return false; + const [a, b] = ip.split(".").map(Number); + return a === 100 && b >= 64 && b <= 127; +} + +/** Agents report IPv4 only, so an IPv6 record can't be judged against them (and vice versa) — only compare within a family. */ +const sameFamilyAsAny = (ip: string, ips: string[]) => ips.some((o) => net.isIP(o) === net.isIP(ip)); + +const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 }; + +/** Which of these servers does a DNS name or an IPAM label refer to? Exact hostname, or the same short name. */ +function serversNamed(name: string, servers: ServerInput[]): ServerInput[] { + const n = cleanName(name); + const short = firstLabel(name); + return servers.filter((s) => { + const host = s.hostname ? cleanName(s.hostname) : null; + return (host !== null && n === host) || short === cleanName(s.name); + }); +} + +// ─── the checks ───────────────────────────────────────────────────────────── + +export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[]; dns: DnsInput[] }): Finding[] { + const findings: Finding[] = []; + const servers = input.servers.map((s) => ({ ...s, ips: [...new Set(s.ips.map(norm))] })); + const withIps = servers.filter((s) => s.ips.length > 0); + const ipamByIp = new Map(input.ipam.map((e) => [norm(e.ip), e])); + // Public DNS records are for the outside world, not this inventory — only private addresses are compared. + const privateDns = input.dns + .filter((r) => (r.type === "A" || r.type === "AAAA") && isPrivateAddress(r.content.trim())) + .map((r) => ({ ...r, name: cleanName(r.name), content: norm(r.content) })); + const dnsByIp = new Map(); + for (const r of privateDns) dnsByIp.set(r.content, [...(dnsByIp.get(r.content) ?? []), r]); + + const add = (f: Omit & Partial>) => + findings.push({ servers: [], dnsNames: [], suggestedLabel: null, ip: null, ...f }); + + // 1. The same address reported by two servers. + const byServerIp = new Map(); + for (const s of withIps) for (const ip of s.ips) byServerIp.set(ip, [...(byServerIp.get(ip) ?? []), s]); + for (const [ip, owners] of byServerIp) { + // Every Docker host has 172.17.0.1 (and similar bridge addresses in 172.16/12) — sharing those is normal. + if (owners.length < 2 || inRange172(ip)) continue; + add({ + key: `ip_conflict|${ip}`, + kind: "ip_conflict", + severity: "error", + title: `${ip} is reported by ${owners.length} servers`, + detail: `${owners.map((o) => o.name).join(", ")} all claim this address — an IP conflict, or a stale agent report.`, + ip, + servers: owners.map((o) => ({ id: o.id, name: o.name })), + }); + } + + // 2. DNS names a server's own name, but pointing somewhere the server isn't. + const seenDns = new Set(); + for (const r of privateDns) { + for (const s of serversNamed(r.name, withIps)) { + if (s.ips.includes(r.content) || !sameFamilyAsAny(r.content, s.ips)) continue; + const key = `dns_stale|${s.id}|${r.name}|${r.content}`; + if (seenDns.has(key)) continue; + seenDns.add(key); + add({ + key, + kind: "dns_stale", + severity: "warning", + title: `${r.name} points to ${r.content}, but ${s.name} reports ${s.ips.join(", ")}`, + detail: `The DNS record (${r.providerName}) doesn't match any address ${s.name} reports — likely out of date after an address change.`, + ip: r.content, + servers: [{ id: s.id, name: s.name }], + dnsNames: [r.name], + }); + } + } + + // 3. IPAM labels a server's name, at an address the server doesn't have. + for (const e of input.ipam) { + if (!e.label || e.source === "tailscale" || e.source === "proxmox") continue; // kept current by their own syncs + const ip = norm(e.ip); + for (const s of serversNamed(e.label, withIps)) { + if (s.ips.includes(ip) || !sameFamilyAsAny(ip, s.ips)) continue; + add({ + key: `ipam_stale|${s.id}|${ip}`, + kind: "ipam_stale", + severity: "warning", + title: `IPAM lists ${e.label} at ${ip}, but ${s.name} reports ${s.ips.join(", ")}`, + detail: `The IPAM entry doesn't match any address ${s.name} reports — update IPAM, or the server moved.`, + ip, + servers: [{ id: s.id, name: s.name }], + }); + } + } + + // 4. In use (a server reports it, or DNS points at it) but not in IPAM. + const candidates = new Set([...byServerIp.keys(), ...dnsByIp.keys()]); + for (const ip of candidates) { + if (ipamByIp.has(ip)) continue; + const owners = byServerIp.get(ip) ?? []; + const records = dnsByIp.get(ip) ?? []; + // A container network on a Docker host that nobody put in DNS isn't something to inventory. + if (records.length === 0 && inRange172(ip)) continue; + const names = [...new Set(records.map((r) => r.name))]; + const label = owners.length === 1 ? owners[0].name : names.length > 0 ? firstLabel(names[0]) : null; + const who = [...owners.map((o) => `reported by ${o.name}`), ...(names.length > 0 ? [`in DNS as ${names.join(", ")}`] : [])].join(" and "); + add({ + key: `not_in_ipam|${ip}`, + kind: "not_in_ipam", + severity: records.length > 0 ? "warning" : "info", + title: `${ip} isn't in IPAM`, + detail: `${who}.`, + ip, + servers: owners.map((o) => ({ id: o.id, name: o.name })), + dnsNames: names, + suggestedLabel: label, + }); + } + + // 4b. A server address nothing in DNS points at — only meaningful once there are DNS records to compare against. + if (input.dns.length > 0) { + for (const s of withIps) { + for (const ip of s.ips) { + if (dnsByIp.has(ip) || net.isIP(ip) === 0 || !isPrivateAddress(ip) || isCgnat(ip) || inRange172(ip)) continue; + add({ + key: `no_dns|${s.id}|${ip}`, + kind: "no_dns", + severity: "info", + title: `No DNS record points at ${ip} (${s.name})`, + detail: `${s.name} reports ${ip}, but no cached A/AAAA record resolves to it.`, + ip, + servers: [{ id: s.id, name: s.name }], + }); + } + } + } + + return findings.sort( + (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.kind.localeCompare(b.kind) || (a.ip ?? "").localeCompare(b.ip ?? "", undefined, { numeric: true }), + ); +} diff --git a/server/src/services/portScan.ts b/server/src/services/portScan.ts index f0db452..7d7c3f1 100644 --- a/server/src/services/portScan.ts +++ b/server/src/services/portScan.ts @@ -76,7 +76,7 @@ function isPrivateIPv6(ip: string): boolean { return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; // unique-local fc00::/7, link-local fe80::/10 } -function isPrivateAddress(ip: string): boolean { +export function isPrivateAddress(ip: string): boolean { const family = net.isIP(ip); if (family === 4) return isPrivateIPv4(ip); if (family === 6) return isPrivateIPv6(ip); diff --git a/web/src/App.tsx b/web/src/App.tsx index ab2c997..bd17fd3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,6 +22,7 @@ import Synology from "./pages/Synology"; import Generator from "./pages/Generator"; import Maintenance from "./pages/Maintenance"; import Domains from "./pages/Domains"; +import Consistency from "./pages/Consistency"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; @@ -90,6 +91,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bbc07c0..d09f969 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -513,6 +513,43 @@ export interface DomainList { checking: boolean; } +export type ConsistencyKind = "ip_conflict" | "dns_stale" | "ipam_stale" | "not_in_ipam" | "no_dns"; +export type ConsistencySeverity = "error" | "warning" | "info"; + +export interface ConsistencyFinding { + key: string; + kind: ConsistencyKind; + severity: ConsistencySeverity; + title: string; + detail: string; + ip: string | null; + servers: { id: number; name: string }[]; + dnsNames: string[]; + suggestedLabel: string | null; +} + +export interface ConsistencyIgnore { + id: number; + key: string; + title: string; + reason: string | null; + createdBy: string | null; + createdAt: string; + stillPresent: boolean; +} + +export interface ConsistencyReport { + findings: ConsistencyFinding[]; + counts: Record; + ignored: ConsistencyIgnore[]; + sources: { + servers: { total: number; withAddresses: number }; + ipam: number; + dns: { zones: number; syncedZones: number; records: number; oldestSyncedAt: string | null; newestSyncedAt: string | null }; + }; + generatedAt: string; +} + export interface DockhandContainer { id: string; name: string; @@ -877,6 +914,12 @@ export const api = { remove: (id: number, portId: number) => request(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }), }, }, + consistency: { + report: () => request("/api/consistency"), + ignore: (key: string, reason?: string) => + request<{ ignore: ConsistencyIgnore }>("/api/consistency/ignore", { method: "POST", body: JSON.stringify({ key, reason }) }), + unignore: (id: number) => request(`/api/consistency/ignore/${id}`, { method: "DELETE" }), + }, domains: { list: () => request("/api/domains"), add: (name: string) => diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index e4519e8..200e332 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -24,6 +24,7 @@ import { IconWand, IconTool, IconWorldWww, + IconListCheck, } from "@tabler/icons-react"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { formatRemaining } from "../utils/duration"; @@ -45,6 +46,7 @@ const NAV_ITEMS: NavItem[] = [ { to: "/synology", label: "Synology", icon: }, { to: "/secrets", label: "Secrets", icon: }, { to: "/dns", label: "DNS", icon: }, + { to: "/consistency", label: "Consistency", icon: }, { to: "/domains", label: "Domains", icon: }, { to: "/ipam", label: "IP Addresses", icon: }, { to: "/tailscale", label: "Tailscale", icon: }, diff --git a/web/src/pages/Consistency.tsx b/web/src/pages/Consistency.tsx new file mode 100644 index 0000000..1a97da9 --- /dev/null +++ b/web/src/pages/Consistency.tsx @@ -0,0 +1,293 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client"; +import { downloadCsv } from "../utils/csv"; +import { formatDateTime } from "../utils/date"; +import { formatAgo } from "../utils/duration"; +import { readableError } from "../utils/errors"; + +const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [ + { kind: "ip_conflict", title: "Address conflicts", blurb: "The same address reported by more than one server." }, + { kind: "dns_stale", title: "DNS out of date", blurb: "A DNS record named after a server that points at an address the server doesn't have." }, + { kind: "ipam_stale", title: "IPAM out of date", blurb: "An IPAM entry labelled with a server's name at an address the server doesn't have." }, + { kind: "not_in_ipam", title: "Not in IPAM", blurb: "Addresses in use — reported by a server or pointed at by DNS — that IPAM doesn't list." }, + { kind: "no_dns", title: "No DNS record", blurb: "Server addresses on your LAN that no DNS record points at." }, +]; + +const SEVERITY_BADGE: Record = { + error: "bg-red-lt text-red", + warning: "bg-yellow-lt text-yellow", + info: "bg-blue-lt text-blue", +}; +const SEVERITY_LABEL: Record = { error: "Conflict", warning: "Mismatch", info: "Gap" }; +const SEVERITY_PLURAL: Record = { error: "Conflicts", warning: "Mismatches", info: "Gaps" }; + +const INITIAL_SHOWN = 25; + +export default function Consistency({ user }: { user: CurrentUser }) { + const canEdit = user.role === "admin" || user.role === "operator"; + const [report, setReport] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [loading, setLoading] = useState(false); + const [severity, setSeverity] = useState<"all" | ConsistencySeverity>("all"); + const [busyKey, setBusyKey] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [showIgnored, setShowIgnored] = useState(false); + + function load() { + setLoading(true); + return api.consistency + .report() + .then((res) => { + setReport(res); + setError(null); + }) + .catch((err) => setError(readableError(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + void load(); + }, []); + + const visible = useMemo(() => (report ? report.findings.filter((f) => severity === "all" || f.severity === severity) : []), [report, severity]); + + async function addToIpam(f: ConsistencyFinding) { + if (!f.ip) return; + setBusyKey(f.key); + setError(null); + setNotice(null); + try { + await api.ipam.create({ ipAddress: f.ip, label: f.suggestedLabel ?? undefined }); + setNotice(`Added ${f.ip}${f.suggestedLabel ? ` as “${f.suggestedLabel}”` : ""} to IPAM.`); + await load(); + } catch (err) { + setError(readableError(err)); + } finally { + setBusyKey(null); + } + } + + async function ignore(f: ConsistencyFinding) { + const reason = window.prompt("Why is this fine? (optional — shown in the Ignored list)", ""); + if (reason === null) return; // cancelled + setBusyKey(f.key); + setError(null); + setNotice(null); + try { + await api.consistency.ignore(f.key, reason.trim() || undefined); + await load(); + } catch (err) { + setError(readableError(err)); + } finally { + setBusyKey(null); + } + } + + async function restore(id: number) { + setError(null); + try { + await api.consistency.unignore(id); + await load(); + } catch (err) { + setError(readableError(err)); + } + } + + function exportCsv() { + downloadCsv( + "consistency.csv", + ["Severity", "Type", "Address", "Finding", "Detail", "Servers", "DNS names"], + visible.map((f) => [f.severity, f.kind, f.ip ?? "", f.title, f.detail, f.servers.map((s) => s.name).join(" "), f.dnsNames.join(" ")]), + ); + } + + const dns = report?.sources.dns; + const dnsPartial = dns && dns.zones > 0 && dns.syncedZones < dns.zones; + + return ( + <> +
+

Consistency

+
+ + +
+
+
+ Where IPAM, DNS and your servers disagree about what lives at an address. It compares what's already stored — nothing is + fetched from the servers or DNS providers when you open this page. +
+ {error &&
{error}
} + {notice &&
{notice}
} + + {report && ( +
+
+ Compared {report.sources.servers.withAddresses} of {report.sources.servers.total} servers (those whose agent has reported addresses),{" "} + {report.sources.ipam} IPAM {report.sources.ipam === 1 ? "entry" : "entries"} and {report.sources.dns.records} DNS records + {dns && dns.zones > 0 && ( + <> + {" "} + from {dns.syncedZones} of {dns.zones} synced zone{dns.zones === 1 ? "" : "s"} + {dns.oldestSyncedAt && <> (oldest sync {formatAgo(dns.oldestSyncedAt)})} + + )} + . + {dnsPartial && ( + <> + {" "} + Zones that haven't been synced have no records here — sync them on the DNS page. + + )} +
+
+ )} + + {report && ( +
+ + {(["error", "warning", "info"] as ConsistencySeverity[]).map((s) => ( + + ))} +
+ )} + + {report && report.findings.length === 0 && ( +
+
+ Nothing to report — everything compared lines up + {report.sources.servers.withAddresses === 0 && " (no server has reported addresses yet, so there wasn't much to compare)"}. +
+
+ )} + + {KINDS.map(({ kind, title, blurb }) => { + const items = visible.filter((f) => f.kind === kind); + if (items.length === 0) return null; + const shown = expanded.has(kind) ? items : items.slice(0, INITIAL_SHOWN); + return ( +
+
+
+

+ {title} · {items.length} +

+
{blurb}
+
+
+
+ {shown.map((f) => ( +
+
+ {SEVERITY_LABEL[f.severity]} +
+
{f.title}
+
{f.detail}
+ {f.servers.length > 0 && ( +
+ {f.servers.map((s, i) => ( + + {i > 0 && ", "} + {s.name} + + ))} +
+ )} +
+ {canEdit && ( +
+ {f.kind === "not_in_ipam" && f.ip && ( + + )} + +
+ )} +
+
+ ))} +
+ {items.length > INITIAL_SHOWN && ( +
+ +
+ )} +
+ ); + })} + + {report && report.ignored.length > 0 && ( +
+
+

Ignored ({report.ignored.length})

+
+ +
+
+ {showIgnored && ( +
+ {report.ignored.map((i) => ( +
+
+
{i.title}
+
+ {i.reason ? `${i.reason} · ` : ""} + {i.createdBy ? `${i.createdBy}, ` : ""} + {formatDateTime(new Date(i.createdAt.includes("T") ? i.createdAt : `${i.createdAt.replace(" ", "T")}Z`))} + {!i.stillPresent && " · no longer occurring"} +
+
+ {canEdit && ( + + )} +
+ ))} +
+ )} +
+ )} + + {report && ( +
+ Only private addresses are compared — public DNS records aren't expected to be in IPAM. Docker bridge networks (172.16–31.x) + reported by agents are left out unless you've put them in DNS, and shared ones aren't counted as conflicts. Servers with no + agent report, and IPv6 records (agents report IPv4 only), can't be judged against a server's addresses. Report generated{" "} + {formatDateTime(new Date(report.generatedAt))}. +
+ )} + + ); +}