#!/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.) 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'(?