GET /nginx/certificates/dns-providers, authenticated, 86 entries on the 2.15.1 image here. verified against the live instance and against the file it reads from, /app/certbot/dns-plugins.json in the container. which settles how the provider picker gets built. the list is per install — whatever certbot plugins that image carries — so curating one centrally and shipping it as the truth would offer somebody a provider their NPM has never heard of, take their API key, and fail at issue time with an error that does not explain itself. ask the instance. and NPM hands back each provider's credential TEMPLATE, carrying its own field names. so the form is generated rather than written per provider, and it stays right for providers we have never heard of. a curated list of ours is left with the human half only: which we recommend, and how you actually get a key. also noted: this endpoint answers 403 unauthenticated, not 404. the 404-means- auth quirk is per route, not a property of the API. the client's heuristic stays correct; the documentation of it was too broad. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
220 lines
12 KiB
Markdown
220 lines
12 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.
|
|
|
|
## How the agent asks, and what it is never told
|
|
|
|
The agent calls **this plugin**, and never Nginx Proxy Manager. Keeping up with NPM's API — its version,
|
|
its shape, the day it changes — is this plugin's job, and the whole point of having one.
|
|
|
|
Most of what it exposes is a thin proxy over NPM's reads, and should stay thin. **The expose route is
|
|
the one place it deliberately is not.** The agent sends what was asked for — a hostname, what it should
|
|
point at, and which network may reach it — and the plugin turns that into the three separate things it
|
|
actually means: a DNS record, a certificate, a proxy host.
|
|
|
|
That split is the whole safety argument. "Reachable only from the private network actually means these
|
|
three things" gets written down once, here, where it can be read and tested. The alternative is teaching
|
|
the agent NPM's own request format, which puts that knowledge in the agent's memory instead — and you
|
|
would discover it had forgotten only when a hostname is reachable that should not be.
|
|
|
|
The agent is never given a credential. Not NPM's, not the DNS provider's. It says the sentence; the
|
|
plugin holds the keys and does the work.
|
|
|
|
## "Private" is a network the user names, not a tailnet
|
|
|
|
Officer can set up a Tailscale network for you, and it knows that network's address range — so it can
|
|
fill this in without asking. **That is a convenience, not the model.** Somewhere with a VPN already in
|
|
place has their own ranges and nothing to do with Tailscale, and they must be able to say so.
|
|
|
|
So what gets stored is a **named network and the addresses it covers**, and the request is *"expose this
|
|
to <name>"*. NPM's access lists take address ranges, so this costs nothing to support properly.
|
|
|
|
The certificate half is unchanged by any of it. What forces the DNS proof is that the name cannot be
|
|
reached from the public internet — true of anybody's private network, whoever runs it.
|
|
|
|
## 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 named networks, no agent,
|
|
no expose route. Everything from "What this is for" through "Private is a network the user names" is
|
|
agreed and entirely unbuilt.
|
|
- **One thing left to check before building the DNS half.** Whether a given GoDaddy account still has
|
|
DNS API access — they restricted it by account size at some point, and it is unverified here.
|
|
Cloudflare is the safer provider to build against first.
|
|
|
|
The other half of this is now answered: **NPM tells you which providers it can drive**, over
|
|
`GET /nginx/certificates/dns-providers`, and it hands back each one's credential field names as a
|
|
template. 86 providers on the 2.15.1 image. So the picker asks the instance rather than trusting a
|
|
list of ours, and the form is generated from what NPM says. See `docs/npm-api.md`.
|
|
- **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
|
|
```
|