615448b875
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>
100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
#!/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
|