#!/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())