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
+32 -11
View File
@@ -118,26 +118,47 @@ class NetworkManager(BaseServiceManager):
logger.error(f"Failed to remove DNS record: {e}")
return False
def bootstrap_dns_records(self, cell_name: str, domain: str) -> None:
def bootstrap_dns_records(self, cell_name: str, domain: str,
ip_range: str = '172.20.0.0/16') -> None:
"""Create default service A records the first time the cell starts up.
Skipped if a zone file already exists (idempotent)."""
zone_file = os.path.join(self.dns_zones_dir, f'{domain}.zone')
if os.path.exists(zone_file):
return
logger.info(f"Bootstrapping DNS records for zone '{domain}'")
records = [
{'name': cell_name, 'type': 'A', 'value': '172.20.0.2'}, # cell hostname → Caddy
{'name': 'api', 'type': 'A', 'value': '172.20.0.10'}, # REST API
{'name': 'webui', 'type': 'A', 'value': '172.20.0.11'}, # Web UI
{'name': 'calendar', 'type': 'A', 'value': '172.20.0.21'}, # Caddy vIP → Radicale
{'name': 'files', 'type': 'A', 'value': '172.20.0.22'}, # Caddy vIP → Filegator
{'name': 'mail', 'type': 'A', 'value': '172.20.0.23'}, # Caddy vIP → Rainloop
{'name': 'webmail', 'type': 'A', 'value': '172.20.0.23'}, # alias for mail
{'name': 'webdav', 'type': 'A', 'value': '172.20.0.24'}, # Caddy vIP → WebDAV
]
records = self._build_dns_records(cell_name, ip_range)
self.update_dns_zone(domain, records)
logger.info(f"Created {len(records)} default DNS records for zone '{domain}'")
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] = []
warnings: List[str] = []
try:
records = self._build_dns_records(cell_name, ip_range)
if self.update_dns_zone(domain, records):
restarted.append('cell-dns (reloaded)')
else:
warnings.append('DNS zone update failed')
except Exception as e:
warnings.append(f'apply_ip_range failed: {e}')
return {'restarted': restarted, 'warnings': warnings}
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records for the given subnet."""
import ip_utils
ips = ip_utils.get_service_ips(ip_range)
return [
{'name': cell_name, 'type': 'A', 'value': ips['caddy']},
{'name': 'api', 'type': 'A', 'value': ips['api']},
{'name': 'webui', 'type': 'A', 'value': ips['webui']},
{'name': 'calendar', 'type': 'A', 'value': ips['vip_calendar']},
{'name': 'files', 'type': 'A', 'value': ips['vip_files']},
{'name': 'mail', 'type': 'A', 'value': ips['vip_mail']},
{'name': 'webmail', 'type': 'A', 'value': ips['vip_mail']},
{'name': 'webdav', 'type': 'A', 'value': ips['vip_webdav']},
]
def get_dns_records(self, zone: str = 'cell') -> List[Dict]:
"""Get all DNS records across all zones"""
all_records = []