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.`