/** * 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(); 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 { 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 { 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 { 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(credentials: NpmCredentials, request: NpmRequest): Promise { const timeoutMs = request.timeoutMs ?? 8_000; const base = normaliseUrl(credentials.url); const send = async (token: string): Promise => 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, }; }