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>
This commit is contained in:
2026-08-29 19:51:07 +01:00
co-authored by Claude Opus 5
parent 89adbe37ce
commit 2153d4875b
2 changed files with 131 additions and 1 deletions
+24 -1
View File
@@ -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<ServerStat
if (devPort === prodPort) throw new Error('The two servers cannot share a port.');
const row = await readRow();
/*
A changed port leaves the old rule behind, and this is the only place that knows both values.
Closing it on STOP would be wrong — a stopped development server is usually about to start again,
and a rule for a port nothing listens on grants nothing. Leaving it on every change is how a
firewall accumulates holes nobody remembers opening.
*/
if (row?.devPort && row.devPort !== devPort) await closeDevPort(row.devPort);
if (row) {
await db
.update(fofosAdminSettings)
@@ -289,6 +300,18 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out
POSTGRES_URL_DEV: url.toString(),
};
/*
Open the firewall to the proxy BEFORE starting, so the port is reachable the moment it binds.
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` and is
invisible to the proxy — which presents as a browser timing out with a valid certificate, a correct
proxy host and a healthy process, and is written down only in nginx's own error log.
Never fatal. A rule that could not be added is a line in the output; the server still starts.
*/
const firewall = await openDevPort(port);
// Removed first so a stale process cannot survive with the old environment. Failure is expected and
// ignored: the usual case is that it was never there.
await run([PM2, 'delete', PROCESS[which]], 20_000);
@@ -312,7 +335,7 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out
return {
ok: started.ok,
output: [`$ pm2 start ${PROCESS[which]} (${which}, port ${port})`, started.output, await persist()]
output: [`$ pm2 start ${PROCESS[which]} (${which}, port ${port})`, firewall, started.output, await persist()]
.filter(Boolean)
.join('\n'),
};