From 673fe04164dafcc73d00bf7d963743cda0e404a9 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Wed, 22 Apr 2026 11:51:10 -0400 Subject: [PATCH 1/4] feat(service-ports): remove hardcoded ports from docker-compose, make all service ports configurable All host port bindings in docker-compose.yml now use \${VAR:-default} substitution, driven by the .env file generated by ip_utils.write_env_file(). Changing a port in Settings triggers a per-container pending-restart banner so only the affected container is restarted on Apply. - ip_utils: add PORT_DEFAULTS, PORT_ENV_VAR_NAMES, PORT_TO_CONTAINERS; extend write_env_file() to accept optional ports dict and write all port env vars - docker-compose: convert all hardcoded port bindings to \${VAR:-default} form - app.py: add _collect_service_ports helper; detect port changes in update_config, write updated .env and call _set_pending_restart with specific container list; update _set_pending_restart to merge/accumulate pending state with containers list; update apply_pending_config to use --no-deps for targeted restarts - config_manager: add submission_port, webmail_port to email schema; add manager_port to files schema - Settings.jsx: make all email/files ports editable, add submission_port, webmail_port, manager_port fields; update stale identity note - tests: 8 new tests for PORT_DEFAULTS, PORT_ENV_VAR_NAMES, and port override in write_env_file Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 123 +++++++++++++++++++++++++++++++---- api/config_manager.py | 7 +- api/ip_utils.py | 66 +++++++++++++++++-- docker-compose.yml | 28 ++++---- scripts/setup_cell.py | 5 +- tests/test_ip_utils.py | 71 ++++++++++++++++++++ webui/src/pages/Settings.jsx | 36 +++++----- 7 files changed, 283 insertions(+), 53 deletions(-) diff --git a/api/app.py b/api/app.py index 25a0a11..e508170 100644 --- a/api/app.py +++ b/api/app.py @@ -417,8 +417,12 @@ def update_config(): # Handle identity fields (cell_name, domain, ip_range, wireguard_port) identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'} identity_updates = {k: v for k, v in data.items() if k in identity_keys} - # Capture old identity BEFORE saving, for apply_cell_name comparison + # Capture old identity and service configs BEFORE saving, for change detection old_identity = dict(config_manager.configs.get('_identity', {})) + old_svc_configs = { + svc: dict(config_manager.configs.get(svc, {})) + for svc in data if svc in config_manager.service_schemas + } if identity_updates: stored = config_manager.configs.get('_identity', {}) stored.update(identity_updates) @@ -491,11 +495,57 @@ def update_config(): # Update firewall virtual IPs (iptables) and Caddy virtual IPs immediately firewall_manager.update_service_ips(new_range) firewall_manager.ensure_caddy_virtual_ips() - # Write new .env so docker-compose picks up new container IPs on next start + # Write new .env with updated IPs (and current ports) for next container start env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') - 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']) + ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs)) + # Mark ALL containers as needing restart (IPs affect every container) + _set_pending_restart([f'ip_range changed to {new_range} — container IPs updated'], ['*']) + + # Detect port changes across service configs and identity + # Maps (service_key, field_name) → (port_env_key, [containers]) + _PORT_CHANGE_MAP = { + ('network', 'dns_port'): ('dns_port', ['dns']), + ('wireguard','port'): ('wg_port', ['wireguard']), + ('email', 'smtp_port'): ('mail_smtp_port', ['mail']), + ('email', 'submission_port'): ('mail_submission_port', ['mail']), + ('email', 'imap_port'): ('mail_imap_port', ['mail']), + ('email', 'webmail_port'): ('rainloop_port', ['rainloop']), + ('calendar', 'port'): ('radicale_port', ['radicale']), + ('files', 'port'): ('webdav_port', ['webdav']), + ('files', 'manager_port'): ('filegator_port', ['filegator']), + } + + port_changed_containers = set() + port_change_messages = [] + + for (svc_key, field), (_env_key, containers) in _PORT_CHANGE_MAP.items(): + if svc_key in data and field in data[svc_key]: + old_val = old_svc_configs.get(svc_key, {}).get(field) + new_val = data[svc_key][field] + if old_val is not None and old_val != new_val: + port_changed_containers.update(containers) + port_change_messages.append( + f'{svc_key} {field}: {old_val} → {new_val}' + ) + + # wireguard_port in identity also drives WG_PORT env var + if 'wireguard_port' in identity_updates: + old_wg = old_identity.get('wireguard_port') + new_wg = identity_updates['wireguard_port'] + if old_wg is not None and old_wg != new_wg: + port_changed_containers.add('wireguard') + port_change_messages.append(f'wireguard_port: {old_wg} → {new_wg}') + + if port_changed_containers: + import ip_utils as _ip_utils_ports + _env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') + _ip_range = config_manager.configs.get('_identity', {}).get( + 'ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16') + ) + _ip_utils_ports.write_env_file( + _ip_range, _env_file, _collect_service_ports(config_manager.configs) + ) + _set_pending_restart(port_change_messages, list(port_changed_containers)) logger.info(f"Updated config, restarted: {all_restarted}") return jsonify({ @@ -512,19 +562,58 @@ def update_config(): # Pending-restart helpers # --------------------------------------------------------------------------- -def _set_pending_restart(changes: list): - """Record that containers need to be restarted to apply configuration.""" +def _collect_service_ports(configs: dict) -> dict: + """Extract current port values from service configs for .env generation.""" + ports = {} + net = configs.get('network', {}) + wg = configs.get('wireguard', {}) + email = configs.get('email', {}) + cal = configs.get('calendar', {}) + files = configs.get('files', {}) + identity = configs.get('_identity', {}) + + if 'dns_port' in net: ports['dns_port'] = net['dns_port'] + if 'port' in wg: ports['wg_port'] = wg['port'] + elif 'wireguard_port' in identity: ports['wg_port'] = identity['wireguard_port'] + if 'smtp_port' in email: ports['mail_smtp_port'] = email['smtp_port'] + if 'submission_port' in email: ports['mail_submission_port'] = email['submission_port'] + if 'imap_port' in email: ports['mail_imap_port'] = email['imap_port'] + if 'webmail_port' in email: ports['rainloop_port'] = email['webmail_port'] + if 'port' in cal: ports['radicale_port'] = cal['port'] + if 'port' in files: ports['webdav_port'] = files['port'] + if 'manager_port' in files: ports['filegator_port'] = files['manager_port'] + return ports + + +def _set_pending_restart(changes: list, containers: list = None): + """Record that specific containers need to be restarted to apply configuration. + + containers: list of docker-compose service names, or None/'*' to restart all. + Merges with any existing pending state so multiple changes accumulate. + """ from datetime import datetime as _dt + existing = config_manager.configs.get('_pending_restart', {}) + existing_changes = existing.get('changes', []) if existing.get('needs_restart') else [] + existing_containers = existing.get('containers', []) if existing.get('needs_restart') else [] + + if containers is None or '*' in (containers or []) or existing_containers == ['*']: + new_containers = ['*'] + else: + new_containers = list(set(existing_containers) | set(containers)) + config_manager.configs['_pending_restart'] = { 'needs_restart': True, 'changed_at': _dt.utcnow().isoformat(), - 'changes': changes, + 'changes': existing_changes + changes, + 'containers': new_containers, } config_manager._save_all_configs() def _clear_pending_restart(): - config_manager.configs['_pending_restart'] = {'needs_restart': False, 'changes': []} + config_manager.configs['_pending_restart'] = { + 'needs_restart': False, 'changes': [], 'containers': [] + } config_manager._save_all_configs() @@ -536,6 +625,7 @@ def get_pending_config(): 'needs_restart': pending.get('needs_restart', False), 'changed_at': pending.get('changed_at'), 'changes': pending.get('changes', []), + 'containers': pending.get('containers', ['*']), }) @@ -557,9 +647,17 @@ def apply_pending_config(): except Exception: pass - # Clear pending flag before we restart so it shows cleared after the new container starts + containers = pending.get('containers', ['*']) + + # Clear pending flag before we restart so it shows cleared after new containers start _clear_pending_restart() + # Build compose args: restart all, or only the specific changed containers + if '*' in containers: + compose_up_args = ['up', '-d'] + else: + compose_up_args = ['up', '-d', '--no-deps'] + containers + # 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(): @@ -569,14 +667,13 @@ def apply_pending_config(): ['docker', 'compose', '--project-directory', project_dir, '-f', '/app/docker-compose.yml', - '--env-file', '/app/.env.compose', - 'up', '-d'], + '--env-file', '/app/.env.compose'] + compose_up_args, 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') + logger.info(f'docker compose {" ".join(compose_up_args)} completed successfully') threading.Thread(target=_do_apply, daemon=False).start() diff --git a/api/config_manager.py b/api/config_manager.py index e9059f4..eb8f0fd 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -60,10 +60,12 @@ class ConfigManager: }, 'email': { 'required': ['domain', 'smtp_port', 'imap_port'], - 'optional': ['users', 'ssl_cert', 'ssl_key'], + 'optional': ['users', 'ssl_cert', 'ssl_key', 'submission_port', 'webmail_port'], 'types': { 'smtp_port': int, + 'submission_port': int, 'imap_port': int, + 'webmail_port': int, 'domain': str } }, @@ -77,9 +79,10 @@ class ConfigManager: }, 'files': { 'required': ['port', 'data_dir'], - 'optional': ['users', 'quota'], + 'optional': ['users', 'quota', 'manager_port'], 'types': { 'port': int, + 'manager_port': int, 'data_dir': str, 'quota': int } diff --git a/api/ip_utils.py b/api/ip_utils.py index 5325348..60491e2 100644 --- a/api/ip_utils.py +++ b/api/ip_utils.py @@ -9,7 +9,7 @@ docker-compose.yml uses ${VAR:-default} substitution to read from it. import ipaddress import os -from typing import Dict +from typing import Dict, List, Optional # Fixed host-number offsets within the subnet (e.g. 172.20.0.0/16 → 172.20.0.) CONTAINER_OFFSETS: Dict[str, int] = { @@ -48,6 +48,57 @@ ENV_VAR_NAMES: Dict[str, str] = { 'filegator': 'FILEGATOR_IP', } +# Default host-port bindings for each service +PORT_DEFAULTS: Dict[str, int] = { + 'dns_port': 53, + 'dhcp_port': 67, + 'ntp_port': 123, + 'mail_smtp_port': 25, + 'mail_submission_port': 587, + 'mail_imap_port': 993, + 'radicale_port': 5232, + 'webdav_port': 8080, + 'wg_port': 51820, + 'api_port': 3000, + 'webui_port': 8081, + 'rainloop_port': 8888, + 'filegator_port': 8082, +} + +# Mapping from port key → docker-compose env var name +PORT_ENV_VAR_NAMES: Dict[str, str] = { + 'dns_port': 'DNS_PORT', + 'dhcp_port': 'DHCP_PORT', + 'ntp_port': 'NTP_PORT', + 'mail_smtp_port': 'MAIL_SMTP_PORT', + 'mail_submission_port': 'MAIL_SUBMISSION_PORT', + 'mail_imap_port': 'MAIL_IMAP_PORT', + 'radicale_port': 'RADICALE_PORT', + 'webdav_port': 'WEBDAV_PORT', + 'wg_port': 'WG_PORT', + 'api_port': 'API_PORT', + 'webui_port': 'WEBUI_PORT', + 'rainloop_port': 'RAINLOOP_PORT', + 'filegator_port': 'FILEGATOR_PORT', +} + +# Mapping from port key → docker-compose service name(s) that must restart on port change +PORT_TO_CONTAINERS: Dict[str, List[str]] = { + 'dns_port': ['dns'], + 'dhcp_port': ['dhcp'], + 'ntp_port': ['ntp'], + 'mail_smtp_port': ['mail'], + 'mail_submission_port': ['mail'], + 'mail_imap_port': ['mail'], + 'radicale_port': ['radicale'], + 'webdav_port': ['webdav'], + 'wg_port': ['wireguard'], + 'api_port': ['api'], + 'webui_port': ['webui'], + 'rainloop_port': ['rainloop'], + 'filegator_port': ['filegator'], +} + def get_service_ips(ip_range: str) -> Dict[str, str]: """ @@ -78,22 +129,27 @@ def get_virtual_ips(ip_range: str) -> Dict[str, str]: } -def write_env_file(ip_range: str, path: str) -> bool: +def write_env_file(ip_range: str, path: str, ports: Optional[Dict[str, int]] = None) -> bool: """ - Write (or overwrite) the docker-compose .env file with IPs derived from ip_range. + Write (or overwrite) the docker-compose .env file with IPs and ports. 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`. + time and whenever ip_range or port values change. + ports: override specific port defaults (keys from PORT_DEFAULTS). Returns True on success, False if the path is not writable. """ try: ips = get_service_ips(ip_range) + merged_ports = dict(PORT_DEFAULTS) + if ports: + merged_ports.update(ports) lines = [f'CELL_NETWORK={ip_range}\n'] for svc, var in ENV_VAR_NAMES.items(): lines.append(f'{var}={ips[svc]}\n') + for key, var in PORT_ENV_VAR_NAMES.items(): + lines.append(f'{var}={merged_ports[key]}\n') os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, 'w') as f: f.writelines(lines) diff --git a/docker-compose.yml b/docker-compose.yml index 162a778..60c2615 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,8 +30,8 @@ services: container_name: cell-dns command: ["-conf", "/etc/coredns/Corefile"] ports: - - "53:53/udp" - - "53:53/tcp" + - "${DNS_PORT:-53}:53/udp" + - "${DNS_PORT:-53}:53/tcp" volumes: - ./config/dns/Corefile:/etc/coredns/Corefile - ./data/dns:/data @@ -50,7 +50,7 @@ services: image: alpine:latest container_name: cell-dhcp ports: - - "67:67/udp" + - "${DHCP_PORT:-67}:67/udp" volumes: - ./config/dhcp/dnsmasq.conf:/etc/dnsmasq.conf - ./data/dhcp:/var/lib/misc @@ -72,7 +72,7 @@ services: image: alpine:latest container_name: cell-ntp ports: - - "123:123/udp" + - "${NTP_PORT:-123}:123/udp" volumes: - ./config/ntp/chrony.conf:/etc/chrony/chrony.conf restart: unless-stopped @@ -96,9 +96,9 @@ services: domainname: cell.local env_file: ./config/mail/mailserver.env ports: - - "25:25" - - "587:587" - - "993:993" + - "${MAIL_SMTP_PORT:-25}:25" + - "${MAIL_SUBMISSION_PORT:-587}:587" + - "${MAIL_IMAP_PORT:-993}:993" volumes: - ./data/maildata:/var/mail - ./data/mailstate:/var/mail-state @@ -122,7 +122,7 @@ services: image: tomsquest/docker-radicale:latest container_name: cell-radicale ports: - - "5232:5232" + - "${RADICALE_PORT:-5232}:5232" volumes: - ./config/radicale:/etc/radicale - ./data/radicale:/data @@ -141,7 +141,7 @@ services: image: bytemark/webdav:latest container_name: cell-webdav ports: - - "8080:80" + - "${WEBDAV_PORT:-8080}:80" environment: - AUTH_TYPE=Basic - USERNAME=admin @@ -167,7 +167,7 @@ services: - PUID=${PUID:-1000} - PGID=${PGID:-1000} ports: - - "51820:51820/udp" + - "${WG_PORT:-51820}:51820/udp" volumes: - ./config/wireguard:/config - /lib/modules:/lib/modules @@ -192,7 +192,7 @@ services: build: ./api container_name: cell-api ports: - - "3000:3000" + - "${API_PORT:-3000}:3000" volumes: - ./data/api:/app/data - ./data/dns:/app/data/dns @@ -222,7 +222,7 @@ services: build: ./webui container_name: cell-webui ports: - - "8081:80" + - "${WEBUI_PORT:-8081}:80" restart: unless-stopped networks: cell-network: @@ -242,7 +242,7 @@ services: cell-network: ipv4_address: ${RAINLOOP_IP:-172.20.0.12} ports: - - "8888:8888" + - "${RAINLOOP_PORT:-8888}:8888" volumes: - ./data/rainloop:/rainloop/data logging: @@ -260,7 +260,7 @@ services: cell-network: ipv4_address: ${FILEGATOR_IP:-172.20.0.13} ports: - - "8082:8080" + - "${FILEGATOR_PORT:-8082}:8080" volumes: - ./data/filegator:/var/www/filegator/private logging: diff --git a/scripts/setup_cell.py b/scripts/setup_cell.py index 8d449ca..f915e4d 100644 --- a/scripts/setup_cell.py +++ b/scripts/setup_cell.py @@ -194,14 +194,15 @@ def write_cell_config(cell_name: str, domain: str, port: int): def write_compose_env(ip_range: str): - """Generate .env at project root so docker-compose picks up correct container IPs.""" + """Generate .env at project root so docker-compose picks up correct IPs and ports.""" sys.path.insert(0, os.path.join(ROOT, 'api')) import ip_utils env_path = os.path.join(ROOT, '.env') + # Pass no custom ports — ip_utils will use PORT_DEFAULTS for all port vars 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') + print(f'[WARN] Could not write .env — containers will use built-in default IPs/ports') def main(): diff --git a/tests/test_ip_utils.py b/tests/test_ip_utils.py index 80b65c7..53e1ad2 100644 --- a/tests/test_ip_utils.py +++ b/tests/test_ip_utils.py @@ -143,5 +143,76 @@ class TestWriteEnvFile(unittest.TestCase): self.assertTrue(val.strip()) +class TestPortDefaults(unittest.TestCase): + def test_port_defaults_exist(self): + self.assertIsInstance(ip_utils.PORT_DEFAULTS, dict) + for key in ('dns_port', 'mail_smtp_port', 'wg_port', 'radicale_port', + 'webdav_port', 'api_port', 'webui_port', 'rainloop_port', 'filegator_port'): + self.assertIn(key, ip_utils.PORT_DEFAULTS) + + def test_port_env_var_names_cover_all_defaults(self): + self.assertEqual(set(ip_utils.PORT_DEFAULTS.keys()), set(ip_utils.PORT_ENV_VAR_NAMES.keys())) + + def test_port_to_containers_covers_all_defaults(self): + self.assertEqual(set(ip_utils.PORT_DEFAULTS.keys()), set(ip_utils.PORT_TO_CONTAINERS.keys())) + + def test_default_values(self): + self.assertEqual(ip_utils.PORT_DEFAULTS['dns_port'], 53) + self.assertEqual(ip_utils.PORT_DEFAULTS['mail_smtp_port'], 25) + self.assertEqual(ip_utils.PORT_DEFAULTS['mail_submission_port'], 587) + self.assertEqual(ip_utils.PORT_DEFAULTS['mail_imap_port'], 993) + self.assertEqual(ip_utils.PORT_DEFAULTS['radicale_port'], 5232) + self.assertEqual(ip_utils.PORT_DEFAULTS['webdav_port'], 8080) + self.assertEqual(ip_utils.PORT_DEFAULTS['wg_port'], 51820) + self.assertEqual(ip_utils.PORT_DEFAULTS['api_port'], 3000) + self.assertEqual(ip_utils.PORT_DEFAULTS['webui_port'], 8081) + self.assertEqual(ip_utils.PORT_DEFAULTS['rainloop_port'], 8888) + self.assertEqual(ip_utils.PORT_DEFAULTS['filegator_port'], 8082) + + +class TestWriteEnvFilePorts(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.env_path = os.path.join(self.tmp, '.env') + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp) + + def test_contains_default_ports(self): + ip_utils.write_env_file('172.20.0.0/16', self.env_path) + content = open(self.env_path).read() + self.assertIn('DNS_PORT=53', content) + self.assertIn('WG_PORT=51820', content) + self.assertIn('MAIL_SMTP_PORT=25', content) + self.assertIn('MAIL_SUBMISSION_PORT=587', content) + self.assertIn('MAIL_IMAP_PORT=993', content) + self.assertIn('RADICALE_PORT=5232', content) + self.assertIn('WEBDAV_PORT=8080', content) + self.assertIn('API_PORT=3000', content) + self.assertIn('WEBUI_PORT=8081', content) + self.assertIn('RAINLOOP_PORT=8888', content) + self.assertIn('FILEGATOR_PORT=8082', content) + + def test_custom_ports_override_defaults(self): + ip_utils.write_env_file('172.20.0.0/16', self.env_path, ports={'wg_port': 12345, 'api_port': 4000}) + content = open(self.env_path).read() + self.assertIn('WG_PORT=12345', content) + self.assertIn('API_PORT=4000', content) + self.assertIn('DNS_PORT=53', content) # unchanged default + + def test_custom_ports_do_not_leak_default(self): + ip_utils.write_env_file('172.20.0.0/16', self.env_path, ports={'wg_port': 12345}) + content = open(self.env_path).read() + self.assertNotIn('WG_PORT=51820', content) + self.assertIn('WG_PORT=12345', content) + + def test_all_port_env_vars_present(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.PORT_ENV_VAR_NAMES.values(): + self.assertIn(var + '=', content, f'{var} missing from .env') + + if __name__ == '__main__': unittest.main() diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index b157e64..eaeac9b 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -195,16 +195,18 @@ function EmailForm({ data, onChange }) { onChange({ ...data, domain: v })} placeholder="mail.example.com" /> - - + + onChange({ ...data, smtp_port: v })} min={1} max={65535} /> - - + + onChange({ ...data, submission_port: v })} min={1} max={65535} /> + + + onChange({ ...data, imap_port: v })} min={1} max={65535} /> + + + onChange({ ...data, webmail_port: v })} min={1} max={65535} /> -

