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
+56
View File
@@ -219,6 +219,62 @@ class TestGenerateCorefileWithCellLinks(unittest.TestCase):
self.assertNotIn('nope.cell', content)
# ---------------------------------------------------------------------------
# generate_corefile with split_horizon_zones
# ---------------------------------------------------------------------------
class TestGenerateCorefileSplitHorizon(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.path = os.path.join(self.tmp, 'Corefile')
def tearDown(self):
shutil.rmtree(self.tmp)
def _content(self):
return open(self.path).read()
def test_split_horizon_zone_block_added(self):
"""A split_horizon_zones entry produces a local file zone block."""
firewall_manager.generate_corefile([], self.path, split_horizon_zones=['pic1.pic.ngo'])
content = self._content()
self.assertIn('pic1.pic.ngo {', content)
self.assertIn('file /data/pic1.pic.ngo.zone', content)
def test_split_horizon_zone_does_not_add_forward(self):
"""Split-horizon blocks must use 'file', not 'forward'."""
firewall_manager.generate_corefile([], self.path, split_horizon_zones=['pic1.pic.ngo'])
content = self._content()
# Only the default internet forwarder; no extra forward for the horizon zone
forward_lines = [l for l in content.splitlines() if 'forward' in l and 'pic1' in l]
self.assertEqual(len(forward_lines), 0)
def test_multiple_split_horizon_zones(self):
"""Multiple zones all get their own file block."""
firewall_manager.generate_corefile(
[], self.path, split_horizon_zones=['a.pic.ngo', 'b.example.com']
)
content = self._content()
self.assertIn('a.pic.ngo {', content)
self.assertIn('file /data/a.pic.ngo.zone', content)
self.assertIn('b.example.com {', content)
self.assertIn('file /data/b.example.com.zone', content)
def test_split_horizon_with_cell_links(self):
"""Split-horizon zones and cell-link forwarding stanzas coexist."""
cell_links = [{'domain': 'other.cell', 'dns_ip': '10.99.0.1'}]
firewall_manager.generate_corefile(
[], self.path,
cell_links=cell_links,
split_horizon_zones=['pic1.pic.ngo'],
)
content = self._content()
self.assertIn('pic1.pic.ngo {', content)
self.assertIn('file /data/pic1.pic.ngo.zone', content)
self.assertIn('other.cell {', content)
self.assertIn('forward . 10.99.0.1', content)
# ---------------------------------------------------------------------------
# apply_peer_rules — iptables call verification
# ---------------------------------------------------------------------------