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:
1 parent
2352689fd3
commit
3f2b5da7be
6 files changed
+223
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, typeof privateDns>();
|
||||
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<string, ServerInput[]>();
|
||||
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",
|
||||
|
||||
@@ -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)[];
|
||||
|
||||
Reference in new issue
Block a user