Files
pic/tests/e2e/wg/test_wg_domain_access.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

245 lines
10 KiB
Python

"""
WireGuard E2E: domain name resolution and HTTP access through the VPN tunnel.
Scenarios covered:
30. All service subdomains resolve to the expected IPs via the CoreDNS server
31. Direct HTTP access to each service IP works through the VPN
32. HTTP access via domain names works through the VPN (DNS + routing)
33. WireGuard config downloaded via /api/peer/services has correct DNS field
34. Peer config DNS points to CoreDNS, not the WireGuard VPN gateway
Domain name is read from the live API config — these tests do NOT hardcode .dev or .lan.
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
# Subdomain → service_ips key for the expected VIP (None = always Caddy).
# Expected IP is read dynamically from /api/config service_ips; falls back to
# Caddy IP (172.20.0.2) when the service is not enabled / VIP not configured.
_SUBDOMAIN_VIP_KEYS = [
('api', None),
('webui', None),
('calendar', 'vip_calendar'),
('files', 'vip_files'),
('mail', 'vip_mail'),
('webmail', 'vip_mail'),
('webdav', 'vip_webdav'),
]
# ── helpers ───────────────────────────────────────────────────────────────────
def _config(admin_client) -> dict:
r = admin_client.get('/api/config')
return r.json() if r.status_code == 200 else {}
def _dns_ip(admin_client) -> str:
cfg = _config(admin_client)
return cfg.get('service_ips', {}).get('dns') or '172.20.0.3'
def _domain(admin_client) -> str:
"""Return the cell's fully-qualified domain (e.g. 'test5.pic.ngo', 'lan')."""
cfg = _config(admin_client)
return cfg.get('domain_name') or cfg.get('domain') or 'lan'
def _cell_name(admin_client) -> str:
return _config(admin_client).get('cell_name') or 'pic0'
# ── Scenario 30: DNS resolution ───────────────────────────────────────────────
@pytest.mark.parametrize('subdomain,vip_key', _SUBDOMAIN_VIP_KEYS)
def test_service_domain_resolves_to_expected_ip(connected_peer, admin_client, subdomain, vip_key):
"""Each service subdomain resolves to the correct IP via CoreDNS.
The full FQDN is built from the configured domain — not hardcoded to any TLD.
The expected IP is read from service_ips; falls back to Caddy when the VIP is
not configured (e.g. when the service is disabled).
"""
cfg = _config(admin_client)
sips = cfg.get('service_ips', {})
caddy_ip = sips.get('caddy', '172.20.0.2')
# Accept both the specific VIP IP and Caddy IP: some zone files use per-service
# VIP records (172.20.0.21 etc.) while others use a wildcard pointing to Caddy.
# Both are correct deployments; what matters is that the domain resolves at all.
expected_ips = {caddy_ip}
if vip_key and sips.get(vip_key):
expected_ips.add(sips[vip_key])
dns_ip = _dns_ip(admin_client)
dom = _domain(admin_client)
fqdn = f'{subdomain}.{dom}'
result = subprocess.run(
['dig', f'@{dns_ip}', fqdn, 'A', '+short', '+time=5'],
capture_output=True, text=True, timeout=10,
)
assert result.returncode == 0, f"dig failed for {fqdn}: {result.stderr}"
resolved = result.stdout.strip()
assert resolved in expected_ips, (
f"{fqdn} resolved to {resolved!r}, expected one of {expected_ips}. "
f"DNS server: {dns_ip}, configured domain: {dom!r}"
)
def test_cell_hostname_resolves_to_caddy(connected_peer, admin_client):
"""The cell hostname (e.g. pic0.lan) resolves to Caddy."""
dns_ip = _dns_ip(admin_client)
dom = _domain(admin_client)
name = _cell_name(admin_client)
fqdn = f'{name}.{dom}'
result = subprocess.run(
['dig', f'@{dns_ip}', fqdn, 'A', '+short', '+time=5'],
capture_output=True, text=True, timeout=10,
)
resolved = result.stdout.strip()
assert resolved == '172.20.0.2', (
f"{fqdn} should resolve to Caddy (172.20.0.2); got {resolved!r}"
)
def test_api_domain_does_not_resolve_to_api_container(connected_peer, admin_client):
"""api.<domain> must route through Caddy — API container listens on :3000, not :80."""
dns_ip = _dns_ip(admin_client)
dom = _domain(admin_client)
result = subprocess.run(
['dig', f'@{dns_ip}', f'api.{dom}', 'A', '+short', '+time=5'],
capture_output=True, text=True, timeout=10,
)
resolved = result.stdout.strip()
assert resolved != '172.20.0.10', (
f"api.{dom} resolves to 172.20.0.10 (API container direct) — "
"this bypasses Caddy so port-80 requests return nothing; must be Caddy 172.20.0.2"
)
assert resolved == '172.20.0.2', f"api.{dom} should be Caddy 172.20.0.2; got {resolved}"
def test_webui_domain_does_not_resolve_to_webui_container(connected_peer, admin_client):
"""webui.<domain> must route through Caddy."""
dns_ip = _dns_ip(admin_client)
dom = _domain(admin_client)
result = subprocess.run(
['dig', f'@{dns_ip}', f'webui.{dom}', 'A', '+short', '+time=5'],
capture_output=True, text=True, timeout=10,
)
resolved = result.stdout.strip()
assert resolved == '172.20.0.2', f"webui.{dom} should be Caddy 172.20.0.2; got {resolved}"
# ── Scenario 31: HTTP via IP ───────────────────────────────────────────────────
def test_caddy_ip_serves_http(connected_peer):
"""Caddy at 172.20.0.2 returns an HTTP response through the VPN."""
result = subprocess.run(
['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5',
'http://172.20.0.2/'],
capture_output=True, text=True, timeout=10,
)
code = result.stdout.strip()
assert code not in ('000', ''), f"No HTTP response from 172.20.0.2; curl exit {result.returncode}"
# ── Scenario 32: HTTP via domain ──────────────────────────────────────────────
def test_http_api_domain_reaches_api(connected_peer, admin_client):
"""curl http://api.<domain>/api/status returns a JSON response via Caddy + CoreDNS."""
dom = _domain(admin_client)
dns_ip = _dns_ip(admin_client)
result = subprocess.run(
['curl', '-s', '--connect-timeout', '5',
'--dns-servers', dns_ip,
f'http://api.{dom}/api/status'],
capture_output=True, text=True, timeout=10,
)
assert result.stdout.strip(), (
f"curl http://api.{dom}/api/status returned no output via DNS {dns_ip}. "
f"stderr: {result.stderr[:200]}"
)
# ── Scenario 33: Config DNS field ─────────────────────────────────────────────
def test_peer_services_config_has_coredns_not_vpn_gateway(admin_client, make_peer, api_base):
"""WireGuard config in /api/peer/services must use CoreDNS IP, not 10.0.0.1."""
from helpers.api_client import PicAPIClient
peer = make_peer('e2etest-dns-config', password='DnsTest123!')
peer_client = PicAPIClient(api_base)
peer_client.login(peer['name'], 'DnsTest123!')
r = peer_client.get('/api/peer/services')
assert r.status_code == 200, f"peer services returned {r.status_code}: {r.text}"
data = r.json()
dns = data.get('wireguard', {}).get('dns', '')
assert dns != '10.0.0.1', (
"wireguard.dns is 10.0.0.1 — this is the WireGuard VPN gateway, not a DNS server; "
"VPN clients using this as DNS will fail to resolve all domain names"
)
config = data.get('wireguard', {}).get('config', '')
if config:
assert 'DNS = 10.0.0.1' not in config, (
"WireGuard client config has DNS = 10.0.0.1 — "
"VPN clients will fail to resolve domain names"
)
for line in config.splitlines():
if line.strip().startswith('DNS ='):
dns_from_config = line.split('=', 1)[1].strip()
assert dns_from_config.startswith('172.'), (
f"DNS in config is {dns_from_config} — expected a 172.x.x.x Docker IP; "
"CoreDNS lives on the Docker bridge, not the WireGuard VPN subnet"
)
break
def test_peer_services_caldav_url_uses_configured_domain(admin_client, make_peer, api_base):
"""CalDAV URL must use the configured domain, not hardcode 'radicale.dev:5232'."""
from helpers.api_client import PicAPIClient
dom = _domain(admin_client)
peer = make_peer('e2etest-caldav-url', password='CaldavTest123!')
peer_client = PicAPIClient(api_base)
peer_client.login(peer['name'], 'CaldavTest123!')
r = peer_client.get('/api/peer/services')
assert r.status_code == 200
url = r.json().get('caldav', {}).get('url', '')
assert f'calendar.{dom}' in url, (
f"CalDAV URL {url!r} does not contain 'calendar.{dom}'"
f"must use configured domain '{dom}', not a hardcoded TLD"
)
assert 'radicale' not in url, (
f"CalDAV URL {url!r} contains 'radicale' — no radicale.<domain> DNS record exists"
)
assert ':5232' not in url, (
f"CalDAV URL {url!r} exposes internal port 5232 — use Caddy-proxied URL"
)
# ── Scenario 34: DNS reachability from VPN ────────────────────────────────────
def test_coredns_reachable_via_vpn(connected_peer, admin_client):
"""CoreDNS is reachable through the WireGuard VPN tunnel."""
dns_ip = _dns_ip(admin_client)
result = subprocess.run(
['dig', f'@{dns_ip}', 'health.check', '+time=3', '+tries=1'],
capture_output=True, text=True, timeout=8,
)
# NXDOMAIN means DNS responded — connectivity is what we test here
responded = 'status:' in result.stdout or result.returncode in (0, 9)
assert responded, (
f"CoreDNS at {dns_ip} did not respond via VPN tunnel. "
f"Check that peer AllowedIPs covers the Docker network or 0.0.0.0/0. "
f"stdout: {result.stdout[:200]}"
)