Files
pic/tests/e2e/wg/test_caddy_routing.py
T
roof 31f76c54fa
Unit Tests / test (push) Successful in 11m15s
Fix: use domain_name as service URL base and harden WG e2e tests
API:
- _configured_domain() now prefers _identity.domain_name (full FQDN
  e.g. 'test5.pic.ngo') over domain ('pic.ngo'). Service URLs in
  /api/peer/services and /api/peer/dashboard now correctly return
  'calendar.test5.pic.ngo' instead of 'calendar.pic.ngo'.

WG e2e tests:
- test_api_domain_returns_json_not_webui: accept 3xx redirect as
  valid routing (Caddy redirects HTTP→HTTPS in pic_ngo mode).
- test_catchall_api_path_returns_json and test_catchall_root_serves_webui:
  skip when Caddy is in HTTPS-redirect mode — catch-all :80 block only
  exists in HTTP-mode cells (lan/local domain).
- test_http_api_domain_reaches_api: replace --dns-servers (requires
  c-ares) with dig + curl --host pattern.

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

305 lines
13 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 or a redirect, 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,), f"curl to api.{dom}/api/status failed completely (code {code})"
# 3xx means Caddy is routing (HTTP→HTTPS redirect in pic_ngo mode) — acceptable
if code in (301, 302, 308):
return
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 api.<domain> block exists in the Caddyfile"
)
assert '{' in body or '"' in body, (
f"api.{dom}/api/status did not return JSON (code={code}, 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, admin_client):
"""The catch-all :80 block must route /api/* to the API (not WebUI).
Only applicable to HTTP-mode cells (e.g. lan/local domain). Cells using
pic_ngo / duckdns HTTPS mode have no catch-all :80 block — Caddy redirects
all plain-HTTP to HTTPS instead.
"""
code, body = _curl_host('172.20.0.2', 'localhost', '/api/status')
if code in (301, 302, 308):
pytest.skip("Caddy is in HTTPS-redirect mode — no catch-all :80 block (expected for pic_ngo cells)")
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, admin_client):
"""The catch-all :80 block serves the WebUI for the root path.
Only applicable to HTTP-mode cells. HTTPS-mode cells redirect :80 → :443.
"""
code, body = _curl_host('172.20.0.2', 'localhost', '/')
if code in (301, 302, 308):
pytest.skip("Caddy is in HTTPS-redirect mode — no catch-all :80 block (expected for pic_ngo cells)")
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."
)