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
+34 -1
View File
@@ -202,7 +202,8 @@ def _bootstrap_dns():
identity = config_manager.configs.get('_identity', {})
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
network_manager.bootstrap_dns_records(cell_name, domain)
ip_range = identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
network_manager.bootstrap_dns_records(cell_name, domain, ip_range)
except Exception as e:
logger.warning(f"DNS bootstrap failed (non-fatal): {e}")
@@ -476,6 +477,38 @@ def update_config():
all_restarted.extend(cn_result.get('restarted', []))
all_warnings.extend(cn_result.get('warnings', []))
# Apply ip_range change: regenerate DNS records, update virtual IPs + firewall rules
if identity_updates.get('ip_range'):
import ip_utils
new_range = identity_updates['ip_range']
old_range = old_identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
cur_identity = config_manager.configs.get('_identity', {})
cur_cell_name = cur_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
cur_domain = cur_identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
# Update DNS zone records
ip_result = network_manager.apply_ip_range(new_range, cur_cell_name, cur_domain)
all_restarted.extend(ip_result.get('restarted', []))
all_warnings.extend(ip_result.get('warnings', []))
# Update firewall virtual IPs (iptables) and Caddy virtual IPs
firewall_manager.update_service_ips(new_range)
firewall_manager.ensure_caddy_virtual_ips()
# Try to update docker-compose.yml (only works outside container / dev mode)
compose_candidates = [
os.environ.get('COMPOSE_FILE', ''),
'/app/../docker-compose.yml',
os.path.join(os.path.dirname(__file__), '..', 'docker-compose.yml'),
]
compose_updated = False
for cpath in compose_candidates:
if cpath and ip_utils.update_docker_compose_ips(old_range, new_range, cpath):
all_warnings.append(
'docker-compose.yml updated — run `make restart` to apply container IP changes')
compose_updated = True
break
if not compose_updated:
all_warnings.append(
'docker-compose.yml not updated (run `make reinstall` to apply container IP changes)')
logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({
"message": "Configuration updated and applied",
+12 -2
View File
@@ -12,14 +12,24 @@ from typing import Dict, List, Any, Optional
logger = logging.getLogger(__name__)
# Virtual IPs assigned to Caddy per service — must match Caddyfile listeners
SERVICE_IPS = {
# Virtual IPs assigned to Caddy per service — must match Caddyfile listeners.
# Populated at import time from the default subnet; call update_service_ips()
# whenever ip_range changes so all downstream callers see the new values.
SERVICE_IPS: Dict[str, str] = {
'calendar': '172.20.0.21',
'files': '172.20.0.22',
'mail': '172.20.0.23',
'webdav': '172.20.0.24',
}
def update_service_ips(ip_range: str) -> None:
"""Recalculate SERVICE_IPS from the new subnet and update in-place."""
from ip_utils import get_virtual_ips
new_ips = get_virtual_ips(ip_range)
SERVICE_IPS.clear()
SERVICE_IPS.update(new_ips)
# Internal RFC-1918 ranges (peer traffic stays inside these = cell-only access)
PRIVATE_NETS = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
IP utility functions for PIC derive all container and virtual IPs from the
docker network subnet so that one ip_range setting drives everything.
"""
import ipaddress
import os
import re
from typing import Dict
# Fixed host-number offsets within the subnet (e.g. 172.20.0.0/16 → 172.20.0.<offset>)
CONTAINER_OFFSETS: Dict[str, int] = {
'caddy': 2,
'dns': 3,
'dhcp': 4,
'ntp': 5,
'mail': 6,
'radicale': 7,
'webdav': 8,
'wireguard': 9,
'api': 10,
'webui': 11,
'rainloop': 12,
'filegator': 13,
# Caddy virtual IPs — each service gets its own IP so Caddy can route by dst addr
'vip_calendar': 21,
'vip_files': 22,
'vip_mail': 23,
'vip_webdav': 24,
}
def get_service_ips(ip_range: str) -> Dict[str, str]:
"""
Derive all container and virtual IPs from the docker network subnet.
Example: '172.20.0.0/16' {'caddy': '172.20.0.2', 'dns': '172.20.0.3', ...}
The offset of each service within the subnet is fixed (see CONTAINER_OFFSETS).
"""
network = ipaddress.IPv4Network(ip_range, strict=False)
base = int(network.network_address)
return {
name: str(ipaddress.IPv4Address(base + offset))
for name, offset in CONTAINER_OFFSETS.items()
}
def get_virtual_ips(ip_range: str) -> Dict[str, str]:
"""
Return only the four Caddy virtual IPs keyed by service name.
Used by firewall_manager to set per-service iptables rules.
"""
ips = get_service_ips(ip_range)
return {
'calendar': ips['vip_calendar'],
'files': ips['vip_files'],
'mail': ips['vip_mail'],
'webdav': ips['vip_webdav'],
}
def update_docker_compose_ips(old_ip_range: str, new_ip_range: str, compose_path: str) -> bool:
"""
Rewrite docker-compose.yml: replace the subnet declaration and every
container ipv4_address that derives from old_ip_range with the new values.
Returns True on success, False if the file is not accessible.
"""
if not os.path.exists(compose_path):
return False
try:
old_ips = get_service_ips(old_ip_range)
new_ips = get_service_ips(new_ip_range)
with open(compose_path) as f:
content = f.read()
# Replace subnet string (e.g. "172.20.0.0/16")
content = content.replace(old_ip_range, new_ip_range)
# Replace each container IP (avoid touching VIPs — they're not in compose)
static_names = [n for n in CONTAINER_OFFSETS if not n.startswith('vip_')]
for name in static_names:
old_ip = old_ips[name]
new_ip = new_ips[name]
if old_ip != new_ip:
# Replace only full IP occurrences (word-boundary aware via regex)
content = re.sub(
r'(?<!\d)' + re.escape(old_ip) + r'(?!\d)',
new_ip,
content,
)
with open(compose_path, 'w') as f:
f.write(content)
return True
except Exception:
return False
+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 = []