From 2352689fd31334e673ee7d3aed65883ac5412eb2 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 26 Sep 2026 04:08:51 +0200 Subject: [PATCH] Add a consistency report across IPAM, DNS and servers New Consistency page listing where the three places this app records what lives at an address disagree: - Address conflicts: the same address reported by more than one server. - DNS out of date: a record named after a server (its hostname, or its short name) that points at an address the server doesn't report. - IPAM out of date: an entry labelled with a server's name at an address the server doesn't report. - Not in IPAM: addresses a server reports or DNS points at that IPAM doesn't list, merged into one finding per address, with a one-click "Add to IPAM" that pre-fills a label. - No DNS record: server LAN addresses no cached A/AAAA record resolves to. It compares data the app already holds and fetches nothing when opened, so the page states how many servers had reported addresses and how many DNS zones are synced (and how old the oldest sync is) -- DNS records are only cached for zones that have been synced, and a report that silently treated missing data as "no records" would mislead. Rules chosen to keep it from crying wolf: - Only private addresses are compared; public DNS records aren't expected to be in IPAM. - Servers with no reported addresses are never judged. - Agents report IPv4 only, so records are only compared within an address family (an AAAA record isn't "stale" for lacking an IPv6 address). - Docker bridge networks (172.16/12) are ignored: shared ones aren't conflicts, and they aren't listed unless someone put them in DNS. - Tailscale addresses don't need DNS records (MagicDNS), and IPAM entries kept current by the Tailscale/Proxmox syncs aren't second-guessed. Findings anyone has decided are fine can be ignored (operators) with a reason. An ignore is keyed on the finding's stable identity so it stays ignored across runs, its stored text comes from the finding rather than the request, and it is marked "no longer occurring" once the condition goes away. Ignore/restore are audit-logged. New table consistency_ignores (migration 0012). portScan's private-address helper is now exported and shared. Verified with 36 checks (each rule and its exclusions, address-family and case/trailing-dot handling, IPv6 case, ordering, stable keys, the report route including a garbled agent report, source counts, ignore/unignore rules and audit entries) and by driving the page against the real routers in a browser: Add to IPAM actually created the entry, ignore and restore, severity filter, "show all", the viewer view, and narrow-width layout (which found and fixed a squeezed badge and clipped buttons). Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 --- README.md | 10 + server/drizzle/0012_cool_harry_osborn.sql | 10 + server/drizzle/meta/0012_snapshot.json | 1544 +++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 15 + server/src/index.ts | 2 + server/src/routes/consistency.ts | 117 ++ server/src/services/consistency.ts | 200 +++ server/src/services/portScan.ts | 2 +- web/src/App.tsx | 2 + web/src/api/client.ts | 43 + web/src/layout/AppShell.tsx | 2 + web/src/pages/Consistency.tsx | 293 ++++ 13 files changed, 2246 insertions(+), 1 deletion(-) create mode 100644 server/drizzle/0012_cool_harry_osborn.sql create mode 100644 server/drizzle/meta/0012_snapshot.json create mode 100644 server/src/routes/consistency.ts create mode 100644 server/src/services/consistency.ts create mode 100644 web/src/pages/Consistency.tsx 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))}. +
+ )} + + ); +}