Files
pic/tests/e2e/wg/test_caddy_routing.py
T
roof b6af71acb5
Unit Tests / test (push) Successful in 11m9s
Fix: accept both VIP and Caddy IP in DNS resolution test
Cells with wildcard zone (e.g. * -> 172.20.0.2) and cells with per-service
VIP DNS records are both valid. Accept either in the assertion so the test
passes regardless of the zone file style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:29:05 -04:00

291 lines
12 KiB
Python

"""
WireGuard E2E: Caddy per-domain routing correctness.
Scenarios covered:
35. api.<domain> proxies to the API (returns JSON), not the WebUI
36. calendar.<domain> via VIP proxies to Radicale, not the WebUI
37. files.<domain> via VIP proxies to Filegator, not the WebUI
38. mail.<domain> via VIP proxies to Rainloop, not the WebUI
39. webdav.<domain> 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 '<!doctype html>'.
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 = '<!doctype html>'
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:
cfg = _config(admin_client)
return cfg.get('domain_name') or cfg.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 to host, optionally resolving via a custom DNS server.
Uses dig to resolve the host (avoiding --dns-servers which requires c-ares),
then curls to the resolved IP with the original Host header.
"""
if dns_ip:
dig = subprocess.run(
['dig', f'@{dns_ip}', host, 'A', '+short', '+time=3', '+tries=1'],
capture_output=True, text=True, timeout=5,
)
resolved_ips = [line for line in dig.stdout.strip().splitlines() if line and not line.startswith(';')]
if resolved_ips:
return _curl_host(resolved_ips[0], host, path, timeout)
return 0, ''
result = subprocess.run(
['curl', '-s', '--connect-timeout', '5',
'-w', '\n__HTTP_CODE__:%{http_code}',
f'http://{host}{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
# ── Scenario 35: api.<domain> routes to API ───────────────────────────────────
def test_api_domain_returns_json_not_webui(connected_peer, admin_client):
"""api.<domain>/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.<domain> to the API; "
"check that the http://api.<domain> 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.<domain> 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.<domain> block is missing or uses wrong TLD"
)
# ── Scenario 36: calendar.<domain> routes to Radicale ────────────────────────
def test_calendar_vip_does_not_serve_webui(connected_peer, admin_client):
"""calendar.<domain> (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.<domain> 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.<domain>, http://172.20.0.21:80 block is missing or stale"
)
# ── Scenario 37: files.<domain> routes to Filegator ──────────────────────────
def test_files_vip_does_not_serve_webui(connected_peer, admin_client):
"""files.<domain> (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.<domain> to Filegator. "
"Check the http://files.<domain>, 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.<domain>, http://172.20.0.22:80 block is missing or stale"
)
# ── Scenario 38: mail.<domain> routes to Rainloop ────────────────────────────
def test_mail_vip_does_not_serve_webui(connected_peer, admin_client):
"""mail.<domain> (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.<domain> to Rainloop."
)
def test_webmail_vip_does_not_serve_webui(connected_peer, admin_client):
"""webmail.<domain> (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.<domain> 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.<domain>, http://172.20.0.23:80 block is missing or stale"
)
# ── Scenario 39: webdav.<domain> routes to WebDAV ────────────────────────────
def test_webdav_vip_does_not_serve_webui(connected_peer, admin_client):
"""webdav.<domain> (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.<domain> 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.<domain>, 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', '/')
# 3xx redirects (e.g. HTTP→HTTPS) are acceptable — they mean Caddy is active but
# not serving a functional response. Only a 200-with-content or WebUI HTML is a problem.
assert _WEBUI_MARKER in body or code in (0, 301, 302, 308, 404, 502, 503), (
"Caddy is still routing calendar.cell with a functional response — "
"stale .cell blocks remain in config. "
"Check that write_caddyfile() is writing to the correct path that Caddy reads."
)