From d7dbd596ab558b9eba09581acc224860de289c8a Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Thu, 28 May 2026 04:31:57 -0400 Subject: [PATCH] feat: route PIC services as subdomains of the cell's effective domain 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 --- api/caddy_manager.py | 67 +++++++++++++++++++++++++++------- api/firewall_manager.py | 20 +++++++++- api/network_manager.py | 30 ++++++++++++++- api/routes/config.py | 52 +++++++++++++++++--------- tests/test_caddy_manager.py | 54 +++++++++++++++++++++++---- tests/test_firewall_manager.py | 56 ++++++++++++++++++++++++++++ tests/test_network_manager.py | 47 ++++++++++++++++++++++++ 7 files changed, 285 insertions(+), 41 deletions(-) diff --git a/api/caddy_manager.py b/api/caddy_manager.py index afb72d7..973592a 100644 --- a/api/caddy_manager.py +++ b/api/caddy_manager.py @@ -161,6 +161,32 @@ class CaddyManager(BaseServiceManager): lines.append("}") return "\n".join(lines) + @staticmethod + def _build_core_service_routes(domain: str) -> str: + """Return 4-space-indented named-matcher + handle blocks for core services.""" + return ( + f" @calendar host calendar.{domain}\n" + f" handle @calendar {{\n" + f" reverse_proxy cell-radicale:5232\n" + f" }}\n" + f" @mail host mail.{domain} webmail.{domain}\n" + f" handle @mail {{\n" + f" reverse_proxy cell-rainloop:8888\n" + f" }}\n" + f" @files host files.{domain}\n" + f" handle @files {{\n" + f" reverse_proxy cell-filegator:8080\n" + f" }}\n" + f" @webdav host webdav.{domain}\n" + f" handle @webdav {{\n" + f" reverse_proxy cell-webdav:80\n" + f" }}\n" + f" @api host api.{domain}\n" + f" handle @api {{\n" + f" reverse_proxy cell-api:3000\n" + f" }}" + ) + @staticmethod def _indent_routes(routes: str, spaces: int = 4) -> str: """Indent a multi-line route block by ``spaces`` columns.""" @@ -203,16 +229,17 @@ class CaddyManager(BaseServiceManager): def _caddyfile_pic_ngo(self, cell_name: str, service_routes: str, core_routes: str) -> str: """pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin.""" - body = [] + domain = f"{cell_name}.pic.ngo" + body = [self._build_core_service_routes(domain)] if service_routes: body.append(self._indent_routes(service_routes)) body.append(core_routes) inner = "\n".join(body) - email = f"admin@{cell_name}.pic.ngo" + email = f"admin@{domain}" return ( f"{self._global_acme_block(email)}\n" "\n" - f"*.{cell_name}.pic.ngo, {cell_name}.pic.ngo {{\n" + f"*.{domain}, {domain} {{\n" " tls {\n" " dns pic_ngo {\n" " token {$PIC_NGO_DDNS_TOKEN}\n" @@ -226,7 +253,7 @@ class CaddyManager(BaseServiceManager): def _caddyfile_cloudflare(self, custom_domain: str, service_routes: str, core_routes: str) -> str: """cloudflare mode: wildcard DNS-01 via the cloudflare plugin.""" - body = [] + body = [self._build_core_service_routes(custom_domain)] if service_routes: body.append(self._indent_routes(service_routes)) body.append(core_routes) @@ -245,7 +272,8 @@ class CaddyManager(BaseServiceManager): def _caddyfile_duckdns(self, cell_name: str, service_routes: str, core_routes: str) -> str: """duckdns mode: DNS-01 via the duckdns plugin.""" - body = [] + domain = f"{cell_name}.duckdns.org" + body = [self._build_core_service_routes(domain)] if service_routes: body.append(self._indent_routes(service_routes)) body.append(core_routes) @@ -253,7 +281,7 @@ class CaddyManager(BaseServiceManager): return ( f"{self._global_acme_block(None)}\n" "\n" - f"*.{cell_name}.duckdns.org {{\n" + f"*.{domain} {{\n" " tls {\n" " dns duckdns {$DUCKDNS_TOKEN}\n" " }\n" @@ -265,23 +293,36 @@ class CaddyManager(BaseServiceManager): installed_services: List[Dict[str, Any]], core_routes: str) -> str: """http01 mode: no wildcard. Each service gets its own block.""" - # Main host block — only the core routes (api + webui). Service - # routes that could otherwise be served as path-prefixes are NOT - # placed here because in http01 mode each service is intended to - # live on its own subdomain (otherwise it could also use a path - # prefix here, but the spec calls for separate blocks). + # Main host block — only the core routes (api + webui). out = [self._global_acme_block('{$ACME_EMAIL}'), ""] out.append(f"{host} {{") out.append(core_routes) out.append("}") - # One block per installed service that has a caddy_route. + # One block per core service subdomain. + _core_services = [ + ('calendar', 'cell-radicale:5232'), + ('mail', 'cell-rainloop:8888'), + ('webmail', 'cell-rainloop:8888'), + ('files', 'cell-filegator:8080'), + ('webdav', 'cell-webdav:80'), + ('api', 'cell-api:3000'), + ] + for subdomain, backend in _core_services: + out.append("") + out.append(f"{subdomain}.{host} {{") + out.append(f" reverse_proxy {backend}") + out.append("}") + + # One block per installed (store plugin) service that has a caddy_route, + # skipping any name that conflicts with a core service. + _core_names = {s for s, _ in _core_services} for svc in installed_services or []: if not svc: continue route = svc.get('caddy_route') name = svc.get('name') or svc.get('subdomain') - if not route or not name: + if not route or not name or name in _core_names: continue out.append("") out.append(f"{name}.{host} {{") diff --git a/api/firewall_manager.py b/api/firewall_manager.py index cd9b40f..3c59800 100644 --- a/api/firewall_manager.py +++ b/api/firewall_manager.py @@ -710,7 +710,8 @@ def _build_acl_block(blocked_peers_by_service: Dict[str, List[str]], def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE_PATH, domain: str = 'cell', - cell_links: Optional[List[Dict[str, Any]]] = None) -> bool: + cell_links: Optional[List[Dict[str, Any]]] = None, + split_horizon_zones: Optional[List[str]] = None) -> bool: """ Rewrite the CoreDNS Corefile with per-peer ACL rules and reload plugin. The file is written to corefile_path (API-side path mapped into CoreDNS container). @@ -718,6 +719,10 @@ def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE cell_links: optional list of cell-to-cell DNS forwarding entries, each a dict with 'domain' and 'dns_ip' keys (same shape as CellLinkManager.list_connections()). When non-empty, a forwarding stanza is appended for each entry. + split_horizon_zones: optional list of FQDNs (e.g. ['pic1.pic.ngo']) for which a + local authoritative zone block is added so LAN clients resolve + service subdomains to the internal Caddy IP without hairpin NAT. + Each zone must have a corresponding zone file under /data/.zone. """ try: # Collect which peers block which services @@ -748,6 +753,17 @@ def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE {primary_zone_block}""" + # Split-horizon zones for DDNS/public domains — LAN clients resolve + # *.pic1.pic.ngo to the internal Caddy IP without hairpin NAT. + if split_horizon_zones: + for sz in split_horizon_zones: + corefile += ( + f'\n{sz} {{\n' + f' file /data/{sz}.zone\n' + f' log\n' + f'}}\n' + ) + # Append cell-to-cell DNS forwarding stanzas if provided if cell_links: for link in cell_links: @@ -762,7 +778,7 @@ def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE f' log\n' f'}}\n' ) - else: + elif not split_horizon_zones: corefile += '\n' # local.{domain} block intentionally omitted: /data/local.zone does not exist diff --git a/api/network_manager.py b/api/network_manager.py index 115a321..be9e2b4 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -45,7 +45,7 @@ class NetworkManager(BaseServiceManager): for rec in records: rname = rec.get('name', '') rvalue = rec.get('value', '') - if rname and not re.match(r'^[a-zA-Z0-9_.*-]{1,253}$', str(rname)): + if rname and not re.match(r'^[a-zA-Z0-9_@.*-]{1,253}$', str(rname)): logger.error(f"update_dns_zone: invalid record name {rname!r}") return False if rvalue and not re.match(r'^[a-zA-Z0-9._: -]{1,512}$', str(rvalue)): @@ -165,6 +165,34 @@ class NetworkManager(BaseServiceManager): self.update_dns_zone(domain, records) logger.info(f"Created {len(records)} default DNS records for zone '{domain}'") + def update_split_horizon_zone(self, effective_domain: str, caddy_ip: str, + primary_domain: str = 'cell', + peers: Optional[List[Dict]] = None, + cell_links: Optional[List[Dict]] = None) -> bool: + """Write a local authoritative zone for effective_domain pointing all + hosts (wildcard) to caddy_ip so LAN clients resolve service subdomains + without hairpin NAT. Regenerates the Corefile and reloads CoreDNS.""" + import firewall_manager as _fm + # SOA/NS are generated by _generate_zone_content; just pass the A records. + records = [ + {'name': '@', 'type': 'A', 'value': caddy_ip}, + {'name': '*', 'type': 'A', 'value': caddy_ip}, + ] + ok = self.update_dns_zone(effective_domain, records) + if not ok: + logger.warning('update_split_horizon_zone: zone file write failed for %s', effective_domain) + + corefile = os.path.join(self.config_dir, 'dns', 'Corefile') + peers_data = peers or [] + ok_cf = _fm.generate_corefile( + peers_data, corefile, primary_domain, + cell_links=cell_links, + split_horizon_zones=[effective_domain], + ) + if ok_cf: + _fm.reload_coredns() + return ok and ok_cf + def apply_ip_range(self, ip_range: str, cell_name: str, domain: str) -> Dict[str, Any]: """Rewrite the primary DNS zone file with IPs derived from the new subnet.""" restarted: List[str] = [] diff --git a/api/routes/config.py b/api/routes/config.py index 7380096..4b65f7a 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -316,11 +316,12 @@ def update_config(): net_result = network_manager.apply_domain(domain, reload=False) all_warnings.extend(net_result.get('warnings', [])) _cur_id = config_manager.configs.get('_identity', {}) - ip_utils.write_caddyfile( - _cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), - _cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), - domain, '/app/config-caddy/Caddyfile' - ) + if _cur_id.get('domain_mode', 'lan') == 'lan': + ip_utils.write_caddyfile( + _cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), + _cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), + domain, '/app/config-caddy/Caddyfile' + ) _set_pending_restart( [f'domain changed to {domain}'], ['dns', 'caddy'], @@ -334,12 +335,13 @@ def update_config(): cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False) all_warnings.extend(cn_result.get('warnings', [])) _cur_id2 = config_manager.configs.get('_identity', {}) - ip_utils.write_caddyfile( - _cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), - new_name, - identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell')), - '/app/config-caddy/Caddyfile' - ) + if _cur_id2.get('domain_mode', 'lan') == 'lan': + ip_utils.write_caddyfile( + _cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), + new_name, + identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell')), + '/app/config-caddy/Caddyfile' + ) _set_pending_restart( [f'cell_name changed to {new_name}'], ['dns'], @@ -370,7 +372,8 @@ def update_config(): firewall_manager.ensure_caddy_virtual_ips() env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs)) - ip_utils.write_caddyfile(new_range, cur_cell_name, cur_domain, '/app/config-caddy/Caddyfile') + if cur_identity.get('domain_mode', 'lan') == 'lan': + ip_utils.write_caddyfile(new_range, cur_cell_name, cur_domain, '/app/config-caddy/Caddyfile') _set_pending_restart( [f'ip_range changed to {new_range} — network will be recreated'], ['*'], network_recreate=True, @@ -379,13 +382,25 @@ def update_config(): if identity_updates: _cur_identity = config_manager.configs.get('_identity', {}) + _eff_domain = config_manager.get_effective_domain() service_bus.publish_event(EventType.IDENTITY_CHANGED, 'config', { 'cell_name': _cur_identity.get('cell_name'), 'domain': _cur_identity.get('domain'), 'domain_name': _cur_identity.get('domain_name'), 'domain_mode': _cur_identity.get('domain_mode'), - 'effective_domain': config_manager.get_effective_domain(), + 'effective_domain': _eff_domain, }) + if _cur_identity.get('domain_mode', 'lan') != 'lan' and _eff_domain: + try: + import ip_utils as _ip_sh + _ip_range = _cur_identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')) + _caddy_ip = _ip_sh.get_service_ips(_ip_range).get('caddy', '172.20.0.2') + _primary_domain = _cur_identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')) + network_manager.update_split_horizon_zone( + _eff_domain, _caddy_ip, primary_domain=_primary_domain + ) + except Exception as _sh_exc: + logger.warning('split-horizon zone update failed: %s', _sh_exc) _PORT_CHANGE_MAP = { ('network', 'dns_port'): ('dns_port', ['dns']), @@ -644,11 +659,12 @@ def cancel_pending_config(): if cur_cell_name and old_cell_name and cur_cell_name != old_cell_name: network_manager.apply_cell_name(cur_cell_name, old_cell_name, reload=False) - _ip_revert.write_caddyfile( - _id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), - _id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), - _dom, '/app/config-caddy/Caddyfile' - ) + if _id.get('domain_mode', 'lan') == 'lan': + _ip_revert.write_caddyfile( + _id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), + _id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), + _dom, '/app/config-caddy/Caddyfile' + ) _clear_pending_restart() return jsonify({'message': 'Pending changes discarded'}) diff --git a/tests/test_caddy_manager.py b/tests/test_caddy_manager.py index ab1bc9e..ca19512 100644 --- a/tests/test_caddy_manager.py +++ b/tests/test_caddy_manager.py @@ -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): diff --git a/tests/test_firewall_manager.py b/tests/test_firewall_manager.py index 5acf637..70f199b 100644 --- a/tests/test_firewall_manager.py +++ b/tests/test_firewall_manager.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_network_manager.py b/tests/test_network_manager.py index 1ee9d33..5a66be3 100644 --- a/tests/test_network_manager.py +++ b/tests/test_network_manager.py @@ -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() \ No newline at end of file