From 5914e96cf300f7ad415554460f63760bceabf36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 08:38:15 +0000 Subject: [PATCH] proxy-manager: nginx proxy manager in officer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 2 + README.md | 124 +++++++++++++++++++++ api/connection.ts | 97 +++++++++++++++++ api/npm-client.ts | 154 ++++++++++++++++++++++++++ api/router.ts | 221 ++++++++++++++++++++++++++++++++++++++ db/schema.ts | 48 +++++++++ docs/npm-api.md | 138 ++++++++++++++++++++++++ manifest.ts | 45 ++++++++ web/CertificatesPanel.tsx | 100 +++++++++++++++++ web/HostsPanel.tsx | 194 +++++++++++++++++++++++++++++++++ web/layout.ts | 13 +++ web/panels.ts | 19 ++++ 12 files changed, 1155 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/connection.ts create mode 100644 api/npm-client.ts create mode 100644 api/router.ts create mode 100644 db/schema.ts create mode 100644 docs/npm-api.md create mode 100644 manifest.ts create mode 100644 web/CertificatesPanel.tsx create mode 100644 web/HostsPanel.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac009b7 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# Proxy Manager + +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. + +--- + +## Why it is called `proxy-manager` and not `npm` + +The container is called `npm` and everybody says NPM. But in a repository where every other sentence is +about bun, lockfiles and `npm install`, a plugin directory called `npm` collides with the package +manager in every grep and every conversation. The label says the real name; the id says what it does. + +The same reasoning renamed `officer-claude` to `officer-anthropic-proxy`. Trivial to change while +nothing depends on it. + +--- + +## What it does + +**Hosts** — every proxy host, sorted by domain, showing where it forwards, whether TLS is on and +forced, whether an access list guards it, and whether nginx currently reports it offline. Selecting one +lets you take it out of service or put it back. + +**Certificates** — sorted by how soon they expire, not by name. That ordering is the whole design: a +list sorted by name is a reference, a list sorted by expiry answers the only question anyone has. +Under 30 days is amber, under 14 is red, because Let's Encrypt renews at 30 and anything inside 14 means +renewal has actually failed. + +Redirection hosts, streams, 404 hosts and access lists are exposed by the API and not yet drawn. + +--- + +## What it deliberately does not do + +**No creating, editing or deleting hosts. No certificate issuance.** NPM's own UI does those well and is +one click away, and each is a form with real consequences — a wrong proxy host takes a domain off the +internet. + +This surface answers *"what is running, and is it healthy"*, and the one write it allows is reversible in +a click. Building the editor is a decision to make deliberately, not to arrive at by accretion. + +--- + +## Permissions + +One permission, `proxy-manager`, and it is **grantable**. Reading which hosts exist and whether their +certificates are healthy is genuinely useful to somebody who is not the owner. + +The credential is not. It is an NPM admin login, NPM has no read-only role, and anyone holding it can +redirect any hostname this machine serves. So `/_config` is `ownerOnly` — the split that field exists +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. + +--- + +## The connection + +One row in `proxy_manager_connection` — URL, identity, and a secret encrypted under this plugin's own +purpose key (`encryptSecret('proxy-manager', …)`), so a leak of that key opens this column and nothing +else. + +**One row, not one per user.** NPM's API is admin or nothing, so a per-user credential would be a +per-user copy of total authority over every hostname on the machine. The owner sets it; members granted +the permission read through it without ever seeing it. + +It is a plugin-owned table rather than core `service_connections` — which fits the shape exactly, with +`url`, `username` and an encrypted `secret` — only because `ServiceName` there is a closed TypeScript +union, and this plugin should install into a platform it did not have to edit. Worth revisiting if that +union opens; `db/schema.ts` says so. + +--- + +## The API quirk that shapes this plugin + +**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 `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 +token and retries once before concluding anything — otherwise a restarted NPM makes every panel look +broken until a reload. + +Verified against the live instance: a wrong credential comes back `likelyAuth = true`, and +`POST /api/tokens` answers **400 "Invalid email or password"** rather than 401. + +--- + +## What is not built + +- **The settings form.** `/_config` works — `GET`, `PUT`, `DELETE`, and `POST /_config/test` which + checks a credential *without storing it*, so a wrong password never replaces a working one. Nothing + 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. + +--- + +## Files + +``` +manifest.ts permission, ownerOnly, readOnlyWrites +api/npm-client.ts tokens, the 404 quirk, retry +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 +web/panels.ts two panels +web/layout.ts how they are arranged +``` diff --git a/api/connection.ts b/api/connection.ts new file mode 100644 index 0000000..71bb7a9 --- /dev/null +++ b/api/connection.ts @@ -0,0 +1,97 @@ +import { db } from 'officerdb/db'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; +import { eq } from 'drizzle-orm'; +import { proxyManagerConnection, type ProxyManagerConnection } from '../db/schema'; +import { normaliseUrl, type NpmCredentials } from './npm-client'; + +/** + * Reading and writing the one connection row. + * + * The secret is encrypted with this plugin's own purpose key — `encryptSecret('proxy-manager', …)` — so + * a leak of that key opens this column and nothing else. That is the per-purpose split the platform's + * secret store already decided on; this only follows it. + * + * **The secret never leaves this module in plaintext except into `npmRequest`.** There is no route that + * returns it, at any permission level, to anybody, including the owner. A settings form that wants to + * show "a secret is set" gets a boolean. + */ + +const PURPOSE = 'proxy-manager'; + +export type ConnectionView = { + configured: boolean; + url: string | null; + identity: string | null; + /** Never the secret itself. Only whether one is stored. */ + hasSecret: boolean; + version: string | null; + lastProbeAt: string | null; + lastProbeOk: boolean | null; +}; + +const row = async (): Promise => { + const rows = await db.select().from(proxyManagerConnection).limit(1); + return rows[0] ?? null; +}; + +export async function readConnection(): Promise { + const found = await row(); + if (!found) { + return { configured: false, url: null, identity: null, hasSecret: false, version: null, lastProbeAt: null, lastProbeOk: null }; + } + return { + configured: true, + url: found.url, + identity: found.identity, + hasSecret: found.secret.length > 0, + version: found.version, + lastProbeAt: found.lastProbeAt ? found.lastProbeAt.toISOString() : null, + lastProbeOk: found.lastProbeOk === null ? null : found.lastProbeOk === 1, + }; +} + +/** Decrypted, for the client only. Never serialise the result of this. */ +export async function readCredentials(): Promise { + const found = await row(); + if (!found) return null; + return { url: found.url, identity: found.identity, secret: decryptSecret(PURPOSE, found.secret) }; +} + +export type SaveParams = { url: string; identity: string; secret?: string }; + +/** + * `secret` omitted means "keep the stored one" — so the owner can correct a typo in the URL without + * retyping a password, and so a settings form never has to round-trip the secret to send it back. + */ +export async function saveConnection(params: SaveParams): Promise { + const url = normaliseUrl(params.url); + const identity = params.identity.trim(); + const existing = await row(); + + if (!existing && !params.secret) throw new Error('A secret is required the first time the connection is set.'); + + const secret = params.secret ? encryptSecret(PURPOSE, params.secret) : (existing?.secret as string); + + if (existing) { + await db + .update(proxyManagerConnection) + .set({ url, identity, secret, updatedAt: new Date() }) + .where(eq(proxyManagerConnection.id, existing.id)); + } else { + await db.insert(proxyManagerConnection).values({ url, identity, secret }); + } + return readConnection(); +} + +export async function deleteConnection(): Promise { + await db.delete(proxyManagerConnection); +} + +export async function recordProbe(version: string | null, ok: boolean): Promise { + const existing = await row(); + if (!existing) return; + await db + .update(proxyManagerConnection) + .set({ version, lastProbeAt: new Date(), lastProbeOk: ok ? 1 : 0 }) + .where(eq(proxyManagerConnection.id, existing.id)); +} diff --git a/api/npm-client.ts b/api/npm-client.ts new file mode 100644 index 0000000..a64d065 --- /dev/null +++ b/api/npm-client.ts @@ -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(); + +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, + }; +} diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..3b86faf --- /dev/null +++ b/api/router.ts @@ -0,0 +1,221 @@ +import { createRouter } from '@@/create-router'; +import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; +import { NpmError, npmRequest, probe, forgetToken } from './npm-client'; + +/** + * Mounted at `/api/proxy-manager`. + * + * Everything here is a read except the enable/disable pair and the settings under `/_config`, which the + * manifest marks owner-only. The platform's permission gate enforces that — this file does not check it + * again, because two places deciding the same thing is how they come to disagree. + * + * ── What this deliberately does not do ── + * + * No creating, editing or deleting hosts, and no certificate issuance. NPM's own UI does those well, it + * is one click away, and each one is a form with real consequences — a wrong proxy host takes a domain + * off the internet. This surface answers "what is running, and is it healthy" and lets you take one out + * of service. Building the editor is a decision to make deliberately, not to arrive at by accretion. + */ +export const router = createRouter(); + +/** NPM's shape, narrowed to what the panels render. */ +type ProxyHost = { + id: number; + domain_names: string[]; + forward_scheme: string; + forward_host: string; + forward_port: number; + enabled: number; + ssl_forced: number; + certificate_id: number; + access_list_id: number; + meta?: { nginx_online?: boolean; nginx_err?: string | null }; +}; + +type Certificate = { + id: number; + provider: string; + nice_name: string; + domain_names: string[]; + expires_on: string | null; +}; + +/** + * Every NPM failure becomes a shaped body rather than a stack trace. + * + * `likelyAuth` is the interesting field: NPM answers 404 when unauthenticated, so without saying this + * out loud the UI would show "not found" for what is actually a wrong password. The panel turns it into + * a sentence pointing at settings. + */ +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, + } as const; + } + return { body: { error: error instanceof Error ? error.message : String(error) }, status: 500 } as const; +}; + +/** Loads the credential, or answers 409 — "configured" is a state the UI has to distinguish. */ +async function withCredentials(handler: (c: NonNullable>>) => Promise) { + const credentials = await readCredentials(); + if (!credentials) return { ok: false as const, status: 409, body: { error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true } }; + try { + return { ok: true as const, data: await handler(credentials) }; + } catch (error) { + const shaped = asError(error); + return { ok: false as const, status: shaped.status, body: shaped.body }; + } +} + +const respond = (ctx: Parameters[1]>[0], result: Awaited>) => + result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500); + +// ── Status ──────────────────────────────────────────────────────────────────────────────────────── + +/** 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(); + if (!connection.configured) return ctx.json({ ...connection, reachable: false }); + + 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 }); + } +}); + +// ── The four kinds of host NPM manages ──────────────────────────────────────────────────────────── + +router.get('/hosts', async (ctx) => + respond( + ctx, + await withCredentials(async (c) => { + const hosts = await npmRequest(c, { path: '/nginx/proxy-hosts?expand=certificate,access_list' }); + // Sorted by first domain rather than by NPM's creation order: a list read by a person should be + // findable, and NPM's own UI sorts this way too. + return hosts.sort((a, b) => (a.domain_names[0] ?? '').localeCompare(b.domain_names[0] ?? '')); + }), + ), +); + +router.get('/redirections', async (ctx) => + respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/redirection-hosts' }))), +); + +router.get('/streams', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/streams' })))); + +/** NPM calls these "404 hosts" — a domain it serves deliberately as nothing. */ +router.get('/dead-hosts', async (ctx) => + respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/dead-hosts' }))), +); + +router.get('/hosts/:id', async (ctx) => + respond( + ctx, + await withCredentials((c) => + npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}?expand=certificate,access_list,owner` }), + ), + ), +); + +// ── Certificates and access lists ───────────────────────────────────────────────────────────────── + +router.get('/certificates', async (ctx) => + respond( + ctx, + await withCredentials(async (c) => { + const certificates = await npmRequest(c, { path: '/nginx/certificates' }); + // The panel's whole job is "which of these is about to expire", so compute it once here rather + // than parsing dates in the browser and getting the timezone wrong. + const now = Date.now(); + return certificates + .map((certificate) => ({ + ...certificate, + daysUntilExpiry: certificate.expires_on + ? Math.floor((Date.parse(certificate.expires_on) - now) / 86_400_000) + : null, + })) + .sort((a, b) => (a.daysUntilExpiry ?? 1e9) - (b.daysUntilExpiry ?? 1e9)); + }), + ), +); + +router.get('/access-lists', async (ctx) => + respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/access-lists?expand=items,clients' }))), +); + +// ── The one mutation ────────────────────────────────────────────────────────────────────────────── + +/** + * Taking a host in or out of service. The only write, and it is reversible in one click — which is why + * it is here when creating and deleting are not. + */ +router.post('/hosts/:id/:action', async (ctx) => { + const action = ctx.req.param('action'); + if (action !== 'enable' && action !== 'disable') return ctx.json({ error: 'Expected enable or disable.' }, 400); + + return respond( + ctx, + await withCredentials(async (c) => { + await npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}/${action}`, method: 'POST' }); + return { id: Number(ctx.req.param('id')), enabled: action === 'enable' }; + }), + ); +}); + +// ── Settings — owner only, by the manifest ──────────────────────────────────────────────────────── + +router.get('/_config', async (ctx) => ctx.json(await readConnection())); + +router.put('/_config', async (ctx) => { + const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null; + if (!body?.url || !body.identity) return ctx.json({ error: 'url and identity are required.' }, 400); + + try { + const saved = await saveConnection({ url: body.url, identity: body.identity, secret: body.secret }); + // The old credential's token must not outlive the credential itself. + const credentials = await readCredentials(); + if (credentials) forgetToken(credentials); + return ctx.json(saved); + } catch (error) { + return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400); + } +}); + +router.delete('/_config', async (ctx) => { + const credentials = await readCredentials(); + if (credentials) forgetToken(credentials); + await deleteConnection(); + return ctx.json({ configured: false }); +}); + +/** + * Test a credential WITHOUT storing it, so a wrong password never replaces a working one. + * + * Declared in the manifest as a `readOnlyWrites` path: it is a POST that changes nothing. + */ +router.post('/_config/test', async (ctx) => { + const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null; + if (!body?.url) return ctx.json({ error: 'url is required.' }, 400); + + try { + const info = await probe(body.url); + if (!body.identity || !body.secret) { + return ctx.json({ reachable: true, authenticated: false, version: info.version, setup: info.setup }); + } + await npmRequest({ url: body.url, identity: body.identity, secret: body.secret }, { path: '/nginx/proxy-hosts' }); + return ctx.json({ reachable: true, authenticated: true, version: info.version, setup: info.setup }); + } catch (error) { + const shaped = asError(error); + return ctx.json({ reachable: !(shaped.body as { unreachable?: boolean }).unreachable, authenticated: false, ...shaped.body }, 200); + } +}); diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..3807db4 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,48 @@ +import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; + +/** + * The connection to Nginx Proxy Manager. + * + * ── Why its own table rather than core `service_connections` ── + * + * `service_connections` fits the shape exactly — it has `url`, `username` and an encrypted `secret`. But + * its `ServiceName` is a closed TypeScript union, so using it means editing the platform to add a name, + * and this plugin is meant to install into a platform it did not change. The table comment there already + * says the column is text precisely so adding a service is not a schema change; the union is the part + * that has not caught up. + * + * Revisit if that union opens. Until then the cost is one small table and one encryption purpose. + * + * ── One row, not one per user ── + * + * NPM has no read-only role. Its API is admin or nothing, so a per-user credential would be a per-user + * copy of the same total authority over every hostname this machine serves. There is one connection, the + * owner sets it, and members granted the permission read through it without ever seeing it. + */ +export const proxyManagerConnection = pgTable('proxy_manager_connection', { + id: serial('id').primaryKey(), + + /** Where NPM's admin API is. Normalised without a trailing slash before write. */ + url: text('url').notNull(), + + /** NPM calls it `identity`; it is an email address. */ + identity: text('identity').notNull(), + + /** + * Encrypted with `encryptSecret('proxy-manager', …)` — its own purpose key, so a leak of this one + * opens this plugin's column and nothing else. Never returned across the API boundary, at any level, + * to anybody, including the owner. + */ + secret: text('secret').notNull(), + + /** From `GET /api/`, recorded on a successful probe. Tells you which NPM you are talking to. */ + version: text('version'), + + lastProbeAt: timestamp('last_probe_at', { withTimezone: true }), + lastProbeOk: integer('last_probe_ok'), + + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type ProxyManagerConnection = typeof proxyManagerConnection.$inferSelect; diff --git a/docs/npm-api.md b/docs/npm-api.md new file mode 100644 index 0000000..970309a --- /dev/null +++ b/docs/npm-api.md @@ -0,0 +1,138 @@ +# Nginx Proxy Manager's API, as observed + +Verified against **2.15.1** on alpha, 2026-08-25. NPM's API is not documented upstream in any form worth +relying on, so this is what was learned by probing it — recorded so nobody has to probe it twice. + +--- + +## Authentication + +``` +POST /api/tokens {"identity": "you@example.com", "secret": "…"} + -> {"token": "eyJ…", "expires": "2026-08-26T10:00:00.000Z"} +``` + +Then `Authorization: Bearer ` on everything else. + +**A bad credential is 400, not 401**, with `{"error":{"code":400,"message":"Invalid email or password"}}`. + +**An unauthenticated read is 404, not 401.** + +``` +GET /api/nginx/proxy-hosts + -> {"error":{"code":404,"message":"Not Found - /nginx/proxy-hosts"}} +``` + +This is the single most important thing on this page. NPM only registers a resource route once the +request carries a token, so *"not signed in"* and *"no such endpoint"* are indistinguishable. Any client +that reports the status honestly will tell its user a feature is missing when the password is wrong. + +`api/npm-client.ts` handles it by minting a fresh token and retrying once, then reporting a persistent +404 as `likelyAuth`. + +## The unauthenticated corner + +`GET /api/` needs no token and is the cheapest proof a URL is an NPM at all: + +``` +{"status":"OK","setup":true,"version":{"major":2,"minor":15,"revision":1}} +``` + +`setup: false` means NPM has not had its first admin created yet. + +## Error shape + +Always the same, which is a small mercy: + +```json +{ "error": { "code": 404, "message": "Not Found - /nginx/proxy-hosts" } } +``` + +--- + +## Resources + +| path | | +| --- | --- | +| `/nginx/proxy-hosts` | the main one | +| `/nginx/redirection-hosts` | 301/302 redirects | +| `/nginx/dead-hosts` | NPM's name for "404 hosts" — a domain served deliberately as nothing | +| `/nginx/streams` | raw TCP/UDP forwarding | +| `/nginx/certificates` | Let's Encrypt and uploaded | +| `/nginx/access-lists` | basic auth and IP rules | +| `/audit-log` | who changed what | +| `/settings`, `/users` | administration | + +### `?expand=` + +Several resources take an expand list, which is how you avoid N+1 fetching: + +``` +/nginx/proxy-hosts?expand=certificate,access_list +/nginx/proxy-hosts/3?expand=certificate,access_list,owner +/nginx/access-lists?expand=items,clients +``` + +### A proxy host, narrowed to the useful fields + +```jsonc +{ + "id": 3, + "domain_names": ["gitea.officer.dev"], + "forward_scheme": "http", + "forward_host": "172.20.0.3", + "forward_port": 3000, + "enabled": 1, // 0/1, not a boolean + "ssl_forced": 1, + "certificate_id": 7, // 0 means none + "access_list_id": 0, // 0 means none + "meta": { + "nginx_online": true, // whether nginx accepted the generated config + "nginx_err": null + } +} +``` + +**`meta.nginx_online` is the interesting one.** A host can be enabled and still be offline because the +generated nginx config failed to load — a certificate that vanished, a syntax error from a custom +config block. It is the difference between "should be running" and "is running", and it is what the +hosts panel shows as a warning triangle. + +Booleans are `0`/`1` integers throughout. + +### Enabling and disabling + +``` +POST /api/nginx/proxy-hosts/:id/enable +POST /api/nginx/proxy-hosts/:id/disable +``` + +No body. This is the only mutation this plugin makes, because it is the only one that is reversible in +a click. + +### Certificates + +```jsonc +{ + "id": 7, + "provider": "letsencrypt", // or "other" for an uploaded one + "nice_name": "officer.dev", + "domain_names": ["officer.dev", "*.officer.dev"], + "expires_on": "2026-11-01T00:00:00.000Z" +} +``` + +`expires_on` is null for some uploaded certificates. The router computes `daysUntilExpiry` server-side +so the browser is not parsing dates and getting the timezone wrong. + +--- + +## Reaching NPM from Officer + +On alpha, NPM is the `npm` container on the `nginx` docker network at **172.20.0.2**, with its admin UI +published on `100.64.0.13:81` and `127.0.0.1:81`. + +Both `http://127.0.0.1:81` and `http://172.20.0.2:81` were probed and both answer. The container address +works **whether or not the port is published**, because the host is the gateway on every docker bridge — +`opm`'s `docs/networking.md` has the measurements. So the connection URL can be the container address, +and the published port on 81 becomes an exposure decision rather than a requirement. diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..9761910 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,45 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +// Nginx Proxy Manager, inside Officer. +// +// ── The name ── +// +// The container is called `npm` and the owner calls it NPM. This plugin is `proxy-manager` anyway: in a +// repository where every other sentence is about bun, lockfiles and `npm install`, a plugin directory +// called `npm` would collide with the package manager in every grep and every conversation. The label +// says the real name; the id says what it does. Same reasoning that renamed `officer-claude` to +// `officer-anthropic-proxy`. +// +// ── The permission, and why settings are carved out ── +// +// Reading which hosts exist, whether their certificates are healthy and whether one is offline is +// genuinely useful to somebody who is not the owner. The CREDENTIAL is not: it is an NPM admin login, +// and NPM has no read-only role, so anyone holding it can redirect any hostname on this machine. +// +// So the permission is grantable and `/_config` is owner-only — the split `ownerOnly` exists for, and +// the same shape Jellyfin uses for its admin token. +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '0.1.0', + platform: '>=1.0.0', + + label: 'Proxy Manager', + summary: 'Nginx Proxy Manager — hosts, certificates and access lists', + icon: 'Route', + color: '#f97316', + + permissions: [ + { + key: 'proxy-manager', + 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. + 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'], + }, + ], +}; diff --git a/web/CertificatesPanel.tsx b/web/CertificatesPanel.tsx new file mode 100644 index 0000000..6848b81 --- /dev/null +++ b/web/CertificatesPanel.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react"; +import { useClient } from "hooks/useClient"; + +/** + * Certificates, ordered by how soon they expire. + * + * That ordering is the whole design. A certificate list sorted by name is a reference; sorted by expiry + * it answers the only question anybody has, which is whether something is about to break. The server + * computes `daysUntilExpiry` so the browser is not parsing dates and getting the timezone wrong. + */ + +type Certificate = { + id: number; + provider: string; + nice_name: string; + domain_names: string[]; + expires_on: string | null; + daysUntilExpiry: number | null; +}; + +/** Let's Encrypt renews at 30 days, so inside 14 something has actually gone wrong. */ +const WARN_DAYS = 30; +const URGENT_DAYS = 14; + +export const CertificatesPanel = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [certificates, setCertificates] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + setCertificates(await clientRef.current.get("/proxy-manager/certificates")); + setError(null); + } catch (caught) { + const body = (caught as { body?: { error?: string } })?.body; + setError(body?.error ?? "Could not load certificates."); + setCertificates(null); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (error) return {error}; + if (!certificates) return Loading certificates…; + if (certificates.length === 0) return No certificates.; + + const expiring = certificates.filter((c) => c.daysUntilExpiry !== null && c.daysUntilExpiry < WARN_DAYS).length; + + return ( +
+
+ + {certificates.length} certificate{certificates.length === 1 ? "" : "s"} + {expiring > 0 ? {expiring} expiring : null} + + +
+ +
    + {certificates.map((certificate) => { + const days = certificate.daysUntilExpiry; + const urgent = days !== null && days < URGENT_DAYS; + const warn = days !== null && days < WARN_DAYS; + return ( +
  • + {warn ? ( + + ) : ( + + )} + + {certificate.nice_name} + + {certificate.domain_names.join(", ")} + + + + {days === null ? certificate.provider : days < 0 ? `expired ${-days}d ago` : `${days}d`} + +
  • + ); + })} +
