fix: add built-in service subdomains to DNS zone on startup
Unit Tests / test (push) Successful in 7m45s

_build_dns_records() only hardcoded 'api' and 'webui', relying on the
optional service registry for the rest. Built-in services (calendar,
files, mail, webdav) were never registered, so they were absent from
the zone file and tests querying webdav.<domain> via CoreDNS got
NXDOMAIN.

Add _BUILTIN_SERVICE_SUBDOMAINS constant and include those names in
every zone build. Also update _stale and apply_cell_name exclusion
sets so DDNS mode correctly removes them from the parent zone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 03:14:34 -04:00
parent e8b8e47aa4
commit 08f46332b0
3 changed files with 18 additions and 20 deletions
+9 -2
View File
@@ -203,7 +203,7 @@ class NetworkManager(BaseServiceManager):
# domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'), # domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'),
# bootstrap service records like 'api', 'calendar' etc. would pollute the # bootstrap service records like 'api', 'calendar' etc. would pollute the
# zone display and shadow the public domain. Remove them. # zone display and shadow the public domain. Remove them.
_stale = {'api', 'webui'} | set(self._get_service_subdomains()) _stale = {'api', 'webui'} | set(self._BUILTIN_SERVICE_SUBDOMAINS) | set(self._get_service_subdomains())
if effective_domain.endswith('.' + primary_domain): if effective_domain.endswith('.' + primary_domain):
existing = self._load_dns_records(primary_domain) existing = self._load_dns_records(primary_domain)
cleaned = [r for r in existing if r.get('name', '') not in _stale] cleaned = [r for r in existing if r.get('name', '') not in _stale]
@@ -270,6 +270,11 @@ class NetworkManager(BaseServiceManager):
logger.warning('_get_service_subdomains: registry error: %s', exc) logger.warning('_get_service_subdomains: registry error: %s', exc)
return [] return []
# Built-in service subdomains that are always present on a PIC instance.
# These must stay in sync with firewall_manager.SERVICE_IPS keys and the
# Caddy routes for each built-in service.
_BUILTIN_SERVICE_SUBDOMAINS = ('calendar', 'files', 'mail', 'webdav')
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]: def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records. """Build the standard set of DNS A records.
@@ -284,6 +289,8 @@ class NetworkManager(BaseServiceManager):
{'name': 'api', 'type': 'A', 'value': wg_ip}, {'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', 'type': 'A', 'value': wg_ip}, {'name': 'webui', 'type': 'A', 'value': wg_ip},
] ]
for sub in self._BUILTIN_SERVICE_SUBDOMAINS:
records.append({'name': sub, 'type': 'A', 'value': wg_ip})
for sub in self._get_service_subdomains(): for sub in self._get_service_subdomains():
records.append({'name': sub, 'type': 'A', 'value': wg_ip}) records.append({'name': sub, 'type': 'A', 'value': wg_ip})
return records return records
@@ -614,7 +621,7 @@ class NetworkManager(BaseServiceManager):
if not new_name: if not new_name:
return {'restarted': restarted, 'warnings': warnings} return {'restarted': restarted, 'warnings': warnings}
# Exclude service names, wildcard, and apex from cell-hostname detection. # Exclude service names, wildcard, and apex from cell-hostname detection.
_service_names = {'api', 'webui'} | set(self._get_service_subdomains()) _service_names = {'api', 'webui'} | set(self._BUILTIN_SERVICE_SUBDOMAINS) | set(self._get_service_subdomains())
_reserved = _service_names | {'@', '*'} _reserved = _service_names | {'@', '*'}
changed = False changed = False
try: try:
+4 -5
View File
@@ -349,12 +349,11 @@ class TestApplyIpRange(unittest.TestCase):
self.nm.apply_ip_range('10.1.2.0/24', 'pictest', 'mycell') self.nm.apply_ip_range('10.1.2.0/24', 'pictest', 'mycell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'mycell.zone') zone_file = os.path.join(self.nm.dns_zones_dir, 'mycell.zone')
content = open(zone_file).read() content = open(zone_file).read()
# Without a registry, only the infrastructure names are generated # Infrastructure and built-in service names are always generated
for host in ('pictest', 'api', 'webui'): for host in ('pictest', 'api', 'webui', 'calendar', 'files', 'mail', 'webdav'):
self.assertIn(host, content) self.assertIn(host, content)
# Service records are only generated when a registry is wired # Non-built-in names are only generated when a registry is wired
for host in ('calendar', 'files', 'mail', 'webmail', 'webdav'): self.assertNotIn('webmail', content)
self.assertNotIn(host, content)
@patch('subprocess.run') @patch('subprocess.run')
def test_same_range_updates_zone_without_error(self, _mock): def test_same_range_updates_zone_without_error(self, _mock):
+5 -13
View File
@@ -501,23 +501,15 @@ class TestDNSZoneRecords:
) )
def test_service_records_absent_without_registry(self): def test_service_records_absent_without_registry(self):
"""Without a registry, service subdomain records are not generated. """Built-in services always get DNS records; optional services require a registry."""
Phase 2: service DNS records only exist when a service is installed
and the registry reports it. The hardcoded fallback is gone.
"""
records = self._records() records = self._records()
names = {r['name'] for r in records} names = {r['name'] for r in records}
assert 'calendar' not in names, \ # Built-in services are always present
'calendar DNS record must not appear without a registry' for svc in ('calendar', 'files', 'mail', 'webdav'):
assert 'files' not in names, \ assert svc in names, f'{svc} DNS record must always be generated'
'files DNS record must not appear without a registry' # Non-built-in names are only generated when a registry is wired
assert 'mail' not in names, \
'mail DNS record must not appear without a registry'
assert 'webmail' not in names, \ assert 'webmail' not in names, \
'webmail DNS record must not appear without a registry' 'webmail DNS record must not appear without a registry'
assert 'webdav' not in names, \
'webdav DNS record must not appear without a registry'
def test_service_records_present_with_registry(self): def test_service_records_present_with_registry(self):
"""With a registry that provides calendar/mail/files, all resolve to WG IP.""" """With a registry that provides calendar/mail/files, all resolve to WG IP."""