diff --git a/.changeset/curly-pans-shave.md b/.changeset/curly-pans-shave.md new file mode 100644 index 0000000..5795d3d --- /dev/null +++ b/.changeset/curly-pans-shave.md @@ -0,0 +1,12 @@ +--- +"sideshow": minor +--- + +serve: add `--host` / `SIDESHOW_HOST` to bind one address + +`serve` listened on every interface with no way to restrict it, and printed +`listening on http://localhost:PORT` regardless — so a server sharing a host or +a network with anything else was reachable from it, and the startup line said +otherwise. The default is unchanged (every interface, which is what containers +and LAN-shared instances need); `--host 127.0.0.1` now keeps it off the network +entirely, and the startup line reports the address actually bound. diff --git a/bin/sideshow.js b/bin/sideshow.js index 55083da..4f8ec7e 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -18,7 +18,10 @@ const SELF = fileURLToPath(import.meta.url); const HELP = `sideshow — a live visual surface for terminal coding agents usage: - sideshow serve [--port N] [--open] start the surface (API + viewer) + sideshow serve [--port N] [--host H] [--open] + start the surface (API + viewer) + --host bind to one address (e.g. 127.0.0.1); default is every + interface sideshow publish [options] publish an HTML post (one html surface) --title post title --md add a markdown surface (prose) — repeatable @@ -146,6 +149,8 @@ environment: SIDESHOW_URL server base URL (default http://localhost:8228; set to a deployed instance, e.g. https://sideshow.you.workers.dev) SIDESHOW_TOKEN bearer token for a deployed instance + SIDESHOW_HOST address serve binds to (default: every interface). Set to + 127.0.0.1 to keep the server off the network entirely SIDESHOW_SESSION fixed session id (overrides auto-detection) SIDESHOW_AGENT agent name used when creating sessions `; @@ -887,14 +892,21 @@ function readStdin() { const commands = { async serve() { const { values: flags } = parse({ - options: { port: { type: "string" }, open: { type: "boolean" } }, + options: { + port: { type: "string" }, + host: { type: "string" }, + open: { type: "boolean" }, + }, }); const port = flags.port ?? process.env.PORT ?? "8228"; + const host = flags.host ?? process.env.SIDESHOW_HOST; const child = spawn(process.execPath, [entrypoint("server", "index.ts")], { stdio: "inherit", - env: { ...process.env, PORT: port }, + env: { ...process.env, PORT: port, ...(host ? { SIDESHOW_HOST: host } : {}) }, }); if (flags.open) { + // Always open localhost: a wildcard or loopback bind is reachable there, + // and it is the only address guaranteed to resolve on this machine. const url = `http://localhost:${port}`; const { opener, openerArgs } = process.platform === "darwin" diff --git a/server/index.ts b/server/index.ts index db522b4..30a0749 100644 --- a/server/index.ts +++ b/server/index.ts @@ -83,7 +83,20 @@ const app = createApp({ }); const port = Number(process.env.PORT ?? 8228); +// SIDESHOW_HOST (or `serve --host`) restricts the listener to one address. +// Unset keeps the previous behaviour — node's default, every interface — because +// that is what a container or a LAN-shared instance needs. Set it to 127.0.0.1 +// when the server shares a host with anything you don't want reaching it; that +// is stronger than the token, which is a single shared secret by design. +const hostname = process.env.SIDESHOW_HOST || undefined; -serve({ fetch: app.fetch, port }, (info) => { - console.log(`sideshow listening on http://localhost:${info.port}`); +serve({ fetch: app.fetch, port, hostname }, (info) => { + // Report the address actually bound. Printing "localhost" unconditionally hid + // the fact that the default listens on every interface. + const shown = hostname ?? "localhost"; + const authority = shown.includes(":") ? `[${shown}]` : shown; + console.log( + `sideshow listening on http://${authority}:${info.port}` + + (hostname ? "" : " (all interfaces — set SIDESHOW_HOST to restrict)"), + ); });