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:
+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.
|
||||
Reference in New Issue
Block a user