From 673fe04164dafcc73d00bf7d963743cda0e404a9 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Wed, 22 Apr 2026 11:51:10 -0400 Subject: [PATCH] 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.