Files
proxy-manager/api/router.ts
T
pastilhasandClaude Opus 5 3380a482ed the clever type cost twelve errors; the untested rule was the load-bearing one
four fixes from a review, and the first one was leaking out of the plugin.

TYPING. `respond` derived its context from `Parameters<typeof router.get>[1]`.
hono's .get is overloaded, `Parameters<>` resolves the FIRST overload — `[path:
string]` — so index 1 was `undefined`, ctx was `never`, and all nine handlers
errored. twelve errors from one line, in a tree where `bunx tsgo` is shared with
the platform and expected clean. imports hono's `Context` instead. tsgo now
exits 0 tree-wide, which it has not done since this plugin landed.

READONLYWRITES. `/_config/test` was declared as a read-only write so a member
would not meet a 403 on the test button. they always would: authorize.ts asks
isOwnerOnlyRoute before isRequestAllowedAtLevel, and ownerOnly is prefix
matched, so `/_config` already covered it and the entry was inert. removed, and
the reason it SHOULD be inert written down — that request carries the admin
secret in its body, so a member who could call it would hold an oracle pointed
at somebody else's password.

STATUS WROTE ON EVERY POLL. recordProbe ran unconditionally on a GET whose own
comment invites polling, so a read endpoint issued an UPDATE per poll per open
panel. the probe is cached 30s and the row is written only when the verdict or
the version changes — a flapping NPM still records every transition, which is
the case the column exists for. the cache is dropped when the credential does.

DEAD TERNARY. `likelyAuth ? 502 : 502`.

and the tests. 22 over a stubbed fetch, 99% of npm-client.ts. the two that
matter are the 404-means-credential rule and the mint-once-and-retry: both are
silent when broken, and a refactor of either turns a working panel into one
that says "not found" forever. every test uses its own credential because the
token cache is module state — sharing one would let an earlier test's token
satisfy the retry test, which would then pass for the wrong reason.

still not installed, still no settings form, still no db:push. README says so.

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

273 lines
12 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, probe, forgetToken } from './npm-client';
/**
* 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.
*
* ── What this deliberately does not do ──
*
* No creating, editing or deleting hosts, and no certificate issuance. NPM's own UI does those well, it
* is one click away, and each one is a form with real consequences — a wrong proxy host takes a domain
* off the internet. This surface answers "what is running, and is it healthy" and lets you take one out
* of service. Building the editor is a decision to make deliberately, not to arrive at by accretion.
*/
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' };
}),
);
});
// ── 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);
}
});