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:
Administrator
2026-04-22 16:27:23 +00:00
10 changed files with 550 additions and 65 deletions
+117 -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,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()
+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:
+15 -3
View File
@@ -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}')
+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()
+213
View File
@@ -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
View File
@@ -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>
+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>
+1
View File
@@ -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'),
};