this plugin refused to create, edit or delete anything, and argued the refusal was care: NPM's own UI does those well and each is a form with real consequences. the owner's call on 2026-08-25 is that the argument ends up somewhere else — "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" and a curated surface cannot keep up anyway. every NPM feature we did not wrap is one the owner cannot reach through Officer at all, so the plugin becomes a worse NPM rather than a way into the real one. so /api/proxy-manager/npm/* forwards method, path, query and body, attaches a token, and returns NPM's own status and body unchanged — errors included. a proxy that improves the answers is not a proxy. authentication is the only thing it does not pass through, which is the entire job. the permission gate needed nothing: isRequestAllowedAtLevel already returns true for safe methods and false otherwise, so read level IS a read-only view of NPM through here, with no code in this file deciding it twice. the curated routes stay, for the panels. a screen wants sorted hosts and a computed daysUntilExpiry; an agent wants NPM. what replaces the refusal is confirmation, and it lives in the skill because that is what it is — an instruction, not a boundary. saying so is better than a boundary that only looks like one. the skill now carries NPM's API, names what must be confirmed before it happens (deleting, editing a live upstream, touching a certificate in use), says creating needs no ceremony, and forbids reaching NPM any way but this route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
334 lines
16 KiB
TypeScript
334 lines
16 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';
|
|
|
|
/**
|
|
* 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' };
|
|
}),
|
|
);
|
|
});
|
|
|
|
// ── 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);
|
|
}
|
|
});
|