diff --git a/README.md b/README.md index 266cdd0..74e856d 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,10 @@ 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. +private addresses are compared; ranges you exclude (Docker's, which repeat the same +subnet on many hosts — 172.16.0.0/12 is excluded by default, remove it if that's a +real LAN for you) are left out of every source; 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 diff --git a/server/src/routes/consistency.ts b/server/src/routes/consistency.ts index 44651e6..0635c0b 100644 --- a/server/src/routes/consistency.ts +++ b/server/src/routes/consistency.ts @@ -5,7 +5,15 @@ 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 { + buildFindings, + countHiddenAddresses, + InvalidRangeError, + MAX_EXCLUDED_RANGES, + normalizeRange, + type Finding, +} from "../services/consistency.js"; +import { getSettings, updateSettings } from "../services/settingsStore.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const consistencyRouter = Router(); @@ -21,7 +29,7 @@ function parseIps(stored: string | null): string[] { } } -async function computeFindings(): Promise<{ findings: Finding[]; sources: Record }> { +async function computeFindings(): Promise<{ findings: Finding[]; sources: Record; excludedRanges: string[]; hiddenAddresses: number }> { 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), @@ -33,7 +41,10 @@ async function computeFindings(): Promise<{ findings: Finding[]; sources: Record ]); 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 { consistency } = await getSettings(); + const excludedRanges = consistency.excludedRanges; + const findings = buildFindings({ servers: serverInputs, ipam: ipamRows, dns: dnsRows, excludedRanges }); + const hiddenAddresses = countHiddenAddresses({ servers: serverInputs, ipam: ipamRows, dns: dnsRows }, excludedRanges); const synced = zoneRows.map((z) => z.syncedAt).filter((t): t is string => !!t).sort(); const sources = { @@ -47,11 +58,11 @@ async function computeFindings(): Promise<{ findings: Finding[]; sources: Record newestSyncedAt: synced[synced.length - 1] ?? null, }, }; - return { findings, sources }; + return { findings, sources, excludedRanges, hiddenAddresses }; } consistencyRouter.get("/", asyncHandler(async (_req, res) => { - const { findings, sources } = await computeFindings(); + const { findings, sources, excludedRanges, hiddenAddresses } = 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)); @@ -65,10 +76,43 @@ consistencyRouter.get("/", asyncHandler(async (_req, res) => { counts, ignored: ignores.map((i) => ({ ...i, stillPresent: present.has(i.key) })), sources, + excludedRanges, + hiddenAddresses, generatedAt: new Date().toISOString(), }); })); +const rangesSchema = z.object({ ranges: z.array(z.string().max(100)).max(200) }); + +// Managed here rather than in admin-only Settings: like ignoring a finding, it's a judgement about the network that +// whoever is looking at the report is best placed to make. +consistencyRouter.put("/excluded-ranges", requireRole("operator"), asyncHandler(async (req, res) => { + const parsed = rangesSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Ranges must be a list of text.", details: parsed.error.flatten() }); + + let ranges: string[]; + try { + ranges = [...new Set(parsed.data.ranges.filter((r) => r.trim()).map(normalizeRange))]; + } catch (err) { + if (err instanceof InvalidRangeError) return res.status(400).json({ error: "invalid_range", message: err.message }); + throw err; + } + if (ranges.length > MAX_EXCLUDED_RANGES) { + return res.status(400).json({ error: "too_many", message: `At most ${MAX_EXCLUDED_RANGES} ranges.` }); + } + + const before = (await getSettings()).consistency.excludedRanges; + await updateSettings({ consistency: { excludedRanges: ranges } }); + await recordAudit({ + actor: req.currentUser!, + category: "consistency", + action: "set_excluded_ranges", + targetType: "consistency_settings", + detail: { before, after: ranges }, + }); + res.json({ excludedRanges: ranges }); +})); + 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 diff --git a/server/src/services/consistency.ts b/server/src/services/consistency.ts index 6c51e11..b20647e 100644 --- a/server/src/services/consistency.ts +++ b/server/src/services/consistency.ts @@ -51,10 +51,56 @@ 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; +// ─── Excluded ranges ──────────────────────────────────────────────────────── + +export const MAX_EXCLUDED_RANGES = 50; +export class InvalidRangeError extends Error {} + +/** "10.0.0.0/8", "fd00::/8" or a single address, in a canonical lowercase form. Throws InvalidRangeError with a message fit to show. */ +export function normalizeRange(input: string): string { + const text = input.trim().toLowerCase(); + const [addr, prefixText, ...extra] = text.split("/"); + const family = net.isIP(addr); + if (!text || extra.length > 0 || family === 0) { + throw new InvalidRangeError(`"${input.trim()}" isn't an address or range — use something like 192.168.16.0/20 or 10.1.2.3.`); + } + if (prefixText === undefined) return addr; + const max = family === 4 ? 32 : 128; + if (!/^\d{1,3}$/.test(prefixText) || Number(prefixText) > max) { + throw new InvalidRangeError(`"${input.trim()}": the part after the slash must be a number from 1 to ${max}.`); + } + if (Number(prefixText) === 0) throw new InvalidRangeError(`"${input.trim()}" would hide every address.`); + return `${addr}/${Number(prefixText)}`; +} + +/** A matcher for a list of already-normalised ranges/addresses. */ +export function makeExclusion(ranges: string[]): (ip: string) => boolean { + if (ranges.length === 0) return () => false; + const list = new net.BlockList(); + for (const r of ranges) { + const [addr, prefix] = r.split("/"); + const family = net.isIP(addr) === 6 ? "ipv6" : "ipv4"; + if (prefix === undefined) list.addAddress(addr, family); + else list.addSubnet(addr, Number(prefix), family); + } + return (ip) => { + const family = net.isIP(ip); + return family !== 0 && list.check(ip, family === 6 ? "ipv6" : "ipv4"); + }; +} + +/** How many distinct addresses the exclusions are currently hiding, so the page can show that they're doing something. */ +export function countHiddenAddresses(input: { servers: ServerInput[]; ipam: IpamInput[]; dns: DnsInput[] }, ranges: string[]): number { + const excluded = makeExclusion(ranges); + const hidden = new Set(); + const consider = (ip: string) => { + const n = norm(ip); + if (excluded(n)) hidden.add(n); + }; + for (const s of input.servers) s.ips.forEach(consider); + for (const e of input.ipam) consider(e.ip); + for (const r of input.dns) if (r.type === "A" || r.type === "AAAA") consider(r.content); + return hidden.size; } /** 100.64.0.0/10 — where Tailscale addresses live. MagicDNS names them, so a missing DNS record isn't a gap. */ @@ -81,14 +127,20 @@ function serversNamed(name: string, servers: ServerInput[]): ServerInput[] { // ─── the checks ───────────────────────────────────────────────────────────── -export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[]; dns: DnsInput[] }): Finding[] { +/** + * `excludedRanges` are dropped from all three sources before anything is compared — an excluded address never + * appears in a finding, whichever side it came from. That's what lets Docker networks, which reuse the same subnet + * on many hosts and don't belong to the LAN, be kept out. + */ +export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[]; dns: DnsInput[]; excludedRanges?: string[] }): Finding[] { const findings: Finding[] = []; - const servers = input.servers.map((s) => ({ ...s, ips: [...new Set(s.ips.map(norm))] })); + const excluded = makeExclusion(input.excludedRanges ?? []); + const servers = input.servers.map((s) => ({ ...s, ips: [...new Set(s.ips.map(norm))].filter((ip) => !excluded(ip)) })); const withIps = servers.filter((s) => s.ips.length > 0); - const ipamByIp = new Map(input.ipam.map((e) => [norm(e.ip), e])); + const ipamByIp = new Map(input.ipam.filter((e) => !excluded(norm(e.ip))).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())) + .filter((r) => (r.type === "A" || r.type === "AAAA") && isPrivateAddress(r.content.trim()) && !excluded(norm(r.content))) .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]); @@ -100,8 +152,7 @@ export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[] 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; + if (owners.length < 2) continue; add({ key: `ip_conflict|${ip}`, kind: "ip_conflict", @@ -136,6 +187,7 @@ export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[] // 3. IPAM labels a server's name, at an address the server doesn't have. for (const e of input.ipam) { + if (excluded(norm(e.ip))) continue; 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)) { @@ -158,8 +210,6 @@ export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[] 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 "); @@ -180,7 +230,7 @@ export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[] 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; + if (dnsByIp.has(ip) || net.isIP(ip) === 0 || !isPrivateAddress(ip) || isCgnat(ip)) continue; add({ key: `no_dns|${s.id}|${ip}`, kind: "no_dns", diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index 31d2d1d..6d88330 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -92,6 +92,11 @@ export interface HealthCheckSettings { domainWarnDays: number; } +export interface ConsistencySettings { + /** CIDR ranges and single addresses the consistency report ignores entirely — e.g. Docker networks that repeat on many hosts. */ + excludedRanges: string[]; +} + export interface AppSettings { gotify: GotifySettings; ntfy: NtfySettings; @@ -104,6 +109,7 @@ export interface AppSettings { logRetention: LogRetentionSettings; quietHours: QuietHoursSettings; healthChecks: HealthCheckSettings; + consistency: ConsistencySettings; } const DEFAULTS: AppSettings = { @@ -133,6 +139,9 @@ const DEFAULTS: AppSettings = { logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 }, quietHours: { enabled: false, start: "22:00", end: "07:00" }, healthChecks: { serverOfflineMinutes: 60, diskUsagePercent: 90, domainWarnDays: 30 }, + // Docker's default bridge (172.17.0.0/16) and the pool it hands custom networks from (172.18–31) live here, and every + // Docker host repeats them. Shown on the Consistency page, where it can be removed if 172.16/12 is used as a real LAN. + consistency: { excludedRanges: ["172.16.0.0/12"] }, }; const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[]; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d09f969..3c4ab63 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -542,6 +542,8 @@ export interface ConsistencyReport { findings: ConsistencyFinding[]; counts: Record; ignored: ConsistencyIgnore[]; + excludedRanges: string[]; + hiddenAddresses: number; sources: { servers: { total: number; withAddresses: number }; ipam: number; @@ -919,6 +921,8 @@ export const api = { 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" }), + setExcludedRanges: (ranges: string[]) => + request<{ excludedRanges: string[] }>("/api/consistency/excluded-ranges", { method: "PUT", body: JSON.stringify({ ranges }) }), }, domains: { list: () => request("/api/domains"), diff --git a/web/src/pages/Consistency.tsx b/web/src/pages/Consistency.tsx index 1a97da9..3fcc784 100644 --- a/web/src/pages/Consistency.tsx +++ b/web/src/pages/Consistency.tsx @@ -34,6 +34,8 @@ export default function Consistency({ user }: { user: CurrentUser }) { const [busyKey, setBusyKey] = useState(null); const [expanded, setExpanded] = useState>(new Set()); const [showIgnored, setShowIgnored] = useState(false); + const [newRange, setNewRange] = useState(""); + const [savingRanges, setSavingRanges] = useState(false); function load() { setLoading(true); @@ -85,6 +87,42 @@ export default function Consistency({ user }: { user: CurrentUser }) { } } + /** Saves the whole list (the server validates and normalises it) and reloads, so the findings reflect it straight away. */ + async function saveRanges(ranges: string[]): Promise { + setSavingRanges(true); + setError(null); + setNotice(null); + try { + await api.consistency.setExcludedRanges(ranges); + await load(); + return true; + } catch (err) { + setError(readableError(err)); + return false; + } finally { + setSavingRanges(false); + } + } + + async function addRange(e: React.FormEvent) { + e.preventDefault(); + if (!report || !newRange.trim()) return; + if (await saveRanges([...report.excludedRanges, newRange.trim()])) setNewRange(""); + } + + async function excludeAround(f: ConsistencyFinding) { + if (!report || !f.ip) return; + const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`; + const range = window.prompt( + `Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike. + +Range (a network like 192.168.16.0/20, or a single address):`, + suggestion, + ); + if (range === null || !range.trim()) return; + await saveRanges([...report.excludedRanges, range.trim()]); + } + async function restore(id: number) { setError(null); try { @@ -149,6 +187,55 @@ export default function Consistency({ user }: { user: CurrentUser }) { )} + {report && ( +
+
+
Excluded ranges
+
+ Addresses in these ranges are left out of the report completely — servers, IPAM and DNS alike. Meant for networks that + aren't part of your LAN, like Docker's, which repeat the same subnet on many hosts. + {report.hiddenAddresses > 0 && ( + <> + {" "} + Currently hiding {report.hiddenAddresses} address{report.hiddenAddresses === 1 ? "" : "es"}. + + )} +
+
+ {report.excludedRanges.length === 0 && Nothing excluded.} + {report.excludedRanges.map((r) => ( + + {r} + {canEdit && ( +
+ {canEdit && ( +
+ setNewRange(e.target.value)} + /> + +
+ )} +
+
+ )} + {report && (
)} + {f.ip && ( + + )} @@ -282,9 +374,8 @@ export default function Consistency({ user }: { user: CurrentUser }) { {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{" "} + Only private addresses are compared — public DNS records aren't expected to be in IPAM. 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))}.
)}