Files
pastilhasandClaude Opus 5 867226d897 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
2026-08-25 20:07:09 +00:00

505 lines
22 KiB
TypeScript

import type { Context } from 'hono';
import { createRouter, type HonoVariables } from '@@/create-router';
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
import { NpmError, npmRequest, npmProxy, probe, forgetToken } from './npm-client';
import {
addDomain,
dryCheck,
listCredentials,
listDomains,
recheckDomain,
removeCredential,
removeDomain,
saveCredential,
issueWithDnsChallenge,
DomainError,
} from './domains';
/**
* Mounted at `/api/proxy-manager`.
*
* Everything here is a read except the enable/disable pair and the settings under `/_config`, which the
* manifest marks owner-only. The platform's permission gate enforces that — this file does not check it
* again, because two places deciding the same thing is how they come to disagree.
*
* ── Two surfaces, on purpose ──
*
* **The curated routes** below — `/hosts`, `/certificates`, `/status` — exist for the PANELS. They sort,
* they compute `daysUntilExpiry`, they shape errors into something a screen can render. A UI wants
* answers, not NPM's raw output.
*
* **`/npm/*` is a pass-through**, and it exists for the AGENT. It forwards method, path, query and body to
* NPM and returns NPM's own status and body unchanged. The contract there is NPM's API, not ours.
*
* ── Why the second one, after this file argued for the first ──
*
* This said, until 2026-08-25, that creating and deleting were deliberately absent because NPM's own UI
* does them well and each is a form with real consequences. The owner's call, that day, was that this
* reasoning ends up treating people as if they cannot be trusted with their own server:
*
* > "even though we are assuming users that don't know how to operate nginx, the last thing I want to
* > do is treat users as stupid"
*
* A curated surface also cannot keep up. Every NPM feature we did not wrap is one the owner cannot reach
* through Officer at all, and the list only grows — so the plugin becomes a worse NPM rather than a way
* into the real one. Handing over the actual API and holding only the credential is the honest shape.
*
* **What replaces the refusal is confirmation, and it lives in the SKILL rather than here.** The agent
* confirms before anything destructive and names what it is about to change. That is an instruction, not
* a boundary, and saying so plainly is better than a boundary that only looks like one — the credential
* sits behind a permission, and `read` level already refuses every unsafe method by itself.
*/
export const router = createRouter();
/** NPM's shape, narrowed to what the panels render. */
type ProxyHost = {
id: number;
domain_names: string[];
forward_scheme: string;
forward_host: string;
forward_port: number;
enabled: number;
ssl_forced: number;
certificate_id: number;
access_list_id: number;
meta?: { nginx_online?: boolean; nginx_err?: string | null };
};
type Certificate = {
id: number;
provider: string;
nice_name: string;
domain_names: string[];
expires_on: string | null;
};
/**
* Every NPM failure becomes a shaped body rather than a stack trace.
*
* `likelyAuth` is the interesting field: NPM answers 404 when unauthenticated, so without saying this
* out loud the UI would show "not found" for what is actually a wrong password. The panel turns it into
* a sentence pointing at settings.
*/
const asError = (error: unknown) => {
if (error instanceof NpmError) {
return {
body: { error: error.message, likelyAuth: error.likelyAuth, unreachable: error.status === 0 },
// 503 when NPM is not answering at all, 502 for everything it did answer — including a refused
// credential, which is upstream's verdict rather than this request's. `likelyAuth` in the body is
// what the panel branches on; it does not need a status of its own.
status: error.status === 0 ? 503 : 502,
} as const;
}
return { body: { error: error instanceof Error ? error.message : String(error) }, status: 500 } as const;
};
/** Loads the credential, or answers 409 — "configured" is a state the UI has to distinguish. */
async function withCredentials<T>(handler: (c: NonNullable<Awaited<ReturnType<typeof readCredentials>>>) => Promise<T>) {
const credentials = await readCredentials();
if (!credentials) return { ok: false as const, status: 409, body: { error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true } };
try {
return { ok: true as const, data: await handler(credentials) };
} catch (error) {
const shaped = asError(error);
return { ok: false as const, status: shaped.status, body: shaped.body };
}
}
// `Context` is imported rather than derived from `router.get`. `Parameters<typeof router.get>` resolves
// Hono's FIRST overload, `[path: string]`, so index 1 is `undefined` and every handler here typed as
// `never` — twelve errors from one clever line, and `bunx tsgo` is shared with the platform.
const respond = (ctx: Context<{ Variables: HonoVariables }>, result: Awaited<ReturnType<typeof withCredentials>>) =>
result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500);
// ── Status ────────────────────────────────────────────────────────────────────────────────────────
/**
* The last probe, in memory, so a polled endpoint does not write to Postgres on every call.
*
* `GET /status` is meant to be polled — two panels open is two callers — and it used to `recordProbe`
* unconditionally, so a READ endpoint issued an UPDATE per poll per panel. The row exists to answer
* "when was this last known good" across a restart, which needs a write when the answer CHANGES, not
* when it is confirmed.
*
* So: the probe itself is cached for `PROBE_TTL_MS`, and the row is written only when the verdict or the
* version differs from what is already stored. A flapping NPM still writes every transition, which is
* exactly the case worth recording.
*/
const PROBE_TTL_MS = 30_000;
type ProbeResult = { ok: boolean; version: string | null; error?: string };
let cachedProbe: { at: number; url: string; result: ProbeResult } | null = null;
async function probeCached(url: string): Promise<ProbeResult> {
if (cachedProbe && cachedProbe.url === url && Date.now() - cachedProbe.at < PROBE_TTL_MS) {
return cachedProbe.result;
}
let result: ProbeResult;
try {
const info = await probe(url);
result = { ok: true, version: info.version };
} catch (error) {
result = { ok: false, version: null, error: asError(error).body.error };
}
cachedProbe = { at: Date.now(), url, result };
return result;
}
/** Forget the cache when the credential changes — the next status must not answer for the old server. */
const forgetProbe = (): void => {
cachedProbe = null;
};
/** Cheap enough for a panel to poll: whether a connection exists, and whether it currently works. */
router.get('/status', async (ctx) => {
const connection = await readConnection();
if (!connection.configured) return ctx.json({ ...connection, reachable: false });
const credentials = await readCredentials();
if (!credentials) return ctx.json({ ...connection, reachable: false });
const result = await probeCached(credentials.url);
const version = result.version ?? connection.version;
// Only when something actually changed. `lastProbeOk` is null on a row that has never been probed, so
// the first poll after configuring always writes.
if (connection.lastProbeOk !== result.ok || connection.version !== version) {
await recordProbe(version, result.ok);
}
return result.ok
? ctx.json({ ...connection, version, reachable: true })
: ctx.json({ ...connection, version, reachable: false, error: result.error });
});
// ── The four kinds of host NPM manages ────────────────────────────────────────────────────────────
router.get('/hosts', async (ctx) =>
respond(
ctx,
await withCredentials(async (c) => {
const hosts = await npmRequest<ProxyHost[]>(c, { path: '/nginx/proxy-hosts?expand=certificate,access_list' });
// Sorted by first domain rather than by NPM's creation order: a list read by a person should be
// findable, and NPM's own UI sorts this way too.
return hosts.sort((a, b) => (a.domain_names[0] ?? '').localeCompare(b.domain_names[0] ?? ''));
}),
),
);
router.get('/redirections', async (ctx) =>
respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/redirection-hosts' }))),
);
router.get('/streams', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/streams' }))));
/** NPM calls these "404 hosts" — a domain it serves deliberately as nothing. */
router.get('/dead-hosts', async (ctx) =>
respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/dead-hosts' }))),
);
router.get('/hosts/:id', async (ctx) =>
respond(
ctx,
await withCredentials((c) =>
npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}?expand=certificate,access_list,owner` }),
),
),
);
// ── Certificates and access lists ─────────────────────────────────────────────────────────────────
router.get('/certificates', async (ctx) =>
respond(
ctx,
await withCredentials(async (c) => {
const certificates = await npmRequest<Certificate[]>(c, { path: '/nginx/certificates' });
// The panel's whole job is "which of these is about to expire", so compute it once here rather
// than parsing dates in the browser and getting the timezone wrong.
const now = Date.now();
return certificates
.map((certificate) => ({
...certificate,
daysUntilExpiry: certificate.expires_on
? Math.floor((Date.parse(certificate.expires_on) - now) / 86_400_000)
: null,
}))
.sort((a, b) => (a.daysUntilExpiry ?? 1e9) - (b.daysUntilExpiry ?? 1e9));
}),
),
);
router.get('/access-lists', async (ctx) =>
respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/access-lists?expand=items,clients' }))),
);
// ── The one mutation ──────────────────────────────────────────────────────────────────────────────
/**
* Taking a host in or out of service. The only write, and it is reversible in one click — which is why
* it is here when creating and deleting are not.
*/
router.post('/hosts/:id/:action', async (ctx) => {
const action = ctx.req.param('action');
if (action !== 'enable' && action !== 'disable') return ctx.json({ error: 'Expected enable or disable.' }, 400);
return respond(
ctx,
await withCredentials(async (c) => {
await npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}/${action}`, method: 'POST' });
return { id: Number(ctx.req.param('id')), enabled: action === 'enable' };
}),
);
});
// ── Domains ─────────────────────────────────────────────────────────────────────────────────────────
/**
* Officer's own record of which domains this machine is responsible for, and what DNS says about them.
*
* **Not NPM's.** NPM knows about hosts it serves; it has no idea which domains you own, and cannot — a
* name is only knowable if you already have it. So this is a list the owner curates, with a cached
* observation attached to each entry.
*
* ── The credential half is under `/_config` on purpose ──
*
* A DNS provider API key can rewrite every record for a domain, which is a strictly larger power than the
* NPM login already kept there. Putting those routes under the prefix the manifest marks `ownerOnly` means
* the rule is declared once and enforced by the platform, with nothing in this file deciding it twice.
*
* Reading the domain LIST is not owner-only. Which names this machine serves is the same class of fact as
* which proxy hosts exist, and `hasCredential` is a boolean rather than a secret.
*/
// `Context`, imported — NOT derived from `router.get`. That trick resolves hono's first overload and
// yields `never`, which is the twelve-error bug this same file was fixed for earlier today. Twice is
// enough to say it out loud.
const domainFailure = (ctx: Context<{ Variables: HonoVariables }>, error: unknown) => {
if (error instanceof DomainError) return ctx.json({ error: error.message }, error.status);
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 500);
};
router.get('/domains', async (ctx) => ctx.json(await listDomains()));
/**
* Look a domain up without storing anything — the check button, before you commit to adding it.
*
* A GET with the domain in the query rather than a POST, because it changes nothing. That also means a
* read-level account can use it, which is right: this asks public DNS a question anybody could ask.
*/
router.get('/domains/check', async (ctx) => {
const domain = ctx.req.query('domain') ?? '';
try {
return ctx.json(await dryCheck(domain));
} catch (error) {
return domainFailure(ctx, error);
}
});
router.post('/domains', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as { domain?: string } | null;
try {
return ctx.json(await addDomain(body?.domain ?? ''));
} catch (error) {
return domainFailure(ctx, error);
}
});
router.post('/domains/:id/recheck', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'Expected a domain id.' }, 400);
try {
return ctx.json(await recheckDomain(id));
} catch (error) {
return domainFailure(ctx, error);
}
});
router.delete('/domains/:id', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'Expected a domain id.' }, 400);
// Removing a domain forgets Officer's note about it. It changes nothing at the registrar, nothing in
// NPM, and nothing about whether the site works — worth saying, because "delete domain" reads much
// larger than it is.
await removeDomain(id);
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()));
router.put('/_config/dns-providers/:provider', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as
| { fields?: Record<string, string>; scope?: string; label?: string }
| null;
const scope = body?.scope === 'broad' ? 'broad' : 'narrow';
try {
return ctx.json(
await saveCredential({
provider: ctx.req.param('provider'),
fields: body?.fields ?? {},
scope,
label: body?.label,
}),
);
} catch (error) {
return domainFailure(ctx, error);
}
});
router.delete('/_config/dns-providers/:provider', async (ctx) => {
await removeCredential(ctx.req.param('provider'));
return ctx.json({ ok: true });
});
// ── The pass-through ────────────────────────────────────────────────────────────────────────────────
/**
* `ALL /api/proxy-manager/npm/*` → `<npm>/api/*`, with a token attached and nothing else changed.
*
* **The permission gate does the authorisation and this route does not re-check it.** `proxy-manager` at
* `read` level permits only safe methods — `isRequestAllowedAtLevel` returns true for GET/HEAD/OPTIONS and
* false for everything else unless it is declared read-only — so a read-level account gets exactly a
* read-only view of NPM through here, with no work in this file. `write` permits the rest. Two places
* deciding the same thing is how they come to disagree.
*
* The query string is forwarded verbatim because NPM's `?expand=` is how half its API is useful, and the
* body is forwarded as raw text rather than re-serialised JSON so that what NPM receives is what the
* caller sent.
*/
router.all('/npm/*', async (ctx) => {
const credentials = await readCredentials();
if (!credentials) {
return ctx.json({ error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true }, 409);
}
const url = new URL(ctx.req.url);
// Everything after `/npm`, so `/npm/nginx/proxy-hosts` reaches NPM as `/api/nginx/proxy-hosts`.
const path = url.pathname.slice(url.pathname.indexOf('/npm') + '/npm'.length) || '/';
const method = ctx.req.method;
const body = method === 'GET' || method === 'HEAD' ? null : await ctx.req.text();
try {
const result = await npmProxy(credentials, { method, path, search: url.search, body });
return new Response(result.body, {
status: result.status,
headers: { 'content-type': result.contentType ?? 'application/json' },
});
} catch (error) {
// Only reachable when NPM could not be spoken to at all — `npmProxy` returns every answer it gets,
// including the bad ones, because the caller is meant to see NPM's own verdict.
const shaped = asError(error);
return ctx.json(shaped.body, shaped.status);
}
});
// ── Settings — owner only, by the manifest ────────────────────────────────────────────────────────
router.get('/_config', async (ctx) => ctx.json(await readConnection()));
router.put('/_config', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null;
if (!body?.url || !body.identity) return ctx.json({ error: 'url and identity are required.' }, 400);
try {
const saved = await saveConnection({ url: body.url, identity: body.identity, secret: body.secret });
// The old credential's token must not outlive the credential itself.
const credentials = await readCredentials();
if (credentials) forgetToken(credentials);
forgetProbe();
return ctx.json(saved);
} catch (error) {
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400);
}
});
router.delete('/_config', async (ctx) => {
const credentials = await readCredentials();
if (credentials) forgetToken(credentials);
forgetProbe();
await deleteConnection();
return ctx.json({ configured: false });
});
/**
* Test a credential WITHOUT storing it, so a wrong password never replaces a working one.
*
* Declared in the manifest as a `readOnlyWrites` path: it is a POST that changes nothing.
*/
router.post('/_config/test', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null;
if (!body?.url) return ctx.json({ error: 'url is required.' }, 400);
try {
const info = await probe(body.url);
if (!body.identity || !body.secret) {
return ctx.json({ reachable: true, authenticated: false, version: info.version, setup: info.setup });
}
await npmRequest<unknown>({ url: body.url, identity: body.identity, secret: body.secret }, { path: '/nginx/proxy-hosts' });
return ctx.json({ reachable: true, authenticated: true, version: info.version, setup: info.setup });
} catch (error) {
const shaped = asError(error);
return ctx.json({ reachable: !(shaped.body as { unreachable?: boolean }).unreachable, authenticated: false, ...shaped.body }, 200);
}
});