#!/usr/bin/env bash # ============================================================================= # L0 static check (offline, no cluster) — pihole-dns-source-ranges # Serves: R-DNS-03 (LAN split-horizon DNS — spec docs/09). # # THE BUG CLASS THIS CLOSES. PiHole's LoadBalancer frontends are the only thing stopping it being # an open resolver, and their `loadBalancerSourceRanges` is pulled in two opposite directions: # * Too WIDE, and the resolver is open: a public range, or 0.0.0.0/0, in this list is the whole # restriction gone, with nothing else in the path to catch it (the CiliumNetworkPolicy admits # `world` on :53, because that is the only peer that can describe post-LoadBalancer traffic). # * Too NARROW, and VPN DNS dies: wg0 is a Cilium kube-proxy-replacement device, so a tunnel # client's query is load-balanced with its 10.90.0.x source intact, ahead of the iptables # MASQUERADE. A LAN-only list is predicted to drop it as DROP_NOT_IN_SRC_RANGE, and the # tunnel would still report a healthy handshake. # # GIVEN the repository as git tracks it, # WHEN every type=LoadBalancer Service in namespace `pihole` is collected and each source range # is resolved through cluster-vars (base value AND the prod overlay's replacement), # THEN (1) the frontends exist and cover both udp/53 and tcp/53; # (2) every frontend lists ${LAN_CIDR} and ${WG_CLIENT_CIDR}; # (3) every range resolves to a valid IPv4 CIDR inside RFC1918 — so no 0.0.0.0/0, no public # range, and no unresolvable token that could hide one; # AND (4) the check is NON-VACUOUS: a real checkout and a floor on the assertion count. # # WHAT THIS DOES NOT PROVE: that a query over the tunnel is actually answered. That needs a # handshake from a real client; see platform/vpn-ingress/README.md. # ============================================================================= set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root PYTHON="${PYTHON:-python3}" cd "${ROOT}" "${PYTHON}" - "${ROOT}" <<'PY' import ipaddress, re, subprocess, sys from pathlib import Path try: import yaml except ImportError: # pragma: no cover print("check FAIL: PyYAML is required (same dependency as catalog/render.py)", file=sys.stderr) raise SystemExit(1) ROOT = Path(sys.argv[1]) FAILURES: list[str] = [] CHECKS = 0 REQUIRED_TOKENS = ("LAN_CIDR", "WG_CLIENT_CIDR") RFC1918 = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")] TOKEN = re.compile(r"^\$\{([A-Z0-9_]+)\}$") def check(cond, msg): global CHECKS CHECKS += 1 if not cond: FAILURES.append(msg) def docs_of(rel: str): try: return [d for d in yaml.safe_load_all((ROOT / rel).read_text(errors="replace")) if isinstance(d, dict)] except Exception: return [] FILES = [p for p in subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"], capture_output=True, text=True, check=True).stdout.split("\0") if p] YAML_FILES = [f for f in FILES if re.search(r"\.ya?ml$", f)] check(len(YAML_FILES) > 50, f"only {len(YAML_FILES)} YAML files seen — not a real checkout") # ------------------------------------------------- every value a ${TOKEN} can take on this tree values: dict[str, set[str]] = {} for d in docs_of("clusters/base/cluster-vars.yaml"): if d.get("kind") == "ConfigMap": for k, v in (d.get("data") or {}).items(): values.setdefault(k, set()).add(str(v)) for d in docs_of("clusters/prod/kustomization.yaml"): for p in d.get("patches") or []: ops = p.get("patch") if isinstance(p, dict) else None ops = yaml.safe_load(ops) if isinstance(ops, str) else ops for op in ops if isinstance(ops, list) else []: m = re.fullmatch(r"/data/([A-Z0-9_]+)", str(op.get("path", ""))) if m and op.get("op") in ("add", "replace"): values.setdefault(m.group(1), set()).add(str(op.get("value"))) for tok in REQUIRED_TOKENS: check(values.get(tok), f"clusters/base/cluster-vars.yaml defines no {tok} (R-DNS-03)") def assert_private(where: str, cidr: str): try: net = ipaddress.ip_network(cidr, strict=False) except ValueError: check(False, f"{where}: {cidr!r} is not a valid CIDR (R-DNS-03)") return check(net.version == 4 and any(net.subnet_of(r) for r in RFC1918), f"{where}: {cidr} is not inside RFC1918, so it opens PiHole beyond the LAN and the VPN " "(R-DNS-03)") # ---------------------------------------------------------------------------- the frontends frontends = [] for rel in YAML_FILES: for d in docs_of(rel): md, spec = d.get("metadata") or {}, d.get("spec") or {} if d.get("kind") == "Service" and md.get("namespace") == "pihole" \ and spec.get("type") == "LoadBalancer": frontends.append((rel, md.get("name"), spec)) protocols = {(p.get("protocol", "TCP"), p.get("port")) for _, _, spec in frontends for p in spec.get("ports") or []} for want in (("UDP", 53), ("TCP", 53)): check(want in protocols, f"no PiHole LoadBalancer Service serves {want[0]}/{want[1]} — this " "check's corpus has moved and must be re-derived (R-DNS-03)") for rel, name, spec in frontends: where = f"{rel}: Service/{name}" ranges = spec.get("loadBalancerSourceRanges") or [] check(ranges, f"{where} has no loadBalancerSourceRanges, so it answers anyone who can reach " "the VIP (R-DNS-03)") tokens = {m.group(1) for m in (TOKEN.match(str(r)) for r in ranges) if m} for tok in REQUIRED_TOKENS: check(tok in tokens, f"{where} does not list ${{{tok}}} in loadBalancerSourceRanges " "(R-DNS-03)") for r in ranges: m = TOKEN.match(str(r)) if not m: assert_private(where, str(r)) continue resolved = values.get(m.group(1)) check(resolved, f"{where}: {r} is not defined in cluster-vars, so its value cannot be " "shown to be private (R-DNS-03)") for v in sorted(resolved or []): assert_private(f"{where} ({r})", v) check(CHECKS >= 15, f"only {CHECKS} assertions ran — this check has gone vacuous (R-DNS-03)") if FAILURES: for f in FAILURES: print(f"check FAIL: {f}", file=sys.stderr) raise SystemExit(1) print(f"check OK: pihole-dns-source-ranges — {CHECKS} assertions over " f"{len(frontends)} PiHole LoadBalancer Service(s)") PY