Public names are proxied CNAMEs to the Cloudflare Tunnel since PR #156 and work from the LAN, so they should resolve publicly there too. Replace the address=/${BASE_DOMAIN}/ wildcard with explicit entries: the LAN-only names (derived from every controller:none HTTPRoute), gitea/nextcloud (off Cloudflare's 100 MB / 100 s limits), keycloak/auth, and the L4 VIPs. Add L0 check pihole-lan-names, mutation-tested. Correct docs that still described public names as CNAME to wan. and the cloudflared entrypoint comment. Co-Authored-By: Claude Opus 5.5 <[email protected]>
270 lines
12 KiB
Bash
Executable File
270 lines
12 KiB
Bash
Executable File
#!/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=/<domain>/<${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} <name>`.
|
|
# =============================================================================
|
|
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=/<domain>/${{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
|