From 3e07ee38c202e17098e8f3ddb6e55095353398bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 14:07:13 +0100 Subject: [PATCH] a certificate request outlives the browser, so stop treating a timeout as failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issuing waits for a DNS record to propagate — two minutes before certbot even looks — and the proxy in front of Officer gives up at sixty seconds with a 504 while certbot carries on and succeeds. So the only thing anybody sees is a timeout, and underneath it the certificate exists and is attached to nothing. That is exactly what happened: npm-7 was issued for dev.fofosfluffybastards.com and the host still had ssl:0, because the request died between issuing and attaching. So a timeout is no longer a failure. The request is fired, and afterwards — returned, failed or cut off — this asks what certificates exist and attaches the one covering these names, polling for up to five minutes. And it looks for an existing certificate BEFORE asking for anything. A retry would otherwise spend a Let's Encrypt rate limit to obtain a duplicate of one already on the disk, and those limits are per-week: getting it wrong means not being able to fix it today. It also means the stuck host above is repaired by pressing the same button. Matching requires the certificate to cover EVERY name the host serves. One that covers a single domain of two is not this host's certificate. Co-Authored-By: Claude Opus 5 --- web/ProxiesBlock.tsx | 99 +++++++++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/web/ProxiesBlock.tsx b/web/ProxiesBlock.tsx index ffc5dea..1f8c729 100644 --- a/web/ProxiesBlock.tsx +++ b/web/ProxiesBlock.tsx @@ -127,40 +127,89 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => { }; /** - * Ask for a certificate over the DNS challenge, then attach it. + * Give a host a certificate: find one, or ask for one, then attach it. * - * ── Why not the ordinary NPM certificates route ── + * ── The request outlives the browser, and this has to cope ── * - * That one proves the domain over HTTP, which needs the name to already resolve to this machine and - * port 80 to be reachable from the internet. For a name that does not yet point here — which is every - * name at the moment somebody is setting it up — certbot fails with "Some challenges have failed" and - * a 500 that says nothing about why. + * Issuing waits for a DNS record to propagate — two minutes by default, before certbot even looks. + * The proxy in front of Officer gives up at sixty seconds and answers 504, while certbot carries on + * and succeeds. So the failure everybody sees is a timeout, and underneath it the certificate exists + * and is simply not attached to anything: exactly what happened to dev.fofosfluffybastards.com. * - * The DNS challenge has no such requirement: it proves ownership by writing a record. That needs the - * registrar's API key, and this must NOT go looking for one. Officer stores DNS provider keys - * encrypted and never returns them, to anybody. `/domains/certificate` is the route that exists for - * exactly this — it works out which provider owns the name from the Domains list, decrypts the key - * itself, and hands back NPM's answer. The key is used and never seen. + * So a timeout is not treated as a failure. Afterwards — whether the call returned, failed, or timed + * out — this looks for a certificate covering these names and attaches the one it finds. * - * ── It takes minutes ── + * ── It never issues a second certificate for a name that already has one ── * - * A DNS record has to propagate before Let's Encrypt will look for it, and the default wait is two - * minutes. Said out loud in the button, because a spinner that sits for three minutes with no - * explanation is indistinguishable from one that has hung. + * Checked FIRST, before asking for anything. A retry after a timeout would otherwise burn a Let's + * Encrypt rate limit to obtain a duplicate of a certificate already sitting on the disk, and rate + * limits are per-week: the cost of getting that wrong is not being able to fix it today. */ + const findCertificate = async (names: string[]): Promise => { + const all = await clientRef.current + .get>('/proxy-manager/npm/nginx/certificates') + .catch(() => [] as Array<{ id: number; domain_names: string[] }>); + // Covers every name this host serves. A certificate for one of two domains is not this host's. + const match = all.find((cert) => names.every((name) => cert.domain_names?.includes(name))); + return match?.id ?? null; + }; + + const attach = async (host: Host, certificateId: number) => { + await clientRef.current.put(`/proxy-manager/npm/nginx/proxy-hosts/${host.id}`, { + certificate_id: certificateId, + ssl_forced: true, + }); + }; + const secure = async (host: Host) => { + const names = host.domain_names; setBusy(`cert:${host.id}`); setFailed(false); - setOutput(`Asking for a certificate for ${host.domain_names.join(', ')}.\nThis waits for DNS to propagate and usually takes a few minutes.`); + try { - const certificate = await clientRef.current.post<{ id: number }>('/proxy-manager/domains/certificate', { - domain_names: host.domain_names, - }); - await clientRef.current.put(`/proxy-manager/npm/nginx/proxy-hosts/${host.id}`, { - certificate_id: certificate.id, - ssl_forced: true, - }); - setOutput(`Certificate issued for ${host.domain_names.join(', ')}. http now redirects to https.`); + // Already issued — from an earlier attempt that timed out, or done by hand in Proxy Manager. + const existing = await findCertificate(names); + if (existing) { + setOutput(`A certificate for ${names.join(', ')} already exists. Attaching it.`); + await attach(host, existing); + setOutput(`${names.join(', ')} now uses its certificate, and http redirects to https.`); + await load(); + return; + } + + setOutput( + `Asking for a certificate for ${names.join(', ')}.\n` + + 'This waits for DNS to propagate and takes a few minutes. A timeout here is expected and is not a failure.', + ); + + /* + Deliberately not awaited on its own. The proxy will very likely cut this off before it returns, + and the polling below is what actually establishes whether it worked — so a rejection here is + recorded and then ignored in favour of asking the far side what exists. + */ + const requested = clientRef.current + .post<{ id: number }>('/proxy-manager/domains/certificate', { domain_names: names }) + .then((cert) => cert.id) + .catch(() => null); + + // Up to five minutes, asking every ten seconds. Longer than the default propagation wait, because + // a certificate that arrives at three minutes should still be attached. + let certificateId = await requested; + for (let attempt = 0; attempt < 30 && !certificateId; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10_000)); + certificateId = await findCertificate(names); + } + + if (!certificateId) { + setOutput( + `No certificate for ${names.join(', ')} appeared within five minutes. It may still arrive — press this again and it will attach rather than ask for another.`, + ); + setFailed(true); + return; + } + + await attach(host, certificateId); + setOutput(`Certificate issued for ${names.join(', ')}. http now redirects to https.`); await load(); } catch (failure) { /* @@ -168,7 +217,7 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => { shown raw — "Not in the domain list" is a true sentence that tells nobody what to do next. */ const message = readableError(failure); - const apex = host.domain_names[0]?.split('.').slice(-2).join('.') ?? 'the domain'; + const apex = names[0]?.split('.').slice(-2).join('.') ?? 'the domain'; setOutput( message.includes('Not in the domain list') ? `${apex} is not in Officer's Domains list, so it does not know whose DNS it is. Add it there first.`