feat: dynamic ip_range propagation to DNS, firewall, and docker-compose

When ip_range changes in Settings, the new subnet is now applied to:
- DNS zone records (network_manager.apply_ip_range)
- Caddy virtual IPs (firewall_manager.ensure_caddy_virtual_ips)
- iptables per-service rules (firewall_manager.update_service_ips)
- docker-compose.yml static IPs if writable (ip_utils.update_docker_compose_ips)

New module ip_utils.py derives all container IPs from the subnet using
fixed offsets so the entire stack stays consistent from one setting.

321 tests pass (72 new tests added for ip_utils, apply_ip_range, update_service_ips).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 10:26:21 -04:00
parent 8e741b5729
commit 615448b875
7 changed files with 473 additions and 14 deletions
+92
View File
@@ -263,6 +263,98 @@ test2 1800 IN CNAME test1
self.assertIn('192.168.1.10', content)
self.assertIn('192.168.1.11', content)
class TestBootstrapDnsRecords(unittest.TestCase):
"""Test bootstrap_dns_records with dynamic IP derivation."""
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(self.data_dir, exist_ok=True)
os.makedirs(self.config_dir, 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(self, _mock):
self.nm.bootstrap_dns_records('mycell', 'cell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
self.assertTrue(os.path.exists(zone_file))
@patch('subprocess.run')
def test_contains_default_caddy_ip(self, _mock):
self.nm.bootstrap_dns_records('mycell', 'cell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
content = open(zone_file).read()
self.assertIn('172.20.0.2', content) # caddy
@patch('subprocess.run')
def test_custom_ip_range_used(self, _mock):
self.nm.bootstrap_dns_records('mycell', 'cell', ip_range='10.5.0.0/24')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
content = open(zone_file).read()
self.assertIn('10.5.0.2', content) # caddy
self.assertIn('10.5.0.21', content) # vip_calendar
self.assertNotIn('172.20', content)
@patch('subprocess.run')
def test_idempotent_skips_existing_zone(self, _mock):
self.nm.bootstrap_dns_records('mycell', 'cell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
mtime1 = os.path.getmtime(zone_file)
self.nm.bootstrap_dns_records('mycell', 'cell')
mtime2 = os.path.getmtime(zone_file)
self.assertEqual(mtime1, mtime2)
class TestApplyIpRange(unittest.TestCase):
"""Test apply_ip_range rewrites DNS zone records."""
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(self.data_dir, exist_ok=True)
os.makedirs(self.config_dir, 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_zone_file_updated_with_new_ips(self, _mock):
# Bootstrap with default range, then change to 10.0.0.0/24
self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16')
result = self.nm.apply_ip_range('10.0.0.0/24', 'mycell', 'cell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
content = open(zone_file).read()
self.assertIn('10.0.0.2', content) # caddy
self.assertIn('10.0.0.21', content) # vip_calendar
self.assertNotIn('172.20', content)
@patch('subprocess.run')
def test_returns_restarted_on_success(self, _mock):
self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16')
result = self.nm.apply_ip_range('10.0.0.0/24', 'mycell', 'cell')
self.assertIn('cell-dns (reloaded)', result['restarted'])
@patch('subprocess.run')
def test_all_standard_records_present(self, _mock):
self.nm.apply_ip_range('10.1.2.0/24', 'pictest', 'mycell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'mycell.zone')
content = open(zone_file).read()
for host in ('pictest', 'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'):
self.assertIn(host, content)
@patch('subprocess.run')
def test_same_range_updates_zone_without_error(self, _mock):
self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16')
result = self.nm.apply_ip_range('172.20.0.0/16', 'mycell', 'cell')
self.assertEqual(result['warnings'], [])
class TestCellDnsForwarding(unittest.TestCase):
"""Test add/remove cell DNS forwarding in Corefile."""