diff --git a/api/firewall.ts b/api/firewall.ts new file mode 100644 index 0000000..1139f9a --- /dev/null +++ b/api/firewall.ts @@ -0,0 +1,107 @@ +/** + * Letting the proxy reach the development server. + * + * ── The failure this prevents ── + * + * Nginx Proxy Manager runs in a container and reaches host services across the docker bridge. ufw on + * this machine allows that subnet to specific ports and nothing else — so a development server on a + * port nobody opened is reachable from the host, answers 200 to `curl 127.0.0.1`, and is completely + * invisible to the proxy. + * + * What that looks like is a browser timing out with a valid certificate, a correct proxy host and a + * healthy process. The only place it is written down is nginx's own error log, and only if you know to + * read it: *"upstream timed out while connecting to upstream: http://172.20.0.1:3011/"*. It cost an + * evening once, which is why this exists rather than a line in the README. + * + * ── Why the plugin does it rather than a person ── + * + * The panel is what chooses the port. A setting that silently requires a second, undocumented step + * elsewhere is a setting that will be wrong most times it is changed. + * + * ── What this can and cannot do ── + * + * It runs `sudo -n ufw` — non-interactive, so a host without passwordless sudo FAILS rather than + * hanging forever on a prompt nothing will answer. Every failure here is reported and never fatal: the + * server still starts, and a rule that could not be added is a line in the output rather than a start + * that did not happen. + * + * The port is an integer validated before it arrives, and every command is an argv array, so there is + * no shell and nothing to inject. That is the whole of the security boundary, and it is why `port` is + * typed `number` rather than taken from a request. + */ + +/** The docker subnet NPM is on. The rules already on this host name the same one. */ +const DOCKER_SUBNET = '172.20.0.0/16'; + +/** So a person reading `ufw status` knows what put it there and what would remove it. */ +const COMMENT = 'fofos-admin: npm proxy -> host dev server'; + +const run = async (argv: string[], timeoutMs = 20_000): Promise<{ ok: boolean; output: string }> => { + const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' }); + const timer = setTimeout(() => proc.kill(), timeoutMs); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { ok: code === 0, output: `${stdout}${stderr}`.trim() }; + } finally { + clearTimeout(timer); + } +}; + +/** Is there a ufw here, and is it on? A host without one needs nothing and should say nothing. */ +async function ufwActive(): Promise { + const status = await run(['sudo', '-n', 'ufw', 'status']); + return status.ok && status.output.toLowerCase().includes('status: active'); +} + +/** + * Allow the docker subnet to reach this port. + * + * Idempotent by ufw's own behaviour — a rule that already exists is skipped, and says so. So this runs + * on every start rather than only the first, which means a firewall reset or a host rebuild repairs + * itself the next time somebody presses Start. + */ +export async function openDevPort(port: number): Promise { + if (!Number.isInteger(port) || port < 1024 || port > 65535) return `refused to open port ${port}`; + if (!(await ufwActive())) return 'no active ufw — nothing to open'; + + const result = await run([ + 'sudo', '-n', 'ufw', 'allow', + 'from', DOCKER_SUBNET, + 'to', 'any', + 'port', String(port), + 'proto', 'tcp', + 'comment', COMMENT, + ]); + + if (!result.ok) return `could not open port ${port} to the proxy: ${result.output}`; + // ufw says "Rule added" or "Skipping adding existing rule" — both are the state we wanted. + return result.output.toLowerCase().includes('skipping') + ? `port ${port} already open to the proxy` + : `opened port ${port} to the proxy`; +} + +/** + * Close a port this plugin opened. + * + * Called when the port CHANGES, not when the server stops. A stopped development server is usually + * about to start again, and a rule for a port nothing is listening on grants nothing — whereas leaving + * the old one behind on every port change means a firewall that slowly accumulates holes nobody + * remembers opening. + */ +export async function closeDevPort(port: number): Promise { + if (!Number.isInteger(port) || port < 1024 || port > 65535) return ''; + if (!(await ufwActive())) return ''; + + const result = await run([ + 'sudo', '-n', 'ufw', 'delete', 'allow', + 'from', DOCKER_SUBNET, + 'to', 'any', + 'port', String(port), + 'proto', 'tcp', + ]); + return result.ok ? `closed port ${port}` : ''; +} diff --git a/api/servers.ts b/api/servers.ts index 2cb4864..6d99464 100644 --- a/api/servers.ts +++ b/api/servers.ts @@ -5,6 +5,7 @@ import { fofosAdminSettings } from '../db/schema'; import { randomBytes } from 'node:crypto'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { readDatabases } from './databases'; +import { closeDevPort, openDevPort } from './firewall'; /** * The site's DEVELOPMENT server, under pm2. @@ -212,6 +213,16 @@ export async function savePorts(dev: unknown, prod: unknown): Promise