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
+45 -2
View File
@@ -7,7 +7,7 @@ import {
ChevronDown, ChevronRight, CheckCircle, XCircle,
RefreshCw, Lock, FolderDown, X, Globe, Loader
} from 'lucide-react';
import { cellAPI, ddnsAPI } from '../services/api';
import { cellAPI, ddnsAPI, caddyAPI } from '../services/api';
import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfig';
// ── constants ────────────────────────────────────────────────────────────────
@@ -354,6 +354,7 @@ function Settings() {
const [ddnsDirty, setDdnsDirty] = useState(false);
const [ddnsSaving, setDdnsSaving] = useState(false);
const [ddnsRegistering, setDdnsRegistering] = useState(false);
const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining}
// service configs
const [serviceConfigs, setServiceConfigs] = useState({});
@@ -374,11 +375,13 @@ function Settings() {
const loadAll = useCallback(async () => {
setIsLoading(true);
try {
const [cfgRes, bkRes] = await Promise.all([
const [cfgRes, bkRes, certRes] = await Promise.all([
cellAPI.getConfig(),
cellAPI.listBackups(),
caddyAPI.getCertStatus().catch(() => null),
]);
const cfg = cfgRes.data;
if (certRes?.data) setCertStatus(certRes.data);
setIdentity({
cell_name: cfg.cell_name || '',
domain: cfg.domain || '',
@@ -937,6 +940,46 @@ function Settings() {
: 'Local-only install — no external domain configured.'}
</div>
)}
{/* TLS Certificate Status */}
{certStatus && (
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="text-sm font-medium text-gray-700 mb-2">TLS Certificate</div>
<div className="flex items-center gap-3">
{certStatus.status === 'valid' && (
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
{certStatus.status === 'expired' && (
<XCircle className="h-4 w-4 text-red-500 flex-shrink-0" />
)}
{(certStatus.status === 'unknown' || certStatus.status === 'internal') && (
<Lock className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div className="text-sm text-gray-600">
{certStatus.status === 'valid' && (
<>
Valid &mdash; expires{' '}
<span className="font-mono text-gray-800">
{new Date(certStatus.expiry).toLocaleDateString()}
</span>
{certStatus.days_remaining != null && (
<span className={`ml-2 font-medium ${certStatus.days_remaining < 14 ? 'text-amber-600' : 'text-green-700'}`}>
({certStatus.days_remaining}d remaining)
</span>
)}
</>
)}
{certStatus.status === 'expired' && (
<span className="text-red-600 font-medium">
Expired on {new Date(certStatus.expiry).toLocaleDateString()}
</span>
)}
{certStatus.status === 'internal' && 'Internal CA certificate (LAN mode)'}
{certStatus.status === 'unknown' && 'Certificate not yet issued or Caddy unreachable'}
</div>
</div>
</div>
)}
</div>
</Section>
+5
View File
@@ -382,4 +382,9 @@ export const containerAPI = {
removeVolume: (name, force = false) => api.delete(`/api/volumes/${name}`, { params: { force } }),
};
// Caddy / TLS API
export const caddyAPI = {
getCertStatus: () => api.get('/api/caddy/cert-status'),
};
export default api;