"""Verify every Caddyfile mode against the real `caddy validate` binary. Also asserts the structural guarantees required for the deployment model: * ONE HTTP endpoint that answers regardless of Host header (catch-all :80) * explicit per-name HTTP blocks for both IP and hostname * HTTPS on 443 only when enabled, using the internal CA for intranet names Run: PYTHONPATH=$(pwd) ./.venv/bin/python docs/tools/verify_caddyfile_modes.py """ import subprocess import sys from app.utils.caddy_manager import CaddyConfigGenerator as G class Cfg: def __init__(self, **kw): self.email = kw.get('email', 'admin@example.com') self.https_enabled = kw.get('https_enabled', False) self.domain = kw.get('domain', '') self.ip_address = kw.get('ip_address', '') self.hostname = kw.get('hostname', '') self.port = kw.get('port', 443) def validate(text): path = '/tmp/_caddyfile_check' open(path, 'w').write(text) r = subprocess.run( ['docker', 'run', '--rm', '-v', f'{path}:/etc/caddy/Caddyfile:ro', 'caddy:2-alpine', 'caddy', 'validate', '--config', '/etc/caddy/Caddyfile'], capture_output=True, text=True) return (r.returncode == 0 and 'Valid configuration' in (r.stdout + r.stderr), (r.stdout + r.stderr)) CASES = [ ('1. Nothing configured -> HTTP only on :80', Cfg(), True, {}), ('2. HTTPS on, IP only -> internal CA + HTTP fallback', Cfg(https_enabled=True, ip_address='192.168.0.152'), True, {'tls internal', 'http://192.168.0.152', 'https://192.168.0.152', ':80'}), ('3. HTTPS on, IP + hostname -> both served', Cfg(https_enabled=True, ip_address='192.168.0.152', hostname='digiserver'), True, {'http://digiserver', 'https://digiserver', 'http://192.168.0.152'}), ('4. HTTPS on, redirect-only -> 301 to published port', Cfg(https_enabled=True, ip_address='192.168.0.152'), False, {'redir https://192.168.0.152{uri} 301', ':80'}), ('5. HTTPS on, public domain -> ACME (no tls internal)', Cfg(https_enabled=True, domain='example.com', ip_address='192.168.0.152'), True, {'https://example.com', 'tls internal'}), ('6. HTTP only even though IP known (HTTPS disabled) -> :80 only', Cfg(https_enabled=False, ip_address='192.168.0.152'), True, {':80'}), ] failures = [] for label, cfg, fallback, must_contain in CASES: text = G.generate_caddyfile(cfg, http_fallback=fallback) ok, raw = validate(text) missing = sorted(s for s in must_contain if s not in text) if missing: ok = False print(f'[{"OK " if ok else "FAIL"}] {label}') blocks = [ln.strip() for ln in text.splitlines() if ln.strip().startswith(('http://', 'https://', ':80'))] print(f' blocks: {blocks}') if missing: print(f' MISSING: {missing}') if not ok: failures.append(label) print(' ', raw.strip()[-400:]) print() # Extra guarantees that are easy to regress silently. text_https = G.generate_caddyfile( Cfg(https_enabled=True, ip_address='192.168.0.152', hostname='digiserver'), http_fallback=True) assert ':80 {' in text_https, 'catch-all :80 block missing' assert 'https://digiserver {' in text_https, 'hostname TLS block missing' assert text_https.count(':80 {') == 1, 'exactly one catch-all :80 expected' text_http = G.generate_caddyfile(Cfg(https_enabled=False)) assert 'https://' not in text_http, 'no TLS should be emitted when disabled' assert ':80 {' in text_http print('Structural guarantees hold (catch-all :80, per-name blocks, TLS only when enabled)') if failures: print(f'FAILED: {failures}') sys.exit(1) print('All Caddyfile modes are VALID')