proxy-manager: nginx proxy manager in officer
reads hosts and certificates against a live NPM 2.15.1. one grantable permission, with /_config owner-only — reading which hosts are healthy is worth sharing, an NPM admin login is not, and NPM has no read-only role. the quirk that shapes the whole plugin: NPM answers an unauthenticated read with 404, not 401, because its router only registers a resource once a token is present. so 'not signed in' and 'no such endpoint' are the same response, and a naive client tells you a feature is missing when your password is wrong. the client mints a fresh token and retries once, then reports a persistent 404 as likelyAuth. certificates sort by expiry rather than name, which is the only ordering that answers a question. no create/edit/delete and no cert issuance: NPM's own UI does those and a wrong proxy host takes a domain off the internet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { db } from 'officerdb/db';
|
||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { proxyManagerConnection, type ProxyManagerConnection } from '../db/schema';
|
||||
import { normaliseUrl, type NpmCredentials } from './npm-client';
|
||||
|
||||
/**
|
||||
* Reading and writing the one connection row.
|
||||
*
|
||||
* The secret is encrypted with this plugin's own purpose key — `encryptSecret('proxy-manager', …)` — so
|
||||
* a leak of that key opens this column and nothing else. That is the per-purpose split the platform's
|
||||
* secret store already decided on; this only follows it.
|
||||
*
|
||||
* **The secret never leaves this module in plaintext except into `npmRequest`.** There is no route that
|
||||
* returns it, at any permission level, to anybody, including the owner. A settings form that wants to
|
||||
* show "a secret is set" gets a boolean.
|
||||
*/
|
||||
|
||||
const PURPOSE = 'proxy-manager';
|
||||
|
||||
export type ConnectionView = {
|
||||
configured: boolean;
|
||||
url: string | null;
|
||||
identity: string | null;
|
||||
/** Never the secret itself. Only whether one is stored. */
|
||||
hasSecret: boolean;
|
||||
version: string | null;
|
||||
lastProbeAt: string | null;
|
||||
lastProbeOk: boolean | null;
|
||||
};
|
||||
|
||||
const row = async (): Promise<ProxyManagerConnection | null> => {
|
||||
const rows = await db.select().from(proxyManagerConnection).limit(1);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
export async function readConnection(): Promise<ConnectionView> {
|
||||
const found = await row();
|
||||
if (!found) {
|
||||
return { configured: false, url: null, identity: null, hasSecret: false, version: null, lastProbeAt: null, lastProbeOk: null };
|
||||
}
|
||||
return {
|
||||
configured: true,
|
||||
url: found.url,
|
||||
identity: found.identity,
|
||||
hasSecret: found.secret.length > 0,
|
||||
version: found.version,
|
||||
lastProbeAt: found.lastProbeAt ? found.lastProbeAt.toISOString() : null,
|
||||
lastProbeOk: found.lastProbeOk === null ? null : found.lastProbeOk === 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decrypted, for the client only. Never serialise the result of this. */
|
||||
export async function readCredentials(): Promise<NpmCredentials | null> {
|
||||
const found = await row();
|
||||
if (!found) return null;
|
||||
return { url: found.url, identity: found.identity, secret: decryptSecret(PURPOSE, found.secret) };
|
||||
}
|
||||
|
||||
export type SaveParams = { url: string; identity: string; secret?: string };
|
||||
|
||||
/**
|
||||
* `secret` omitted means "keep the stored one" — so the owner can correct a typo in the URL without
|
||||
* retyping a password, and so a settings form never has to round-trip the secret to send it back.
|
||||
*/
|
||||
export async function saveConnection(params: SaveParams): Promise<ConnectionView> {
|
||||
const url = normaliseUrl(params.url);
|
||||
const identity = params.identity.trim();
|
||||
const existing = await row();
|
||||
|
||||
if (!existing && !params.secret) throw new Error('A secret is required the first time the connection is set.');
|
||||
|
||||
const secret = params.secret ? encryptSecret(PURPOSE, params.secret) : (existing?.secret as string);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(proxyManagerConnection)
|
||||
.set({ url, identity, secret, updatedAt: new Date() })
|
||||
.where(eq(proxyManagerConnection.id, existing.id));
|
||||
} else {
|
||||
await db.insert(proxyManagerConnection).values({ url, identity, secret });
|
||||
}
|
||||
return readConnection();
|
||||
}
|
||||
|
||||
export async function deleteConnection(): Promise<void> {
|
||||
await db.delete(proxyManagerConnection);
|
||||
}
|
||||
|
||||
export async function recordProbe(version: string | null, ok: boolean): Promise<void> {
|
||||
const existing = await row();
|
||||
if (!existing) return;
|
||||
await db
|
||||
.update(proxyManagerConnection)
|
||||
.set({ version, lastProbeAt: new Date(), lastProbeOk: ok ? 1 : 0 })
|
||||
.where(eq(proxyManagerConnection.id, existing.id));
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* A client for Nginx Proxy Manager's admin API (verified against 2.15.1).
|
||||
*
|
||||
* ── The quirk worth knowing before you debug anything here ──
|
||||
*
|
||||
* NPM answers an unauthenticated read with **404, not 401**:
|
||||
*
|
||||
* GET /api/nginx/proxy-hosts -> {"error":{"code":404,"message":"Not Found - /nginx/proxy-hosts"}}
|
||||
*
|
||||
* Its router only registers a resource once the request carries a token, so "not signed in" and "no such
|
||||
* endpoint" are the same response. Left alone that produces the worst kind of bug report — a feature that
|
||||
* reads as missing when the credential is simply wrong. So a 404 on a path we know exists is reported as
|
||||
* an authentication failure, and `NpmError.likelyAuth` carries that judgement rather than hiding it.
|
||||
*
|
||||
* ── Tokens ──
|
||||
*
|
||||
* `POST /api/tokens {identity, secret}` returns `{token, expires}`. Tokens are cached in memory only,
|
||||
* never persisted: the credential is already stored encrypted, and a second copy of a derived token on
|
||||
* disk would be a second thing to protect for no benefit.
|
||||
*/
|
||||
|
||||
export type NpmCredentials = { url: string; identity: string; secret: string };
|
||||
|
||||
export class NpmError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
/** NPM cannot distinguish "unauthenticated" from "no such route" — see the note above. */
|
||||
readonly likelyAuth: boolean = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'NpmError';
|
||||
}
|
||||
}
|
||||
|
||||
type TokenCacheEntry = { token: string; expiresAt: number };
|
||||
|
||||
/** Keyed by url+identity, so changing the credential cannot serve a token minted for the old one. */
|
||||
const tokenCache = new Map<string, TokenCacheEntry>();
|
||||
|
||||
const cacheKey = (c: NpmCredentials) => `${c.url} ${c.identity}`;
|
||||
|
||||
export const forgetToken = (c: NpmCredentials): void => {
|
||||
tokenCache.delete(cacheKey(c));
|
||||
};
|
||||
|
||||
export const normaliseUrl = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
return body.error?.message ?? `${response.status} ${response.statusText}`;
|
||||
} catch {
|
||||
return `${response.status} ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function mintToken(credentials: NpmCredentials, timeoutMs: number): Promise<string> {
|
||||
const response = await fetch(`${normaliseUrl(credentials.url)}/api/tokens`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ identity: credentials.identity, secret: credentials.secret }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
}).catch((error: Error) => {
|
||||
throw new NpmError(`Cannot reach Nginx Proxy Manager at ${credentials.url}: ${error.message}`, 0);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// NPM returns 400 "Invalid email or password" for a bad credential, not 401.
|
||||
const message = await readError(response);
|
||||
throw new NpmError(message, response.status, /invalid email or password/i.test(message));
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { token?: string; expires?: string };
|
||||
if (!body.token) throw new NpmError('Nginx Proxy Manager returned no token', 500);
|
||||
|
||||
const expiresAt = body.expires ? Date.parse(body.expires) : Date.now() + 60 * 60 * 1000;
|
||||
// A minute of headroom: a token that expires mid-flight fails as a 404, which reads as a missing route.
|
||||
tokenCache.set(cacheKey(credentials), { token: body.token, expiresAt: expiresAt - 60_000 });
|
||||
return body.token;
|
||||
}
|
||||
|
||||
async function tokenFor(credentials: NpmCredentials, timeoutMs: number): Promise<string> {
|
||||
const cached = tokenCache.get(cacheKey(credentials));
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
||||
return mintToken(credentials, timeoutMs);
|
||||
}
|
||||
|
||||
export type NpmRequest = { path: string; method?: string; body?: unknown; timeoutMs?: number };
|
||||
|
||||
export async function npmRequest<T>(credentials: NpmCredentials, request: NpmRequest): Promise<T> {
|
||||
const timeoutMs = request.timeoutMs ?? 8_000;
|
||||
const base = normaliseUrl(credentials.url);
|
||||
|
||||
const send = async (token: string): Promise<Response> =>
|
||||
fetch(`${base}/api${request.path}`, {
|
||||
method: request.method ?? 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(request.body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
}).catch((error: Error) => {
|
||||
throw new NpmError(`Cannot reach Nginx Proxy Manager at ${base}: ${error.message}`, 0);
|
||||
});
|
||||
|
||||
let response = await send(await tokenFor(credentials, timeoutMs));
|
||||
|
||||
// A cached token NPM has already forgotten looks like a missing route. Mint once and retry before
|
||||
// concluding anything — without this, a restarted NPM makes every panel look broken until a reload.
|
||||
if (response.status === 404 || response.status === 401) {
|
||||
forgetToken(credentials);
|
||||
response = await send(await mintToken(credentials, timeoutMs));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await readError(response);
|
||||
throw new NpmError(
|
||||
response.status === 404
|
||||
? `Nginx Proxy Manager refused "${request.path}". Its API answers 404 when unauthenticated, so this is most likely the credential rather than a missing endpoint.`
|
||||
: message,
|
||||
response.status,
|
||||
response.status === 404 || response.status === 401,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** `GET /api/` needs no token — the cheapest proof that a URL is an NPM at all. */
|
||||
export async function probe(url: string, timeoutMs = 5_000): Promise<{ version: string; setup: boolean }> {
|
||||
const response = await fetch(`${normaliseUrl(url)}/api/`, { signal: AbortSignal.timeout(timeoutMs) }).catch(
|
||||
(error: Error) => {
|
||||
throw new NpmError(`Cannot reach ${url}: ${error.message}`, 0);
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new NpmError(`${url} answered ${response.status} — is that an Nginx Proxy Manager?`, response.status);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
status?: string;
|
||||
setup?: boolean;
|
||||
version?: { major: number; minor: number; revision: number };
|
||||
};
|
||||
if (!body.version) throw new NpmError(`${url} answered, but not like an Nginx Proxy Manager`, response.status);
|
||||
|
||||
return {
|
||||
version: `${body.version.major}.${body.version.minor}.${body.version.revision}`,
|
||||
setup: body.setup ?? false,
|
||||
};
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
import { createRouter } 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 },
|
||||
status: error.status === 0 ? 503 : error.likelyAuth ? 502 : 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 };
|
||||
}
|
||||
}
|
||||
|
||||
const respond = (ctx: Parameters<Parameters<typeof router.get>[1]>[0], result: Awaited<ReturnType<typeof withCredentials>>) =>
|
||||
result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500);
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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 });
|
||||
|
||||
try {
|
||||
const info = await probe(credentials.url);
|
||||
await recordProbe(info.version, true);
|
||||
return ctx.json({ ...connection, version: info.version, reachable: true });
|
||||
} catch (error) {
|
||||
await recordProbe(connection.version, false);
|
||||
const shaped = asError(error);
|
||||
return ctx.json({ ...connection, reachable: false, error: shaped.body.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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user