diff --git a/api/npm-client.ts b/api/npm-client.ts index a64d065..5222e33 100644 --- a/api/npm-client.ts +++ b/api/npm-client.ts @@ -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 { + 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 => + 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'), + }; +} diff --git a/api/router.ts b/api/router.ts index 1ff16b0..148c436 100644 --- a/api/router.ts +++ b/api/router.ts @@ -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/*` → `/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())); diff --git a/skills/proxy-manager/SKILL.md b/skills/proxy-manager/SKILL.md index 8610272..d8193c7 100644 --- a/skills/proxy-manager/SKILL.md +++ b/skills/proxy-manager/SKILL.md @@ -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/ -> /api/ ``` -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.