feat: replace hardcoded docker-compose IPs with .env-based substitution

docker-compose.yml now uses ${VAR:-default} for every container IP and
the network subnet, so there are no hardcoded addresses in the YAML.

How it works:
- setup_cell.py generates .env at project root from ip_range (gitignored).
- docker-compose reads .env automatically at startup.
- When ip_range changes in Settings, the API writes a new .env via
  ip_utils.write_env_file(); DNS/firewall/vIPs update immediately.
- User runs `make start` to recreate containers with the new IPs.

api/ip_utils.py gains ENV_VAR_NAMES dict and write_env_file(ip_range, path).
The old update_docker_compose_ips() direct-patch approach is removed from app.py.
3 new tests added (TestWriteEnvFile); total 324 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 10:43:33 -04:00
parent 615448b875
commit 1c939249e4
5 changed files with 123 additions and 120 deletions
+9 -18
View File
@@ -481,33 +481,24 @@ def update_config():
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
# Update DNS zone records immediately
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
# Update firewall virtual IPs (iptables) and Caddy virtual IPs immediately
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:
# Write new .env so docker-compose picks up new container IPs on next start
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
if ip_utils.write_env_file(new_range, env_file):
all_warnings.append(
'docker-compose.yml not updated (run `make reinstall` to apply container IP changes)')
'Container IPs updated run `make start` to apply to running containers')
else:
all_warnings.append(
'Could not write .env — run `make setup && make start` to apply container IP changes')
logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({
+34 -31
View File
@@ -2,11 +2,13 @@
"""
IP utility functions for PIC derive all container and virtual IPs from the
docker network subnet so that one ip_range setting drives everything.
The canonical source of IPs is the .env file at the project root.
docker-compose.yml uses ${VAR:-default} substitution to read from it.
"""
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>)
@@ -30,6 +32,22 @@ CONTAINER_OFFSETS: Dict[str, int] = {
'vip_webdav': 24,
}
# Mapping from service key → docker-compose env var name (static containers only)
ENV_VAR_NAMES: Dict[str, str] = {
'caddy': 'CADDY_IP',
'dns': 'DNS_IP',
'dhcp': 'DHCP_IP',
'ntp': 'NTP_IP',
'mail': 'MAIL_IP',
'radicale': 'RADICALE_IP',
'webdav': 'WEBDAV_IP',
'wireguard': 'WG_IP',
'api': 'API_IP',
'webui': 'WEBUI_IP',
'rainloop': 'RAINLOOP_IP',
'filegator': 'FILEGATOR_IP',
}
def get_service_ips(ip_range: str) -> Dict[str, str]:
"""
@@ -60,40 +78,25 @@ def get_virtual_ips(ip_range: str) -> Dict[str, str]:
}
def update_docker_compose_ips(old_ip_range: str, new_ip_range: str, compose_path: str) -> bool:
def write_env_file(ip_range: str, 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.
Write (or overwrite) the docker-compose .env file with IPs derived from ip_range.
Returns True on success, False if the file is not accessible.
docker-compose reads this file automatically at startup to substitute
${VAR:-default} placeholders in docker-compose.yml. Call this at setup
time and whenever ip_range changes so containers get the right IPs on
the next `docker-compose up -d`.
Returns True on success, False if the path is not writable.
"""
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)
ips = get_service_ips(ip_range)
lines = [f'CELL_NETWORK={ip_range}\n']
for svc, var in ENV_VAR_NAMES.items():
lines.append(f'{var}={ips[svc]}\n')
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, 'w') as f:
f.writelines(lines)
return True
except Exception:
return False