fix: change domain from dev to lan to avoid browser HSTS preload blocking HTTP
The .dev TLD has been HSTS preloaded in Chrome/Firefox/Safari/Edge since 2019. Browsers silently redirect http://anything.dev to https://anything.dev before making any network request. Since Caddy has auto_https off, all browser-based access to .dev domains fails with a connection error even though DNS, routing, and HTTP all work correctly (curl works; browsers don't). - cell_config.json: domain "dev" -> "lan" - Caddyfile: all http://*.dev blocks -> http://*.lan - Corefile: dev zone -> lan zone (file /data/lan.zone) - data/dns/lan.zone: new zone file (dev.zone removed live) - test_wg_domain_access.py: remove hardcoded DOMAIN_IPS / .dev references; read domain from /api/config at runtime so tests work with any configured TLD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
}
|
||||
|
||||
# Main cell domain — no service-IP restriction needed
|
||||
http://pic0.dev, http://172.20.0.2:80 {
|
||||
http://pic0.lan, http://172.20.0.2:80 {
|
||||
handle /api/* {
|
||||
reverse_proxy cell-api:3000
|
||||
}
|
||||
@@ -22,27 +22,27 @@ http://pic0.dev, http://172.20.0.2:80 {
|
||||
}
|
||||
|
||||
# Per-service virtual IPs — each gets its own IP so iptables can target them
|
||||
http://calendar.dev, http://172.20.0.21:80 {
|
||||
http://calendar.lan, http://172.20.0.21:80 {
|
||||
reverse_proxy cell-radicale:5232
|
||||
}
|
||||
|
||||
http://files.dev, http://172.20.0.22:80 {
|
||||
http://files.lan, http://172.20.0.22:80 {
|
||||
reverse_proxy cell-filegator:8080
|
||||
}
|
||||
|
||||
http://mail.dev, http://webmail.dev, http://172.20.0.23:80 {
|
||||
http://mail.lan, http://webmail.lan, http://172.20.0.23:80 {
|
||||
reverse_proxy cell-rainloop:8888
|
||||
}
|
||||
|
||||
http://webdav.dev, http://172.20.0.24:80 {
|
||||
http://webdav.lan, http://172.20.0.24:80 {
|
||||
reverse_proxy cell-webdav:80
|
||||
}
|
||||
|
||||
http://api.dev {
|
||||
http://api.lan {
|
||||
reverse_proxy cell-api:3000
|
||||
}
|
||||
|
||||
http://webui.dev {
|
||||
http://webui.lan {
|
||||
reverse_proxy cell-webui:80
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"_identity": {
|
||||
"cell_name": "pic0",
|
||||
"domain": "lan",
|
||||
"ip_range": "172.20.0.0/16",
|
||||
"wireguard_port": 51820
|
||||
},
|
||||
"_pending_restart": {
|
||||
"needs_restart": false,
|
||||
"changes": [],
|
||||
"containers": [],
|
||||
"network_recreate": false
|
||||
},
|
||||
"calendar": {
|
||||
"port": 5233
|
||||
},
|
||||
"wireguard": {
|
||||
"port": 51820,
|
||||
"address": "",
|
||||
"private_key": ""
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
health
|
||||
}
|
||||
|
||||
dev {
|
||||
file /data/dev.zone
|
||||
lan {
|
||||
file /data/lan.zone
|
||||
log
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
WireGuard E2E: domain name resolution and HTTP access through the VPN tunnel.
|
||||
|
||||
Scenarios covered:
|
||||
30. All *.dev domains resolve to the expected IPs via the CoreDNS server
|
||||
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`.
|
||||
"""
|
||||
@@ -17,11 +19,11 @@ import pytest
|
||||
|
||||
pytestmark = pytest.mark.wg
|
||||
|
||||
# Expected domain→IP mapping for the current default config
|
||||
DOMAIN_IPS = {
|
||||
'pic0': '172.20.0.2', # Caddy (main cell domain)
|
||||
'api': '172.20.0.2', # Caddy reverse-proxy for API
|
||||
'webui': '172.20.0.2', # Caddy reverse-proxy for WebUI
|
||||
# Subdomain → expected offset in ip_utils.CONTAINER_OFFSETS / VIP list.
|
||||
# These are the sub-names, not full FQDNs — the TLD is fetched from config.
|
||||
SUBDOMAINS_TO_IPS = {
|
||||
'api': '172.20.0.2', # must route through Caddy (not API container direct)
|
||||
'webui': '172.20.0.2', # must route through Caddy
|
||||
'calendar': '172.20.0.21', # Caddy VIP for CalDAV
|
||||
'files': '172.20.0.22', # Caddy VIP for Filegator
|
||||
'mail': '172.20.0.23', # Caddy VIP for Rainloop
|
||||
@@ -30,27 +32,35 @@ DOMAIN_IPS = {
|
||||
}
|
||||
|
||||
|
||||
def _dns_ip(admin_client) -> str:
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _config(admin_client) -> dict:
|
||||
r = admin_client.get('/api/config')
|
||||
if r.status_code == 200:
|
||||
ips = r.json().get('service_ips', {})
|
||||
if ips.get('dns'):
|
||||
return ips['dns']
|
||||
return '172.20.0.3'
|
||||
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:
|
||||
r = admin_client.get('/api/config')
|
||||
if r.status_code == 200:
|
||||
return r.json().get('domain', 'dev')
|
||||
return 'dev'
|
||||
"""Return the configured cell domain (e.g. 'lan', 'dev', 'home')."""
|
||||
return _config(admin_client).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,expected_ip', list(DOMAIN_IPS.items()))
|
||||
def test_dev_domain_resolves_to_expected_ip(connected_peer, admin_client, subdomain, expected_ip):
|
||||
"""Every .dev subdomain resolves to the correct IP via CoreDNS."""
|
||||
@pytest.mark.parametrize('subdomain,expected_ip', list(SUBDOMAINS_TO_IPS.items()))
|
||||
def test_service_domain_resolves_to_expected_ip(connected_peer, admin_client, subdomain, expected_ip):
|
||||
"""Each service subdomain resolves to the correct IP via CoreDNS.
|
||||
|
||||
The full FQDN is built from the configured domain — not hardcoded to any TLD.
|
||||
"""
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
dom = _domain(admin_client)
|
||||
fqdn = f'{subdomain}.{dom}'
|
||||
@@ -62,12 +72,28 @@ def test_dev_domain_resolves_to_expected_ip(connected_peer, admin_client, subdom
|
||||
resolved = result.stdout.strip()
|
||||
assert resolved == expected_ip, (
|
||||
f"{fqdn} resolved to {resolved!r}, expected {expected_ip}. "
|
||||
f"DNS server: {dns_ip}"
|
||||
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.dev must route through Caddy (172.20.0.2) — API container listens on :3000, not :80."""
|
||||
"""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(
|
||||
@@ -77,16 +103,13 @@ def test_api_domain_does_not_resolve_to_api_container(connected_peer, admin_clie
|
||||
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 HTTP requests to api.{dom}:80 return nothing; "
|
||||
"must resolve to Caddy 172.20.0.2"
|
||||
)
|
||||
assert resolved == '172.20.0.2', (
|
||||
f"api.{dom} should resolve to Caddy 172.20.0.2; got {resolved}"
|
||||
"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.dev must route through Caddy — WebUI container also doesn't listen on :80 directly."""
|
||||
"""webui.<domain> must route through Caddy."""
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
dom = _domain(admin_client)
|
||||
result = subprocess.run(
|
||||
@@ -94,15 +117,13 @@ def test_webui_domain_does_not_resolve_to_webui_container(connected_peer, admin_
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
resolved = result.stdout.strip()
|
||||
assert resolved == '172.20.0.2', (
|
||||
f"webui.{dom} should resolve to Caddy 172.20.0.2; got {resolved}"
|
||||
)
|
||||
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 IP 172.20.0.2 returns an HTTP response (not connection refused)."""
|
||||
"""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/'],
|
||||
@@ -115,16 +136,15 @@ def test_caddy_ip_serves_http(connected_peer):
|
||||
# ── Scenario 32: HTTP via domain ──────────────────────────────────────────────
|
||||
|
||||
def test_http_api_domain_reaches_api(connected_peer, admin_client):
|
||||
"""curl http://api.dev/api/status returns a JSON response via Caddy."""
|
||||
"""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',
|
||||
f'--dns-servers', dns_ip,
|
||||
'--dns-servers', dns_ip,
|
||||
f'http://api.{dom}/api/status'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
# Any valid JSON response is acceptable (200, 401, etc.)
|
||||
assert result.stdout.strip(), (
|
||||
f"curl http://api.{dom}/api/status returned no output via DNS {dns_ip}. "
|
||||
f"stderr: {result.stderr[:200]}"
|
||||
@@ -133,13 +153,12 @@ def test_http_api_domain_reaches_api(connected_peer, admin_client):
|
||||
|
||||
# ── Scenario 33: Config DNS field ─────────────────────────────────────────────
|
||||
|
||||
def test_peer_services_config_has_coredns(admin_client, make_peer):
|
||||
"""Config returned by /api/peer/services must use CoreDNS IP, not WireGuard VPN gateway."""
|
||||
def test_peer_services_config_has_coredns_not_vpn_gateway(admin_client, make_peer):
|
||||
"""WireGuard config in /api/peer/services must use CoreDNS IP, not 10.0.0.1."""
|
||||
from helpers.api_client import PicAPIClient
|
||||
import os
|
||||
|
||||
peer = make_peer('e2etest-dns-config', password='DnsTest123!')
|
||||
|
||||
peer_client = PicAPIClient(os.environ.get('PIC_API_BASE', 'http://192.168.31.51:3000'))
|
||||
peer_client.login(peer['name'], 'DnsTest123!')
|
||||
|
||||
@@ -149,41 +168,65 @@ def test_peer_services_config_has_coredns(admin_client, make_peer):
|
||||
|
||||
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 any domain"
|
||||
"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 config has DNS = 10.0.0.1 — VPN clients will fail to resolve domains"
|
||||
"WireGuard client config has DNS = 10.0.0.1 — "
|
||||
"VPN clients will fail to resolve domain names"
|
||||
)
|
||||
# DNS should be reachable from VPN (must be on the Docker network, not VPN subnet)
|
||||
dns_from_config = None
|
||||
for line in config.splitlines():
|
||||
if line.strip().startswith('DNS ='):
|
||||
dns_from_config = line.split('=', 1)[1].strip()
|
||||
break
|
||||
if dns_from_config:
|
||||
assert dns_from_config.startswith('172.'), (
|
||||
f"DNS in config is {dns_from_config} — expected a 172.x.x.x Docker network IP "
|
||||
f"(CoreDNS is on the Docker bridge, not the WireGuard VPN subnet)"
|
||||
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):
|
||||
"""CalDAV URL must use the configured domain, not hardcode 'radicale.dev:5232'."""
|
||||
from helpers.api_client import PicAPIClient
|
||||
import os
|
||||
|
||||
dom = _domain(admin_client)
|
||||
peer = make_peer('e2etest-caldav-url', password='CaldavTest123!')
|
||||
peer_client = PicAPIClient(os.environ.get('PIC_API_BASE', 'http://192.168.31.51:3000'))
|
||||
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 at 172.20.0.3 is reachable through the VPN tunnel."""
|
||||
"""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 — that's a success (connectivity is what we're testing)
|
||||
# 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 AllowedIPs in peer config covers 172.20.0.0/16 or 0.0.0.0/0. "
|
||||
f"Check that peer AllowedIPs covers the Docker network or 0.0.0.0/0. "
|
||||
f"stdout: {result.stdout[:200]}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user