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:
+119
-21
@@ -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'})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user