Merge branch 'feature/service-ports' into 'main'
feat(service-ports): remove hardcoded ports from docker-compose, make all... See merge request root/pic!5
This commit is contained in:
+117
-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,9 +625,17 @@ 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', ['*']),
|
||||
})
|
||||
|
||||
|
||||
@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."""
|
||||
@@ -557,9 +654,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 +674,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)
|
||||
|
||||
+14
-14
@@ -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:
|
||||
|
||||
+15
-3
@@ -194,14 +194,25 @@ 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 _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():
|
||||
@@ -209,7 +220,8 @@ def main():
|
||||
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}')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
+28
-5
@@ -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 (
|
||||
<>
|
||||
<div className="mb-6 bg-warning-50 border border-warning-300 rounded-lg p-4">
|
||||
@@ -66,16 +76,25 @@ function PendingRestartBanner({ pending, onApply }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={applying || cancelling}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white hover:bg-gray-50 disabled:opacity-50 text-warning-700 text-sm font-medium rounded-md border border-warning-300 transition-colors"
|
||||
>
|
||||
{cancelling ? 'Discarding…' : 'Discard'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={applying}
|
||||
className="ml-4 flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 bg-warning-600 hover:bg-warning-700 disabled:opacity-50 text-white text-sm font-medium rounded-md transition-colors"
|
||||
disabled={applying || cancelling}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-warning-600 hover:bg-warning-700 disabled:opacity-50 text-white text-sm font-medium rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${applying ? 'animate-spin' : ''}`} />
|
||||
{applying ? 'Restarting…' : 'Apply Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirming && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
@@ -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 && (
|
||||
<PendingRestartBanner pending={pending} onApply={handleApply} />
|
||||
<PendingRestartBanner pending={pending} onApply={handleApply} onCancel={handleCancel} />
|
||||
)}
|
||||
|
||||
<Routes>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user