""" WireGuard E2E: Caddy per-domain routing correctness. Scenarios covered: 35. api. proxies to the API (returns JSON), not the WebUI 36. calendar. via VIP proxies to Radicale, not the WebUI 37. files. via VIP proxies to Filegator, not the WebUI 38. mail. via VIP proxies to Rainloop, not the WebUI 39. webdav. via VIP proxies to the WebDAV service, not the WebUI 40. Direct VIP requests (by IP) go to the correct service 41. Catch-all :80 serves WebUI for unknown hosts but routes /api/* to API The WebUI serves a React app — its HTML starts with ''. Any service domain that returns that string is incorrectly falling through to the catch-all :80 block instead of being routed by its Host header. These tests require a live PIC stack with WireGuard and are marked `wg`. They run via `make test-e2e-wg` or `pytest tests/e2e/wg/ -m wg`. """ import subprocess import pytest pytestmark = pytest.mark.wg _WEBUI_MARKER = '' def _config(admin_client) -> dict: r = admin_client.get('/api/config') return r.json() if r.status_code == 200 else {} def _domain(admin_client) -> str: return _config(admin_client).get('domain') or 'lan' def _dns_ip(admin_client) -> str: cfg = _config(admin_client) return cfg.get('service_ips', {}).get('dns') or '172.20.0.3' def _curl_host(ip: str, host: str, path: str = '/', timeout: int = 8) -> tuple[int, str]: """ Make an HTTP request to `ip` with the given Host header. Returns (http_code, body_snippet). """ result = subprocess.run( ['curl', '-s', '--connect-timeout', '5', '-H', f'Host: {host}', '-w', '\n__HTTP_CODE__:%{http_code}', f'http://{ip}{path}'], capture_output=True, text=True, timeout=timeout, ) output = result.stdout body = '' code = 0 if '__HTTP_CODE__:' in output: parts = output.rsplit('__HTTP_CODE__:', 1) body = parts[0].lower() try: code = int(parts[1].strip()) except ValueError: pass return code, body def _curl_domain(host: str, path: str = '/', dns_ip: str = '', timeout: int = 8) -> tuple[int, str]: """Make an HTTP request using curl's --dns-servers to resolve via CoreDNS.""" cmd = ['curl', '-s', '--connect-timeout', '5', '-w', '\n__HTTP_CODE__:%{http_code}', f'http://{host}{path}'] if dns_ip: cmd = ['curl', '-s', '--connect-timeout', '5', '--dns-servers', dns_ip, '-w', '\n__HTTP_CODE__:%{http_code}', f'http://{host}{path}'] result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) output = result.stdout body = '' code = 0 if '__HTTP_CODE__:' in output: parts = output.rsplit('__HTTP_CODE__:', 1) body = parts[0].lower() try: code = int(parts[1].strip()) except ValueError: pass return code, body # ── Scenario 35: api. routes to API ─────────────────────────────────── def test_api_domain_returns_json_not_webui(connected_peer, admin_client): """api./api/status must return JSON, not the React WebUI HTML.""" dom = _domain(admin_client) dns_ip = _dns_ip(admin_client) code, body = _curl_domain(f'api.{dom}', '/api/status', dns_ip) assert code not in (0, 000), f"curl to api.{dom}/api/status failed (code {code})" assert _WEBUI_MARKER not in body, ( f"api.{dom}/api/status returned WebUI HTML — " "Caddy is not routing api. to the API; " "check that the http://api. block exists in the Caddyfile " "and uses the configured domain (not a stale .cell or .dev TLD)" ) assert '{' in body or '"' in body, ( f"api.{dom}/api/status did not return JSON (body: {body[:100]!r})" ) def test_api_vip_host_header_routes_to_api(connected_peer, admin_client): """Caddy routes api. by Host header even when accessed via the Caddy VIP.""" dom = _domain(admin_client) code, body = _curl_host('172.20.0.2', f'api.{dom}', '/api/status') assert _WEBUI_MARKER not in body, ( f"Host: api.{dom} via 172.20.0.2 returned WebUI HTML — " "Caddy http://api. block is missing or uses wrong TLD" ) # ── Scenario 36: calendar. routes to Radicale ──────────────────────── def test_calendar_vip_does_not_serve_webui(connected_peer, admin_client): """calendar. (VIP 172.20.0.21) must proxy to Radicale, not the WebUI.""" dom = _domain(admin_client) dns_ip = _dns_ip(admin_client) code, body = _curl_domain(f'calendar.{dom}', '/', dns_ip) assert code not in (0,), f"curl to calendar.{dom} failed completely" assert _WEBUI_MARKER not in body, ( f"calendar.{dom} returned WebUI HTML — " "Caddy is not routing calendar. to Radicale. " "This happens when Caddy has old (e.g. .cell) domain blocks and all " "traffic falls through to the catch-all :80 block." ) def test_calendar_vip_ip_does_not_serve_webui(connected_peer): """Direct request to VIP 172.20.0.21 must NOT return the WebUI.""" code, body = _curl_host('172.20.0.21', 'calendar.lan') assert _WEBUI_MARKER not in body, ( "172.20.0.21 (calendar VIP) returned WebUI HTML — " "Caddy http://calendar., http://172.20.0.21:80 block is missing or stale" ) # ── Scenario 37: files. routes to Filegator ────────────────────────── def test_files_vip_does_not_serve_webui(connected_peer, admin_client): """files. (VIP 172.20.0.22) must proxy to Filegator, not the WebUI.""" dom = _domain(admin_client) dns_ip = _dns_ip(admin_client) code, body = _curl_domain(f'files.{dom}', '/', dns_ip) assert code not in (0,), f"curl to files.{dom} failed completely" assert _WEBUI_MARKER not in body, ( f"files.{dom} returned WebUI HTML — " "Caddy is not routing files. to Filegator. " "Check the http://files., http://172.20.0.22:80 Caddyfile block." ) def test_files_vip_ip_does_not_serve_webui(connected_peer): """Direct request to VIP 172.20.0.22 must NOT return the WebUI.""" code, body = _curl_host('172.20.0.22', 'files.lan') assert _WEBUI_MARKER not in body, ( "172.20.0.22 (files VIP) returned WebUI HTML — " "Caddy http://files., http://172.20.0.22:80 block is missing or stale" ) # ── Scenario 38: mail. routes to Rainloop ──────────────────────────── def test_mail_vip_does_not_serve_webui(connected_peer, admin_client): """mail. (VIP 172.20.0.23) must proxy to Rainloop, not the WebUI.""" dom = _domain(admin_client) dns_ip = _dns_ip(admin_client) code, body = _curl_domain(f'mail.{dom}', '/', dns_ip) assert code not in (0,), f"curl to mail.{dom} failed completely" assert _WEBUI_MARKER not in body, ( f"mail.{dom} returned WebUI HTML — " "Caddy is not routing mail. to Rainloop." ) def test_webmail_vip_does_not_serve_webui(connected_peer, admin_client): """webmail. (alias, same VIP 172.20.0.23) must NOT return the WebUI.""" dom = _domain(admin_client) dns_ip = _dns_ip(admin_client) code, body = _curl_domain(f'webmail.{dom}', '/', dns_ip) assert _WEBUI_MARKER not in body, ( f"webmail.{dom} returned WebUI HTML — " "Caddy http://webmail. block is missing or stale" ) def test_mail_vip_ip_does_not_serve_webui(connected_peer): """Direct request to VIP 172.20.0.23 must NOT return the WebUI.""" code, body = _curl_host('172.20.0.23', 'mail.lan') assert _WEBUI_MARKER not in body, ( "172.20.0.23 (mail VIP) returned WebUI HTML — " "Caddy http://mail., http://172.20.0.23:80 block is missing or stale" ) # ── Scenario 39: webdav. routes to WebDAV ──────────────────────────── def test_webdav_vip_does_not_serve_webui(connected_peer, admin_client): """webdav. (VIP 172.20.0.24) must proxy to the WebDAV service.""" dom = _domain(admin_client) dns_ip = _dns_ip(admin_client) code, body = _curl_domain(f'webdav.{dom}', '/', dns_ip) assert code not in (0,), f"curl to webdav.{dom} failed completely" assert _WEBUI_MARKER not in body, ( f"webdav.{dom} returned WebUI HTML — " "Caddy is not routing webdav. to the WebDAV service." ) def test_webdav_vip_ip_does_not_serve_webui(connected_peer): """Direct request to VIP 172.20.0.24 must NOT return the WebUI.""" code, body = _curl_host('172.20.0.24', 'webdav.lan') assert _WEBUI_MARKER not in body, ( "172.20.0.24 (webdav VIP) returned WebUI HTML — " "Caddy http://webdav., http://172.20.0.24:80 block is missing or stale" ) # ── Scenario 40: VIP IPs without Host header ───────────────────────────────── @pytest.mark.parametrize('vip,expected_not', [ ('172.20.0.21', _WEBUI_MARKER), ('172.20.0.22', _WEBUI_MARKER), ('172.20.0.23', _WEBUI_MARKER), ('172.20.0.24', _WEBUI_MARKER), ]) def test_vip_direct_access_not_webui(connected_peer, vip, expected_not): """Each service VIP accessed directly (no special Host) must not return WebUI.""" code, body = _curl_host(vip, vip) assert expected_not not in body, ( f"VIP {vip} returned WebUI HTML — " "Caddy catch-all :80 is taking over; the per-VIP blocks must listen on port 80" ) # ── Scenario 41: Catch-all :80 routes API path correctly ───────────────────── def test_catchall_api_path_returns_json(connected_peer): """The catch-all :80 block must route /api/* to the API (not WebUI).""" code, body = _curl_host('172.20.0.2', 'localhost', '/api/status') assert _WEBUI_MARKER not in body, ( "Catch-all :80 returned WebUI HTML for /api/status — " "the `handle /api/*` directive in the :80 block is missing or wrong" ) assert '{' in body or '"' in body, ( f"/api/status via catch-all did not return JSON (body: {body[:100]!r})" ) def test_catchall_root_serves_webui(connected_peer): """The catch-all :80 block serves the WebUI for the root path.""" code, body = _curl_host('172.20.0.2', 'localhost', '/') assert _WEBUI_MARKER in body, ( "Catch-all :80 / did not return WebUI HTML — " "something is broken with the catch-all :80 block" ) # ── Scenario extra: stale TLD detection ────────────────────────────────────── def test_caddy_does_not_route_cell_tld(connected_peer): """Caddy must NOT have active routing for .cell domains — they are from old config.""" code, body = _curl_host('172.20.0.2', 'calendar.cell', '/') assert _WEBUI_MARKER in body or code in (0, 404, 502, 503), ( "Caddy is still routing calendar.cell — stale .cell blocks remain in config. " "Check that write_caddyfile() is writing to the correct path that Caddy reads." )