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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user