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)
|
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
|
||||||
identity_keys = {'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}
|
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_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:
|
if identity_updates:
|
||||||
stored = config_manager.configs.get('_identity', {})
|
stored = config_manager.configs.get('_identity', {})
|
||||||
stored.update(identity_updates)
|
stored.update(identity_updates)
|
||||||
@@ -491,11 +495,57 @@ def update_config():
|
|||||||
# Update firewall virtual IPs (iptables) and Caddy virtual IPs immediately
|
# Update firewall virtual IPs (iptables) and Caddy virtual IPs immediately
|
||||||
firewall_manager.update_service_ips(new_range)
|
firewall_manager.update_service_ips(new_range)
|
||||||
firewall_manager.ensure_caddy_virtual_ips()
|
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')
|
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
|
||||||
ip_utils.write_env_file(new_range, env_file)
|
ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs))
|
||||||
# Mark containers as needing restart
|
# Mark ALL containers as needing restart (IPs affect every container)
|
||||||
_set_pending_restart([f'ip_range changed to {new_range} — container IPs updated'])
|
_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}")
|
logger.info(f"Updated config, restarted: {all_restarted}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -512,19 +562,58 @@ def update_config():
|
|||||||
# Pending-restart helpers
|
# Pending-restart helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _set_pending_restart(changes: list):
|
def _collect_service_ports(configs: dict) -> dict:
|
||||||
"""Record that containers need to be restarted to apply configuration."""
|
"""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
|
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'] = {
|
config_manager.configs['_pending_restart'] = {
|
||||||
'needs_restart': True,
|
'needs_restart': True,
|
||||||
'changed_at': _dt.utcnow().isoformat(),
|
'changed_at': _dt.utcnow().isoformat(),
|
||||||
'changes': changes,
|
'changes': existing_changes + changes,
|
||||||
|
'containers': new_containers,
|
||||||
}
|
}
|
||||||
config_manager._save_all_configs()
|
config_manager._save_all_configs()
|
||||||
|
|
||||||
|
|
||||||
def _clear_pending_restart():
|
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()
|
config_manager._save_all_configs()
|
||||||
|
|
||||||
|
|
||||||
@@ -536,9 +625,17 @@ def get_pending_config():
|
|||||||
'needs_restart': pending.get('needs_restart', False),
|
'needs_restart': pending.get('needs_restart', False),
|
||||||
'changed_at': pending.get('changed_at'),
|
'changed_at': pending.get('changed_at'),
|
||||||
'changes': pending.get('changes', []),
|
'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'])
|
@app.route('/api/config/apply', methods=['POST'])
|
||||||
def apply_pending_config():
|
def apply_pending_config():
|
||||||
"""Apply pending configuration by restarting containers via docker compose up -d."""
|
"""Apply pending configuration by restarting containers via docker compose up -d."""
|
||||||
@@ -557,9 +654,17 @@ def apply_pending_config():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
_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
|
# 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.
|
# finish sending this response before cell-api itself gets recreated.
|
||||||
def _do_apply():
|
def _do_apply():
|
||||||
@@ -569,14 +674,13 @@ def apply_pending_config():
|
|||||||
['docker', 'compose',
|
['docker', 'compose',
|
||||||
'--project-directory', project_dir,
|
'--project-directory', project_dir,
|
||||||
'-f', '/app/docker-compose.yml',
|
'-f', '/app/docker-compose.yml',
|
||||||
'--env-file', '/app/.env.compose',
|
'--env-file', '/app/.env.compose'] + compose_up_args,
|
||||||
'up', '-d'],
|
|
||||||
capture_output=True, text=True, timeout=120
|
capture_output=True, text=True, timeout=120
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error(f"docker compose up failed: {result.stderr.strip()}")
|
logger.error(f"docker compose up failed: {result.stderr.strip()}")
|
||||||
else:
|
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()
|
threading.Thread(target=_do_apply, daemon=False).start()
|
||||||
|
|
||||||
|
|||||||
@@ -60,10 +60,12 @@ class ConfigManager:
|
|||||||
},
|
},
|
||||||
'email': {
|
'email': {
|
||||||
'required': ['domain', 'smtp_port', 'imap_port'],
|
'required': ['domain', 'smtp_port', 'imap_port'],
|
||||||
'optional': ['users', 'ssl_cert', 'ssl_key'],
|
'optional': ['users', 'ssl_cert', 'ssl_key', 'submission_port', 'webmail_port'],
|
||||||
'types': {
|
'types': {
|
||||||
'smtp_port': int,
|
'smtp_port': int,
|
||||||
|
'submission_port': int,
|
||||||
'imap_port': int,
|
'imap_port': int,
|
||||||
|
'webmail_port': int,
|
||||||
'domain': str
|
'domain': str
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -77,9 +79,10 @@ class ConfigManager:
|
|||||||
},
|
},
|
||||||
'files': {
|
'files': {
|
||||||
'required': ['port', 'data_dir'],
|
'required': ['port', 'data_dir'],
|
||||||
'optional': ['users', 'quota'],
|
'optional': ['users', 'quota', 'manager_port'],
|
||||||
'types': {
|
'types': {
|
||||||
'port': int,
|
'port': int,
|
||||||
|
'manager_port': int,
|
||||||
'data_dir': str,
|
'data_dir': str,
|
||||||
'quota': int
|
'quota': int
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-5
@@ -9,7 +9,7 @@ docker-compose.yml uses ${VAR:-default} substitution to read from it.
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
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>)
|
# Fixed host-number offsets within the subnet (e.g. 172.20.0.0/16 → 172.20.0.<offset>)
|
||||||
CONTAINER_OFFSETS: Dict[str, int] = {
|
CONTAINER_OFFSETS: Dict[str, int] = {
|
||||||
@@ -48,6 +48,57 @@ ENV_VAR_NAMES: Dict[str, str] = {
|
|||||||
'filegator': 'FILEGATOR_IP',
|
'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]:
|
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
|
docker-compose reads this file automatically at startup to substitute
|
||||||
${VAR:-default} placeholders in docker-compose.yml. Call this at setup
|
${VAR:-default} placeholders in docker-compose.yml. Call this at setup
|
||||||
time and whenever ip_range changes so containers get the right IPs on
|
time and whenever ip_range or port values change.
|
||||||
the next `docker-compose up -d`.
|
|
||||||
|
|
||||||
|
ports: override specific port defaults (keys from PORT_DEFAULTS).
|
||||||
Returns True on success, False if the path is not writable.
|
Returns True on success, False if the path is not writable.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ips = get_service_ips(ip_range)
|
ips = get_service_ips(ip_range)
|
||||||
|
merged_ports = dict(PORT_DEFAULTS)
|
||||||
|
if ports:
|
||||||
|
merged_ports.update(ports)
|
||||||
lines = [f'CELL_NETWORK={ip_range}\n']
|
lines = [f'CELL_NETWORK={ip_range}\n']
|
||||||
for svc, var in ENV_VAR_NAMES.items():
|
for svc, var in ENV_VAR_NAMES.items():
|
||||||
lines.append(f'{var}={ips[svc]}\n')
|
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)
|
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||||
with open(path, 'w') as f:
|
with open(path, 'w') as f:
|
||||||
f.writelines(lines)
|
f.writelines(lines)
|
||||||
|
|||||||
+14
-14
@@ -30,8 +30,8 @@ services:
|
|||||||
container_name: cell-dns
|
container_name: cell-dns
|
||||||
command: ["-conf", "/etc/coredns/Corefile"]
|
command: ["-conf", "/etc/coredns/Corefile"]
|
||||||
ports:
|
ports:
|
||||||
- "53:53/udp"
|
- "${DNS_PORT:-53}:53/udp"
|
||||||
- "53:53/tcp"
|
- "${DNS_PORT:-53}:53/tcp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/dns/Corefile:/etc/coredns/Corefile
|
- ./config/dns/Corefile:/etc/coredns/Corefile
|
||||||
- ./data/dns:/data
|
- ./data/dns:/data
|
||||||
@@ -50,7 +50,7 @@ services:
|
|||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
container_name: cell-dhcp
|
container_name: cell-dhcp
|
||||||
ports:
|
ports:
|
||||||
- "67:67/udp"
|
- "${DHCP_PORT:-67}:67/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/dhcp/dnsmasq.conf:/etc/dnsmasq.conf
|
- ./config/dhcp/dnsmasq.conf:/etc/dnsmasq.conf
|
||||||
- ./data/dhcp:/var/lib/misc
|
- ./data/dhcp:/var/lib/misc
|
||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
container_name: cell-ntp
|
container_name: cell-ntp
|
||||||
ports:
|
ports:
|
||||||
- "123:123/udp"
|
- "${NTP_PORT:-123}:123/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/ntp/chrony.conf:/etc/chrony/chrony.conf
|
- ./config/ntp/chrony.conf:/etc/chrony/chrony.conf
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -96,9 +96,9 @@ services:
|
|||||||
domainname: cell.local
|
domainname: cell.local
|
||||||
env_file: ./config/mail/mailserver.env
|
env_file: ./config/mail/mailserver.env
|
||||||
ports:
|
ports:
|
||||||
- "25:25"
|
- "${MAIL_SMTP_PORT:-25}:25"
|
||||||
- "587:587"
|
- "${MAIL_SUBMISSION_PORT:-587}:587"
|
||||||
- "993:993"
|
- "${MAIL_IMAP_PORT:-993}:993"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/maildata:/var/mail
|
- ./data/maildata:/var/mail
|
||||||
- ./data/mailstate:/var/mail-state
|
- ./data/mailstate:/var/mail-state
|
||||||
@@ -122,7 +122,7 @@ services:
|
|||||||
image: tomsquest/docker-radicale:latest
|
image: tomsquest/docker-radicale:latest
|
||||||
container_name: cell-radicale
|
container_name: cell-radicale
|
||||||
ports:
|
ports:
|
||||||
- "5232:5232"
|
- "${RADICALE_PORT:-5232}:5232"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/radicale:/etc/radicale
|
- ./config/radicale:/etc/radicale
|
||||||
- ./data/radicale:/data
|
- ./data/radicale:/data
|
||||||
@@ -141,7 +141,7 @@ services:
|
|||||||
image: bytemark/webdav:latest
|
image: bytemark/webdav:latest
|
||||||
container_name: cell-webdav
|
container_name: cell-webdav
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "${WEBDAV_PORT:-8080}:80"
|
||||||
environment:
|
environment:
|
||||||
- AUTH_TYPE=Basic
|
- AUTH_TYPE=Basic
|
||||||
- USERNAME=admin
|
- USERNAME=admin
|
||||||
@@ -167,7 +167,7 @@ services:
|
|||||||
- PUID=${PUID:-1000}
|
- PUID=${PUID:-1000}
|
||||||
- PGID=${PGID:-1000}
|
- PGID=${PGID:-1000}
|
||||||
ports:
|
ports:
|
||||||
- "51820:51820/udp"
|
- "${WG_PORT:-51820}:51820/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/wireguard:/config
|
- ./config/wireguard:/config
|
||||||
- /lib/modules:/lib/modules
|
- /lib/modules:/lib/modules
|
||||||
@@ -192,7 +192,7 @@ services:
|
|||||||
build: ./api
|
build: ./api
|
||||||
container_name: cell-api
|
container_name: cell-api
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "${API_PORT:-3000}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/api:/app/data
|
- ./data/api:/app/data
|
||||||
- ./data/dns:/app/data/dns
|
- ./data/dns:/app/data/dns
|
||||||
@@ -222,7 +222,7 @@ services:
|
|||||||
build: ./webui
|
build: ./webui
|
||||||
container_name: cell-webui
|
container_name: cell-webui
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "${WEBUI_PORT:-8081}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
@@ -242,7 +242,7 @@ services:
|
|||||||
cell-network:
|
cell-network:
|
||||||
ipv4_address: ${RAINLOOP_IP:-172.20.0.12}
|
ipv4_address: ${RAINLOOP_IP:-172.20.0.12}
|
||||||
ports:
|
ports:
|
||||||
- "8888:8888"
|
- "${RAINLOOP_PORT:-8888}:8888"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/rainloop:/rainloop/data
|
- ./data/rainloop:/rainloop/data
|
||||||
logging:
|
logging:
|
||||||
@@ -260,7 +260,7 @@ services:
|
|||||||
cell-network:
|
cell-network:
|
||||||
ipv4_address: ${FILEGATOR_IP:-172.20.0.13}
|
ipv4_address: ${FILEGATOR_IP:-172.20.0.13}
|
||||||
ports:
|
ports:
|
||||||
- "8082:8080"
|
- "${FILEGATOR_PORT:-8082}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/filegator:/var/www/filegator/private
|
- ./data/filegator:/var/www/filegator/private
|
||||||
logging:
|
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):
|
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'))
|
sys.path.insert(0, os.path.join(ROOT, 'api'))
|
||||||
import ip_utils
|
import ip_utils
|
||||||
env_path = os.path.join(ROOT, '.env')
|
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):
|
if ip_utils.write_env_file(ip_range, env_path):
|
||||||
print(f'[CREATED] .env (ip_range={ip_range})')
|
print(f'[CREATED] .env (ip_range={ip_range})')
|
||||||
else:
|
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():
|
def main():
|
||||||
@@ -209,7 +220,8 @@ def main():
|
|||||||
domain = os.environ.get('CELL_DOMAIN', 'cell')
|
domain = os.environ.get('CELL_DOMAIN', 'cell')
|
||||||
vpn_address = os.environ.get('VPN_ADDRESS', '10.0.0.1/24')
|
vpn_address = os.environ.get('VPN_ADDRESS', '10.0.0.1/24')
|
||||||
wg_port = int(os.environ.get('WG_PORT', '51820'))
|
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('--- Personal Internet Cell: Setup ---')
|
||||||
print(f' cell={cell_name} domain={domain} vpn={vpn_address} port={wg_port}')
|
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())
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.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()
|
||||||
+34
-11
@@ -35,9 +35,10 @@ import Vault from './pages/Vault';
|
|||||||
import ContainerDashboard from './components/ContainerDashboard';
|
import ContainerDashboard from './components/ContainerDashboard';
|
||||||
import CellNetwork from './pages/CellNetwork';
|
import CellNetwork from './pages/CellNetwork';
|
||||||
|
|
||||||
function PendingRestartBanner({ pending, onApply }) {
|
function PendingRestartBanner({ pending, onApply, onCancel }) {
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
|
||||||
const handleApply = async () => {
|
const handleApply = async () => {
|
||||||
setApplying(true);
|
setApplying(true);
|
||||||
@@ -49,6 +50,15 @@ function PendingRestartBanner({ pending, onApply }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
await onCancel();
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6 bg-warning-50 border border-warning-300 rounded-lg p-4">
|
<div className="mb-6 bg-warning-50 border border-warning-300 rounded-lg p-4">
|
||||||
@@ -66,14 +76,23 @@ function PendingRestartBanner({ pending, onApply }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="ml-4 flex-shrink-0 flex items-center gap-2">
|
||||||
onClick={() => setConfirming(true)}
|
<button
|
||||||
disabled={applying}
|
onClick={handleCancel}
|
||||||
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-white hover:bg-gray-50 disabled:opacity-50 text-warning-700 text-sm font-medium rounded-md border border-warning-300 transition-colors"
|
||||||
<RefreshCw className={`h-4 w-4 ${applying ? 'animate-spin' : ''}`} />
|
>
|
||||||
{applying ? 'Restarting…' : 'Apply Now'}
|
{cancelling ? 'Discarding…' : 'Discard'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,7 +166,11 @@ function App() {
|
|||||||
|
|
||||||
const handleApply = useCallback(async () => {
|
const handleApply = useCallback(async () => {
|
||||||
await cellAPI.applyPending();
|
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: [] });
|
setPending({ needs_restart: false, changes: [] });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -209,7 +232,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isOnline && pending.needs_restart && (
|
{isOnline && pending.needs_restart && (
|
||||||
<PendingRestartBanner pending={pending} onApply={handleApply} />
|
<PendingRestartBanner pending={pending} onApply={handleApply} onCancel={handleCancel} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -195,16 +195,18 @@ function EmailForm({ data, onChange }) {
|
|||||||
<Field label="Mail Domain">
|
<Field label="Mail Domain">
|
||||||
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
|
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="SMTP Port" hint="Fixed by docker-compose.yml">
|
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)">
|
||||||
<TextInput value={data.smtp_port ?? 587} readOnly />
|
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="IMAP Port" hint="Fixed by docker-compose.yml">
|
<Field label="Submission Port" hint="Client mail send (default 587)">
|
||||||
<TextInput value={data.imap_port ?? 993} readOnly />
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,8 +227,11 @@ function CalendarForm({ data, onChange }) {
|
|||||||
function FilesForm({ data, onChange }) {
|
function FilesForm({ data, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Field label="Internal Port" hint="Fixed by docker-compose.yml">
|
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)">
|
||||||
<TextInput value={data.port ?? 80} readOnly />
|
<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>
|
||||||
<Field label="Data Directory">
|
<Field label="Data Directory">
|
||||||
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
|
<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)">
|
<Field label="Default Quota (MB)">
|
||||||
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
|
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
|
||||||
</Field>
|
</Field>
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Clients always connect on port 80 via Caddy reverse proxy, regardless of internal port.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -271,9 +273,9 @@ function VaultForm({ data, onChange }) {
|
|||||||
const SERVICE_DEFS = [
|
const SERVICE_DEFS = [
|
||||||
{ key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } },
|
{ 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: '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: '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: '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 } },
|
{ key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } },
|
||||||
];
|
];
|
||||||
@@ -499,8 +501,8 @@ function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-2">
|
<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.
|
IP Range and port changes update the .env file and mark affected containers for restart.
|
||||||
Changes here are stored in config and take effect on next container start.
|
Use the banner above to apply when ready.
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const cellAPI = {
|
|||||||
exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }),
|
exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }),
|
||||||
importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }),
|
importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }),
|
||||||
getPending: () => api.get('/api/config/pending'),
|
getPending: () => api.get('/api/config/pending'),
|
||||||
|
cancelPending: () => api.delete('/api/config/pending'),
|
||||||
applyPending: () => api.post('/api/config/apply'),
|
applyPending: () => api.post('/api/config/apply'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user