the clever type cost twelve errors; the untested rule was the load-bearing one
four fixes from a review, and the first one was leaking out of the plugin. TYPING. `respond` derived its context from `Parameters<typeof router.get>[1]`. hono's .get is overloaded, `Parameters<>` resolves the FIRST overload — `[path: string]` — so index 1 was `undefined`, ctx was `never`, and all nine handlers errored. twelve errors from one line, in a tree where `bunx tsgo` is shared with the platform and expected clean. imports hono's `Context` instead. tsgo now exits 0 tree-wide, which it has not done since this plugin landed. READONLYWRITES. `/_config/test` was declared as a read-only write so a member would not meet a 403 on the test button. they always would: authorize.ts asks isOwnerOnlyRoute before isRequestAllowedAtLevel, and ownerOnly is prefix matched, so `/_config` already covered it and the entry was inert. removed, and the reason it SHOULD be inert written down — that request carries the admin secret in its body, so a member who could call it would hold an oracle pointed at somebody else's password. STATUS WROTE ON EVERY POLL. recordProbe ran unconditionally on a GET whose own comment invites polling, so a read endpoint issued an UPDATE per poll per open panel. the probe is cached 30s and the row is written only when the verdict or the version changes — a flapping NPM still records every transition, which is the case the column exists for. the cache is dropped when the credential does. DEAD TERNARY. `likelyAuth ? 502 : 502`. and the tests. 22 over a stubbed fetch, 99% of npm-client.ts. the two that matter are the 404-means-credential rule and the mint-once-and-retry: both are silent when broken, and a refactor of either turns a working panel into one that says "not found" forever. every test uses its own credential because the token cache is module state — sharing one would let an earlier test's token satisfy the retry test, which would then pass for the wrong reason. still not installed, still no settings form, still no db:push. README says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
Nginx Proxy Manager inside Officer — which hosts are running, whether they are healthy, and which
|
||||
certificates are about to expire.
|
||||
|
||||
**Status: first round. Reads work against a live NPM 2.15.1; the settings form is not built yet** —
|
||||
see "What is not built" below.
|
||||
**Status: first round. Reads work against a live NPM 2.15.1; the settings form is not built and the
|
||||
plugin is not installed** — see "What is not built" below.
|
||||
|
||||
---
|
||||
|
||||
@@ -57,6 +57,12 @@ for, and the same shape Jellyfin uses for its admin token.
|
||||
**The secret is never returned across the API boundary**, at any level, to anybody, including the owner.
|
||||
A settings form asking "is one set" gets a boolean.
|
||||
|
||||
`POST /_config/test` is owner-only too, and that is on purpose rather than by accident of the prefix
|
||||
match. It is a genuine read-only write — it validates a credential and stores nothing — but the request
|
||||
carries the admin secret in its BODY, so a member who could call it would have an oracle pointed at
|
||||
somebody else's password. Testing a connection belongs to whoever owns it. (Declaring it in
|
||||
`readOnlyWrites` would also have done nothing: `authorize.ts` asks `isOwnerOnlyRoute` first.)
|
||||
|
||||
---
|
||||
|
||||
## The connection
|
||||
@@ -89,7 +95,10 @@ endpoint"* are the same response. Left alone that produces the worst kind of bug
|
||||
reads as missing when the credential is simply wrong.
|
||||
|
||||
So `npm-client.ts` treats a 404 on a path it knows exists as an authentication failure, carries that as
|
||||
`NpmError.likelyAuth`, and the panel turns it into a sentence pointing at settings. It also mints a fresh
|
||||
`NpmError.likelyAuth`, and the panel turns it into a sentence pointing at settings. Both halves of that
|
||||
— the rule and the single retry — are the plugin's most load-bearing behaviour and its only tested one:
|
||||
`api/npm-client.test.ts` covers them against a stubbed fetch, because a refactor of either fails silently
|
||||
and turns a working panel into one that says "not found" forever. It also mints a fresh
|
||||
token and retries once before concluding anything — otherwise a restarted NPM makes every panel look
|
||||
broken until a reload.
|
||||
|
||||
@@ -105,17 +114,21 @@ Verified against the live instance: a wrong credential comes back `likelyAuth =
|
||||
renders it yet, so the connection has to be inserted by hand for now.
|
||||
- **`db:push` has not been run**, so `proxy_manager_connection` does not exist in Postgres yet.
|
||||
- **Redirections, streams, 404 hosts and access lists** have routes and no panels.
|
||||
- **Nothing is polled.** Both panels load once and have a refresh button.
|
||||
- **No tests.** The client's behaviour was verified by hand against the live NPM; that should be a test
|
||||
with a stubbed fetch.
|
||||
- **Nothing is polled.** Both panels load once and have a refresh button. `GET /status` is built to be
|
||||
polled when something does: the probe is cached for 30 seconds and the row is written only when the
|
||||
verdict or the version changes, so a poll is not an UPDATE.
|
||||
- **It is not installed.** `plugin_installs` has no row for it and `proxy_manager_connection` does not
|
||||
exist in Postgres, so nothing here has been through the permission gate, `assertPermissionTotality` or
|
||||
a real request. The reads were verified by direct invocation against a live NPM 2.15.1.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
manifest.ts permission, ownerOnly, readOnlyWrites
|
||||
manifest.ts permission, ownerOnly
|
||||
api/npm-client.ts tokens, the 404 quirk, retry
|
||||
api/npm-client.test.ts 22 tests over a stubbed fetch
|
||||
api/connection.ts the one row; encryption; the secret never leaves
|
||||
api/router.ts mounted at /api/proxy-manager
|
||||
db/schema.ts proxy_manager_connection
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
||||
import { NpmError, forgetToken, normaliseUrl, npmRequest, probe } from './npm-client';
|
||||
|
||||
/**
|
||||
* The client, against a stubbed `fetch`.
|
||||
*
|
||||
* Two behaviours here are worth a test more than anything else in the plugin, because both are invisible
|
||||
* when they break: **a 404 means the credential, not a missing route**, and **a stale token is re-minted
|
||||
* once before anything is concluded**. Neither throws, neither logs, and a plausible refactor of either
|
||||
* turns a working panel into one that says "not found" forever.
|
||||
*
|
||||
* The token cache is module state keyed on `url + identity`, so every test uses its own credential and
|
||||
* clears it afterwards. Sharing one would let an earlier test's cached token satisfy a later one, and the
|
||||
* test that then passes for the wrong reason is the retry test.
|
||||
*/
|
||||
|
||||
type StubbedCall = { url: string; init: RequestInit | undefined };
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
let calls: StubbedCall[] = [];
|
||||
|
||||
/** Queue one Response per expected call, in order. A call past the end of the queue fails loudly. */
|
||||
const stubFetch = (responses: (() => Response | Promise<Response>)[]): void => {
|
||||
let index = 0;
|
||||
calls = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init });
|
||||
const next = responses[index++];
|
||||
if (!next) throw new Error(`unexpected fetch #${index} to ${String(input)}`);
|
||||
return next();
|
||||
}) as typeof fetch;
|
||||
};
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const token = (value: string, expires?: string) => () => json({ token: value, expires });
|
||||
const npmNotFound = (path: string) => () => json({ error: { code: 404, message: `Not Found - ${path}` } }, 404);
|
||||
|
||||
let counter = 0;
|
||||
const freshCredentials = () => ({ url: 'http://npm.test', identity: `admin${counter++}@test`, secret: 'pw' });
|
||||
|
||||
let used: ReturnType<typeof freshCredentials>[] = [];
|
||||
const credentials = () => {
|
||||
const c = freshCredentials();
|
||||
used.push(c);
|
||||
return c;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
used = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
for (const c of used) forgetToken(c);
|
||||
});
|
||||
|
||||
describe('normaliseUrl', () => {
|
||||
test('strips trailing slashes and surrounding space', () => {
|
||||
expect(normaliseUrl(' http://npm.test/ ')).toBe('http://npm.test');
|
||||
expect(normaliseUrl('http://npm.test///')).toBe('http://npm.test');
|
||||
expect(normaliseUrl('http://npm.test')).toBe('http://npm.test');
|
||||
});
|
||||
|
||||
// The base is concatenated with `/api${path}`, so a surviving slash produces `//api/...`, which NPM
|
||||
// answers with — of course — a 404, and then the 404 rule blames the credential.
|
||||
test('a normalised url produces exactly one slash before /api', async () => {
|
||||
const c = { ...credentials(), url: 'http://npm.test/' };
|
||||
stubFetch([token('t'), () => json([])]);
|
||||
await npmRequest(c, { path: '/nginx/proxy-hosts' });
|
||||
expect(calls[1]?.url).toBe('http://npm.test/api/nginx/proxy-hosts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokens', () => {
|
||||
test('mints once and reuses the cached token', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t1'), () => json([]), () => json([])]);
|
||||
|
||||
await npmRequest(c, { path: '/nginx/proxy-hosts' });
|
||||
await npmRequest(c, { path: '/nginx/certificates' });
|
||||
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]?.url).toBe('http://npm.test/api/tokens');
|
||||
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
|
||||
expect(calls[2]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
|
||||
});
|
||||
|
||||
test('the cache is keyed on identity, so a changed credential cannot reuse a token', async () => {
|
||||
const first = credentials();
|
||||
const second = { ...credentials(), url: first.url };
|
||||
|
||||
stubFetch([token('t1'), () => json([]), token('t2'), () => json([])]);
|
||||
await npmRequest(first, { path: '/nginx/proxy-hosts' });
|
||||
await npmRequest(second, { path: '/nginx/proxy-hosts' });
|
||||
|
||||
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
|
||||
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
|
||||
});
|
||||
|
||||
test('forgetToken forces a fresh mint', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t1'), () => json([]), token('t2'), () => json([])]);
|
||||
|
||||
await npmRequest(c, { path: '/nginx/proxy-hosts' });
|
||||
forgetToken(c);
|
||||
await npmRequest(c, { path: '/nginx/proxy-hosts' });
|
||||
|
||||
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
|
||||
});
|
||||
|
||||
// A token cached right up to its stated expiry fails mid-flight as a 404, which reads as a missing
|
||||
// route. The client keeps a minute of headroom; an already-expired one must never be served.
|
||||
test('an expiry inside the headroom window is not cached', async () => {
|
||||
const c = credentials();
|
||||
const soon = new Date(Date.now() + 30_000).toISOString();
|
||||
stubFetch([token('t1', soon), () => json([]), token('t2', soon), () => json([])]);
|
||||
|
||||
await npmRequest(c, { path: '/nginx/proxy-hosts' });
|
||||
await npmRequest(c, { path: '/nginx/proxy-hosts' });
|
||||
|
||||
expect(calls[2]?.url).toBe('http://npm.test/api/tokens');
|
||||
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
|
||||
});
|
||||
|
||||
test('a bad credential is reported as auth, not as a 400', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([() => json({ error: { message: 'Invalid email or password' } }, 400)]);
|
||||
|
||||
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
|
||||
expect(error).toBeInstanceOf(NpmError);
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.likelyAuth).toBe(true);
|
||||
});
|
||||
|
||||
test('a token response with no token is an error rather than a bearer of undefined', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([() => json({ expires: '2030-01-01T00:00:00Z' })]);
|
||||
|
||||
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
|
||||
expect(error).toBeInstanceOf(NpmError);
|
||||
expect(error.message).toMatch(/no token/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the 404 quirk', () => {
|
||||
// The whole reason this client exists in the shape it does. NPM registers a resource only once the
|
||||
// request carries a token, so unauthenticated and non-existent are the same response.
|
||||
test('a stale token is re-minted once and the request retried', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('stale'), npmNotFound('/nginx/proxy-hosts'), token('fresh'), () => json([{ id: 1 }])]);
|
||||
|
||||
const hosts = await npmRequest<{ id: number }[]>(c, { path: '/nginx/proxy-hosts' });
|
||||
|
||||
expect(hosts).toEqual([{ id: 1 }]);
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer stale' });
|
||||
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer fresh' });
|
||||
});
|
||||
|
||||
test('401 gets the same retry as 404', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('stale'), () => json({ error: { message: 'nope' } }, 401), token('fresh'), () => json([])]);
|
||||
|
||||
await npmRequest(c, { path: '/nginx/proxy-hosts' });
|
||||
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer fresh' });
|
||||
});
|
||||
|
||||
test('a 404 that survives the retry blames the credential, not the route', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t1'), npmNotFound('/nginx/proxy-hosts'), token('t2'), npmNotFound('/nginx/proxy-hosts')]);
|
||||
|
||||
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
|
||||
expect(error.status).toBe(404);
|
||||
expect(error.likelyAuth).toBe(true);
|
||||
expect(error.message).toMatch(/credential/i);
|
||||
});
|
||||
|
||||
test('retries exactly once — a second 404 does not mint a third token', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t1'), npmNotFound('/x'), token('t2'), npmNotFound('/x')]);
|
||||
|
||||
await npmRequest(c, { path: '/x' }).catch(() => undefined);
|
||||
expect(calls.filter((call) => call.url.endsWith('/api/tokens'))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requests', () => {
|
||||
test('a body is sent as JSON with a content-type; a bodyless request has neither', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t'), () => json({ ok: true }), () => json({ ok: true })]);
|
||||
|
||||
await npmRequest(c, { path: '/x', method: 'POST', body: { a: 1 } });
|
||||
await npmRequest(c, { path: '/y' });
|
||||
|
||||
expect(calls[1]?.init?.method).toBe('POST');
|
||||
expect(calls[1]?.init?.headers).toMatchObject({ 'content-type': 'application/json' });
|
||||
expect(calls[1]?.init?.body).toBe('{"a":1}');
|
||||
expect(calls[2]?.init?.body).toBeUndefined();
|
||||
expect(calls[2]?.init?.headers).not.toMatchObject({ 'content-type': 'application/json' });
|
||||
});
|
||||
|
||||
// NPM answers 204 to some writes. `response.json()` on an empty body throws, and that throw would
|
||||
// surface as an unshaped 500 rather than a success.
|
||||
test('204 resolves to undefined instead of failing to parse', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t'), () => new Response(null, { status: 204 })]);
|
||||
expect(await npmRequest(c, { path: '/x', method: 'POST' })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('an unreachable server is status 0, not a raw fetch error', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([
|
||||
() => {
|
||||
throw new Error('connect ECONNREFUSED');
|
||||
},
|
||||
]);
|
||||
|
||||
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
|
||||
expect(error).toBeInstanceOf(NpmError);
|
||||
expect(error.status).toBe(0);
|
||||
expect(error.message).toMatch(/Cannot reach/);
|
||||
});
|
||||
|
||||
test("an error body's message is preferred over the status text", async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t'), () => json({ error: { message: 'Access list not found' } }, 400)]);
|
||||
|
||||
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
|
||||
expect(error.message).toBe('Access list not found');
|
||||
expect(error.likelyAuth).toBe(false);
|
||||
});
|
||||
|
||||
test('a non-JSON error body falls back to the status rather than throwing', async () => {
|
||||
const c = credentials();
|
||||
stubFetch([token('t'), () => new Response('<html>502</html>', { status: 502 })]);
|
||||
|
||||
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
|
||||
expect(error).toBeInstanceOf(NpmError);
|
||||
expect(error.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probe', () => {
|
||||
test('reads the version from GET /api/ and sends no token', async () => {
|
||||
stubFetch([() => json({ status: 'OK', setup: true, version: { major: 2, minor: 15, revision: 1 } })]);
|
||||
|
||||
expect(await probe('http://npm.test/')).toEqual({ version: '2.15.1', setup: true });
|
||||
expect(calls[0]?.url).toBe('http://npm.test/api/');
|
||||
expect(calls[0]?.init?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
test('setup defaults to false when NPM omits it', async () => {
|
||||
stubFetch([() => json({ version: { major: 2, minor: 15, revision: 1 } })]);
|
||||
expect((await probe('http://npm.test')).setup).toBe(false);
|
||||
});
|
||||
|
||||
test('something that answers but is not an NPM is rejected', async () => {
|
||||
stubFetch([() => json({ hello: 'world' })]);
|
||||
|
||||
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
|
||||
expect(error).toBeInstanceOf(NpmError);
|
||||
expect(error.message).toMatch(/not like an Nginx Proxy Manager/);
|
||||
});
|
||||
|
||||
test('a non-200 names the url rather than reporting a parse failure', async () => {
|
||||
stubFetch([() => new Response('nope', { status: 500 })]);
|
||||
|
||||
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
|
||||
expect(error.status).toBe(500);
|
||||
expect(error.message).toMatch(/is that an Nginx Proxy Manager/);
|
||||
});
|
||||
|
||||
test('unreachable is status 0', async () => {
|
||||
stubFetch([
|
||||
() => {
|
||||
throw new Error('ETIMEDOUT');
|
||||
},
|
||||
]);
|
||||
|
||||
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
|
||||
expect(error.status).toBe(0);
|
||||
});
|
||||
});
|
||||
+62
-11
@@ -1,4 +1,5 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter, type HonoVariables } from '@@/create-router';
|
||||
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
|
||||
import { NpmError, npmRequest, probe, forgetToken } from './npm-client';
|
||||
|
||||
@@ -51,7 +52,10 @@ 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,
|
||||
// 503 when NPM is not answering at all, 502 for everything it did answer — including a refused
|
||||
// credential, which is upstream's verdict rather than this request's. `likelyAuth` in the body is
|
||||
// what the panel branches on; it does not need a status of its own.
|
||||
status: error.status === 0 ? 503 : 502,
|
||||
} as const;
|
||||
}
|
||||
return { body: { error: error instanceof Error ? error.message : String(error) }, status: 500 } as const;
|
||||
@@ -69,11 +73,53 @@ async function withCredentials<T>(handler: (c: NonNullable<Awaited<ReturnType<ty
|
||||
}
|
||||
}
|
||||
|
||||
const respond = (ctx: Parameters<Parameters<typeof router.get>[1]>[0], result: Awaited<ReturnType<typeof withCredentials>>) =>
|
||||
// `Context` is imported rather than derived from `router.get`. `Parameters<typeof router.get>` resolves
|
||||
// Hono's FIRST overload, `[path: string]`, so index 1 is `undefined` and every handler here typed as
|
||||
// `never` — twelve errors from one clever line, and `bunx tsgo` is shared with the platform.
|
||||
const respond = (ctx: Context<{ Variables: HonoVariables }>, result: Awaited<ReturnType<typeof withCredentials>>) =>
|
||||
result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500);
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The last probe, in memory, so a polled endpoint does not write to Postgres on every call.
|
||||
*
|
||||
* `GET /status` is meant to be polled — two panels open is two callers — and it used to `recordProbe`
|
||||
* unconditionally, so a READ endpoint issued an UPDATE per poll per panel. The row exists to answer
|
||||
* "when was this last known good" across a restart, which needs a write when the answer CHANGES, not
|
||||
* when it is confirmed.
|
||||
*
|
||||
* So: the probe itself is cached for `PROBE_TTL_MS`, and the row is written only when the verdict or the
|
||||
* version differs from what is already stored. A flapping NPM still writes every transition, which is
|
||||
* exactly the case worth recording.
|
||||
*/
|
||||
const PROBE_TTL_MS = 30_000;
|
||||
|
||||
type ProbeResult = { ok: boolean; version: string | null; error?: string };
|
||||
let cachedProbe: { at: number; url: string; result: ProbeResult } | null = null;
|
||||
|
||||
async function probeCached(url: string): Promise<ProbeResult> {
|
||||
if (cachedProbe && cachedProbe.url === url && Date.now() - cachedProbe.at < PROBE_TTL_MS) {
|
||||
return cachedProbe.result;
|
||||
}
|
||||
|
||||
let result: ProbeResult;
|
||||
try {
|
||||
const info = await probe(url);
|
||||
result = { ok: true, version: info.version };
|
||||
} catch (error) {
|
||||
result = { ok: false, version: null, error: asError(error).body.error };
|
||||
}
|
||||
|
||||
cachedProbe = { at: Date.now(), url, result };
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Forget the cache when the credential changes — the next status must not answer for the old server. */
|
||||
const forgetProbe = (): void => {
|
||||
cachedProbe = null;
|
||||
};
|
||||
|
||||
/** 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();
|
||||
@@ -82,15 +128,18 @@ router.get('/status', async (ctx) => {
|
||||
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 });
|
||||
const result = await probeCached(credentials.url);
|
||||
const version = result.version ?? connection.version;
|
||||
|
||||
// Only when something actually changed. `lastProbeOk` is null on a row that has never been probed, so
|
||||
// the first poll after configuring always writes.
|
||||
if (connection.lastProbeOk !== result.ok || connection.version !== version) {
|
||||
await recordProbe(version, result.ok);
|
||||
}
|
||||
|
||||
return result.ok
|
||||
? ctx.json({ ...connection, version, reachable: true })
|
||||
: ctx.json({ ...connection, version, reachable: false, error: result.error });
|
||||
});
|
||||
|
||||
// ── The four kinds of host NPM manages ────────────────────────────────────────────────────────────
|
||||
@@ -185,6 +234,7 @@ router.put('/_config', async (ctx) => {
|
||||
// The old credential's token must not outlive the credential itself.
|
||||
const credentials = await readCredentials();
|
||||
if (credentials) forgetToken(credentials);
|
||||
forgetProbe();
|
||||
return ctx.json(saved);
|
||||
} catch (error) {
|
||||
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||
@@ -194,6 +244,7 @@ router.put('/_config', async (ctx) => {
|
||||
router.delete('/_config', async (ctx) => {
|
||||
const credentials = await readCredentials();
|
||||
if (credentials) forgetToken(credentials);
|
||||
forgetProbe();
|
||||
await deleteConnection();
|
||||
return ctx.json({ configured: false });
|
||||
});
|
||||
|
||||
+11
-5
@@ -34,12 +34,18 @@ export const manifest: PluginManifest = {
|
||||
label: 'Proxy Manager',
|
||||
description: 'Nginx Proxy Manager — proxy hosts, redirections, streams and certificates',
|
||||
|
||||
// The connection form and the probe. Everything under here reads or writes the admin credential.
|
||||
// The connection form and the probe. Everything under here reads or writes the admin credential,
|
||||
// and the match is by PREFIX — so `/_config/test` is covered too, deliberately.
|
||||
//
|
||||
// There is no `readOnlyWrites` here, and the omission is the decision rather than an oversight.
|
||||
// `POST /_config/test` is a genuine read-only write: it validates a credential and stores nothing.
|
||||
// Declaring it would still change nothing, because `authorize.ts` asks `isOwnerOnlyRoute` before
|
||||
// `isRequestAllowedAtLevel` — ownerOnly wins, so the entry would be inert.
|
||||
//
|
||||
// And it should be inert. The test request carries the admin secret in its BODY. A member who can
|
||||
// call it can offer this server a credential and be told whether it works, which is an oracle
|
||||
// pointed at somebody else's password. Testing a connection belongs to whoever owns it.
|
||||
ownerOnly: ['/_config'],
|
||||
|
||||
// Probes that look like writes and are not. Without declaring these a read-level account meets
|
||||
// what reads as a broken feature: the "test connection" button 403s.
|
||||
readOnlyWrites: ['/_config/test'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user