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:
+110
-13
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user