Files
proxy-manager/api/npm-client.ts
T
pastilhasandClaude Fable 5 5914e96cf3 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>
2026-08-25 08:38:15 +00:00

155 lines
6.3 KiB
TypeScript

/**
* 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,
};
}