Adds a real README describing the 4-step deploy flow, with pfSense Unbound host overrides as step 1 (the prerequisite that, if skipped, silently breaks cert-manager HTTP-01 for ~2h per cert until manually diagnosed — root cause of the 2026-04-22 cluster-wide cert outage). Adds scripts/check-pfsense-dns.py: parses every apps/*/*.yaml, extracts hostnames from Certificate.spec.dnsNames and Traefik IngressRoute `Host(...)` match rules, and fails the check if any don't resolve via the system DNS (pfSense Unbound on this LAN). Ignores IRC server-link labels, image tags, comments — only checks hostnames cert-manager and Traefik will actually use. Run before `git push` or wire into pre-commit / Gitea Actions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
3.7 KiB
Python
120 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
check-pfsense-dns.py
|
|
|
|
Fails if any apps/*/*.yaml references an iamworkin.lan hostname in a
|
|
cert-manager Certificate `spec.dnsNames` or a Traefik IngressRoute
|
|
`Host(...)` match rule that does NOT resolve via the system DNS resolver
|
|
(which on this LAN is pfSense Unbound at 10.0.56.1).
|
|
|
|
Run from anywhere that uses pfSense as a resolver (LAN hosts, noc1, BLUEJAY-WS):
|
|
|
|
python scripts/check-pfsense-dns.py
|
|
|
|
Exit code 0: all referenced hosts resolve. 1: at least one doesn't.
|
|
|
|
This is intentionally narrow: it only flags hostnames that cert-manager will
|
|
actually try to validate via HTTP-01, or that Traefik will route. IRC
|
|
server-link names, Docker image tags, comments, etc. are ignored.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml # PyYAML
|
|
except ImportError:
|
|
sys.exit("PyYAML required: pip install pyyaml")
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
APPS_DIR = REPO_ROOT / "apps"
|
|
|
|
HOST_RE = re.compile(r"Host\(`([^`]+)`\)")
|
|
|
|
|
|
def extract_hosts_from_doc(doc: dict) -> set[str]:
|
|
"""Pull iamworkin.lan hostnames from a single K8s manifest doc."""
|
|
out: set[str] = set()
|
|
if not isinstance(doc, dict):
|
|
return out
|
|
|
|
kind = doc.get("kind", "")
|
|
spec = doc.get("spec") or {}
|
|
|
|
if kind == "Certificate":
|
|
for name in spec.get("dnsNames", []) or []:
|
|
if isinstance(name, str) and name.endswith(".iamworkin.lan"):
|
|
out.add(name)
|
|
|
|
elif kind == "IngressRoute":
|
|
for route in spec.get("routes", []) or []:
|
|
match = route.get("match", "") if isinstance(route, dict) else ""
|
|
for h in HOST_RE.findall(match):
|
|
if h.endswith(".iamworkin.lan"):
|
|
out.add(h)
|
|
|
|
return out
|
|
|
|
|
|
def collect_hosts() -> dict[str, list[str]]:
|
|
"""hostname -> [list of manifest files that referenced it]."""
|
|
index: dict[str, list[str]] = {}
|
|
for path in sorted(APPS_DIR.rglob("*.yaml")):
|
|
try:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
for doc in yaml.safe_load_all(f):
|
|
for host in extract_hosts_from_doc(doc):
|
|
index.setdefault(host, []).append(str(path.relative_to(REPO_ROOT)))
|
|
except yaml.YAMLError as e:
|
|
print(f"warn: could not parse {path}: {e}", file=sys.stderr)
|
|
return index
|
|
|
|
|
|
def resolves(host: str) -> str | None:
|
|
try:
|
|
return socket.gethostbyname(host)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
hosts = collect_hosts()
|
|
if not hosts:
|
|
print(f"No iamworkin.lan hostnames found in {APPS_DIR} — nothing to check.")
|
|
return 0
|
|
|
|
failed: list[tuple[str, list[str]]] = []
|
|
for host in sorted(hosts):
|
|
ip = resolves(host)
|
|
if ip:
|
|
print(f"OK {host:<45} -> {ip}")
|
|
else:
|
|
print(f"FAIL {host:<45} (no pfSense Unbound override)")
|
|
failed.append((host, hosts[host]))
|
|
|
|
if failed:
|
|
print()
|
|
print(f"ERROR: {len(failed)} host(s) referenced in manifests but not in pfSense Unbound.")
|
|
for host, files in failed:
|
|
print(f" {host} (referenced in: {', '.join(sorted(set(files)))})")
|
|
print()
|
|
print("Add them before merging — see README.md step 1.")
|
|
print()
|
|
print("From FlowerCore.Notes:")
|
|
print(" # edit HOSTS list in scripts/pfsense-add-dns-overrides.py")
|
|
print(" export PFSENSE_PASS=$(get_cred 'pfSense Admin')")
|
|
print(" python scripts/pfsense-add-dns-overrides.py")
|
|
return 1
|
|
|
|
print()
|
|
print(f"All {len(hosts)} iamworkin.lan host(s) resolve via pfSense. Safe to deploy.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|