/** * 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}` : ''; }