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'): if identity_updates.get('ip_range'):
import ip_utils import ip_utils
new_range = identity_updates['ip_range'] 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_identity = config_manager.configs.get('_identity', {})
cur_cell_name = cur_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')) 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')) 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) ip_result = network_manager.apply_ip_range(new_range, cur_cell_name, cur_domain)
all_restarted.extend(ip_result.get('restarted', [])) all_restarted.extend(ip_result.get('restarted', []))
all_warnings.extend(ip_result.get('warnings', [])) 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.update_service_ips(new_range)
firewall_manager.ensure_caddy_virtual_ips() firewall_manager.ensure_caddy_virtual_ips()
# Try to update docker-compose.yml (only works outside container / dev mode) # Write new .env so docker-compose picks up new container IPs on next start
compose_candidates = [ env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
os.environ.get('COMPOSE_FILE', ''), if ip_utils.write_env_file(new_range, env_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( 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}") logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({ return jsonify({
+34 -31
View File
@@ -2,11 +2,13 @@
""" """
IP utility functions for PIC derive all container and virtual IPs from the IP utility functions for PIC derive all container and virtual IPs from the
docker network subnet so that one ip_range setting drives everything. 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 ipaddress
import os import os
import re
from typing import Dict from typing import Dict
# Fixed host-number offsets within the subnet (e.g. 172.20.0.0/16 → 172.20.0.<offset>) # 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, '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]: 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 Write (or overwrite) the docker-compose .env file with IPs derived from ip_range.
container ipv4_address that derives from old_ip_range with the new values.
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: try:
old_ips = get_service_ips(old_ip_range) ips = get_service_ips(ip_range)
new_ips = get_service_ips(new_ip_range) lines = [f'CELL_NETWORK={ip_range}\n']
for svc, var in ENV_VAR_NAMES.items():
with open(compose_path) as f: lines.append(f'{var}={ips[svc]}\n')
content = f.read() os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, 'w') as f:
# Replace subnet string (e.g. "172.20.0.0/16") f.writelines(lines)
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 return True
except Exception: except Exception:
return False return False
+14 -13
View File
@@ -17,7 +17,7 @@ services:
- NET_ADMIN - NET_ADMIN
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.2 ipv4_address: ${CADDY_IP:-172.20.0.2}
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -38,7 +38,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.3 ipv4_address: ${DNS_IP:-172.20.0.3}
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -57,7 +57,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.4 ipv4_address: ${DHCP_IP:-172.20.0.4}
command: ["/bin/sh", "-c", "apk add --no-cache dnsmasq && dnsmasq -d -C /etc/dnsmasq.conf"] command: ["/bin/sh", "-c", "apk add --no-cache dnsmasq && dnsmasq -d -C /etc/dnsmasq.conf"]
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
@@ -78,7 +78,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.5 ipv4_address: ${NTP_IP:-172.20.0.5}
cap_add: cap_add:
- SYS_TIME - SYS_TIME
command: ["/bin/sh", "-c", "apk add --no-cache chrony && rm -f /var/run/chrony/chronyd.pid && exec chronyd -d -f /etc/chrony/chrony.conf -n"] command: ["/bin/sh", "-c", "apk add --no-cache chrony && rm -f /var/run/chrony/chronyd.pid && exec chronyd -d -f /etc/chrony/chrony.conf -n"]
@@ -108,7 +108,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.6 ipv4_address: ${MAIL_IP:-172.20.0.6}
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
logging: logging:
@@ -129,7 +129,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.7 ipv4_address: ${RADICALE_IP:-172.20.0.7}
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -151,7 +151,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.8 ipv4_address: ${WEBDAV_IP:-172.20.0.8}
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -174,7 +174,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.9 ipv4_address: ${WG_IP:-172.20.0.9}
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_MODULE - SYS_MODULE
@@ -201,11 +201,12 @@ services:
- ./config/dns:/app/config/dns - ./config/dns:/app/config/dns
- ./data/logs:/app/api/data/logs - ./data/logs:/app/api/data/logs
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env.compose
pid: host pid: host
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.10 ipv4_address: ${API_IP:-172.20.0.10}
depends_on: depends_on:
- wireguard - wireguard
- dns - dns
@@ -224,7 +225,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.11 ipv4_address: ${WEBUI_IP:-172.20.0.11}
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -238,7 +239,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.12 ipv4_address: ${RAINLOOP_IP:-172.20.0.12}
ports: ports:
- "8888:8888" - "8888:8888"
volumes: volumes:
@@ -256,7 +257,7 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.13 ipv4_address: ${FILEGATOR_IP:-172.20.0.13}
ports: ports:
- "8082:8080" - "8082:8080"
volumes: volumes:
@@ -272,4 +273,4 @@ networks:
driver: bridge driver: bridge
ipam: ipam:
config: config:
- subnet: 172.20.0.0/16 - subnet: ${CELL_NETWORK:-172.20.0.0/16}
+13
View File
@@ -193,11 +193,23 @@ def write_cell_config(cell_name: str, domain: str, port: int):
print(f'[CREATED] config/api/cell_config.json name={cell_name} domain={domain}') print(f'[CREATED] config/api/cell_config.json name={cell_name} domain={domain}')
def write_compose_env(ip_range: str):
"""Generate .env at project root so docker-compose picks up correct container IPs."""
sys.path.insert(0, os.path.join(ROOT, 'api'))
import ip_utils
env_path = os.path.join(ROOT, '.env')
if ip_utils.write_env_file(ip_range, env_path):
print(f'[CREATED] .env (ip_range={ip_range})')
else:
print(f'[WARN] Could not write .env — containers will use built-in default IPs')
def main(): def main():
cell_name = os.environ.get('CELL_NAME', 'mycell') cell_name = os.environ.get('CELL_NAME', 'mycell')
domain = os.environ.get('CELL_DOMAIN', 'cell') domain = os.environ.get('CELL_DOMAIN', 'cell')
vpn_address = os.environ.get('VPN_ADDRESS', '10.0.0.1/24') vpn_address = os.environ.get('VPN_ADDRESS', '10.0.0.1/24')
wg_port = int(os.environ.get('WG_PORT', '51820')) wg_port = int(os.environ.get('WG_PORT', '51820'))
ip_range = os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')
print('--- Personal Internet Cell: Setup ---') print('--- Personal Internet Cell: Setup ---')
print(f' cell={cell_name} domain={domain} vpn={vpn_address} port={wg_port}') print(f' cell={cell_name} domain={domain} vpn={vpn_address} port={wg_port}')
@@ -212,6 +224,7 @@ def main():
priv, _pub = generate_wg_keys() priv, _pub = generate_wg_keys()
write_wg0_conf(priv, vpn_address, wg_port) write_wg0_conf(priv, vpn_address, wg_port)
write_cell_config(cell_name, domain, wg_port) write_cell_config(cell_name, domain, wg_port)
write_compose_env(ip_range)
print() print()
print('--- Setup complete! Run: make start ---') print('--- Setup complete! Run: make start ---')
+53 -58
View File
@@ -80,72 +80,67 @@ class TestGetVirtualIps(unittest.TestCase):
self.assertEqual(vips['webdav'], '10.10.0.24') self.assertEqual(vips['webdav'], '10.10.0.24')
class TestUpdateDockerComposeIps(unittest.TestCase): class TestWriteEnvFile(unittest.TestCase):
COMPOSE_TEMPLATE = """\
version: '3.3'
services:
caddy:
networks:
cell-network:
ipv4_address: 172.20.0.2
dns:
networks:
cell-network:
ipv4_address: 172.20.0.3
api:
networks:
cell-network:
ipv4_address: 172.20.0.10
networks:
cell-network:
ipam:
config:
- subnet: 172.20.0.0/16
"""
def setUp(self): def setUp(self):
self.tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) self.tmp = tempfile.mkdtemp()
self.tmp.write(self.COMPOSE_TEMPLATE) self.env_path = os.path.join(self.tmp, '.env')
self.tmp.close()
def tearDown(self): def tearDown(self):
os.unlink(self.tmp.name) import shutil
shutil.rmtree(self.tmp)
def test_returns_false_for_missing_file(self): def test_creates_file(self):
self.assertFalse( ip_utils.write_env_file('172.20.0.0/16', self.env_path)
ip_utils.update_docker_compose_ips('172.20.0.0/16', '10.0.0.0/24', '/nonexistent/path.yml') self.assertTrue(os.path.exists(self.env_path))
)
def test_subnet_updated(self):
ip_utils.update_docker_compose_ips('172.20.0.0/16', '10.0.0.0/24', self.tmp.name)
with open(self.tmp.name) as f:
content = f.read()
self.assertIn('10.0.0.0/24', content)
self.assertNotIn('172.20.0.0/16', content)
def test_caddy_ip_updated(self):
ip_utils.update_docker_compose_ips('172.20.0.0/16', '10.0.0.0/24', self.tmp.name)
with open(self.tmp.name) as f:
content = f.read()
self.assertIn('10.0.0.2', content)
self.assertNotIn('172.20.0.2', content)
def test_api_ip_updated(self):
ip_utils.update_docker_compose_ips('172.20.0.0/16', '10.0.0.0/24', self.tmp.name)
with open(self.tmp.name) as f:
content = f.read()
self.assertIn('10.0.0.10', content)
self.assertNotIn('172.20.0.10', content)
def test_returns_true_on_success(self): def test_returns_true_on_success(self):
result = ip_utils.update_docker_compose_ips('172.20.0.0/16', '10.0.0.0/24', self.tmp.name) result = ip_utils.write_env_file('172.20.0.0/16', self.env_path)
self.assertTrue(result) self.assertTrue(result)
def test_noop_when_ranges_same(self): def test_returns_false_on_unwritable_path(self):
ip_utils.update_docker_compose_ips('172.20.0.0/16', '172.20.0.0/16', self.tmp.name) result = ip_utils.write_env_file('172.20.0.0/16', '/nonexistent/deep/path/.env')
with open(self.tmp.name) as f: self.assertFalse(result)
content = f.read()
self.assertEqual(content, self.COMPOSE_TEMPLATE) def test_contains_cell_network(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
self.assertIn('CELL_NETWORK=172.20.0.0/16', content)
def test_contains_caddy_ip(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
self.assertIn('CADDY_IP=172.20.0.2', content)
def test_contains_all_env_vars(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
for var in ip_utils.ENV_VAR_NAMES.values():
self.assertIn(var + '=', content, f'{var} missing from .env')
def test_custom_subnet_generates_correct_ips(self):
ip_utils.write_env_file('10.5.0.0/24', self.env_path)
content = open(self.env_path).read()
self.assertIn('CELL_NETWORK=10.5.0.0/24', content)
self.assertIn('CADDY_IP=10.5.0.2', content)
self.assertIn('DNS_IP=10.5.0.3', content)
self.assertIn('API_IP=10.5.0.10', content)
self.assertNotIn('172.20', content)
def test_overwrite_updates_ips(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
ip_utils.write_env_file('10.0.0.0/24', self.env_path)
content = open(self.env_path).read()
self.assertIn('CADDY_IP=10.0.0.2', content)
self.assertNotIn('172.20', content)
def test_each_line_is_key_equals_value(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
for line in open(self.env_path).read().splitlines():
if line.strip():
self.assertIn('=', line, f'Bad line format: {line!r}')
key, _, val = line.partition('=')
self.assertTrue(key.isupper() or '_' in key)
self.assertTrue(val.strip())
if __name__ == '__main__': if __name__ == '__main__':