Files
pic/tests/e2e/wg/test_caddy_routing.py
T
roof a43f9fbf0d fix: full security audit remediation — P0/P1/P2/P3 fixes + 1020 passing tests
P0 — Broken functionality:
- Fix 12+ endpoints with wrong manager method signatures (email/calendar/file/routing)
- Fix email_manager.delete_email_user() missing domain arg
- Fix cell-link DNS forwarding wiped on every peer change (generate_corefile now
  accepts cell_links param; add/remove_cell_dns_forward no longer clobber the file)
- Fix Flask SECRET_KEY regenerating on every restart (persisted to DATA_DIR)
- Fix _next_peer_ip exhaustion returning 500 instead of 409
- Fix ConfigManager Caddyfile path (/app/config-caddy/)
- Fix UI double-add and wrong-key peer bugs in Peers.jsx / WireGuard.jsx
- Remove hardcoded credentials from Dashboard.jsx

P1 — Security:
- CSRF token validation on all POST/PUT/DELETE/PATCH to /api/* (double-submit pattern)
- enforce_auth: 503 only when users file readable but empty; never bypass on IOError
- WireGuard add_cell_peer: validate pubkey, name, endpoint against strict regexes
- DNS add_cell_dns_forward: validate IP and domain; reject injection chars
- DNS zone write: realpath containment + record content validation
- iptables comment /32 suffix prevents substring match deleting wrong peer rules
- is_local_request() trusts only loopback + 172.16.0.0/12 (Docker bridge)
- POST /api/containers: volume allow-list prevents arbitrary host mounts
- file_manager: bcrypt ($2b→$2y) for WebDAV; realpath containment in delete_user
- email/calendar: stop persisting plaintext passwords in user records
- routing_manager: validate IPs, networks, and interface names
- peer_registry: write peers.json at mode 0o600
- vault_manager: Fernet key file at mode 0o600
- CORS: lock down to explicit origin list
- domain/cell_name validation: reject newline, brace, semicolon injection chars

P2 — Architecture:
- Peer add: rollback registry entry if firewall rules fail post-add
- restart_service(): base class now calls _restart_container(); email and calendar
  managers call cell-mail / cell-radicale respectively
- email/calendar managers sync user list (no passwords) to cell_config.json
- Pending-restart flag cleared only after helper subprocess exits with code 0
- docker-compose.yml: add config-caddy volume to API container

P3 — Tests (854 → 1020):
- Fill test_email_endpoints.py, test_calendar_endpoints.py,
  test_network_endpoints.py, test_routing_endpoints.py
- New: test_peer_management_update.py, test_peer_management_edge_cases.py,
  test_input_validation.py, test_enforce_auth_configured.py,
  test_cell_link_dns.py, test_logs_endpoints.py, test_cells_endpoints.py,
  test_is_local_request_per_endpoint.py, test_caddy_routing.py
- E2E conftest: skip WireGuard suite when wg-quick absent
- Update existing tests to match fixed signatures and comment formats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:30:21 -04:00

276 lines
11 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:
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.<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', '/')
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."
)