proxy-manager: nginx proxy manager in officer
reads hosts and certificates against a live NPM 2.15.1. one grantable permission, with /_config owner-only — reading which hosts are healthy is worth sharing, an NPM admin login is not, and NPM has no read-only role. the quirk that shapes the whole plugin: NPM answers an unauthenticated read with 404, not 401, because its router only registers a resource once a token is present. so 'not signed in' and 'no such endpoint' are the same response, and a naive client tells you a feature is missing when your password is wrong. the client mints a fresh token and retries once, then reports a persistent 404 as likelyAuth. certificates sort by expiry rather than name, which is the only ordering that answers a question. no create/edit/delete and no cert issuance: NPM's own UI does those and a wrong proxy host takes a domain off the internet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user