- Ports 587 (SMTP) and 993 (IMAP) are set by docker-compose port bindings and cannot be changed at runtime. - Only domain is applied on Save. -

); } @@ -225,8 +227,11 @@ function CalendarForm({ data, onChange }) { function FilesForm({ data, onChange }) { return (
- - + + onChange({ ...data, port: v })} min={1} max={65535} /> + + + onChange({ ...data, manager_port: v })} min={1} max={65535} /> onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" /> @@ -234,9 +239,6 @@ function FilesForm({ data, onChange }) { onChange({ ...data, quota: v })} min={0} /> -

- Clients always connect on port 80 via Caddy reverse proxy, regardless of internal port. -

); } @@ -271,9 +273,9 @@ function VaultForm({ data, onChange }) { const SERVICE_DEFS = [ { key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } }, { key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } }, - { key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 587, imap_port: 993 } }, + { key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 } }, { key: 'calendar', label: 'Calendar (CalDAV)', icon: Calendar, Form: CalendarForm, defaults: { port: 5232, data_dir: '' } }, - { key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 80, data_dir: '', quota: 1024 } }, + { key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 } }, { key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } }, { key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } }, ]; @@ -499,8 +501,8 @@ function Settings() {

- Note: IP Range and WireGuard Port are also set via environment variables in docker-compose.yml. - Changes here are stored in config and take effect on next container start. + IP Range and port changes update the .env file and mark affected containers for restart. + Use the banner above to apply when ready.

From b5462f84e0372e5413c18dfd0d014d97039d4a02 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Wed, 22 Apr 2026 11:59:07 -0400 Subject: [PATCH 2/4] fix(setup): preserve existing ip_range when re-running make setup setup_cell.py now reads ip_range from cell_config.json before falling back to CELL_IP_RANGE env var, so re-running make setup on an existing install doesn't reset the .env subnet to the default 172.20.0.0/16. Co-Authored-By: Claude Sonnet 4.6 --- scripts/setup_cell.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/setup_cell.py b/scripts/setup_cell.py index f915e4d..78111fb 100644 --- a/scripts/setup_cell.py +++ b/scripts/setup_cell.py @@ -205,12 +205,23 @@ def write_compose_env(ip_range: str): print(f'[WARN] Could not write .env — containers will use built-in default IPs/ports') +def _read_existing_ip_range() -> str: + """Read ip_range from existing cell_config.json if present, else return None.""" + cfg_path = os.path.join(ROOT, 'config', 'api', 'cell_config.json') + try: + existing = json.loads(open(cfg_path).read()) + return existing.get('_identity', {}).get('ip_range') or None + except Exception: + return None + + 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') + # Prefer existing config ip_range over env var so `make setup` is safe to re-run + ip_range = os.environ.get('CELL_IP_RANGE') or _read_existing_ip_range() or '172.20.0.0/16' print('--- Personal Internet Cell: Setup ---') print(f' cell={cell_name} domain={domain} vpn={vpn_address} port={wg_port}') From 16609da52977120a351888dc70c6cc2525a5e881 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Wed, 22 Apr 2026 12:07:39 -0400 Subject: [PATCH 3/4] feat(pending-banner): add Discard button to cancel pending restart without applying - DELETE /api/config/pending endpoint calls _clear_pending_restart() - cellAPI.cancelPending() calls the new endpoint - PendingRestartBanner shows a "Discard" button alongside "Apply Now"; clicking it drops the pending state without restarting any containers Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 7 ++++++ webui/src/App.jsx | 45 +++++++++++++++++++++++++++++---------- webui/src/services/api.js | 1 + 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/api/app.py b/api/app.py index e508170..b4d502c 100644 --- a/api/app.py +++ b/api/app.py @@ -629,6 +629,13 @@ def get_pending_config(): }) +@app.route('/api/config/pending', methods=['DELETE']) +def cancel_pending_config(): + """Discard pending configuration changes without restarting any containers.""" + _clear_pending_restart() + return jsonify({'message': 'Pending changes discarded'}) + + @app.route('/api/config/apply', methods=['POST']) def apply_pending_config(): """Apply pending configuration by restarting containers via docker compose up -d.""" diff --git a/webui/src/App.jsx b/webui/src/App.jsx index b57c40d..1d3299c 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -35,9 +35,10 @@ import Vault from './pages/Vault'; import ContainerDashboard from './components/ContainerDashboard'; import CellNetwork from './pages/CellNetwork'; -function PendingRestartBanner({ pending, onApply }) { +function PendingRestartBanner({ pending, onApply, onCancel }) { const [confirming, setConfirming] = useState(false); const [applying, setApplying] = useState(false); + const [cancelling, setCancelling] = useState(false); const handleApply = async () => { setApplying(true); @@ -49,6 +50,15 @@ function PendingRestartBanner({ pending, onApply }) { } }; + const handleCancel = async () => { + setCancelling(true); + try { + await onCancel(); + } finally { + setCancelling(false); + } + }; + return ( <>
@@ -66,14 +76,23 @@ function PendingRestartBanner({ pending, onApply }) { )}
- +
+ + +
@@ -147,7 +166,11 @@ function App() { const handleApply = useCallback(async () => { await cellAPI.applyPending(); - // Optimistically clear the banner; containers are restarting + setPending({ needs_restart: false, changes: [] }); + }, []); + + const handleCancel = useCallback(async () => { + await cellAPI.cancelPending(); setPending({ needs_restart: false, changes: [] }); }, []); @@ -209,7 +232,7 @@ function App() { )} {isOnline && pending.needs_restart && ( - + )} diff --git a/webui/src/services/api.js b/webui/src/services/api.js index e4947fe..79e12b7 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -44,6 +44,7 @@ export const cellAPI = { 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'), + cancelPending: () => api.delete('/api/config/pending'), applyPending: () => api.post('/api/config/apply'), }; From b46d8d9b8f66adb6c54c0513591952f1db7f25c3 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Wed, 22 Apr 2026 12:27:01 -0400 Subject: [PATCH 4/4] test(pending-restart): add 28 tests for pending restart system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers _set_pending_restart (accumulation, wildcard merge, no duplicates), _clear_pending_restart, _collect_service_ports (all service port mappings), GET /api/config/pending (containers field), and DELETE /api/config/pending (cancel — clears state, idempotent, verified via follow-up GET). Co-Authored-By: Claude Sonnet 4.6 --- tests/test_pending_restart.py | 213 ++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/test_pending_restart.py diff --git a/tests/test_pending_restart.py b/tests/test_pending_restart.py new file mode 100644 index 0000000..cab4cf1 --- /dev/null +++ b/tests/test_pending_restart.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Tests for the pending-restart system: helpers, endpoints, and cancel.""" + +import sys +import os +import json +import unittest +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch, MagicMock + +api_dir = Path(__file__).parent.parent / 'api' +sys.path.insert(0, str(api_dir)) + +from app import app, _set_pending_restart, _clear_pending_restart, _collect_service_ports, config_manager + + +class TestSetPendingRestart(unittest.TestCase): + def setUp(self): + _clear_pending_restart() + + def tearDown(self): + _clear_pending_restart() + + def test_sets_needs_restart(self): + _set_pending_restart(['something changed']) + p = config_manager.configs.get('_pending_restart', {}) + self.assertTrue(p['needs_restart']) + + def test_stores_changes(self): + _set_pending_restart(['port x changed']) + p = config_manager.configs['_pending_restart'] + self.assertIn('port x changed', p['changes']) + + def test_default_containers_is_wildcard(self): + _set_pending_restart(['some change']) + p = config_manager.configs['_pending_restart'] + self.assertEqual(p['containers'], ['*']) + + def test_specific_containers_stored(self): + _set_pending_restart(['dns port changed'], ['dns']) + p = config_manager.configs['_pending_restart'] + self.assertEqual(p['containers'], ['dns']) + + def test_accumulates_changes_on_second_call(self): + _set_pending_restart(['change A'], ['dns']) + _set_pending_restart(['change B'], ['mail']) + p = config_manager.configs['_pending_restart'] + self.assertIn('change A', p['changes']) + self.assertIn('change B', p['changes']) + + def test_accumulates_containers_on_second_call(self): + _set_pending_restart(['A'], ['dns']) + _set_pending_restart(['B'], ['mail']) + p = config_manager.configs['_pending_restart'] + self.assertIn('dns', p['containers']) + self.assertIn('mail', p['containers']) + + def test_wildcard_absorbs_specific(self): + _set_pending_restart(['ip range changed'], ['*']) + _set_pending_restart(['port changed'], ['dns']) + p = config_manager.configs['_pending_restart'] + self.assertEqual(p['containers'], ['*']) + + def test_specific_escalates_to_wildcard(self): + _set_pending_restart(['A'], ['dns']) + _set_pending_restart(['B'], ['*']) + p = config_manager.configs['_pending_restart'] + self.assertEqual(p['containers'], ['*']) + + def test_no_duplicate_containers(self): + _set_pending_restart(['A'], ['mail']) + _set_pending_restart(['B'], ['mail']) + p = config_manager.configs['_pending_restart'] + self.assertEqual(p['containers'].count('mail'), 1) + + +class TestClearPendingRestart(unittest.TestCase): + def test_clears_flag(self): + _set_pending_restart(['something']) + _clear_pending_restart() + p = config_manager.configs.get('_pending_restart', {}) + self.assertFalse(p.get('needs_restart', False)) + + def test_clears_changes(self): + _set_pending_restart(['something']) + _clear_pending_restart() + p = config_manager.configs.get('_pending_restart', {}) + self.assertEqual(p.get('changes', []), []) + + def test_clears_containers(self): + _set_pending_restart(['something'], ['dns']) + _clear_pending_restart() + p = config_manager.configs.get('_pending_restart', {}) + self.assertEqual(p.get('containers', []), []) + + +class TestCollectServicePorts(unittest.TestCase): + def test_extracts_dns_port(self): + cfg = {'network': {'dns_port': 5353}} + ports = _collect_service_ports(cfg) + self.assertEqual(ports['dns_port'], 5353) + + def test_extracts_wg_port_from_wireguard(self): + cfg = {'wireguard': {'port': 12345}} + ports = _collect_service_ports(cfg) + self.assertEqual(ports['wg_port'], 12345) + + def test_extracts_wg_port_from_identity_fallback(self): + cfg = {'_identity': {'wireguard_port': 9999}} + ports = _collect_service_ports(cfg) + self.assertEqual(ports['wg_port'], 9999) + + def test_wireguard_port_takes_priority_over_identity(self): + cfg = {'wireguard': {'port': 12345}, '_identity': {'wireguard_port': 9999}} + ports = _collect_service_ports(cfg) + self.assertEqual(ports['wg_port'], 12345) + + def test_extracts_email_ports(self): + cfg = {'email': {'smtp_port': 2525, 'submission_port': 465, 'imap_port': 1993, 'webmail_port': 9000}} + ports = _collect_service_ports(cfg) + self.assertEqual(ports['mail_smtp_port'], 2525) + self.assertEqual(ports['mail_submission_port'], 465) + self.assertEqual(ports['mail_imap_port'], 1993) + self.assertEqual(ports['rainloop_port'], 9000) + + def test_extracts_calendar_port(self): + cfg = {'calendar': {'port': 5233}} + ports = _collect_service_ports(cfg) + self.assertEqual(ports['radicale_port'], 5233) + + def test_extracts_files_ports(self): + cfg = {'files': {'port': 8181, 'manager_port': 9090}} + ports = _collect_service_ports(cfg) + self.assertEqual(ports['webdav_port'], 8181) + self.assertEqual(ports['filegator_port'], 9090) + + def test_missing_keys_not_in_result(self): + ports = _collect_service_ports({}) + self.assertNotIn('dns_port', ports) + self.assertNotIn('wg_port', ports) + + def test_empty_sections_not_in_result(self): + ports = _collect_service_ports({'email': {}}) + self.assertNotIn('mail_smtp_port', ports) + + +class TestGetPendingEndpoint(unittest.TestCase): + def setUp(self): + app.config['TESTING'] = True + self.client = app.test_client() + _clear_pending_restart() + + def tearDown(self): + _clear_pending_restart() + + def test_returns_not_pending_by_default(self): + r = self.client.get('/api/config/pending') + self.assertEqual(r.status_code, 200) + data = json.loads(r.data) + self.assertFalse(data['needs_restart']) + + def test_returns_pending_state(self): + _set_pending_restart(['dns port: 53 → 5353'], ['dns']) + r = self.client.get('/api/config/pending') + data = json.loads(r.data) + self.assertTrue(data['needs_restart']) + self.assertIn('dns port: 53 → 5353', data['changes']) + self.assertIn('dns', data['containers']) + + def test_returns_containers_field(self): + _set_pending_restart(['x'], ['wireguard']) + data = json.loads(self.client.get('/api/config/pending').data) + self.assertIn('containers', data) + self.assertEqual(data['containers'], ['wireguard']) + + +class TestCancelPendingEndpoint(unittest.TestCase): + def setUp(self): + app.config['TESTING'] = True + self.client = app.test_client() + _clear_pending_restart() + + def tearDown(self): + _clear_pending_restart() + + def test_cancel_clears_pending(self): + _set_pending_restart(['something'], ['dns']) + r = self.client.delete('/api/config/pending') + self.assertEqual(r.status_code, 200) + p = config_manager.configs.get('_pending_restart', {}) + self.assertFalse(p.get('needs_restart', False)) + + def test_cancel_returns_message(self): + _set_pending_restart(['something']) + data = json.loads(self.client.delete('/api/config/pending').data) + self.assertIn('message', data) + + def test_cancel_idempotent_when_nothing_pending(self): + r = self.client.delete('/api/config/pending') + self.assertEqual(r.status_code, 200) + + def test_get_after_cancel_shows_not_pending(self): + _set_pending_restart(['x']) + self.client.delete('/api/config/pending') + data = json.loads(self.client.get('/api/config/pending').data) + self.assertFalse(data['needs_restart']) + self.assertEqual(data['changes'], []) + + +if __name__ == '__main__': + unittest.main()