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:
2026-08-25 08:38:15 +00:00
co-authored by Claude Fable 5
commit 5914e96cf3
12 changed files with 1155 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.log
+124
View File
@@ -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
```
+97
View File
@@ -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<ProxyManagerConnection | null> => {
const rows = await db.select().from(proxyManagerConnection).limit(1);
return rows[0] ?? null;
};
export async function readConnection(): Promise<ConnectionView> {
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<NpmCredentials | null> {
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<ConnectionView> {
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<void> {
await db.delete(proxyManagerConnection);
}
export async function recordProbe(version: string | null, ok: boolean): Promise<void> {
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));
}
+154
View File
@@ -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,
};
}
+221
View File
@@ -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<T>(handler: (c: NonNullable<Awaited<ReturnType<typeof readCredentials>>>) => Promise<T>) {
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<Parameters<typeof router.get>[1]>[0], result: Awaited<ReturnType<typeof withCredentials>>) =>
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<ProxyHost[]>(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<Certificate[]>(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<unknown>({ 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);
}
});
+48
View File
@@ -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;
+138
View File
@@ -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 <token>` 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.
+45
View File
@@ -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'],
},
],
};
+100
View File
@@ -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<Certificate[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setCertificates(await clientRef.current.get<Certificate[]>("/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 <Centred>{error}</Centred>;
if (!certificates) return <Centred>Loading certificates…</Centred>;
if (certificates.length === 0) return <Centred>No certificates.</Centred>;
const expiring = certificates.filter((c) => c.daysUntilExpiry !== null && c.daysUntilExpiry < WARN_DAYS).length;
return (
<div className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">
{certificates.length} certificate{certificates.length === 1 ? "" : "s"}
{expiring > 0 ? <span className="ml-2 text-xs text-amber-500">{expiring} expiring</span> : null}
</span>
<button type="button" onClick={() => void load()} className="text-muted-foreground hover:text-foreground">
<RefreshCw className="size-4" />
</button>
</header>
<ul className="flex-1 overflow-auto">
{certificates.map((certificate) => {
const days = certificate.daysUntilExpiry;
const urgent = days !== null && days < URGENT_DAYS;
const warn = days !== null && days < WARN_DAYS;
return (
<li key={certificate.id} className="flex items-center gap-2 border-b px-3 py-2">
{warn ? (
<ShieldAlert className={`size-4 shrink-0 ${urgent ? "text-destructive" : "text-amber-500"}`} />
) : (
<ShieldCheck className="size-4 shrink-0 text-emerald-500" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{certificate.nice_name}</span>
<span className="block truncate text-xs text-muted-foreground">
{certificate.domain_names.join(", ")}
</span>
</span>
<span className={`shrink-0 text-xs ${urgent ? "text-destructive" : warn ? "text-amber-500" : "text-muted-foreground"}`}>
{days === null ? certificate.provider : days < 0 ? `expired ${-days}d ago` : `${days}d`}
</span>
</li>
);
})}
</ul>
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
{children}
</div>
);
+194
View File
@@ -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=<id>`), 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<ProxyHost[] | null>(null);
const [failure, setFailure] = useState<Failure | null>(null);
const [busy, setBusy] = useState<number | null>(null);
const [params, setParams] = useSearchParams();
const selected = params.get("host");
const load = useCallback(async () => {
try {
setHosts(await clientRef.current.get<ProxyHost[]>("/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 <Failed failure={failure} onRetry={() => void load()} />;
if (!hosts) return <Centred>Loading hosts…</Centred>;
if (hosts.length === 0) return <Centred>Nginx Proxy Manager has no proxy hosts.</Centred>;
return (
<div className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">
{hosts.length} host{hosts.length === 1 ? "" : "s"}
</span>
<button type="button" onClick={() => void load()} className="text-muted-foreground hover:text-foreground">
<RefreshCw className="size-4" />
</button>
</header>
<ul className="flex-1 overflow-auto">
{hosts.map((host) => {
const offline = host.meta?.nginx_online === false;
return (
<li key={host.id}>
<button
type="button"
onClick={() => setParams((p) => { p.set("host", String(host.id)); return p; }, { replace: true })}
className={`flex w-full items-center gap-2 border-b px-3 py-2 text-left hover:bg-muted/50 ${
selected === String(host.id) ? "bg-muted" : ""
} ${host.enabled ? "" : "opacity-50"}`}
>
<Globe className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{host.domain_names.join(", ")}</span>
<span className="block truncate text-xs text-muted-foreground">
→ {host.forward_scheme}://{host.forward_host}:{host.forward_port}
</span>
</span>
{/* Certificate state, not certificate identity — "is it https" is the question here. */}
{host.certificate_id ? (
<Lock className={`size-3.5 shrink-0 ${host.ssl_forced ? "text-emerald-500" : "text-muted-foreground"}`} />
) : (
<LockOpen className="size-3.5 shrink-0 text-amber-500" />
)}
{host.access_list_id ? <Users className="size-3.5 shrink-0 text-sky-500" /> : null}
{offline ? <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> : null}
</button>
</li>
);
})}
</ul>
{selected ? (
<footer className="border-t p-2">
<ToggleButton
hosts={hosts}
selected={selected}
busy={busy}
onToggle={(host) => void toggle(host)}
/>
</footer>
) : null}
</div>
);
};
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 (
<button
type="button"
disabled={busy === host.id}
onClick={() => onToggle(host)}
className="flex w-full items-center justify-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
>
<Power className="size-4" />
{busy === host.id ? "Working…" : host.enabled ? "Take out of service" : "Put back in service"}
</button>
);
};
/**
* 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 }) => (
<div className="flex size-full flex-col items-center justify-center gap-3 p-6 text-center">
<ShieldCheck className="size-8 text-muted-foreground" />
{failure.unconfigured ? (
<>
<p className="text-sm text-muted-foreground">No Nginx Proxy Manager connection is set.</p>
<p className="max-w-xs text-xs text-muted-foreground">The owner sets it in this plugin's settings.</p>
</>
) : failure.likelyAuth ? (
<>
<p className="text-sm text-muted-foreground">Nginx Proxy Manager refused the credential.</p>
<p className="max-w-xs text-xs text-muted-foreground">
Its API answers 404 when unauthenticated, so this is almost always a wrong identity or secret rather than a
missing endpoint.
</p>
</>
) : failure.unreachable ? (
<>
<p className="text-sm text-muted-foreground">Nginx Proxy Manager is not answering.</p>
<p className="max-w-xs text-xs text-muted-foreground">{failure.error}</p>
</>
) : (
<p className="max-w-xs text-sm text-muted-foreground">{failure.error}</p>
)}
<button type="button" onClick={onRetry} className="flex items-center gap-1 text-xs hover:text-foreground">
<RefreshCw className="size-3" /> try again
</button>
</div>
);
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">{children}</div>
);
+13
View File
@@ -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 },
],
};
+19
View File
@@ -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,
},
];