feat: HTTPS cert status, IDENTITY_CHANGED wiring, remove stale ip_utils Caddyfile writes
Unit Tests / test (push) Successful in 11m18s

- CaddyManager: add refresh_cert_status() and get_cert_status_fresh() that
  open a live TLS connection to cell-caddy:443 to read cert expiry; avoids
  needing a volume mount into the API container
- CaddyManager: periodic cert refresh in health_monitor_loop (every 60 cycles)
- config.py PUT /api/ddns: publish IDENTITY_CHANGED so CaddyManager regenerates
  the Caddyfile immediately after any domain/cell_name change — previously the
  event was never fired from this route
- config.py: remove all ip_utils.write_caddyfile() calls; CaddyManager is now
  the sole authority for Caddyfile generation
- app.py: add GET /api/caddy/cert-status route
- app.py: add GET /api/egress/status and PUT /api/egress/services/<id>/exit routes
- Settings.jsx: display cert status badge (valid/expired/internal/unknown) with
  expiry date and days-remaining in the domain section
- Tests: TestRefreshCertStatus (8 tests), TestDdnsConfigUpdatesFiresIdentityChanged,
  TestCaddyCertStatusRoute added; fix expired-cert helper to set not_valid_before
  relative to expiry so it's always earlier

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 11:39:36 -04:00
parent 41d09c598b
commit 0267dce73d
7 changed files with 398 additions and 26 deletions
+21 -23
View File
@@ -316,13 +316,6 @@ def update_config():
domain = identity_updates['domain']
net_result = network_manager.apply_domain(domain, reload=False)
all_warnings.extend(net_result.get('warnings', []))
_cur_id = config_manager.configs.get('_identity', {})
if _cur_id.get('domain_mode', 'lan') == 'lan':
ip_utils.write_caddyfile(
_cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
_cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
domain, '/app/config-caddy/Caddyfile'
)
_set_pending_restart(
[f'domain changed to {domain}'],
['dns', 'caddy'],
@@ -335,14 +328,6 @@ def update_config():
if old_name != new_name:
cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False)
all_warnings.extend(cn_result.get('warnings', []))
_cur_id2 = config_manager.configs.get('_identity', {})
if _cur_id2.get('domain_mode', 'lan') == 'lan':
ip_utils.write_caddyfile(
_cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
new_name,
identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
'/app/config-caddy/Caddyfile'
)
_set_pending_restart(
[f'cell_name changed to {new_name}'],
['dns'],
@@ -373,8 +358,6 @@ def update_config():
firewall_manager.ensure_caddy_virtual_ips()
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs))
if cur_identity.get('domain_mode', 'lan') == 'lan':
ip_utils.write_caddyfile(new_range, cur_cell_name, cur_domain, '/app/config-caddy/Caddyfile')
_set_pending_restart(
[f'ip_range changed to {new_range} — network will be recreated'],
['*'], network_recreate=True,
@@ -581,6 +564,21 @@ def update_ddns_config():
config_manager.set_identity_field('duckdns_token', duck_token)
config_manager.set_identity_field('duckdns_subdomain', duck_sub)
# Fire IDENTITY_CHANGED so CaddyManager regenerates the Caddyfile
# for the new domain mode without requiring a container restart.
try:
from app import service_bus as _sbus, EventType as _ET
_cur = config_manager.configs.get('_identity', {})
_sbus.publish_event(_ET.IDENTITY_CHANGED, 'config', {
'cell_name': _cur.get('cell_name'),
'domain': _cur.get('domain'),
'domain_name': _cur.get('domain_name'),
'domain_mode': _cur.get('domain_mode'),
'effective_domain': config_manager.get_effective_domain(),
})
except Exception as _ev_err:
logger.warning('update_ddns_config: failed to fire IDENTITY_CHANGED: %s', _ev_err)
logger.info('DDNS config updated: domain_mode=%r domain_name=%r', domain_mode, domain_name)
return jsonify({'updated': True})
except Exception as e:
@@ -660,12 +658,12 @@ def cancel_pending_config():
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)
if _id.get('domain_mode', 'lan') == 'lan':
_ip_revert.write_caddyfile(
_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
_dom, '/app/config-caddy/Caddyfile'
)
# Regenerate Caddyfile for the reverted identity (all domain modes)
try:
from app import caddy_manager as _cm
_cm.regenerate_with_installed([])
except Exception as _cm_err:
logger.warning('cancel_pending_config: caddy regenerate failed (non-fatal): %s', _cm_err)
_clear_pending_restart()
return jsonify({'message': 'Pending changes discarded'})