From 1c939249e48ba19b0f135c9f18dcbbdfb349018c Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Wed, 22 Apr 2026 10:43:33 -0400 Subject: [PATCH 1/2] 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 --- api/app.py | 27 ++++------ api/ip_utils.py | 65 ++++++++++++------------ docker-compose.yml | 27 +++++----- scripts/setup_cell.py | 13 +++++ tests/test_ip_utils.py | 111 ++++++++++++++++++++--------------------- 5 files changed, 123 insertions(+), 120 deletions(-) diff --git a/api/app.py b/api/app.py index 2065c58..b87527f 100644 --- a/api/app.py +++ b/api/app.py @@ -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({ diff --git a/api/ip_utils.py b/api/ip_utils.py index f2ebfbc..5325348 100644 --- a/api/ip_utils.py +++ b/api/ip_utils.py @@ -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.) @@ -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'(? Date: Wed, 22 Apr 2026 11:29:26 -0400 Subject: [PATCH 2/2] feat: pending-restart banner + Apply button for config changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ip_range changes, a persistent amber banner appears at the top of every page showing what changed and a "Apply Now" button. Clicking it shows a confirmation modal ("containers will restart briefly"), then calls POST /api/config/apply which runs docker compose up -d from inside the API container — no manual make start needed. Backend: - _set_pending_restart() / _clear_pending_restart() helpers track state in config_manager so it survives page refresh - GET /api/config/pending returns { needs_restart, changed_at, changes } - POST /api/config/apply runs docker compose up -d via the mounted docker.sock, using the project working_dir label to resolve host paths - docker-compose.yml mounts docker-compose.yml itself read-only into the API container so docker compose can read it from inside Frontend (App.jsx): - Polls /api/config/pending every 5 s alongside the health check - PendingRestartBanner component with confirmation modal - Optimistically clears banner on Apply click; API and containers restart in the background Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 91 ++++++++++++++++++++-- docker-compose.yml | 1 + webui/src/App.jsx | 155 +++++++++++++++++++++++++++++++------- webui/src/services/api.js | 2 + 4 files changed, 215 insertions(+), 34 deletions(-) diff --git a/api/app.py b/api/app.py index b87527f..25a0a11 100644 --- a/api/app.py +++ b/api/app.py @@ -493,12 +493,9 @@ def update_config(): firewall_manager.ensure_caddy_virtual_ips() # 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( - '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') + ip_utils.write_env_file(new_range, env_file) + # Mark containers as needing restart + _set_pending_restart([f'ip_range changed to {new_range} — container IPs updated']) logger.info(f"Updated config, restarted: {all_restarted}") return jsonify({ @@ -510,6 +507,88 @@ def update_config(): logger.error(f"Error updating config: {e}") return jsonify({"error": str(e)}), 500 + +# --------------------------------------------------------------------------- +# Pending-restart helpers +# --------------------------------------------------------------------------- + +def _set_pending_restart(changes: list): + """Record that containers need to be restarted to apply configuration.""" + from datetime import datetime as _dt + config_manager.configs['_pending_restart'] = { + 'needs_restart': True, + 'changed_at': _dt.utcnow().isoformat(), + 'changes': changes, + } + config_manager._save_all_configs() + + +def _clear_pending_restart(): + config_manager.configs['_pending_restart'] = {'needs_restart': False, 'changes': []} + config_manager._save_all_configs() + + +@app.route('/api/config/pending', methods=['GET']) +def get_pending_config(): + """Return whether there are unapplied configuration changes that require a restart.""" + pending = config_manager.configs.get('_pending_restart', {}) + return jsonify({ + 'needs_restart': pending.get('needs_restart', False), + 'changed_at': pending.get('changed_at'), + 'changes': pending.get('changes', []), + }) + + +@app.route('/api/config/apply', methods=['POST']) +def apply_pending_config(): + """Apply pending configuration by restarting containers via docker compose up -d.""" + try: + pending = config_manager.configs.get('_pending_restart', {}) + if not pending.get('needs_restart'): + return jsonify({'message': 'No pending changes to apply'}) + + # Get project working dir from our own container labels (set by docker-compose) + project_dir = '/home/roof/pic' + try: + import docker as _docker_sdk + _client = _docker_sdk.from_env() + _self = _client.containers.get('cell-api') + project_dir = _self.labels.get('com.docker.compose.project.working_dir', project_dir) + except Exception: + pass + + # Clear pending flag before we restart so it shows cleared after the new container starts + _clear_pending_restart() + + # Run docker compose up -d in a background thread; the 0.3s delay lets Flask + # finish sending this response before cell-api itself gets recreated. + def _do_apply(): + import time as _time + _time.sleep(0.3) + result = subprocess.run( + ['docker', 'compose', + '--project-directory', project_dir, + '-f', '/app/docker-compose.yml', + '--env-file', '/app/.env.compose', + 'up', '-d'], + capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + logger.error(f"docker compose up failed: {result.stderr.strip()}") + else: + logger.info('docker compose up -d completed successfully') + + threading.Thread(target=_do_apply, daemon=False).start() + + return jsonify({ + 'message': 'Applying configuration — containers are restarting', + 'restart_in_progress': True, + }) + except Exception as e: + logger.error(f"Error applying config: {e}") + return jsonify({'error': str(e)}), 500 + + # Configuration management endpoints @app.route('/api/config/backup', methods=['POST']) def create_config_backup(): diff --git a/docker-compose.yml b/docker-compose.yml index 72c4c8a..162a778 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -202,6 +202,7 @@ services: - ./data/logs:/app/api/data/logs - /var/run/docker.sock:/var/run/docker.sock - ./.env:/app/.env.compose + - ./docker-compose.yml:/app/docker-compose.yml:ro pid: host restart: unless-stopped networks: diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 5d34688..b57c40d 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,22 +1,24 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; -import { useState, useEffect } from 'react'; -import { - Home, - Users, - Network, - Shield, - Mail, - Calendar as CalendarIcon, - FolderOpen, +import { useState, useEffect, useCallback } from 'react'; +import { + Home, + Users, + Network, + Shield, + Mail, + Calendar as CalendarIcon, + FolderOpen, Activity, Wifi, Server, Key, Package2, Settings as SettingsIcon, - Link2 + Link2, + RefreshCw, + AlertTriangle, } from 'lucide-react'; -import { healthAPI } from './services/api'; +import { healthAPI, cellAPI } from './services/api'; import { ConfigProvider } from './contexts/ConfigContext'; import Sidebar from './components/Sidebar'; import Dashboard from './pages/Dashboard'; @@ -33,27 +35,120 @@ import Vault from './pages/Vault'; import ContainerDashboard from './components/ContainerDashboard'; import CellNetwork from './pages/CellNetwork'; +function PendingRestartBanner({ pending, onApply }) { + const [confirming, setConfirming] = useState(false); + const [applying, setApplying] = useState(false); + + const handleApply = async () => { + setApplying(true); + setConfirming(false); + try { + await onApply(); + } finally { + setApplying(false); + } + }; + + return ( + <> +
+
+
+ +
+

+ Configuration changes pending — containers need restart +

+ {pending.changes?.length > 0 && ( +
    + {pending.changes.map((c, i) =>
  • {c}
  • )} +
+ )} +
+
+ +
+
+ + {confirming && ( +
+
+
+ +

Restart containers?

+
+

+ All containers will be restarted to apply the new configuration. + The UI will be briefly unavailable during the restart. +

+
+ + +
+
+
+ )} + + ); +} + function App() { const [isOnline, setIsOnline] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [pending, setPending] = useState({ needs_restart: false, changes: [] }); + + const checkHealth = useCallback(async () => { + try { + await healthAPI.check(); + setIsOnline(true); + } catch { + setIsOnline(false); + } finally { + setIsLoading(false); + } + }, []); + + const checkPending = useCallback(async () => { + try { + const res = await cellAPI.getPending(); + setPending(res.data); + } catch { + // ignore — not critical + } + }, []); useEffect(() => { - const checkHealth = async () => { - try { - await healthAPI.check(); - setIsOnline(true); - } catch (error) { - console.error('Backend not available:', error); - setIsOnline(false); - } finally { - setIsLoading(false); - } - }; - checkHealth(); - const interval = setInterval(checkHealth, 5000); // Check every 30 seconds + checkPending(); + const healthInterval = setInterval(checkHealth, 5000); + const pendingInterval = setInterval(checkPending, 5000); + return () => { + clearInterval(healthInterval); + clearInterval(pendingInterval); + }; + }, [checkHealth, checkPending]); - return () => clearInterval(interval); + const handleApply = useCallback(async () => { + await cellAPI.applyPending(); + // Optimistically clear the banner; containers are restarting + setPending({ needs_restart: false, changes: [] }); }, []); const navigation = [ @@ -88,7 +183,7 @@ function App() {
- +
@@ -104,7 +199,7 @@ function App() {

- Unable to connect to the Personal Internet Cell backend. + Unable to connect to the Personal Internet Cell backend. Please ensure the API server is running on port 3000.

@@ -112,7 +207,11 @@ function App() {
)} - + + {isOnline && pending.needs_restart && ( + + )} + } /> } /> diff --git a/webui/src/services/api.js b/webui/src/services/api.js index 24c10a6..e4947fe 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -43,6 +43,8 @@ export const cellAPI = { deleteBackup: (id) => api.delete(`/api/config/backups/${id}`), exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }), importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }), + getPending: () => api.get('/api/config/pending'), + applyPending: () => api.post('/api/config/apply'), }; // Network Services API