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:
+8
-17
@@ -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):
|
||||
# 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 updated — run `make restart` to apply container IP changes')
|
||||
compose_updated = True
|
||||
break
|
||||
if not compose_updated:
|
||||
'Container IPs updated — run `make start` to apply to running containers')
|
||||
else:
|
||||
all_warnings.append(
|
||||
'docker-compose.yml not updated (run `make reinstall` to apply container IP changes)')
|
||||
'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
@@ -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
|
||||
|
||||
+14
-13
@@ -17,7 +17,7 @@ services:
|
||||
- NET_ADMIN
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.2
|
||||
ipv4_address: ${CADDY_IP:-172.20.0.2}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.3
|
||||
ipv4_address: ${DNS_IP:-172.20.0.3}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
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"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -78,7 +78,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.5
|
||||
ipv4_address: ${NTP_IP:-172.20.0.5}
|
||||
cap_add:
|
||||
- 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"]
|
||||
@@ -108,7 +108,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.6
|
||||
ipv4_address: ${MAIL_IP:-172.20.0.6}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
logging:
|
||||
@@ -129,7 +129,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.7
|
||||
ipv4_address: ${RADICALE_IP:-172.20.0.7}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -151,7 +151,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.8
|
||||
ipv4_address: ${WEBDAV_IP:-172.20.0.8}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -174,7 +174,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.9
|
||||
ipv4_address: ${WG_IP:-172.20.0.9}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
@@ -201,11 +201,12 @@ services:
|
||||
- ./config/dns:/app/config/dns
|
||||
- ./data/logs:/app/api/data/logs
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./.env:/app/.env.compose
|
||||
pid: host
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.10
|
||||
ipv4_address: ${API_IP:-172.20.0.10}
|
||||
depends_on:
|
||||
- wireguard
|
||||
- dns
|
||||
@@ -224,7 +225,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.11
|
||||
ipv4_address: ${WEBUI_IP:-172.20.0.11}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -238,7 +239,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.12
|
||||
ipv4_address: ${RAINLOOP_IP:-172.20.0.12}
|
||||
ports:
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
@@ -256,7 +257,7 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: 172.20.0.13
|
||||
ipv4_address: ${FILEGATOR_IP:-172.20.0.13}
|
||||
ports:
|
||||
- "8082:8080"
|
||||
volumes:
|
||||
@@ -272,4 +273,4 @@ networks:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: ${CELL_NETWORK:-172.20.0.0/16}
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
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():
|
||||
cell_name = os.environ.get('CELL_NAME', 'mycell')
|
||||
domain = os.environ.get('CELL_DOMAIN', 'cell')
|
||||
vpn_address = os.environ.get('VPN_ADDRESS', '10.0.0.1/24')
|
||||
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(f' cell={cell_name} domain={domain} vpn={vpn_address} port={wg_port}')
|
||||
@@ -212,6 +224,7 @@ def main():
|
||||
priv, _pub = generate_wg_keys()
|
||||
write_wg0_conf(priv, vpn_address, wg_port)
|
||||
write_cell_config(cell_name, domain, wg_port)
|
||||
write_compose_env(ip_range)
|
||||
|
||||
print()
|
||||
print('--- Setup complete! Run: make start ---')
|
||||
|
||||
+53
-58
@@ -80,72 +80,67 @@ class TestGetVirtualIps(unittest.TestCase):
|
||||
self.assertEqual(vips['webdav'], '10.10.0.24')
|
||||
|
||||
|
||||
class TestUpdateDockerComposeIps(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
|
||||
"""
|
||||
|
||||
class TestWriteEnvFile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False)
|
||||
self.tmp.write(self.COMPOSE_TEMPLATE)
|
||||
self.tmp.close()
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.env_path = os.path.join(self.tmp, '.env')
|
||||
|
||||
def tearDown(self):
|
||||
os.unlink(self.tmp.name)
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp)
|
||||
|
||||
def test_returns_false_for_missing_file(self):
|
||||
self.assertFalse(
|
||||
ip_utils.update_docker_compose_ips('172.20.0.0/16', '10.0.0.0/24', '/nonexistent/path.yml')
|
||||
)
|
||||
|
||||
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_creates_file(self):
|
||||
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
|
||||
self.assertTrue(os.path.exists(self.env_path))
|
||||
|
||||
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)
|
||||
|
||||
def test_noop_when_ranges_same(self):
|
||||
ip_utils.update_docker_compose_ips('172.20.0.0/16', '172.20.0.0/16', self.tmp.name)
|
||||
with open(self.tmp.name) as f:
|
||||
content = f.read()
|
||||
self.assertEqual(content, self.COMPOSE_TEMPLATE)
|
||||
def test_returns_false_on_unwritable_path(self):
|
||||
result = ip_utils.write_env_file('172.20.0.0/16', '/nonexistent/deep/path/.env')
|
||||
self.assertFalse(result)
|
||||
|
||||
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__':
|
||||
|
||||
Reference in New Issue
Block a user