diff --git a/.changeset/curly-pans-shave.md b/.changeset/curly-pans-shave.md new file mode 100644 index 00000000..5795d3d7 --- /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/serveUrl.d.ts b/bin/serveUrl.d.ts new file mode 100644 index 00000000..ce5e5737 --- /dev/null +++ b/bin/serveUrl.d.ts @@ -0,0 +1 @@ +export function serveUrl(host: string | undefined, port: string): string; diff --git a/bin/serveUrl.js b/bin/serveUrl.js new file mode 100644 index 00000000..9279d63f --- /dev/null +++ b/bin/serveUrl.js @@ -0,0 +1,7 @@ +// A wildcard listener is reachable locally, but its unspecified address is not +// a useful browser destination. Concrete bind addresses should be opened as-is. +export function serveUrl(host, port) { + const address = !host || host === "0.0.0.0" || host === "::" ? "localhost" : host; + const authority = address.includes(":") ? `[${address}]` : address; + return `http://${authority}:${port}`; +} diff --git a/bin/sideshow.js b/bin/sideshow.js index 55083dae..98112269 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -6,6 +6,7 @@ import { homedir, tmpdir, userInfo } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; +import { serveUrl } from "./serveUrl.js"; const BASE = (process.env.SIDESHOW_URL ?? "http://localhost:8228").replace(/\/$/, ""); const TOKEN = process.env.SIDESHOW_TOKEN; @@ -18,7 +19,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 +150,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,15 +893,20 @@ 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) { - const url = `http://localhost:${port}`; + const url = serveUrl(host, port); const { opener, openerArgs } = process.platform === "darwin" ? { opener: "open", openerArgs: [url] } diff --git a/server/index.ts b/server/index.ts index db522b41..30a07498 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)"), + ); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 4e989e58..e92c862c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { serve } from "@hono/node-server"; import { createApp } from "../server/app.ts"; import { JsonFileStore } from "../server/storage.ts"; +import { serveUrl } from "../bin/serveUrl.js"; const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "sideshow.js"); @@ -97,6 +98,14 @@ test("version runs end-to-end (update check is best-effort)", async () => { assert.match(stdout, /^sideshow \d+\.\d+\.\d+/); }); +test("serve --open URL uses the concrete bind address", () => { + assert.equal(serveUrl(undefined, "8228"), "http://localhost:8228"); + assert.equal(serveUrl("0.0.0.0", "8228"), "http://localhost:8228"); + assert.equal(serveUrl("::", "8228"), "http://localhost:8228"); + assert.equal(serveUrl("127.0.0.2", "8228"), "http://127.0.0.2:8228"); + assert.equal(serveUrl("::1", "8228"), "http://[::1]:8228"); +}); + // None of these reach the network: --help and option errors resolve in // parsing, before any request (no server needs to be running).