hand over NPM's API instead of a curated subset of it
this plugin refused to create, edit or delete anything, and argued the refusal was care: NPM's own UI does those well and each is a form with real consequences. the owner's call on 2026-08-25 is that the argument ends up somewhere else — "even though we are assuming users that don't know how to operate nginx, the last thing I want to do is treat users as stupid" and a curated surface cannot keep up anyway. every NPM feature we did not wrap is one the owner cannot reach through Officer at all, so the plugin becomes a worse NPM rather than a way into the real one. so /api/proxy-manager/npm/* forwards method, path, query and body, attaches a token, and returns NPM's own status and body unchanged — errors included. a proxy that improves the answers is not a proxy. authentication is the only thing it does not pass through, which is the entire job. the permission gate needed nothing: isRequestAllowedAtLevel already returns true for safe methods and false otherwise, so read level IS a read-only view of NPM through here, with no code in this file deciding it twice. the curated routes stay, for the panels. a screen wants sorted hosts and a computed daysUntilExpiry; an agent wants NPM. what replaces the refusal is confirmation, and it lives in the skill because that is what it is — an instruction, not a boundary. saying so is better than a boundary that only looks like one. the skill now carries NPM's API, names what must be confirmed before it happens (deleting, editing a live upstream, touching a certificate in use), says creating needs no ceremony, and forbids reaching NPM any way but this route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -152,3 +152,59 @@ export async function probe(url: string, timeoutMs = 5_000): Promise<{ version:
|
||||
setup: body.setup ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one request to NPM and hand back what it said, unchanged.
|
||||
*
|
||||
* ── Why this exists beside `npmRequest` ──
|
||||
*
|
||||
* `npmRequest` is for the platform's own curated routes: it parses, it throws a shaped `NpmError`, and it
|
||||
* decides what a 404 means. All of that is right when a panel is the caller and wrong when an AGENT is.
|
||||
* The agent is being handed NPM's API as the contract, so it must see NPM's own status and NPM's own body
|
||||
* — including the failures. A proxy that improves the answers is not a proxy.
|
||||
*
|
||||
* What is NOT passed through is authentication. That is the whole job: the caller never holds the
|
||||
* credential, and this mints, caches, and re-mints a token around a request it otherwise leaves alone.
|
||||
*
|
||||
* ── The retry is kept ──
|
||||
*
|
||||
* Same mint-once-and-retry as `npmRequest`, for the same reason: a token NPM has forgotten comes back as a
|
||||
* 404, and passing that through faithfully would mean handing the agent a "not found" for a route that
|
||||
* exists. Retrying is not improving the answer — it is making sure the answer is about the request rather
|
||||
* than about our token.
|
||||
*/
|
||||
export type NpmProxyRequest = { method: string; path: string; search?: string; body?: string | null; timeoutMs?: number };
|
||||
|
||||
export type NpmProxyResponse = { status: number; body: string; contentType: string | null };
|
||||
|
||||
export async function npmProxy(credentials: NpmCredentials, request: NpmProxyRequest): Promise<NpmProxyResponse> {
|
||||
const timeoutMs = request.timeoutMs ?? 15_000;
|
||||
const base = normaliseUrl(credentials.url);
|
||||
const target = `${base}/api${request.path}${request.search ?? ''}`;
|
||||
|
||||
const send = async (token: string): Promise<Response> =>
|
||||
fetch(target, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(request.body == null ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
body: request.body ?? undefined,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
}).catch((error: Error) => {
|
||||
throw new NpmError(`Cannot reach Nginx Proxy Manager at ${base}: ${error.message}`, 0);
|
||||
});
|
||||
|
||||
let response = await send(await tokenFor(credentials, timeoutMs));
|
||||
|
||||
if (response.status === 404 || response.status === 401) {
|
||||
forgetToken(credentials);
|
||||
response = await send(await mintToken(credentials, timeoutMs));
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
contentType: response.headers.get('content-type'),
|
||||
};
|
||||
}
|
||||
|
||||
+67
-6
@@ -1,7 +1,7 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter, type HonoVariables } from '@@/create-router';
|
||||
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
|
||||
import { NpmError, npmRequest, probe, forgetToken } from './npm-client';
|
||||
import { NpmError, npmRequest, npmProxy, probe, forgetToken } from './npm-client';
|
||||
|
||||
/**
|
||||
* Mounted at `/api/proxy-manager`.
|
||||
@@ -10,12 +10,32 @@ import { NpmError, npmRequest, probe, forgetToken } from './npm-client';
|
||||
* manifest marks owner-only. The platform's permission gate enforces that — this file does not check it
|
||||
* again, because two places deciding the same thing is how they come to disagree.
|
||||
*
|
||||
* ── What this deliberately does not do ──
|
||||
* ── Two surfaces, on purpose ──
|
||||
*
|
||||
* No creating, editing or deleting hosts, and no certificate issuance. NPM's own UI does those well, it
|
||||
* is one click away, and each one 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 lets you take one out
|
||||
* of service. Building the editor is a decision to make deliberately, not to arrive at by accretion.
|
||||
* **The curated routes** below — `/hosts`, `/certificates`, `/status` — exist for the PANELS. They sort,
|
||||
* they compute `daysUntilExpiry`, they shape errors into something a screen can render. A UI wants
|
||||
* answers, not NPM's raw output.
|
||||
*
|
||||
* **`/npm/*` is a pass-through**, and it exists for the AGENT. It forwards method, path, query and body to
|
||||
* NPM and returns NPM's own status and body unchanged. The contract there is NPM's API, not ours.
|
||||
*
|
||||
* ── Why the second one, after this file argued for the first ──
|
||||
*
|
||||
* This said, until 2026-08-25, that creating and deleting were deliberately absent because NPM's own UI
|
||||
* does them well and each is a form with real consequences. The owner's call, that day, was that this
|
||||
* reasoning ends up treating people as if they cannot be trusted with their own server:
|
||||
*
|
||||
* > "even though we are assuming users that don't know how to operate nginx, the last thing I want to
|
||||
* > do is treat users as stupid"
|
||||
*
|
||||
* A curated surface also cannot keep up. Every NPM feature we did not wrap is one the owner cannot reach
|
||||
* through Officer at all, and the list only grows — so the plugin becomes a worse NPM rather than a way
|
||||
* into the real one. Handing over the actual API and holding only the credential is the honest shape.
|
||||
*
|
||||
* **What replaces the refusal is confirmation, and it lives in the SKILL rather than here.** The agent
|
||||
* confirms before anything destructive and names what it is about to change. That is an instruction, not
|
||||
* a boundary, and saying so plainly is better than a boundary that only looks like one — the credential
|
||||
* sits behind a permission, and `read` level already refuses every unsafe method by itself.
|
||||
*/
|
||||
export const router = createRouter();
|
||||
|
||||
@@ -221,6 +241,47 @@ router.post('/hosts/:id/:action', async (ctx) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── The pass-through ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `ALL /api/proxy-manager/npm/*` → `<npm>/api/*`, with a token attached and nothing else changed.
|
||||
*
|
||||
* **The permission gate does the authorisation and this route does not re-check it.** `proxy-manager` at
|
||||
* `read` level permits only safe methods — `isRequestAllowedAtLevel` returns true for GET/HEAD/OPTIONS and
|
||||
* false for everything else unless it is declared read-only — so a read-level account gets exactly a
|
||||
* read-only view of NPM through here, with no work in this file. `write` permits the rest. Two places
|
||||
* deciding the same thing is how they come to disagree.
|
||||
*
|
||||
* The query string is forwarded verbatim because NPM's `?expand=` is how half its API is useful, and the
|
||||
* body is forwarded as raw text rather than re-serialised JSON so that what NPM receives is what the
|
||||
* caller sent.
|
||||
*/
|
||||
router.all('/npm/*', async (ctx) => {
|
||||
const credentials = await readCredentials();
|
||||
if (!credentials) {
|
||||
return ctx.json({ error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true }, 409);
|
||||
}
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
// Everything after `/npm`, so `/npm/nginx/proxy-hosts` reaches NPM as `/api/nginx/proxy-hosts`.
|
||||
const path = url.pathname.slice(url.pathname.indexOf('/npm') + '/npm'.length) || '/';
|
||||
const method = ctx.req.method;
|
||||
const body = method === 'GET' || method === 'HEAD' ? null : await ctx.req.text();
|
||||
|
||||
try {
|
||||
const result = await npmProxy(credentials, { method, path, search: url.search, body });
|
||||
return new Response(result.body, {
|
||||
status: result.status,
|
||||
headers: { 'content-type': result.contentType ?? 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
// Only reachable when NPM could not be spoken to at all — `npmProxy` returns every answer it gets,
|
||||
// including the bad ones, because the caller is meant to see NPM's own verdict.
|
||||
const shaped = asError(error);
|
||||
return ctx.json(shaped.body, shaped.status);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Settings — owner only, by the manifest ────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/_config', async (ctx) => ctx.json(await readConnection()));
|
||||
|
||||
@@ -1,94 +1,110 @@
|
||||
---
|
||||
name: Proxy Manager
|
||||
description: Use when asked which domains this machine serves, where a hostname forwards to, whether a site is up, or when a TLS certificate expires — and before taking a proxy host in or out of service. Carries the routes, the one 404 that means something else, and what this surface deliberately cannot do.
|
||||
description: Use for anything about this machine's domains, proxying or TLS — which hostnames it serves, where one forwards, whether a site is down, when a certificate expires, and creating, editing, enabling or deleting any of it. Carries the pass-through route into Nginx Proxy Manager's own API, the one 404 that means something else, and what to confirm before doing.
|
||||
version: 1
|
||||
---
|
||||
|
||||
Officer proxies through **Nginx Proxy Manager**, and this plugin is how you reach it. Everything below is
|
||||
`/api/proxy-manager/...`, authenticated as the signed-in account like any other platform route.
|
||||
Officer proxies through **Nginx Proxy Manager**. This plugin holds NPM's admin credential and hands you
|
||||
NPM's own API through it.
|
||||
|
||||
**Go through the platform, never at NPM directly.** The admin credential lives encrypted on the server and
|
||||
is never handed out — not to you, not to the owner. A route here already holds it, already translates NPM's
|
||||
odder answers, and is the only path that works for an account that is not the owner.
|
||||
|
||||
## What is running, and where it goes
|
||||
## The route
|
||||
|
||||
```
|
||||
GET /api/proxy-manager/hosts proxy hosts, sorted by first domain
|
||||
GET /api/proxy-manager/hosts/:id one host, with its certificate and access list expanded
|
||||
GET /api/proxy-manager/redirections redirection hosts
|
||||
GET /api/proxy-manager/streams TCP/UDP streams
|
||||
GET /api/proxy-manager/dead-hosts domains served deliberately as nothing (NPM calls them 404 hosts)
|
||||
GET /api/proxy-manager/access-lists with their items and clients
|
||||
GET /api/proxy-manager/status is a connection configured, and does it answer
|
||||
/api/proxy-manager/npm/<anything> -> <npm>/api/<anything>
|
||||
```
|
||||
|
||||
A host carries `domain_names`, `forward_scheme`, `forward_host`, `forward_port`, `enabled`, `ssl_forced`,
|
||||
`certificate_id` (0 means none), `access_list_id`, and `meta.nginx_online` — which is `false` when nginx
|
||||
itself reports the site down, and is the field to check when somebody says a site is broken.
|
||||
Method, path, query string and body are forwarded unchanged, and **NPM's own status and body come back
|
||||
unchanged** — including its errors. A token is attached for you.
|
||||
|
||||
**This is the honest answer to "what domains does this machine serve".** Better than any document: a
|
||||
hand-maintained list in a repository is right on the day it is written, and this is what nginx is actually
|
||||
configured with right now.
|
||||
|
||||
## Certificates
|
||||
|
||||
```
|
||||
GET /api/proxy-manager/certificates
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
||||
"$OFFICER_API_URL/api/proxy-manager/npm/nginx/proxy-hosts?expand=certificate,access_list"
|
||||
```
|
||||
|
||||
Sorted by expiry rather than name, with `daysUntilExpiry` computed server-side. That ordering is the point:
|
||||
a list by name is a reference, a list by expiry answers the only question anyone has.
|
||||
**The contract is NPM's API, not ours.** If NPM can do it, this can do it — `docs/npm-api.md` in the
|
||||
plugin has the endpoints, and NPM's own docs cover the rest. Do not go looking for a curated Officer route
|
||||
for each operation; there isn't one, and that is the design.
|
||||
|
||||
**Read the numbers the way Let's Encrypt behaves.** Renewal runs at 30 days, so under 30 is normal and not
|
||||
worth mentioning. Under 14 means renewal has actually failed and the domain goes dark at zero — that is
|
||||
worth raising unprompted. `expires_on` is null for some uploaded certificates, and `daysUntilExpiry` is
|
||||
then null too; that is unknown, not urgent.
|
||||
**Never reach NPM directly.** Not by its port, not by `docker exec`, not by finding the credential. The
|
||||
credential is deliberately not yours, this route is the sanctioned path, and going around it is
|
||||
unauthorised, unlogged and unbounded. If this route refuses you, that refusal is the answer.
|
||||
|
||||
**Nothing warns anybody.** No notification exists yet. If you notice a certificate inside 14 days, say so
|
||||
in the conversation — there is no other mechanism that will.
|
||||
### The panels use different routes
|
||||
|
||||
## The one write
|
||||
`/api/proxy-manager/hosts`, `/certificates` and `/status` also exist. They sort and compute
|
||||
`daysUntilExpiry` for the screen. Read them if you want the same shaped answer the panel shows; use
|
||||
`/npm/*` for anything else.
|
||||
|
||||
```
|
||||
POST /api/proxy-manager/hosts/:id/enable
|
||||
POST /api/proxy-manager/hosts/:id/disable
|
||||
```
|
||||
## What you can do
|
||||
|
||||
Taking a host in or out of service. **It is the only write this surface has**, deliberately: it is
|
||||
reversible in one click, and creating, editing and deleting hosts are not. Do not look for a create route —
|
||||
there isn't one, and its absence is a decision rather than a gap.
|
||||
Everything NPM can, which is roughly:
|
||||
|
||||
**Disabling a host takes a domain off the internet.** Confirm before you do it, name the domains, and do not
|
||||
infer it from something adjacent. "Is `foo.example.com` up?" is a question about `meta.nginx_online`, not an
|
||||
instruction to change anything.
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET/POST/PUT/DELETE /npm/nginx/proxy-hosts[/:id]` | the proxy hosts |
|
||||
| `POST /npm/nginx/proxy-hosts/:id/enable` · `/disable` | in and out of service |
|
||||
| `…/redirection-hosts`, `…/dead-hosts`, `…/streams` | the other three kinds of host |
|
||||
| `…/certificates[/:id]`, `…/certificates/:id/renew` | Let's Encrypt and uploaded |
|
||||
| `GET /npm/nginx/certificates/dns-providers` | which DNS providers this NPM can drive, with their credential field names |
|
||||
| `…/access-lists[/:id]` | who may reach a host |
|
||||
|
||||
For anything this cannot do — a new proxy host, a certificate to issue, an access list to edit — say so and
|
||||
point at NPM's own UI. Do not attempt it another way.
|
||||
A proxy host carries `domain_names`, `forward_scheme`, `forward_host`, `forward_port`, `enabled`,
|
||||
`ssl_forced`, `certificate_id` (0 means none), `access_list_id`, and `meta.nginx_online` — **which is the
|
||||
field to read when somebody says a site is down**, not whether the row exists.
|
||||
|
||||
## Confirm before you change something
|
||||
|
||||
**Do what is asked.** The owner runs this machine; a request to create, edit, enable, disable or delete a
|
||||
host is a legitimate request and refusing it is not caution, it is being unhelpful about somebody's own
|
||||
server.
|
||||
|
||||
**But say what will happen first, and wait, when the change is not trivially reversible:**
|
||||
|
||||
- **Deleting** a proxy host, a redirection, a stream, a certificate or an access list. Name exactly what
|
||||
goes and what it currently serves. A deleted host is not recoverable from here.
|
||||
- **Editing** an existing host's domains or upstream. That is a live site changing where it points.
|
||||
- **Disabling.** One click back, so a short confirmation is enough — but a domain goes dark meanwhile.
|
||||
- **Anything touching a certificate** that a live host is using.
|
||||
|
||||
Creating something new needs no ceremony: report what you made.
|
||||
|
||||
**Never infer a change from a question.** "Is `foo.example.com` up?" asks about `meta.nginx_online`. It is
|
||||
not an instruction to restart, disable or recreate anything.
|
||||
|
||||
## Certificates, read the way Let's Encrypt behaves
|
||||
|
||||
Renewal runs at **30 days**, so under 30 is normal and not worth mentioning. **Under 14 means renewal has
|
||||
actually failed** and the domain goes dark at zero — raise that unprompted. `expires_on` is null on some
|
||||
uploaded certificates; that is unknown, not urgent.
|
||||
|
||||
**Nothing warns anybody.** There is no notification for this yet. If you notice one inside 14 days, say so
|
||||
in the conversation, because nothing else will.
|
||||
|
||||
## The 404 that is not a 404
|
||||
|
||||
**NPM answers an unauthenticated read with 404, not 401.** Its router only registers a resource once the
|
||||
request carries a token, so "not signed in" and "no such endpoint" are the same response.
|
||||
request carries a token, so "not signed in" and "no such endpoint" are identical responses.
|
||||
|
||||
The plugin already knows this: it re-mints its token and retries once, and if a 404 survives that it reports
|
||||
the credential rather than the route. So a failure reaching you through these routes has already been
|
||||
interpreted — **read the message, do not re-diagnose it as a missing endpoint.**
|
||||
The pass-through already re-mints its token and retries once, so a 404 that reaches you is about your
|
||||
request rather than about the token. Read it as "no such thing" — but if every route 404s, the stored
|
||||
credential is wrong and the owner needs to fix it at `/proxy-manager?config=1`.
|
||||
|
||||
It is not a rule about the whole API, only about the routes that need it. `GET /nginx/certificates/dns-providers`,
|
||||
for instance, answers a proper 403.
|
||||
It is not a rule about the whole API: `GET /npm/nginx/certificates/dns-providers` answers a proper 403
|
||||
unauthenticated.
|
||||
|
||||
## Two states that are not failures
|
||||
## Two answers that are not failures
|
||||
|
||||
- **409 with `unconfigured`** — no NPM connection is stored. Nothing is broken; the owner has not connected
|
||||
one. Only the owner can, at `/proxy-manager?config=1`.
|
||||
- **403 on anything under `/_config`** — the connection settings are owner-only, including the test. It is
|
||||
an NPM admin login and NPM has no read-only role, so anyone holding it can redirect any hostname this
|
||||
machine serves. Reading the hosts and certificates is grantable; the credential is not.
|
||||
- **409 `unconfigured`** — no NPM connection is stored. Nothing is broken; the owner has not connected one.
|
||||
Only the owner can, at `/proxy-manager?config=1`.
|
||||
- **403** — either the account lacks `proxy-manager`, or it has it at **read** level, which permits GET and
|
||||
refuses every write. Do not look for another way round; report it.
|
||||
|
||||
## What is coming, so you do not promise it
|
||||
## A private hostname needs a DNS credential, and that is not built
|
||||
|
||||
Handing DNS and certificates over properly — a domain registry, provider API keys in the secret store, and
|
||||
one request that turns *"expose sub.example.com on the private network over HTTPS"* into a DNS record, a
|
||||
certificate and a proxy host. **None of it is built.** `plugins/proxy-manager/README.md` has the design.
|
||||
Until it exists, do not offer to create a host or issue a certificate.
|
||||
TLS on a name that only resolves inside a VPN cannot use the usual challenge — Let's Encrypt cannot reach
|
||||
it — so it needs a DNS-01 challenge, which needs that domain's provider API key.
|
||||
|
||||
**Officer does not store DNS provider credentials yet.** `GET /npm/nginx/certificates/dns-providers` lists
|
||||
what this NPM can drive and the field names each one wants, and a certificate can be created through the
|
||||
pass-through if the owner supplies the credential in the request. Do not promise the smoother version:
|
||||
`plugins/proxy-manager/README.md` has the design and none of it exists.
|
||||
|
||||
Reference in New Issue
Block a user