the brief that produced this plugin was "build me an NPM proxy", in full. no vision was given, so the agent inferred one — a careful read-only window onto NPM, with creating and editing deliberately excluded — and inferred it wrong. the point is the handover. two paths, and setup should ask out loud which one you are on: give Officer your NPM login and your DNS provider key and then ask for what you want in a sentence, or keep them and manage NPM yourself on a public address. strip the credentials out and what remains is a list NPM already shows plus a warning you could set up yourself — a notifier, not a plugin. it exists for the handover. which makes the clearest case worth stating on its own: TLS on a tailnet-only name is impossible without the DNS credential. let's encrypt cannot visit a private address, so the only check left is a DNS record, which needs the provider's API. that is not a limitation of the design, it is the shape of the problem. half the old principle survives. creating is additive and cannot break a host that already works, so it is the write path the agent should get and it is the product rather than a later phase. editing and deleting are not, and stay in NPM's UI until there is a reason they should not. nothing here is built. said so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
183 lines
9.7 KiB
Markdown
183 lines
9.7 KiB
Markdown
# Proxy Manager
|
|
|
|
Hand your proxying over to Officer, and tell it what you want in a sentence.
|
|
|
|
**Status: first round, and built to the wrong thesis.** Reads work against a live NPM 2.15.1. The brief
|
|
that produced this code was, in full, "build me an NPM proxy" — no vision was given, so the agent that
|
|
wrote it inferred a sensible one and inferred it wrong. What is below under "What this is for" is the
|
|
actual intent, written down after the fact. Read that before extending anything.
|
|
|
|
---
|
|
|
|
## 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 this is for
|
|
|
|
Not a second window onto Nginx Proxy Manager. NPM already has a perfectly good one.
|
|
|
|
The point is to let someone hand over the parts of this they would rather not think about, and to make
|
|
handing over safe enough that it is a reasonable thing to do. There are two paths and the setup should
|
|
ask, out loud, which one you are on.
|
|
|
|
**You hand over the credentials.** Your NPM login, and your DNS provider's API key — GoDaddy,
|
|
Cloudflare, whoever you registered with. Both go into the secret store, each encrypted under its own
|
|
purpose key, and neither is ever returned across the API to anybody, including you. From then on you ask
|
|
for what you want in a sentence — *"expose sub.mydomain.com on the tailnet, over HTTPS"* — and an agent
|
|
does the three things that sentence actually means: the DNS record, the certificate, the proxy host.
|
|
|
|
**You keep them.** Then you run NPM yourself on a public address and handle DNS at your provider. That
|
|
is a completely reasonable way to run a server, and if it is you, this plugin has little to offer: a
|
|
list you already have, plus a certificate warning you could set up yourself.
|
|
|
|
Which is the honest test of whether this should exist at all. **It exists for the handover.** Everything
|
|
here is worth building in proportion to how much it helps somebody who does not want to learn what a
|
|
proxy host is — and it should still be legible to somebody who has been doing this by hand for years,
|
|
because that person is who decides whether the handover looks safe.
|
|
|
|
## Why a private name needs your DNS key
|
|
|
|
The clearest case, and the one that shows the credential is a requirement rather than a nice-to-have.
|
|
|
|
HTTPS needs a certificate. Let's Encrypt issues them free, but only after checking you control the name,
|
|
and normally it checks by visiting it over the internet. A name that resolves to a tailnet address
|
|
cannot be visited. There is exactly one other check it accepts: prove control by placing a specific
|
|
record in that domain's DNS — which needs the provider's API.
|
|
|
|
So **TLS on a VPN-only hostname is impossible without the credential.** That is not a limitation of this
|
|
design, it is the shape of the problem. The alternative is not "the same thing without a key", it is a
|
|
publicly exposed address with DNS you manage yourself.
|
|
|
|
## Read-only is a state, not the design
|
|
|
|
The first round creates nothing, edits nothing and deletes nothing, and the original README argued that
|
|
as a principle: NPM's own UI does those well, and each is a form with real consequences.
|
|
|
|
Half of that survives. **Creating is additive** — a new proxy host cannot break one that already works —
|
|
so it is the write path the agent should get, and it is the product rather than a later phase. **Editing
|
|
and deleting are not**, and those can stay in NPM's UI until there is a reason they should not.
|
|
|
|
Read-only is what the plugin shows before anyone has handed anything over. It is the fallback, not the
|
|
thesis.
|
|
|
|
## 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.
|
|
- **All of the above.** No domain registry, no DNS provider credentials, no agent, no write path. The
|
|
vision in "What this is for" is entirely unbuilt — the sentence a user should be able to say is the
|
|
thing to design next, and where domains and provider keys live is an open boundary question rather
|
|
than a settled one.
|
|
- **Certificate expiry notifies nobody.** `daysUntilExpiry` is computed in `api/router.ts` and thrown
|
|
away when the panel closes. NPM renews at 30 days; inside 14 means renewal has actually failed and the
|
|
domain goes dark at zero. Nothing on the machine will say so.
|
|
- **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
|
|
```
|