feat: route PIC services as subdomains of the cell's effective domain
Unit Tests / test (push) Successful in 11m33s
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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -412,5 +412,52 @@ class TestCellDnsForwarding(unittest.TestCase):
|
||||
# The Corefile is regenerated (new canonical format) — that's correct.
|
||||
|
||||
|
||||
class TestUpdateSplitHorizonZone(unittest.TestCase):
|
||||
"""Test update_split_horizon_zone writes zone file and Corefile."""
|
||||
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.data_dir = os.path.join(self.test_dir, 'data')
|
||||
self.config_dir = os.path.join(self.test_dir, 'config')
|
||||
os.makedirs(os.path.join(self.data_dir, 'dns'), exist_ok=True)
|
||||
os.makedirs(os.path.join(self.config_dir, 'dns'), exist_ok=True)
|
||||
self.nm = NetworkManager(self.data_dir, self.config_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_creates_zone_file_with_wildcard(self, _mock):
|
||||
"""Zone file must contain wildcard A record pointing to caddy_ip."""
|
||||
self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||
zone_path = os.path.join(self.data_dir, 'dns', 'pic1.pic.ngo.zone')
|
||||
self.assertTrue(os.path.exists(zone_path))
|
||||
content = open(zone_path).read()
|
||||
self.assertIn('172.20.0.2', content)
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_corefile_contains_split_horizon_block(self, _mock):
|
||||
"""Corefile must reference the new zone file."""
|
||||
self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||
corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
|
||||
self.assertTrue(os.path.exists(corefile))
|
||||
content = open(corefile).read()
|
||||
self.assertIn('pic1.pic.ngo {', content)
|
||||
self.assertIn('file /data/pic1.pic.ngo.zone', content)
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_returns_true_on_success(self, _mock):
|
||||
ok = self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||
self.assertTrue(ok)
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_sends_sigusr1_to_coredns(self, mock_run):
|
||||
"""CoreDNS reload (SIGUSR1) must be triggered after writing."""
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||
calls = [str(c) for c in mock_run.call_args_list]
|
||||
self.assertTrue(any('SIGUSR1' in c for c in calls))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user