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 <service> 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 11:51:10 -04:00
parent c3b2c8d8e5
commit 673fe04164
7 changed files with 283 additions and 53 deletions
+110 -13
View File
@@ -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()
+5 -2
View File
@@ -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
}
+61 -5
View File
@@ -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.<offset>)
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)
+14 -14
View File
@@ -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:
+3 -2
View File
@@ -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():
+71
View File
@@ -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()
+19 -17
View File
@@ -195,16 +195,18 @@ function EmailForm({ data, onChange }) {
<Field label="Mail Domain">
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
</Field>
<Field label="SMTP Port" hint="Fixed by docker-compose.yml">
<TextInput value={data.smtp_port ?? 587} readOnly />
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)">
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Fixed by docker-compose.yml">
<TextInput value={data.imap_port ?? 993} readOnly />
<Field label="Submission Port" hint="Client mail send (default 587)">
<NumberInput value={data.submission_port ?? 587} onChange={(v) => onChange({ ...data, submission_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Client mail fetch (default 993)">
<NumberInput value={data.imap_port ?? 993} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
</Field>
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)">
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
</Field>
<p className="text-xs text-gray-400">
Ports 587 (SMTP) and 993 (IMAP) are set by docker-compose port bindings and cannot be changed at runtime.
Only <strong>domain</strong> is applied on Save.
</p>
</div>
);
}
@@ -225,8 +227,11 @@ function CalendarForm({ data, onChange }) {
function FilesForm({ data, onChange }) {
return (
<div className="space-y-3">
<Field label="Internal Port" hint="Fixed by docker-compose.yml">
<TextInput value={data.port ?? 80} readOnly />
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)">
<NumberInput value={data.port ?? 8080} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="File Manager Port" hint="Filegator host port (default 8082)">
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
@@ -234,9 +239,6 @@ function FilesForm({ data, onChange }) {
<Field label="Default Quota (MB)">
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
</Field>
<p className="text-xs text-gray-400">
Clients always connect on port 80 via Caddy reverse proxy, regardless of internal port.
</p>
</div>
);
}
@@ -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() {
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
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.
</p>
</Section>