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:
2026-04-27 01:54:33 -04:00
parent 32272420cb
commit 0c12e3fc97
4 changed files with 129 additions and 64 deletions
+7 -7
View File
@@ -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
}
+22
View File
@@ -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
View File
@@ -5,8 +5,8 @@
health
}
dev {
file /data/dev.zone
lan {
file /data/lan.zone
log
}
+92 -49
View File
@@ -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]}"
)