Fix DDNS security and reliability gaps (#2, #3, #5, #6, #7)
Unit Tests / test (push) Successful in 7m23s

- Fix #2: Move DDNS bearer token from cell_config.json to data/api/ddns_token.
  Token is now in the secrets store (data/) rather than the config store (config/).
  Auto-migrates existing installs on first access. ConfigManager.get/set_ddns_token()
  added. set_ddns_config() now strips 'token' key to prevent it leaking back.

- Fix #3: Set Caddyfile permissions to 0o600 after write so the token embedded
  in the Caddyfile is not world-readable on the host filesystem.

- Fix #5: Heartbeat now fires IDENTITY_CHANGED after re-registration so Caddy
  regenerates its config with the new token automatically — users no longer need
  to click Re-register in Settings after a wizard registration failure.
  Also: heartbeat skips the 401-cycle when no token exists and goes straight to
  registration instead. DDNSManager now accepts service_bus= and is wired up.

- Fix #6: Settings page starts polling GET /api/caddy/cert-status every 15s
  after a successful DDNS re-registration and shows "Acquiring certificate…"
  feedback until Let's Encrypt issues the cert (up to 5 minutes).

- Fix #7: regenerate_with_installed() is debounced (5 s window) so two rapid
  IDENTITY_CHANGED events (e.g. wizard + heartbeat) can't start simultaneous
  ACME orders that interfere with each other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 03:37:48 -04:00
parent 40f9d90fad
commit 3d750ed1e8
8 changed files with 248 additions and 67 deletions
+63 -30
View File
@@ -357,6 +357,8 @@ function Settings() {
const [ddnsStatus, setDdnsStatus] = useState(null);
const [ddnsStatusLoading, setDdnsStatusLoading] = useState(false);
const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining}
const [certAcquiring, setCertAcquiring] = useState(false);
const certPollRef = useRef(null);
// service configs
const [serviceConfigs, setServiceConfigs] = useState({});
@@ -412,6 +414,7 @@ function Settings() {
}, []);
useEffect(() => { loadAll(); }, [loadAll]);
useEffect(() => () => clearInterval(certPollRef.current), []);
useEffect(() => {
if (domainMode === 'pic_ngo') checkDdnsStatus();
@@ -532,6 +535,26 @@ function Settings() {
}
}, []);
const startCertPolling = useCallback(() => {
clearInterval(certPollRef.current);
setCertAcquiring(true);
let attempts = 0;
certPollRef.current = setInterval(async () => {
attempts++;
try {
const res = await caddyAPI.getCertStatus();
const status = res.data?.status;
if (status) setCertStatus(res.data);
if (status === 'valid' || attempts >= 20) {
clearInterval(certPollRef.current);
setCertAcquiring(false);
}
} catch {
// non-fatal
}
}, 15_000);
}, []);
const reRegister = useCallback(async () => {
setDdnsRegistering(true);
try {
@@ -541,12 +564,14 @@ function Settings() {
setPicAvail(null);
toast(`Registered as ${res.data.subdomain}`);
checkDdnsStatus();
// IDENTITY_CHANGED fires after registration → Caddy starts ACME; poll for cert
startCertPolling();
} catch (err) {
toast(err.response?.data?.error || 'Registration failed', 'error');
} finally {
setDdnsRegistering(false);
}
}, [checkDdnsStatus]);
}, [checkDdnsStatus, startCertPolling]);
const verifyDuck = useCallback(async () => {
if (!ddnsDuckToken.trim()) return;
@@ -990,42 +1015,50 @@ function Settings() {
)}
{/* TLS Certificate Status */}
{certStatus && (
{(certStatus || certAcquiring) && (
<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">
{certAcquiring && (
<div className="flex items-center gap-2 text-sm text-purple-700 bg-purple-50 rounded px-3 py-2 mb-2">
<Loader className="h-4 w-4 animate-spin flex-shrink-0" />
Acquiring certificate from Let&apos;s Encrypt this takes up to 2 minutes&hellip;
</div>
)}
{certStatus && !certAcquiring && (
<div className="flex items-center gap-3">
{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>
)}
</>
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
{certStatus.status === 'expired' && (
<span className="text-red-600 font-medium">
Expired on {new Date(certStatus.expiry).toLocaleDateString()}
</span>
<XCircle className="h-4 w-4 text-red-500 flex-shrink-0" />
)}
{certStatus.status === 'internal' && 'Internal CA certificate (LAN mode)'}
{certStatus.status === 'unknown' && 'Certificate not yet issued or Caddy unreachable'}
{(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>
)}
</div>