Add the at-home peer (10.90.0.3) and list ${WG_CLIENT_CIDR} in the PiHole
DNS frontends' loadBalancerSourceRanges. wg0 is a Cilium device, so tunnel
clients reach the source-range check untranslated; a LAN-only list is
predicted to drop their DNS. Adds an L0 guard that both ranges are present
and every range is RFC1918, and corrects the README's SNAT and IPv6 claims.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
147 lines
6.6 KiB
Bash
Executable File
147 lines
6.6 KiB
Bash
Executable File
#!/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
|