#!/usr/bin/env bash # ============================================================================= # L0 static check (offline, no cluster) — pihole-lan-names # Serves: R-DNS-03 (LAN split-horizon answers), and decisions/2026-11-09-pihole-explicit-lan-names.md. # # WHY THIS EXISTS. Since PR #156 every PUBLIC name is a proxied CNAME to the Cloudflare Tunnel and # works from the LAN, so PiHole no longer answers the whole zone with a wildcard: it answers an # EXPLICIT list (catalog/pihole.yaml, FTLCONF_misc_dnsmasq_lines) and forwards everything else to # the public resolvers. That inverts the old failure mode. With the wildcard, a forgotten name still # resolved; now a forgotten LAN-ONLY name — a route disowned from public DNS, which the tunnel also # 404s — resolves NOWHERE, and the app is silently unreachable from every client. Nothing in the # cluster turns red when that happens. So the list is derived from the tree and asserted here. # # GIVEN the repository as authored, # THEN (0) the dnsmasq value parses (only `local-ttl=` and `address=//<${VAR}>` entries, # every VAR is a cluster-var), the catalog value and the rendered apps/wave-0/pihole.yaml # value are identical, and the inventories below are non-empty (anti-vacuous-pass); # (a) NO entry covers the whole zone: no `address=/${BASE_DOMAIN}/`, no `address=/#/`, and no # entry for any parent of the zone (dnsmasq matches every subdomain of an entry); # (b) every HTTPRoute hostname disowned from public DNS # (`external-dns.alpha.kubernetes.io/controller: none`, anywhere in apps/ platform/ # runbooks/) has an explicit entry to ${GATEWAY_VIP} — a disowned route with no # `hostnames:` is itself a FAIL, because it cannot be checked; # (c) every hostname platform/cloudflared/config.yaml maps to http_status:404 has an entry; # the L4 DNS-only names (derived: Services carrying both # `external-dns.alpha.kubernetes.io/hostname` and `lbipam.cilium.io/ips` — unifi, # crafty-mc) must map to THAT Service's VIP, not the Gateway; # (d) gitea and nextcloud are pinned to ${GATEWAY_VIP} (Cloudflare's 100 MB body / 100 s # limits break git push/LFS and large uploads); # AND (e) the evaluator is not vacuous: it is run against mutated copies of the real entry list # (zone wildcard added; a disowned host dropped; a 404'd host dropped; gitea repointed; # unifi repointed to the Gateway) and must flag each one. # # NOT proven (static): that the running PiHole pods carry this value (FTL applies FTLCONF_* at # start; the env change rolls the StatefulSet), that public resolution of the unlisted names # reaches Cloudflare, or the substituted IPs. Runtime check: `dig @${PIHOLE_DNS_VIP} `. # ============================================================================= set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root exec python3 - "$ROOT" <<'PY' import copy import re import sys from pathlib import Path try: import yaml except ImportError: # pragma: no cover print("CHECK FAIL (pihole-lan-names): PyYAML is required", file=sys.stderr) raise SystemExit(1) ROOT = Path(sys.argv[1]) FAILURES, CHECKS = [], 0 NAME = "pihole-lan-names" ZONE = "${BASE_DOMAIN}" GW = "${GATEWAY_VIP}" DISOWN = "external-dns.alpha.kubernetes.io/controller" EDNS_HOST = "external-dns.alpha.kubernetes.io/hostname" LB_IPS = "lbipam.cilium.io/ips" # Public, but must bypass Cloudflare's 100 MB request body / 100 s response limits. LAN_PINNED = ("gitea", "nextcloud") # Trees that hold applied manifests. conformance/ fixtures and .claude/ worktrees are not authored # surface (CLAUDE.md §0); decisions/ is prose. SCAN = ("apps", "platform", "runbooks") def check(cond, desc): global CHECKS CHECKS += 1 if not cond: FAILURES.append(desc) def docs(path): try: return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)] except yaml.YAMLError: return [] def dnsmasq_value(objs): """The FTLCONF_misc_dnsmasq_lines value from a list of docs (a ServiceDefinition or manifests).""" found = [] for d in objs: envs = list(d.get("env") or []) tmpl = (((d.get("spec") or {}).get("template") or {}).get("spec") or {}) for c in tmpl.get("containers") or []: envs += c.get("env") or [] found += [e.get("value") for e in envs if e.get("name") == "FTLCONF_misc_dnsmasq_lines"] return found def die(msg): print(f"CHECK FAIL ({NAME}): {msg}", file=sys.stderr) raise SystemExit(1) CATALOG = ROOT / "catalog" / "pihole.yaml" RENDERED = ROOT / "apps" / "wave-0" / "pihole.yaml" CF = ROOT / "platform" / "cloudflared" / "config.yaml" for p in (CATALOG, RENDERED, CF): if not p.is_file(): die(f"{p.relative_to(ROOT)} is absent — the surface this check guards moved; re-point or " "delete this check in the SAME change") cat_vals = dnsmasq_value(docs(CATALOG)) ren_vals = dnsmasq_value(docs(RENDERED)) check(len(cat_vals) == 1, f"(0) catalog/pihole.yaml declares FTLCONF_misc_dnsmasq_lines exactly " f"once (found {len(cat_vals)})") check(len(ren_vals) == 1, f"(0) apps/wave-0/pihole.yaml carries FTLCONF_misc_dnsmasq_lines exactly " f"once (found {len(ren_vals)})") if not cat_vals: die("no dnsmasq value to check") check(cat_vals[:1] == ren_vals[:1], "(0) the rendered apps/wave-0/pihole.yaml dnsmasq value differs from catalog/pihole.yaml — " "re-render pihole (catalog/render.py --no-image-automation catalog/pihole.yaml)") cv = next(d for d in docs(ROOT / "clusters" / "base" / "cluster-vars.yaml") if d.get("kind") == "ConfigMap") CLUSTER_VARS = set(cv["data"]) BASE_DOMAIN_VALUE = cv["data"]["BASE_DOMAIN"] ENTRY = re.compile(r"^address=/([^/]+)/(\$\{[A-Z0-9_]+\})$") def parse(value): """Return (entries {domain: target}, problems).""" entries, problems = {}, [] for raw in value.split(";"): line = raw.strip() if raw != line: problems.append(f"entry {raw!r} has surrounding whitespace (FTL writes it verbatim)") if not line: problems.append("empty entry (doubled or trailing `;`)") continue if re.fullmatch(r"local-ttl=\d+", line): continue m = ENTRY.match(line) if not m: problems.append(f"entry {line!r} is not `local-ttl=N` or `address=//${{VAR}}` — " f"this check cannot reason about it") continue dom, tgt = m.groups() if tgt[2:-1] not in CLUSTER_VARS: problems.append(f"{dom} -> {tgt}: not a key of clusters/base/cluster-vars.yaml") if dom in entries and entries[dom] != tgt: problems.append(f"{dom} is listed twice with different targets") entries[dom] = tgt return entries, problems entries, problems = parse(cat_vals[0]) for pr in problems: check(False, f"(0) {pr}") # ── inventories, derived from the tree ────────────────────────────────────────────────────────── disowned, unnamed = set(), [] l4 = {} for top in SCAN: for f in sorted((ROOT / top).rglob("*.yaml")): for d in docs(f): meta = d.get("metadata") or {} ann = meta.get("annotations") or {} if d.get("kind") == "HTTPRoute" and str(ann.get(DISOWN, "")).strip() == "none": hosts = (d.get("spec") or {}).get("hostnames") or [] if not hosts: unnamed.append(f"{f.relative_to(ROOT)}:{meta.get('name')}") disowned.update(hosts) if d.get("kind") == "Service" and EDNS_HOST in ann and LB_IPS in ann: for h in str(ann[EDNS_HOST]).split(","): l4[h.strip()] = str(ann[LB_IPS]).strip() for u in unnamed: check(False, f"(b) disowned HTTPRoute {u} declares no `hostnames:` — it inherits a listener " f"hostname this check cannot see; name it explicitly") check(len(disowned) >= 5, f"(0) only {len(disowned)} disowned HTTPRoute hostnames found — the scan " f"is vacuous (the disown annotation was renamed?)") check(len(l4) >= 2, f"(0) only {len(l4)} L4 Services publishing their own hostname found (expected " f"unifi and crafty-mc) — the scan is vacuous") cf = yaml.safe_load(CF.read_text()) cf_404 = {r["hostname"] for r in cf.get("ingress") or [] if r.get("hostname") and r.get("service") == "http_status:404"} check(len(cf_404) >= 5, f"(0) only {len(cf_404)} http_status:404 hostnames in the cloudflared " f"config — the scan is vacuous") def covers_zone(dom): """True if an entry for `dom` answers every name in the zone (dom is the zone or a parent).""" if dom in ("#", "", "."): return True if dom == ZONE: return True # A literal parent (e.g. `keithjackson.dev`, `dev`) of the zone's substituted value. real = dom.replace(ZONE, BASE_DOMAIN_VALUE) return real == BASE_DOMAIN_VALUE or BASE_DOMAIN_VALUE.endswith("." + real) def evaluate(entries): bad = [] # (a) for dom in entries: if covers_zone(dom): bad.append(f"(a) `address=/{dom}/` answers EVERY name in the zone — the wildcard is gone " f"on purpose (public names must resolve to Cloudflare from the LAN)") # (b) for h in sorted(disowned): if entries.get(h) != GW: bad.append(f"(b) {h} is disowned from public DNS but PiHole maps it to " f"{entries.get(h, 'NOTHING')} — expected {GW}; without it the app is " f"unreachable everywhere") # (c) for h in sorted(cf_404): if h in l4: if entries.get(h) != l4[h]: bad.append(f"(c) {h} is an L4 DNS-only name; PiHole maps it to " f"{entries.get(h, 'NOTHING')}, expected its own VIP {l4[h]}") elif h not in entries: bad.append(f"(c) the tunnel 404s {h} but PiHole has no entry for it — it would resolve " f"only publicly, and the tunnel refuses it") # (d) for n in LAN_PINNED: h = f"{n}.{ZONE}" if entries.get(h) != GW: bad.append(f"(d) {h} maps to {entries.get(h, 'NOTHING (public/Cloudflare)')}; it must be " f"pinned to {GW} — Cloudflare caps request bodies at 100 MB and responses at " f"100 s") return bad for v in evaluate(entries): check(False, v) # (e) mutation self-test mutants = {} m = dict(entries); m[ZONE] = GW mutants["zone wildcard added"] = m m = dict(entries); m[BASE_DOMAIN_VALUE.split(".", 1)[1]] = GW mutants["parent-of-zone wildcard added"] = m m = dict(entries); m.pop(sorted(disowned)[0], None) mutants[f"disowned host {sorted(disowned)[0]} dropped"] = m pure_404 = sorted(h for h in cf_404 if h not in l4 and h not in disowned) if pure_404: m = dict(entries); m.pop(pure_404[0], None) mutants[f"404'd host {pure_404[0]} dropped"] = m m = dict(entries); m.pop(f"gitea.{ZONE}", None) mutants["gitea unpinned"] = m m = dict(entries); m[f"nextcloud.{ZONE}"] = "${UNIFI_L4_VIP}" mutants["nextcloud repointed"] = m if l4: h = sorted(l4)[0] m = dict(entries); m[h] = GW mutants[f"{h} repointed to the Gateway"] = m for name, mut in mutants.items(): check(evaluate(mut), f"(e) self-test: the evaluator did NOT flag the mutant '{name}' — vacuous") check(len(mutants) >= 5, f"(e) only {len(mutants)} mutants built — the self-test degenerated") _, pr = parse("local-ttl=60;address=/x.${BASE_DOMAIN}/${NOT_A_CLUSTER_VAR}; address=/y/${GATEWAY_VIP}") check(len(pr) >= 2, "(e) self-test: the parser accepted an unknown VAR or a whitespace-padded entry") if CHECKS < 15: FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated") if FAILURES: print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) for f in FAILURES: print(f" - {f}", file=sys.stderr) raise SystemExit(1) print(f"PASS ({NAME}): {CHECKS} assertions; {len(entries)} PiHole names, {len(disowned)} disowned " f"routes, {len(cf_404)} tunnel-404 names, {len(l4)} L4 names") PY