a certificate for a name Let's Encrypt cannot visit
the whole reason the credential store exists. a tailnet-only hostname cannot be reached on port 80, so HTTP-01 is impossible and a DNS record is the only proof Let's Encrypt accepts — which needs the provider's API key. POST /domains/certificate is the one route here that is deliberately not a pass-through, and it has to be: the request needs dns_provider_credentials, and officer never returns a stored key to any caller. so the platform resolves which provider owns the name from the Domains list, decrypts, builds the request and returns NPM's own answer unchanged. same arrangement as the NPM token one level up — the thing that wants the work done is not the thing holding the password. NOT under /_config. reading and writing a DNS key stays owner-only; USING it on the owner's behalf is the feature, and gating this to the owner would defeat it. a read-level account still cannot reach it, because it is a POST. three refusals, all before anything is placed at the registrar: a name whose apex is not in the domain list, a provider with no key stored, and a certificate spanning two providers — NPM takes one credential per certificate, and discovering that halfway through leaves a challenge record behind. tested, because each one otherwise fails deep inside certbot with an error about something else. ten minute timeout, 120s default propagation. both err long on purpose: NPM waits for the record to spread before asking Let's Encrypt to look, and abandoning the request does not stop it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
removeCredential,
|
||||
removeDomain,
|
||||
saveCredential,
|
||||
issueWithDnsChallenge,
|
||||
DomainError,
|
||||
} from './domains';
|
||||
|
||||
@@ -325,6 +326,64 @@ router.delete('/domains/:id', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `POST /domains/certificate` — a certificate for a name Let's Encrypt cannot visit.
|
||||
*
|
||||
* **The one route here that is deliberately not a pass-through.** Everything else hands NPM's API through;
|
||||
* this one cannot, because the request needs a DNS credential that is never returned to any caller. So the
|
||||
* platform decrypts it, builds the request, and returns NPM's own answer unchanged.
|
||||
*
|
||||
* Same arrangement as the NPM token itself, one level up: the thing that wants the work done is not the
|
||||
* thing holding the password. An agent asks for a certificate; it never sees the key that got it.
|
||||
*
|
||||
* **NOT under `/_config`.** The credential is owner-only to READ and WRITE, and that stays true — but
|
||||
* USING it on the owner's behalf is the feature, and gating this route to the owner would defeat it. A
|
||||
* `read`-level account still cannot reach it: this is a POST, and `isRequestAllowedAtLevel` refuses every
|
||||
* unsafe method below `write`.
|
||||
*
|
||||
* For a publicly-resolving name, do NOT use this. `POST /npm/nginx/certificates` needs no credential and
|
||||
* no propagation wait — see the skill.
|
||||
*/
|
||||
router.post('/domains/certificate', async (ctx) => {
|
||||
const body = (await ctx.req.json().catch(() => null)) as
|
||||
| { domain_names?: string[]; propagation_seconds?: number }
|
||||
| null;
|
||||
|
||||
const credentials = await readCredentials();
|
||||
if (!credentials) {
|
||||
return ctx.json({ error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true }, 409);
|
||||
}
|
||||
|
||||
let request: Awaited<ReturnType<typeof issueWithDnsChallenge>>;
|
||||
try {
|
||||
request = await issueWithDnsChallenge({
|
||||
domainNames: body?.domain_names ?? [],
|
||||
propagationSeconds: body?.propagation_seconds,
|
||||
});
|
||||
} catch (error) {
|
||||
return domainFailure(ctx, error);
|
||||
}
|
||||
|
||||
try {
|
||||
// A long timeout on purpose: NPM waits for the challenge record to propagate before it asks Let's
|
||||
// Encrypt to look, so this legitimately takes minutes. The default 15s would abandon a request that
|
||||
// is going to succeed — and abandoning it does not stop it, which is the worst of both.
|
||||
const result = await npmProxy(credentials, {
|
||||
method: 'POST',
|
||||
path: '/nginx/certificates',
|
||||
body: JSON.stringify(request.body),
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
});
|
||||
return new Response(result.body, {
|
||||
status: result.status,
|
||||
headers: { 'content-type': result.contentType ?? 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
const shaped = asError(error);
|
||||
return ctx.json(shaped.body, shaped.status);
|
||||
}
|
||||
});
|
||||
|
||||
// ── DNS credentials — owner only, by the manifest's `/_config` prefix ────────────────────────────────
|
||||
|
||||
router.get('/_config/dns-providers', async (ctx) => ctx.json(await listCredentials()));
|
||||
|
||||
Reference in New Issue
Block a user