diff --git a/web/ProxiesBlock.tsx b/web/ProxiesBlock.tsx
index a8e77fc..ffc5dea 100644
--- a/web/ProxiesBlock.tsx
+++ b/web/ProxiesBlock.tsx
@@ -127,29 +127,57 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
};
/**
- * Ask Let's Encrypt for a certificate, then attach it.
+ * Ask for a certificate over the DNS challenge, then attach it.
*
- * Two calls, because NPM issues a certificate as its own object and a host merely references one. The
- * body is exactly `{provider, domain_names}`: `letsencrypt_email` and `letsencrypt_agree` are what
- * every tutorial shows and NPM 2.15 rejects the request outright for including them.
+ * ── Why not the ordinary NPM certificates route ──
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * ── It takes minutes ──
+ *
+ * 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.
*/
const secure = async (host: Host) => {
setBusy(`cert:${host.id}`);
setFailed(false);
- setOutput(null);
+ 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/npm/nginx/certificates', {
- provider: 'letsencrypt',
+ 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(', ')} and http now redirects to https.`);
+ setOutput(`Certificate issued for ${host.domain_names.join(', ')}. http now redirects to https.`);
await load();
} catch (failure) {
- setOutput(readableError(failure));
+ /*
+ Its refusals are specific and each one is somebody's to fix, so they are translated rather than
+ 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';
+ 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.`
+ : message.includes('No API key')
+ ? `${apex} is known but has no DNS API key stored. Add it in the Domains section.`
+ : message.includes('two DNS providers')
+ ? 'One certificate can only cover names from a single DNS provider. Ask for them separately.'
+ : message,
+ );
setFailed(true);
} finally {
setBusy(null);
@@ -200,7 +228,7 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
) : (
- Removing or repointing a domain lives in Proxy Manager, where the whole picture is. + Certificates are proved over DNS with the key Officer holds for the domain, so a name works + before it points here — and takes a few minutes. Removing or repointing a domain lives in + Proxy Manager, where the whole picture is.
> )}