Fix DDNS security and reliability gaps (#2, #3, #5, #6, #7)
Unit Tests / test (push) Successful in 7m23s
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:
@@ -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's Encrypt — this takes up to 2 minutes…
|
||||
</div>
|
||||
)}
|
||||
{certStatus && !certAcquiring && (
|
||||
<div className="flex items-center gap-3">
|
||||
{certStatus.status === 'valid' && (
|
||||
<>
|
||||
Valid — 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 — 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>
|
||||
|
||||
Reference in New Issue
Block a user