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>
155 lines
6.3 KiB
TypeScript
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,
|
|
};
|
|
}
|