Let the consistency report exclude address ranges

Docker reuses the same subnet on many hosts, and those networks aren't
part of the LAN, so they show up as conflicts and unlisted addresses. The
report only knew about 172.16.0.0/12 through a hardcoded rule; Docker can
just as well pick 192.168.x or 10.x.

The Consistency page now has an Excluded ranges card: CIDR ranges (IPv4
or IPv6) and single addresses, added with a form and removed with one
click, shown to everyone and editable by operators. There's also an
"Exclude range" button on each finding that pre-fills a /24 (or /64) around
its address to edit. Exclusions are applied to servers, IPAM and DNS
before anything is compared, so an excluded address never appears in any
kind of finding, whichever source it came from, and the card says how many
addresses are currently being hidden so it's clear the filter is doing
something.

The old hardcoded rule becomes a visible default (172.16.0.0/12) that can
be removed -- it was silently wrong for anyone using 172.16/12 as a real
LAN. That default is also slightly stronger than before: an address in the
range is now left out even if it is in IPAM or DNS, where the old rule only
skipped it when nothing else mentioned it. Remove or narrow it if that
isn't wanted.

Ranges are validated and normalised on the server (both families, prefix
bounds, no /0, at most 50), a bad one is rejected with a message naming it
and nothing is saved, and changes are audit-logged with before/after.
Matching uses Node's BlockList. Stored as a settings value; managed from
the report rather than admin-only Settings, like ignoring a finding.

Verified with 44 checks (range parsing and rejection, boundary addresses
just inside and outside a range, IPv6, single addresses, exclusion across
all sources and finding kinds, the hidden-address count, route
validation/roles/audit) and in a browser against the real router: add,
invalid, remove the default, exclude from a finding. Real dev database
mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-26 18:57:28 +02:00
1 parent 2352689fd3
commit 3f2b5da7be
6 files changed
+223 -23

No files matched your search

+49 -5
View File
@@ -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<string, unknown> }> {
async function computeFindings(): Promise<{ findings: Finding[]; sources: Record<string, unknown>; 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