Files
proxy-manager/api/router.ts
T
pastilhasandClaude Opus 5 07cf3317f8 domains: what you own, who runs its DNS, and the keys
one section, not two. a provider serves many domains so the lists differ, but a
person thinks in DOMAINS — the provider is something the check discovers, and a
key only matters as the answer to "can officer do anything for THIS domain".
two tabs would make somebody hold that join in their head, and the providers
tab would be two rows most of the time. so the key is entered from the row that
needs one and stored against its provider: add it for one cloudflare domain and
every other cloudflare domain turns green at once.

THE CHECK runs before you commit, because its answer is usually the reason you
would or would not add it, and a list of unchecked rows is a list somebody has
to remember to revisit.

it reports five verdicts, not two, and the extra three are the interesting
ones. `private` — atelier.officer.dev resolves to 100.64.x, which is CORRECT
and reads as broken against any public-IP comparison. `unknown` — we could not
establish our own address, so "is that us" has no answer and saying so beats a
confident wrong one. `nowhere` — a name bought ten minutes ago. plus
maybeProxied, because a cloudflare domain resolving to cloudflare is a working
setup rather than a misconfiguration.

NS gives the DNS HOST, not the registrar. officer.dev is godaddy on both counts
here, but a domain bought at godaddy and delegated to cloudflare answers
cloudflare — and that is the one that matters, since its API is what places an
ACME challenge. the UI says so where it would otherwise be read as wrong.

only an apex has NS records, so the lookup walks up to find them. verified
live: platform.officer.dev now answers godaddy instead of nothing.

CREDENTIALS are keyed on the provider, encrypted under this plugin's purpose
key, never returned by any route. scope is narrow|broad and NOT read|write —
DNS-01 works by WRITING a TXT record, so no read-only key can ever issue a
certificate, and offering one would produce a setup that looks configured and
fails at the first renewal sixty days later. the dialog says that out loud.

the form is generated from NPM's own dns-providers template, so the field names
are the provider's and it stays right for providers we have never heard of.
the routes live under /_config, which the manifest already marks ownerOnly: a
DNS key can rewrite every record for a domain, which is strictly more than the
NPM login kept there, and declaring it once beats checking it twice.

tables verified by query rather than by db:push's exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 19:27:06 +00:00

446 lines
20 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,
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 });
});
// ── 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);
}
});