+
+ ); +}; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); diff --git a/web/HostsPanel.tsx b/web/HostsPanel.tsx new file mode 100644 index 0000000..e55eb7b --- /dev/null +++ b/web/HostsPanel.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, ShieldCheck, Users } from "lucide-react"; +import { useClient } from "hooks/useClient"; +import { useSearchParams } from "react-router"; + +/** + * The proxy hosts, which is the thing anybody opens this plugin to see. + * + * Selection lives in the URL (`?host=`), not in a channel — `docs/navigation-audit.md` is the + * authority and this is the case it names: the detail panel reads the same parameter rather than being + * handed the id through a bus. + */ + +type ProxyHost = { + id: number; + domain_names: string[]; + forward_scheme: string; + forward_host: string; + forward_port: number; + enabled: number; + ssl_forced: number; + certificate_id: number; + access_list_id: number; + meta?: { nginx_online?: boolean; nginx_err?: string | null }; +}; + +type Failure = { error: string; likelyAuth?: boolean; unreachable?: boolean; unconfigured?: boolean }; + +export const HostsPanel = () => { + const client = useClient(); + // useClient() rebuilds its verbs every render, so it must not sit in a dependency list. + const clientRef = useRef(client); + clientRef.current = client; + + const [hosts, setHosts] = useState(null); + const [failure, setFailure] = useState(null); + const [busy, setBusy] = useState(null); + const [params, setParams] = useSearchParams(); + const selected = params.get("host"); + + const load = useCallback(async () => { + try { + setHosts(await clientRef.current.get("/proxy-manager/hosts")); + setFailure(null); + } catch (error) { + const body = (error as { body?: Failure })?.body; + setFailure(body ?? { error: error instanceof Error ? error.message : "Could not load hosts." }); + setHosts(null); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const toggle = async (host: ProxyHost) => { + setBusy(host.id); + try { + await clientRef.current.post(`/proxy-manager/hosts/${host.id}/${host.enabled ? "disable" : "enable"}`, {}); + await load(); + } catch { + setFailure({ error: `Could not ${host.enabled ? "disable" : "enable"} that host.` }); + } finally { + setBusy(null); + } + }; + + if (failure) return void load()} />; + if (!hosts) return Loading hosts…; + if (hosts.length === 0) return Nginx Proxy Manager has no proxy hosts.; + + return ( +
+
+ + {hosts.length} host{hosts.length === 1 ? "" : "s"} + + +
+ +
    + {hosts.map((host) => { + const offline = host.meta?.nginx_online === false; + return ( +
  • + +
  • + ); + })} +
+ + {selected ? ( +
+ void toggle(host)} + /> +
+ ) : null} +
+ ); +}; + +const ToggleButton = ({ + hosts, + selected, + busy, + onToggle, +}: { + hosts: ProxyHost[]; + selected: string; + busy: number | null; + onToggle: (host: ProxyHost) => void; +}) => { + const host = hosts.find((h) => String(h.id) === selected); + if (!host) return null; + return ( + + ); +}; + +/** + * NPM answers 404 when unauthenticated, so a naive client shows "not found" for a wrong password. The + * server marks that case `likelyAuth`; this turns it into a sentence that points somewhere useful. + */ +const Failed = ({ failure, onRetry }: { failure: Failure; onRetry: () => void }) => ( +
+ + {failure.unconfigured ? ( + <> +

No Nginx Proxy Manager connection is set.

+

The owner sets it in this plugin's settings.

+ + ) : failure.likelyAuth ? ( + <> +

Nginx Proxy Manager refused the credential.

+

+ Its API answers 404 when unauthenticated, so this is almost always a wrong identity or secret rather than a + missing endpoint. +

+ + ) : failure.unreachable ? ( + <> +

Nginx Proxy Manager is not answering.

+

{failure.error}

+ + ) : ( +

{failure.error}

+ )} + +
+); + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..dded01f --- /dev/null +++ b/web/layout.ts @@ -0,0 +1,13 @@ +import type { LayoutNode } from 'officerdev'; + +// Hosts wide, certificates beside it. Certificates is a glance rather than a workspace — you look at it +// when something is red — so it gets the narrower half. +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'proxy-manager-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'proxy-manager-hosts', appType: 'proxy-manager-hosts' }, size: 62 }, + { node: { type: 'panel', id: 'proxy-manager-certificates', appType: 'proxy-manager-certificates' }, size: 38 }, + ], +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..685823a --- /dev/null +++ b/web/panels.ts @@ -0,0 +1,19 @@ +import { Route, ShieldCheck } from 'lucide-react'; +import type { AppRegistryMeta } from 'officerdev'; +import { HostsPanel } from './HostsPanel'; +import { CertificatesPanel } from './CertificatesPanel'; + +// Two panels. Hosts is what the plugin is for; certificates is the thing that quietly breaks and is +// worth seeing beside it rather than a click away. +// +// `availableOnPanel: false` — these belong to this plugin's screen, not to the generic panel picker. +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'proxy-manager-hosts', name: 'Hosts', icon: Route, component: HostsPanel, availableOnPanel: false }, + { + key: 'proxy-manager-certificates', + name: 'Certificates', + icon: ShieldCheck, + component: CertificatesPanel, + availableOnPanel: false, + }, +];