four fixes from a review, and the first one was leaking out of the plugin. TYPING. `respond` derived its context from `Parameters<typeof router.get>[1]`. hono's .get is overloaded, `Parameters<>` resolves the FIRST overload — `[path: string]` — so index 1 was `undefined`, ctx was `never`, and all nine handlers errored. twelve errors from one line, in a tree where `bunx tsgo` is shared with the platform and expected clean. imports hono's `Context` instead. tsgo now exits 0 tree-wide, which it has not done since this plugin landed. READONLYWRITES. `/_config/test` was declared as a read-only write so a member would not meet a 403 on the test button. they always would: authorize.ts asks isOwnerOnlyRoute before isRequestAllowedAtLevel, and ownerOnly is prefix matched, so `/_config` already covered it and the entry was inert. removed, and the reason it SHOULD be inert written down — that request carries the admin secret in its body, so a member who could call it would hold an oracle pointed at somebody else's password. STATUS WROTE ON EVERY POLL. recordProbe ran unconditionally on a GET whose own comment invites polling, so a read endpoint issued an UPDATE per poll per open panel. the probe is cached 30s and the row is written only when the verdict or the version changes — a flapping NPM still records every transition, which is the case the column exists for. the cache is dropped when the credential does. DEAD TERNARY. `likelyAuth ? 502 : 502`. and the tests. 22 over a stubbed fetch, 99% of npm-client.ts. the two that matter are the 404-means-credential rule and the mint-once-and-retry: both are silent when broken, and a refactor of either turns a working panel into one that says "not found" forever. every test uses its own credential because the token cache is module state — sharing one would let an earlier test's token satisfy the retry test, which would then pass for the wrong reason. still not installed, still no settings form, still no db:push. README says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
138 lines
6.5 KiB
Markdown
138 lines
6.5 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 and the
|
|
plugin is not installed** — 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.
|
|
|
|
`POST /_config/test` is owner-only too, and that is on purpose rather than by accident of the prefix
|
|
match. It is a genuine read-only write — it validates a credential and stores nothing — but the request
|
|
carries the admin secret in its BODY, so a member who could call it would have an oracle pointed at
|
|
somebody else's password. Testing a connection belongs to whoever owns it. (Declaring it in
|
|
`readOnlyWrites` would also have done nothing: `authorize.ts` asks `isOwnerOnlyRoute` first.)
|
|
|
|
---
|
|
|
|
## The connection
|
|
|
|
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. Both halves of that
|
|
— the rule and the single retry — are the plugin's most load-bearing behaviour and its only tested one:
|
|
`api/npm-client.test.ts` covers them against a stubbed fetch, because a refactor of either fails silently
|
|
and turns a working panel into one that says "not found" forever. It also mints a fresh
|
|
token and retries once before concluding anything — otherwise a restarted NPM makes every panel look
|
|
broken until a reload.
|
|
|
|
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. `GET /status` is built to be
|
|
polled when something does: the probe is cached for 30 seconds and the row is written only when the
|
|
verdict or the version changes, so a poll is not an UPDATE.
|
|
- **It is not installed.** `plugin_installs` has no row for it and `proxy_manager_connection` does not
|
|
exist in Postgres, so nothing here has been through the permission gate, `assertPermissionTotality` or
|
|
a real request. The reads were verified by direct invocation against a live NPM 2.15.1.
|
|
|
|
---
|
|
|
|
## Files
|
|
|
|
```
|
|
manifest.ts permission, ownerOnly
|
|
api/npm-client.ts tokens, the 404 quirk, retry
|
|
api/npm-client.test.ts 22 tests over a stubbed fetch
|
|
api/connection.ts the one row; encryption; the secret never leaves
|
|
api/router.ts mounted at /api/proxy-manager
|
|
db/schema.ts proxy_manager_connection
|
|
web/panels.ts two panels
|
|
web/layout.ts how they are arranged
|
|
```
|