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>
125 lines
5.3 KiB
Markdown
125 lines
5.3 KiB
Markdown
# 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
|
|
```
|