b6af71acb5
Unit Tests / test (push) Successful in 11m9s
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>
291 lines
12 KiB
Python
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."
|
|
)
|