import { createRouter } from '@@/create-router'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; import { NpmError, npmRequest, probe, forgetToken } from './npm-client'; /** * Mounted at `/api/proxy-manager`. * * Everything here is a read except the enable/disable pair and the settings under `/_config`, which the * 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 ── * * 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. */ export const router = createRouter(); /** NPM's shape, narrowed to what the panels render. */ type ProxyHost = { id: number; domain_names: string[]; forward_scheme: string; forward_host: string; forward_port: number; enabled: number; ssl_forced: number; certificate_id: number; access_list_id: number; meta?: { nginx_online?: boolean; nginx_err?: string | null }; }; type Certificate = { id: number; provider: string; nice_name: string; domain_names: string[]; expires_on: string | null; }; /** * Every NPM failure becomes a shaped body rather than a stack trace. * * `likelyAuth` is the interesting field: NPM answers 404 when unauthenticated, so without saying this * out loud the UI would show "not found" for what is actually a wrong password. The panel turns it into * a sentence pointing at settings. */ const asError = (error: unknown) => { if (error instanceof NpmError) { return { body: { error: error.message, likelyAuth: error.likelyAuth, unreachable: error.status === 0 }, status: error.status === 0 ? 503 : error.likelyAuth ? 502 : 502, } as const; } return { body: { error: error instanceof Error ? error.message : String(error) }, status: 500 } as const; }; /** Loads the credential, or answers 409 — "configured" is a state the UI has to distinguish. */ async function withCredentials(handler: (c: NonNullable>>) => Promise) { const credentials = await readCredentials(); if (!credentials) return { ok: false as const, status: 409, body: { error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true } }; try { return { ok: true as const, data: await handler(credentials) }; } catch (error) { const shaped = asError(error); return { ok: false as const, status: shaped.status, body: shaped.body }; } } const respond = (ctx: Parameters[1]>[0], result: Awaited>) => result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500); // ── Status ──────────────────────────────────────────────────────────────────────────────────────── /** Cheap enough for a panel to poll: whether a connection exists, and whether it currently works. */ router.get('/status', async (ctx) => { const connection = await readConnection(); if (!connection.configured) return ctx.json({ ...connection, reachable: false }); const credentials = await readCredentials(); if (!credentials) return ctx.json({ ...connection, reachable: false }); try { const info = await probe(credentials.url); await recordProbe(info.version, true); return ctx.json({ ...connection, version: info.version, reachable: true }); } catch (error) { await recordProbe(connection.version, false); const shaped = asError(error); return ctx.json({ ...connection, reachable: false, error: shaped.body.error }); } }); // ── The four kinds of host NPM manages ──────────────────────────────────────────────────────────── router.get('/hosts', async (ctx) => respond( ctx, await withCredentials(async (c) => { const hosts = await npmRequest(c, { path: '/nginx/proxy-hosts?expand=certificate,access_list' }); // Sorted by first domain rather than by NPM's creation order: a list read by a person should be // findable, and NPM's own UI sorts this way too. return hosts.sort((a, b) => (a.domain_names[0] ?? '').localeCompare(b.domain_names[0] ?? '')); }), ), ); router.get('/redirections', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/redirection-hosts' }))), ); router.get('/streams', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/streams' })))); /** NPM calls these "404 hosts" — a domain it serves deliberately as nothing. */ router.get('/dead-hosts', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/dead-hosts' }))), ); router.get('/hosts/:id', async (ctx) => respond( ctx, await withCredentials((c) => npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}?expand=certificate,access_list,owner` }), ), ), ); // ── Certificates and access lists ───────────────────────────────────────────────────────────────── router.get('/certificates', async (ctx) => respond( ctx, await withCredentials(async (c) => { const certificates = await npmRequest(c, { path: '/nginx/certificates' }); // The panel's whole job is "which of these is about to expire", so compute it once here rather // than parsing dates in the browser and getting the timezone wrong. const now = Date.now(); return certificates .map((certificate) => ({ ...certificate, daysUntilExpiry: certificate.expires_on ? Math.floor((Date.parse(certificate.expires_on) - now) / 86_400_000) : null, })) .sort((a, b) => (a.daysUntilExpiry ?? 1e9) - (b.daysUntilExpiry ?? 1e9)); }), ), ); router.get('/access-lists', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/access-lists?expand=items,clients' }))), ); // ── The one mutation ────────────────────────────────────────────────────────────────────────────── /** * Taking a host in or out of service. The only write, and it is reversible in one click — which is why * it is here when creating and deleting are not. */ router.post('/hosts/:id/:action', async (ctx) => { const action = ctx.req.param('action'); if (action !== 'enable' && action !== 'disable') return ctx.json({ error: 'Expected enable or disable.' }, 400); return respond( ctx, await withCredentials(async (c) => { await npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}/${action}`, method: 'POST' }); return { id: Number(ctx.req.param('id')), enabled: action === 'enable' }; }), ); }); // ── Settings — owner only, by the manifest ──────────────────────────────────────────────────────── router.get('/_config', async (ctx) => ctx.json(await readConnection())); router.put('/_config', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null; if (!body?.url || !body.identity) return ctx.json({ error: 'url and identity are required.' }, 400); try { const saved = await saveConnection({ url: body.url, identity: body.identity, secret: body.secret }); // The old credential's token must not outlive the credential itself. const credentials = await readCredentials(); if (credentials) forgetToken(credentials); return ctx.json(saved); } catch (error) { return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400); } }); router.delete('/_config', async (ctx) => { const credentials = await readCredentials(); if (credentials) forgetToken(credentials); await deleteConnection(); return ctx.json({ configured: false }); }); /** * Test a credential WITHOUT storing it, so a wrong password never replaces a working one. * * Declared in the manifest as a `readOnlyWrites` path: it is a POST that changes nothing. */ router.post('/_config/test', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null; if (!body?.url) return ctx.json({ error: 'url is required.' }, 400); try { const info = await probe(body.url); if (!body.identity || !body.secret) { return ctx.json({ reachable: true, authenticated: false, version: info.version, setup: info.setup }); } await npmRequest({ url: body.url, identity: body.identity, secret: body.secret }, { path: '/nginx/proxy-hosts' }); return ctx.json({ reachable: true, authenticated: true, version: info.version, setup: info.setup }); } catch (error) { const shaped = asError(error); return ctx.json({ reachable: !(shaped.body as { unreachable?: boolean }).unreachable, authenticated: false, ...shaped.body }, 200); } });