Files
pastilhasandClaude Opus 5 2153d4875b open the firewall to the proxy when the dev server starts
NPM runs in a container and crosses the docker bridge to reach host services;
ufw allows that subnet to named ports only. A development server on a port
nobody opened answers `curl 127.0.0.1` perfectly and is invisible to the proxy.

What that looks like is a browser timing out against a valid certificate, a
correct proxy host and a healthy process — with the cause written down in
exactly one place nobody thinks to read: nginx's own error log, "upstream timed
out while connecting to upstream: http://172.20.0.1:3011/". It cost an evening,
which is why this is code rather than a line in a README.

The panel is what chooses the port, so the panel is what opens it. A setting that
silently requires a second undocumented step elsewhere will be wrong most times
it is changed.

Opened BEFORE the process starts, so the port is reachable the moment it binds,
and idempotent by ufw's own behaviour — it runs on every start rather than only
the first, so a firewall reset repairs itself next time somebody presses Start.
Never fatal: a rule that could not be added is a line in the output and the
server still starts.

Closed when the port CHANGES, in savePorts, which is the only place that knows
both values. Not on stop — a stopped dev server is usually about to start again,
and a rule for a port nothing listens on grants nothing, while leaving it on
every change is how a firewall accumulates holes nobody remembers opening.

`sudo -n`, so a host without passwordless sudo fails rather than hanging on a
prompt nothing will answer. 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 boundary, and it is why the parameter is typed number rather
than read from a request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:51:07 +01:00

108 lines
4.5 KiB
TypeScript

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