feat: route PIC services as subdomains of the cell's effective domain
Unit Tests / test (push) Successful in 11m33s

In DDNS modes (pic_ngo, cloudflare, duckdns, http01), all built-in
services are now reachable as subdomains of the cell domain, e.g.
calendar.pic1.pic.ngo instead of pic1.pic.ngo/calendar.

Key changes:
- CaddyManager._build_core_service_routes(): new helper generates
  Caddy named-matcher host blocks for calendar, mail/webmail, files,
  webdav, and api subdomains within the wildcard TLS server block.
- All ACME modes (pic_ngo, cloudflare, duckdns) use the new
  subdomain matchers; http01 emits a dedicated server block per service.
- http01: installed store-plugin services whose name clashes with a
  core service are skipped to prevent duplicate server blocks.
- routes/config.py: ip_utils.write_caddyfile() is skipped in non-LAN
  modes so LAN Caddy config never overwrites the ACME config.
- firewall_manager.generate_corefile(): new split_horizon_zones param
  adds local authoritative file zones so LAN clients resolve
  *.pic1.pic.ngo to the internal Caddy IP without hairpin NAT.
- NetworkManager.update_split_horizon_zone(): writes the wildcard zone
  file and regenerates the Corefile with the split-horizon block;
  called automatically after every identity change in non-LAN mode.
- Added @ to allowed record-name chars in update_dns_zone validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 04:31:57 -04:00
parent 1f016de855
commit d7dbd596ab
7 changed files with 285 additions and 41 deletions
+47 -7
View File
@@ -70,6 +70,21 @@ class TestGenerateCaddyfilePicNgo(unittest.TestCase):
# ACME staging hook
self.assertIn('acme_ca {$ACME_CA_URL}', out)
def test_pic_ngo_has_subdomain_service_routes(self):
mgr = _mgr()
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
out = mgr.generate_caddyfile(identity, [])
# Core services get named-matcher subdomain routing
self.assertIn('@calendar host calendar.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-radicale:5232', out)
self.assertIn('@mail host mail.alpha.pic.ngo webmail.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-rainloop:8888', out)
self.assertIn('@files host files.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-filegator:8080', out)
self.assertIn('@webdav host webdav.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-webdav:80', out)
self.assertIn('@api host api.alpha.pic.ngo', out)
class TestGenerateCaddyfileCloudflare(unittest.TestCase):
def test_cloudflare_has_dns_cloudflare(self):
@@ -101,6 +116,9 @@ class TestGenerateCaddyfileCloudflare(unittest.TestCase):
self.assertNotIn('*.home.local', out)
# 'custom_domain' must not appear literally as a key in the output
self.assertNotIn('custom_domain', out)
# Service subdomains use the correct public domain
self.assertIn('@calendar host calendar.home.example.com', out)
self.assertIn('@files host files.home.example.com', out)
class TestGenerateCaddyfileDuckDns(unittest.TestCase):
@@ -110,6 +128,8 @@ class TestGenerateCaddyfileDuckDns(unittest.TestCase):
out = mgr.generate_caddyfile(identity, [])
self.assertIn('dns duckdns {$DUCKDNS_TOKEN}', out)
self.assertIn('*.gamma.duckdns.org', out)
self.assertIn('@calendar host calendar.gamma.duckdns.org', out)
self.assertIn('@files host files.gamma.duckdns.org', out)
class TestGenerateCaddyfileHttp01(unittest.TestCase):
@@ -120,24 +140,44 @@ class TestGenerateCaddyfileHttp01(unittest.TestCase):
'domain_mode': 'http01',
'domain_name': 'delta.noip.me',
}
# Store-plugin service (not a core service name)
services = [
{'name': 'calendar', 'caddy_route':
'reverse_proxy cell-radicale:5232'},
{'name': 'files', 'caddy_route':
'reverse_proxy cell-filegator:8080'},
{'name': 'chat', 'caddy_route': 'reverse_proxy cell-chat:8090'},
]
out = mgr.generate_caddyfile(identity, services)
# No wildcard, no DNS-01 plugins.
self.assertNotIn('*.delta', out)
self.assertNotIn('dns ', out)
# No explicit tls block (no internal CA, no plugin) — the host block
# itself is left empty so Caddy uses HTTP-01 by default.
# No explicit tls block — Caddy uses HTTP-01 by default.
self.assertNotIn('tls {', out)
# Per-service blocks
# Core service blocks are always generated
self.assertIn('calendar.delta.noip.me {', out)
self.assertIn('files.delta.noip.me {', out)
self.assertIn('mail.delta.noip.me {', out)
self.assertIn('webmail.delta.noip.me {', out)
self.assertIn('webdav.delta.noip.me {', out)
self.assertIn('api.delta.noip.me {', out)
self.assertIn('reverse_proxy cell-radicale:5232', out)
self.assertIn('reverse_proxy cell-filegator:8080', out)
# Installed plugin service block
self.assertIn('chat.delta.noip.me {', out)
self.assertIn('reverse_proxy cell-chat:8090', out)
def test_http01_installed_service_with_core_name_is_skipped(self):
"""An installed service named 'calendar' must not produce a duplicate block."""
mgr = _mgr()
identity = {
'cell_name': 'delta',
'domain_mode': 'http01',
'domain_name': 'delta.noip.me',
}
services = [{'name': 'calendar', 'caddy_route': 'reverse_proxy cell-other:9000'}]
out = mgr.generate_caddyfile(identity, services)
# Only one calendar block (the core one)
self.assertEqual(out.count('calendar.delta.noip.me {'), 1)
# The core backend wins
self.assertIn('reverse_proxy cell-radicale:5232', out)
self.assertNotIn('cell-other:9000', out)
class TestServiceRoutesIncluded(unittest.TestCase):