fix: silent autosave, pending dedup, domain/cell_name pending, containers access

- Settings: remove Save buttons; autosave is silent (no toast on success, error only)
- Settings: loadAll() resets dirty flags to prevent stale autosave after discard
- app.py: fix domain/ip_range "actually changed" check — full identity is always
  sent on save so these were triggering pending on every keystroke regardless
- app.py: _dedup_changes handles port-change format "service field: old → new"
  (split on ':' not ' changed') so dns_port changed twice shows one entry
- app.py: domain + cell_name changes now go through pending restart banner;
  apply_domain/apply_cell_name write files immediately (reload=False) and set
  pending; Discard restores zone files + Caddyfile to pre-change state
- app.py: _set_pending_restart captures pre-change snapshot BEFORE config writes
  (was snapshotting after, making Discard a no-op)
- app.py: is_local_request reads /proc/net/route to allow the actual Docker
  bridge subnet (172.0.0.0/24) which is not RFC-1918; fixes Containers page 403
- container_manager: get_container_logs raises instead of swallowing exceptions
  so nonexistent container returns 500+error not 200+empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 07:16:13 -04:00
parent 4215e03ac6
commit 2bd6545f0e
5 changed files with 184 additions and 103 deletions
+119 -21
View File
@@ -319,6 +319,26 @@ def health_monitor_loop():
health_monitor_thread = threading.Thread(target=health_monitor_loop, daemon=True)
health_monitor_thread.start()
def _local_subnets():
"""Return all subnets the container is directly connected to (from routing table)."""
import ipaddress as _ipa, socket as _sock, struct as _struct
nets = []
try:
with open('/proc/net/route') as _f:
for _line in _f.readlines()[1:]:
_parts = _line.strip().split()
if len(_parts) < 8 or _parts[0] == 'lo':
continue
_dest = _sock.inet_ntoa(_struct.pack('<I', int(_parts[1], 16)))
_mask = _sock.inet_ntoa(_struct.pack('<I', int(_parts[7], 16)))
if _dest == '0.0.0.0':
continue
nets.append(_ipa.ip_network(f'{_dest}/{_mask}', strict=False))
except Exception:
pass
return nets
def is_local_request():
remote_addr = request.remote_addr
forwarded_for = request.headers.get('X-Forwarded-For', '')
@@ -331,13 +351,21 @@ def is_local_request():
try:
import ipaddress as _ipa
ip = _ipa.ip_address(addr)
if ip.is_private or ip.is_loopback:
if ip.is_loopback:
return True
# Also allow IPs in the configured cell-network, which may fall outside
# RFC-1918 (e.g. 172.0.0.0/24 is not in 172.16.0.0/12).
cell_net = config_manager.configs.get('_identity', {}).get(
# RFC-1918 private ranges
for _rfc in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'):
if ip in _ipa.ip_network(_rfc):
return True
# Any subnet the container is directly attached to (handles non-RFC-1918
# Docker bridge networks such as 172.0.0.0/24).
for _net in _local_subnets():
if ip in _net:
return True
# Configured cell ip_range (WireGuard peer subnet)
_cell = config_manager.configs.get('_identity', {}).get(
'ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
if ip in _ipa.ip_network(cell_net, strict=False):
if ip in _ipa.ip_network(_cell, strict=False):
return True
except Exception:
pass
@@ -345,8 +373,7 @@ def is_local_request():
if _allowed(remote_addr):
return True
# Only trust the LAST X-Forwarded-For entry — that is what Caddy appended.
# Iterating all entries allows clients to spoof local origin by prepending 127.0.0.1.
# Only trust the LAST X-Forwarded-For entry — that is what the reverse proxy appended.
if forwarded_for:
last_hop = forwarded_for.split(',')[-1].strip()
if _allowed(last_hop):
@@ -517,12 +544,18 @@ def update_config():
except ValueError as _e:
return jsonify({'error': f'wireguard.address is not a valid IP/CIDR: {_e}'}), 400
# Capture old identity and service configs BEFORE saving, for change detection
# Capture old identity and service configs BEFORE saving, for change detection + revert
import copy as _copy
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
}
# Full pre-change snapshot — used by Discard to revert to original state.
# Must be captured here, before any config writes, so it holds the true old values.
_pre_change_snapshot = {k: _copy.deepcopy(v) for k, v in config_manager.configs.items()
if not k.startswith('_')}
_pre_change_snapshot['_identity'] = _copy.deepcopy(config_manager.configs.get('_identity', {}))
if identity_updates:
stored = config_manager.configs.get('_identity', {})
stored.update(identity_updates)
@@ -571,11 +604,10 @@ def update_config():
config_manager.configs['_identity'] = _id
config_manager._save_all_configs()
# Apply cell identity domain to network and email services
if identity_updates.get('domain'):
# Apply cell identity domain to network and email services (write files, defer reload)
if identity_updates.get('domain') and identity_updates['domain'] != old_identity.get('domain', ''):
domain = identity_updates['domain']
net_result = network_manager.apply_domain(domain)
all_restarted.extend(net_result.get('restarted', []))
net_result = network_manager.apply_domain(domain, reload=False)
all_warnings.extend(net_result.get('warnings', []))
# Regenerate Caddyfile — virtual host names change with the domain
import ip_utils as _ip_domain
@@ -583,14 +615,18 @@ def update_config():
_cur_range = _cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
_cur_name = _cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
_ip_domain.write_caddyfile(_cur_range, _cur_name, domain, '/app/config/caddy/Caddyfile')
_set_pending_restart(
[f'domain changed to {domain}'],
['dns', 'caddy'],
pre_change_snapshot=_pre_change_snapshot,
)
# Apply cell name change to DNS hostname record
# Apply cell name change to DNS hostname record (write files, defer reload)
if identity_updates.get('cell_name'):
old_name = old_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
new_name = identity_updates['cell_name']
if old_name != new_name:
cn_result = network_manager.apply_cell_name(old_name, new_name)
all_restarted.extend(cn_result.get('restarted', []))
cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False)
all_warnings.extend(cn_result.get('warnings', []))
# Regenerate Caddyfile — main virtual host name changes with cell_name
import ip_utils as _ip_name
@@ -598,9 +634,14 @@ def update_config():
_cur_range2 = _cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
_cur_domain2 = identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
_ip_name.write_caddyfile(_cur_range2, new_name, _cur_domain2, '/app/config/caddy/Caddyfile')
_set_pending_restart(
[f'cell_name changed to {new_name}'],
['dns'],
pre_change_snapshot=_pre_change_snapshot,
)
# Apply ip_range change: regenerate DNS records, update virtual IPs + firewall rules
if identity_updates.get('ip_range'):
if identity_updates.get('ip_range') and identity_updates['ip_range'] != old_identity.get('ip_range', ''):
import ip_utils
new_range = identity_updates['ip_range']
cur_identity = config_manager.configs.get('_identity', {})
@@ -623,7 +664,8 @@ def update_config():
# docker compose down is required before up (Docker can't change subnet in-place)
_set_pending_restart(
[f'ip_range changed to {new_range} — network will be recreated'],
['*'], network_recreate=True
['*'], network_recreate=True,
pre_change_snapshot=_pre_change_snapshot,
)
# Detect port changes across service configs and identity
@@ -677,7 +719,8 @@ def update_config():
_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))
_set_pending_restart(port_change_messages, list(port_changed_containers),
pre_change_snapshot=_pre_change_snapshot)
logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({
@@ -717,11 +760,28 @@ def _collect_service_ports(configs: dict) -> dict:
return ports
def _set_pending_restart(changes: list, containers: list = None, network_recreate: bool = False):
def _dedup_changes(existing: list, new: list) -> list:
"""Merge change lists, keeping only the latest entry per config key."""
def key_of(msg: str) -> str:
# "ip_range changed to X" → "ip_range"
if ' changed' in msg:
return msg.split(' changed')[0].strip()
# "network dns_port: 52 → 53" → "network dns_port"
if ':' in msg:
return msg.split(':')[0].strip()
return msg
merged = {key_of(c): c for c in existing}
merged.update({key_of(c): c for c in new})
return list(merged.values())
def _set_pending_restart(changes: list, containers: list = None, network_recreate: bool = False,
pre_change_snapshot: dict = None):
"""Record that specific containers need to be restarted to apply configuration.
containers: list of docker-compose service names, or None/'*' to restart all.
network_recreate: True when the Docker bridge subnet changed (requires down+up).
pre_change_snapshot: full config captured BEFORE this save (for Discard to revert).
Merges with any existing pending state so multiple changes accumulate.
"""
from datetime import datetime as _dt
@@ -729,6 +789,13 @@ def _set_pending_restart(changes: list, containers: list = None, network_recreat
existing_changes = existing.get('changes', []) if existing.get('needs_restart') else []
existing_containers = existing.get('containers', []) if existing.get('needs_restart') else []
# Keep the oldest snapshot (the true pre-change state). Never overwrite it with a
# later snapshot — subsequent changes while pending should still revert to origin.
if not existing.get('needs_restart'):
snapshot = pre_change_snapshot or {}
else:
snapshot = existing.get('_snapshot', {})
if containers is None or '*' in (containers or []) or existing_containers == ['*']:
new_containers = ['*']
else:
@@ -737,9 +804,10 @@ def _set_pending_restart(changes: list, containers: list = None, network_recreat
config_manager.configs['_pending_restart'] = {
'needs_restart': True,
'changed_at': _dt.utcnow().isoformat(),
'changes': existing_changes + changes,
'changes': _dedup_changes(existing_changes, changes),
'containers': new_containers,
'network_recreate': network_recreate or existing.get('network_recreate', False),
'_snapshot': snapshot,
}
config_manager._save_all_configs()
@@ -765,7 +833,37 @@ def get_pending_config():
@app.route('/api/config/pending', methods=['DELETE'])
def cancel_pending_config():
"""Discard pending configuration changes without restarting any containers."""
"""Discard pending configuration changes and restore config to pre-change snapshot."""
pending = config_manager.configs.get('_pending_restart', {})
snapshot = pending.get('_snapshot', {})
if snapshot:
# Capture current (changed) identity before reverting, to rewrite config files
cur_identity = dict(config_manager.configs.get('_identity', {}))
old_identity = snapshot.get('_identity', {})
# Restore config values from snapshot
for k, v in snapshot.items():
config_manager.configs[k] = v
# Rewrite DNS/Caddy config files back to old values so they match restored config
import ip_utils as _ip_revert
_id = config_manager.configs.get('_identity', {})
_range = _id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
_cell = _id.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
_dom = _id.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
cur_domain = cur_identity.get('domain', '')
old_domain = old_identity.get('domain', '')
if cur_domain and old_domain and cur_domain != old_domain:
network_manager.apply_domain(old_domain, reload=False)
cur_cell_name = cur_identity.get('cell_name', '')
old_cell_name = old_identity.get('cell_name', '')
if cur_cell_name and old_cell_name and cur_cell_name != old_cell_name:
network_manager.apply_cell_name(cur_cell_name, old_cell_name, reload=False)
_ip_revert.write_caddyfile(_range, _cell, _dom, '/app/config/caddy/Caddyfile')
_clear_pending_restart()
return jsonify({'message': 'Pending changes discarded'})
+1 -6
View File
@@ -283,15 +283,10 @@ class ContainerManager(BaseServiceManager):
def get_container_logs(self, name: str, tail: int = 100) -> str:
"""Get container logs"""
try:
if not self.client:
return "Docker client not available"
raise RuntimeError("Docker client not available")
container = self.client.containers.get(name)
return container.logs(tail=tail).decode('utf-8')
except Exception as e:
logger.error(f"Error getting logs for container {name}: {e}")
return str(e)
def get_container_stats(self, name: str) -> dict:
"""Get container statistics"""
+15 -10
View File
@@ -5,6 +5,7 @@ Handles DNS, DHCP, and NTP functionality
"""
import os
import re
import json
import subprocess
import logging
@@ -383,8 +384,11 @@ class NetworkManager(BaseServiceManager):
return {'restarted': restarted, 'warnings': warnings}
def apply_domain(self, domain: str) -> Dict[str, Any]:
"""Update domain across dnsmasq, Corefile, and zone file; reload DNS + DHCP."""
def apply_domain(self, domain: str, reload: bool = True) -> Dict[str, Any]:
"""Update domain across dnsmasq, Corefile, and zone file; reload DNS + DHCP.
reload=False writes config files only use when deferring container restart.
"""
restarted = []
warnings = []
@@ -400,6 +404,7 @@ class NetworkManager(BaseServiceManager):
]
with open(dhcp_conf, 'w') as f:
f.writelines(lines)
if reload:
self._reload_dhcp_service()
restarted.append('cell-dhcp (reloaded)')
except Exception as e:
@@ -424,8 +429,6 @@ class NetworkManager(BaseServiceManager):
dns_data = os.path.join(self.data_dir, 'dns')
if os.path.isdir(dns_data):
dst = os.path.join(dns_data, f'{domain}.zone')
# Find the best source: prefer a non-target zone (old domain) so we
# can migrate its content; fall back to the target zone itself.
zone_files = [
os.path.join(dns_data, f)
for f in os.listdir(dns_data)
@@ -443,7 +446,6 @@ class NetworkManager(BaseServiceManager):
r'^\$ORIGIN\s+\S+', f'$ORIGIN {domain}.', zone_content, flags=re.MULTILINE)
with open(dst, 'w') as f:
f.write(zone_content)
# Remove every zone file that is not the current domain's file
for zone_path in zone_files:
if zone_path != dst:
try:
@@ -453,7 +455,8 @@ class NetworkManager(BaseServiceManager):
except Exception as e:
warnings.append(f"zone file domain update failed: {e}")
# 4. Reload CoreDNS
# 4. Reload CoreDNS (only when not deferring to Apply)
if reload:
try:
self._reload_dns_service()
restarted.append('cell-dns (reloaded)')
@@ -462,8 +465,11 @@ class NetworkManager(BaseServiceManager):
return {'restarted': restarted, 'warnings': warnings}
def apply_cell_name(self, old_name: str, new_name: str) -> Dict[str, Any]:
"""Update the cell hostname record in the primary DNS zone file."""
def apply_cell_name(self, old_name: str, new_name: str, reload: bool = True) -> Dict[str, Any]:
"""Update the cell hostname record in the primary DNS zone file.
reload=False writes the zone file only use when deferring container restart.
"""
restarted = []
warnings = []
if not old_name or not new_name or old_name == new_name:
@@ -476,8 +482,6 @@ class NetworkManager(BaseServiceManager):
zone_file = os.path.join(dns_data, fname)
with open(zone_file) as f:
content = f.read()
# Replace hostname record: old_name IN A ...
import re
content = re.sub(
rf'^{re.escape(old_name)}(\s+IN\s+A\s+)',
f'{new_name}\\1',
@@ -486,6 +490,7 @@ class NetworkManager(BaseServiceManager):
with open(zone_file, 'w') as f:
f.write(content)
break
if reload:
self._reload_dns_service()
restarted.append('cell-dns (reloaded)')
except Exception as e:
+1
View File
@@ -215,6 +215,7 @@ function AppCore() {
const handleCancel = useCallback(async () => {
await cellAPI.cancelPending();
setPending({ needs_restart: false, changes: [] });
window.dispatchEvent(new CustomEvent('pic-config-discarded'));
}, []);
const navigation = [
+36 -54
View File
@@ -4,7 +4,7 @@ import { useDraftConfig } from '../contexts/DraftConfigContext';
import {
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
Save, ChevronDown, ChevronRight, CheckCircle, XCircle, AlertCircle,
ChevronDown, ChevronRight, CheckCircle, XCircle,
RefreshCw, Lock
} from 'lucide-react';
import { cellAPI } from '../services/api';
@@ -402,12 +402,10 @@ function Settings() {
// identity
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [identityDirty, setIdentityDirty] = useState(false);
const [identitySaving, setIdentitySaving] = useState(false);
// service configs
const [serviceConfigs, setServiceConfigs] = useState({});
const [serviceDirty, setServiceDirty] = useState({});
const [serviceSaving, setServiceSaving] = useState({});
const portConflicts = useMemo(() => detectPortConflicts(serviceConfigs), [serviceConfigs]);
@@ -431,7 +429,9 @@ function Settings() {
domain: cfg.domain || '',
ip_range: cfg.ip_range || '',
});
setIdentityDirty(false);
setServiceConfigs(cfg.service_configs || {});
setServiceDirty({});
setBackups(bkRes.data || []);
} catch (err) {
toast('Failed to load configuration', 'error');
@@ -442,15 +442,11 @@ function Settings() {
useEffect(() => { loadAll(); }, [loadAll]);
const _applyResult = (res, label) => {
const { restarted = [], warnings = [] } = res.data || {};
if (restarted.length > 0) {
toast(`${label} saved — restarted: ${restarted.join(', ')}`);
} else {
toast(`${label} saved`);
}
warnings.forEach((w) => toast(w, 'warning'));
};
useEffect(() => {
const handler = () => loadAll();
window.addEventListener('pic-config-discarded', handler);
return () => window.removeEventListener('pic-config-discarded', handler);
}, [loadAll]);
// identity save
const ipRangeError = identity.ip_range && !isRFC1918Cidr(identity.ip_range)
@@ -465,42 +461,34 @@ function Settings() {
? 'Domain must be 255 characters or fewer'
: (!identity.domain ? 'Domain is required' : null);
const saveIdentity = async () => {
const saveIdentity = useCallback(async () => {
if (ipRangeError || cellNameError || domainError) return;
setIdentitySaving(true);
try {
const res = await cellAPI.updateConfig(identity);
await cellAPI.updateConfig(identity);
setIdentityDirty(false);
draftConfig?.setDirty('identity', false);
_applyResult(res, 'Cell identity');
refreshConfig();
} catch (err) {
toast(err.response?.data?.error || 'Failed to save identity', 'error');
} finally {
setIdentitySaving(false);
}
};
}, [identity, ipRangeError, cellNameError, domainError, draftConfig, refreshConfig]);
// service config save
const saveService = async (key) => {
const saveService = useCallback(async (key) => {
const { defaults } = SERVICE_DEFS.find((d) => d.key === key) || {};
const data = { ...(defaults || {}), ...(serviceConfigs[key] || {}) };
const hasFieldErrors = Object.keys(validateServiceConfig(key, data)).length > 0;
const hasConflicts = (PORT_CONFLICT_FIELDS[key] || []).some(f => portConflicts[`${key}|${f}`]);
if (hasFieldErrors || hasConflicts) return;
setServiceSaving((s) => ({ ...s, [key]: true }));
try {
const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
setServiceDirty((d) => ({ ...d, [key]: false }));
draftConfig?.setDirty(key, false);
_applyResult(res, key);
refreshConfig();
} catch (err) {
toast(err.response?.data?.error || `Failed to save ${key} config`, 'error');
} finally {
setServiceSaving((s) => ({ ...s, [key]: false }));
}
};
}, [serviceConfigs, portConflicts, draftConfig, refreshConfig]);
const updateServiceConfig = (key, data) => {
setServiceConfigs((prev) => ({ ...prev, [key]: data }));
@@ -541,6 +529,28 @@ function Settings() {
}, [draftConfig]);
//
// Debounced auto-save
useEffect(() => {
if (!identityDirty) return;
if (ipRangeError || cellNameError || domainError) return;
const timer = setTimeout(() => saveIdentityRef.current(), 800);
return () => clearTimeout(timer);
}, [identity, identityDirty, ipRangeError, cellNameError, domainError]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const timers = SERVICE_DEFS
.filter(({ key }) => serviceDirty[key])
.filter(({ key, defaults }) => {
const data = { ...defaults, ...(serviceConfigs[key] || {}) };
const hasFieldErrors = Object.keys(validateServiceConfig(key, data)).length > 0;
const hasConflicts = (PORT_CONFLICT_FIELDS[key] || []).some(f => portConflicts[`${key}|${f}`]);
return !hasFieldErrors && !hasConflicts;
})
.map(({ key }) => setTimeout(() => saveServiceRef.current(key), 800));
return () => timers.forEach(clearTimeout);
}, [serviceConfigs, serviceDirty, portConflicts]); // eslint-disable-line react-hooks/exhaustive-deps
//
// backups
const createBackup = async () => {
setBackupCreating(true);
@@ -654,20 +664,6 @@ function Settings() {
/>
</Field>
</div>
<div className="flex justify-end mt-4">
<button
onClick={saveIdentity}
disabled={!identityDirty || identitySaving || !!ipRangeError || !!cellNameError || !!domainError}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{identitySaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Identity
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
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>
{/* Service Configurations */}
@@ -682,23 +678,9 @@ function Settings() {
if (msg) conflictErrors[field] = msg;
}
const errors = { ...validateServiceConfig(key, data), ...conflictErrors };
const hasErrors = Object.keys(errors).length > 0;
const dirty = serviceDirty[key];
const saving = serviceSaving[key];
return (
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} errors={errors} />
<div className="flex items-center justify-between mt-4">
<span className="text-xs text-gray-400">Port/directory changes take effect after container restart.</span>
<button
onClick={() => saveService(key)}
disabled={!dirty || saving || hasErrors}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</button>
</div>
</Section>
);
})}