apply_cell_name() now skips multi-label zone files (split-horizon DDNS zones like pic2.pic.ngo.zone) and excludes '*' and '@' from hostname candidate detection, preventing the wildcard record from being renamed to the old cell name during a cell rename. update_split_horizon_zone() now deletes stale zone files from previous cell names sharing the same TLD (e.g. pic3.pic.ngo.zone when renaming to pic2.pic.ngo), eliminating orphaned DNS entries. _bootstrap_dns() now detects non-LAN domain modes and calls update_split_horizon_zone() instead of apply_ip_range(), preventing service records (api, calendar, files…) from being re-injected into the DDNS parent zone on every container restart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+14
-2
@@ -390,8 +390,20 @@ def _bootstrap_dns():
|
|||||||
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
||||||
domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
|
domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
|
||||||
ip_range = identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
|
ip_range = identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
|
||||||
# Bootstrap on first start; then always regenerate to ensure A records use WG server IP.
|
domain_mode = identity.get('domain_mode', 'lan')
|
||||||
network_manager.apply_ip_range(ip_range, cell_name, domain)
|
if domain_mode == 'lan':
|
||||||
|
# LAN mode: write full service records into the primary local zone.
|
||||||
|
network_manager.apply_ip_range(ip_range, cell_name, domain)
|
||||||
|
else:
|
||||||
|
# Non-LAN mode (DDNS/ACME): ensure the split-horizon zone is present so
|
||||||
|
# LAN clients resolve service subdomains to the internal Caddy IP.
|
||||||
|
# Never call apply_ip_range here — it would pollute the DDNS parent zone.
|
||||||
|
effective_domain = config_manager.get_effective_domain()
|
||||||
|
if effective_domain and effective_domain != domain:
|
||||||
|
import ip_utils
|
||||||
|
caddy_ip = ip_utils.get_service_ips(ip_range).get('caddy', '172.20.0.2')
|
||||||
|
network_manager.update_split_horizon_zone(
|
||||||
|
effective_domain, caddy_ip, primary_domain=domain)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"DNS bootstrap failed (non-fatal): {e}")
|
logger.warning(f"DNS bootstrap failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|||||||
+56
-30
@@ -182,6 +182,21 @@ class NetworkManager(BaseServiceManager):
|
|||||||
if not ok:
|
if not ok:
|
||||||
logger.warning('update_split_horizon_zone: zone file write failed for %s', effective_domain)
|
logger.warning('update_split_horizon_zone: zone file write failed for %s', effective_domain)
|
||||||
|
|
||||||
|
# Delete split-horizon zone files for prior cell names sharing the same TLD.
|
||||||
|
# E.g. when renaming from pic3.pic.ngo → pic2.pic.ngo, remove pic3.pic.ngo.zone.
|
||||||
|
eff_parts = effective_domain.split('.')
|
||||||
|
if len(eff_parts) >= 2:
|
||||||
|
tld_suffix = '.' + '.'.join(eff_parts[1:])
|
||||||
|
for fname in os.listdir(self.dns_zones_dir):
|
||||||
|
if fname.endswith('.zone'):
|
||||||
|
z = fname[:-5]
|
||||||
|
if z.endswith(tld_suffix) and z != effective_domain:
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(self.dns_zones_dir, fname))
|
||||||
|
logger.info('Deleted stale split-horizon zone: %s', fname)
|
||||||
|
except OSError as _e:
|
||||||
|
logger.warning('Failed to delete stale zone %s: %s', fname, _e)
|
||||||
|
|
||||||
# If the internal zone name happens to be a parent of the effective DDNS
|
# If the internal zone name happens to be a parent of the effective DDNS
|
||||||
# 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
|
||||||
@@ -579,42 +594,53 @@ class NetworkManager(BaseServiceManager):
|
|||||||
warnings = []
|
warnings = []
|
||||||
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.
|
||||||
_service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
|
_service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
|
||||||
|
_reserved = _service_names | {'@', '*'}
|
||||||
changed = False
|
changed = False
|
||||||
try:
|
try:
|
||||||
dns_data = os.path.join(self.data_dir, 'dns')
|
dns_data = os.path.join(self.data_dir, 'dns')
|
||||||
if os.path.isdir(dns_data):
|
if os.path.isdir(dns_data):
|
||||||
for fname in os.listdir(dns_data):
|
for fname in os.listdir(dns_data):
|
||||||
if fname.endswith('.zone') and 'local' not in fname:
|
if not fname.endswith('.zone'):
|
||||||
zone_file = os.path.join(dns_data, fname)
|
continue
|
||||||
with open(zone_file) as f:
|
zone_name = fname[:-5]
|
||||||
content = f.read()
|
# Skip split-horizon DDNS zones (multi-label, e.g. 'pic2.pic.ngo.zone')
|
||||||
# Determine which name to replace: prefer old_name if present,
|
# and any zone with 'local' in its name. The cell hostname only lives
|
||||||
# otherwise detect from zone (non-service A record not in _service_names)
|
# in the primary single-label zone (e.g. 'cell.zone').
|
||||||
actual_old = old_name if (
|
if 'local' in zone_name or '.' in zone_name:
|
||||||
old_name and re.search(
|
continue
|
||||||
rf'^{re.escape(old_name)}\s', content, re.MULTILINE)
|
zone_file = os.path.join(dns_data, fname)
|
||||||
) else None
|
with open(zone_file) as f:
|
||||||
if actual_old is None:
|
content = f.read()
|
||||||
for m in re.finditer(
|
# Determine which name to replace: prefer old_name if present,
|
||||||
r'^(\S+)\s+(?:\d+\s+)?IN\s+A\s+\S+', content, re.MULTILINE
|
# otherwise detect from zone (non-service A record not in _reserved)
|
||||||
):
|
actual_old = old_name if (
|
||||||
candidate = m.group(1)
|
old_name and re.search(
|
||||||
if candidate not in _service_names and candidate != '@':
|
rf'^{re.escape(old_name)}\s', content, re.MULTILINE)
|
||||||
actual_old = candidate
|
) else None
|
||||||
break
|
if actual_old is None:
|
||||||
if actual_old is None or actual_old == new_name:
|
for m in re.finditer(
|
||||||
break
|
r'^(\S+)\s+(?:\d+\s+)?IN\s+A\s+\S+', content, re.MULTILINE
|
||||||
new_content = re.sub(
|
):
|
||||||
rf'^{re.escape(actual_old)}(\s+(?:\d+\s+)?IN\s+A\s+)',
|
candidate = m.group(1)
|
||||||
f'{new_name}\\1',
|
if candidate not in _reserved:
|
||||||
content, flags=re.MULTILINE
|
actual_old = candidate
|
||||||
)
|
break
|
||||||
if new_content != content:
|
if actual_old is None:
|
||||||
with open(zone_file, 'w') as f:
|
continue # no hostname in this zone; try next
|
||||||
f.write(new_content)
|
if actual_old == new_name:
|
||||||
changed = True
|
break # already correct
|
||||||
break
|
new_content = re.sub(
|
||||||
|
rf'^{re.escape(actual_old)}(\s+(?:\d+\s+)?IN\s+A\s+)',
|
||||||
|
f'{new_name}\\1',
|
||||||
|
content, flags=re.MULTILINE
|
||||||
|
)
|
||||||
|
if new_content != content:
|
||||||
|
with open(zone_file, 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
if changed and reload:
|
if changed and reload:
|
||||||
self._reload_dns_service()
|
self._reload_dns_service()
|
||||||
restarted.append('cell-dns (reloaded)')
|
restarted.append('cell-dns (reloaded)')
|
||||||
|
|||||||
@@ -496,5 +496,131 @@ class TestUpdateSplitHorizonZone(unittest.TestCase):
|
|||||||
self.assertIn('calendar', content)
|
self.assertIn('calendar', content)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyCellName(unittest.TestCase):
|
||||||
|
"""Tests for apply_cell_name — hostname rename in primary DNS zone."""
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _write_zone(self, name: str, content: str):
|
||||||
|
path = os.path.join(self.data_dir, 'dns', f'{name}.zone')
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_renames_hostname_in_primary_zone(self, _mock):
|
||||||
|
"""Old cell name is replaced with new name in the primary zone."""
|
||||||
|
self._write_zone('cell', (
|
||||||
|
'$ORIGIN cell.\n'
|
||||||
|
'@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n'
|
||||||
|
'@ 300 IN NS ns1\n'
|
||||||
|
'oldname 300 IN A 172.20.0.2\n'
|
||||||
|
'api 300 IN A 172.20.0.10\n'
|
||||||
|
))
|
||||||
|
self.nm.apply_cell_name('oldname', 'newname', reload=False)
|
||||||
|
content = open(os.path.join(self.data_dir, 'dns', 'cell.zone')).read()
|
||||||
|
self.assertIn('newname', content)
|
||||||
|
self.assertNotIn('oldname', content)
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_does_not_corrupt_split_horizon_zone(self, _mock):
|
||||||
|
"""A multi-label DDNS zone (e.g. pic2.pic.ngo.zone) must not be touched."""
|
||||||
|
sh_path = self._write_zone('pic2.pic.ngo', (
|
||||||
|
'$ORIGIN pic2.pic.ngo.\n'
|
||||||
|
'@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n'
|
||||||
|
'@ 300 IN NS ns1\n'
|
||||||
|
'@ 300 IN A 172.20.0.2\n'
|
||||||
|
'* 300 IN A 172.20.0.2\n'
|
||||||
|
))
|
||||||
|
self._write_zone('cell', (
|
||||||
|
'$ORIGIN cell.\n'
|
||||||
|
'@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n'
|
||||||
|
'oldname 300 IN A 172.20.0.2\n'
|
||||||
|
))
|
||||||
|
self.nm.apply_cell_name('oldname', 'newname', reload=False)
|
||||||
|
# Split-horizon zone must be unchanged (wildcard not renamed)
|
||||||
|
sh_content = open(sh_path).read()
|
||||||
|
self.assertNotIn('newname', sh_content)
|
||||||
|
self.assertIn('* 300 IN A 172.20.0.2', sh_content)
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_wildcard_not_treated_as_hostname(self, _mock):
|
||||||
|
"""Wildcard record in a zone must never be detected as the cell hostname."""
|
||||||
|
zone_path = self._write_zone('cell', (
|
||||||
|
'$ORIGIN cell.\n'
|
||||||
|
'@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n'
|
||||||
|
'@ 300 IN A 172.20.0.2\n'
|
||||||
|
'* 300 IN A 172.20.0.2\n'
|
||||||
|
))
|
||||||
|
self.nm.apply_cell_name('', 'newname', reload=False)
|
||||||
|
content = open(zone_path).read()
|
||||||
|
# Wildcard must remain; 'newname' must not appear
|
||||||
|
self.assertIn('* 300 IN A', content)
|
||||||
|
self.assertNotIn('newname', content)
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_skips_zone_with_local_in_name(self, _mock):
|
||||||
|
"""Zones with 'local' in the filename are ignored."""
|
||||||
|
local_path = self._write_zone('home.local', (
|
||||||
|
'$ORIGIN home.local.\n'
|
||||||
|
'oldname 300 IN A 172.20.0.2\n'
|
||||||
|
))
|
||||||
|
self.nm.apply_cell_name('oldname', 'newname', reload=False)
|
||||||
|
content = open(local_path).read()
|
||||||
|
self.assertIn('oldname', content)
|
||||||
|
self.assertNotIn('newname', content)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateSplitHorizonZoneStaleCleanup(unittest.TestCase):
|
||||||
|
"""Tests for stale split-horizon zone deletion in update_split_horizon_zone."""
|
||||||
|
|
||||||
|
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_deletes_old_cell_zone_same_tld(self, _mock):
|
||||||
|
"""When renaming pic3.pic.ngo → pic2.pic.ngo the old zone file is removed."""
|
||||||
|
old_zone = os.path.join(self.data_dir, 'dns', 'pic3.pic.ngo.zone')
|
||||||
|
with open(old_zone, 'w') as f:
|
||||||
|
f.write('@ 300 IN A 172.20.0.2\n')
|
||||||
|
self.nm.update_split_horizon_zone('pic2.pic.ngo', '172.20.0.2')
|
||||||
|
self.assertFalse(os.path.exists(old_zone), 'stale pic3.pic.ngo.zone should be deleted')
|
||||||
|
new_zone = os.path.join(self.data_dir, 'dns', 'pic2.pic.ngo.zone')
|
||||||
|
self.assertTrue(os.path.exists(new_zone))
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_keeps_zone_for_different_tld(self, _mock):
|
||||||
|
"""Zone files under a different TLD are not deleted."""
|
||||||
|
other_zone = os.path.join(self.data_dir, 'dns', 'myhost.example.com.zone')
|
||||||
|
with open(other_zone, 'w') as f:
|
||||||
|
f.write('@ 300 IN A 1.2.3.4\n')
|
||||||
|
self.nm.update_split_horizon_zone('pic2.pic.ngo', '172.20.0.2')
|
||||||
|
self.assertTrue(os.path.exists(other_zone), 'unrelated zone must not be deleted')
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_keeps_current_effective_zone(self, _mock):
|
||||||
|
"""The current effective_domain zone file is never deleted."""
|
||||||
|
self.nm.update_split_horizon_zone('pic2.pic.ngo', '172.20.0.2')
|
||||||
|
current_zone = os.path.join(self.data_dir, 'dns', 'pic2.pic.ngo.zone')
|
||||||
|
self.assertTrue(os.path.exists(current_zone))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in New Issue
Block a user