certificates go over the DNS challenge, using the key Officer already holds
The HTTP challenge proves a domain by being visited on port 80, which requires the name to already resolve to this machine — which is exactly what is not true at the moment somebody is setting one up. certbot then 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 exists for precisely 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. Its refusals are translated rather than shown raw. "Not in the domain list" is a true sentence that tells nobody what to do; which list, and who can add to it, is the part that matters. And the button says it is issuing, because a DNS record has to propagate before Let's Encrypt will look for it — the wait is minutes, and a spinner sitting for three of them with no explanation is indistinguishable from one that has hung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+41
-11
@@ -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[] }) => {
|
||||
) : (
|
||||
<ShieldCheck className="size-3.5" />
|
||||
)}
|
||||
Add certificate
|
||||
{busy === `cert:${host.id}` ? 'Issuing…' : 'Add certificate'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -253,7 +281,9 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
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.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user