From c3c69a5f1b826c06c59337f1742d44dee2909925 Mon Sep 17 00:00:00 2001 From: Hee Sung Son Date: Sun, 11 Oct 2026 01:03:20 +0900 Subject: [PATCH 1/6] feat(settings): give this PC a public address through Portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Phone & devices → Portal installs Portal's official v2.6.1 release (checked against a pinned SHA-256), starts it in routed mode with Tailscale-User-Login stripped on a relay the user enters, and shows the address with its QR code. Install and start only from this PC itself and only with HERDR_WEB_TOKEN set; any device that can type stops it; an address left on comes back with the server. Refs #202. --- CHANGELOG.md | 7 + DESIGN.md | 5 +- docs/guide.md | 3 + server/AGENTS.md | 1 + server/api.contract.test.ts | 48 ++- server/device-access.contract.test.ts | 2 +- server/index.ts | 11 +- server/portal.test.ts | 308 ++++++++++++++++ server/portal.ts | 483 ++++++++++++++++++++++++++ shared/protocol.ts | 38 ++ site/demo/transport.ts | 4 +- src/components/PhonePanel.tsx | 2 +- src/components/PortalPanel.css | 14 + src/components/PortalPanel.tsx | 65 ++++ src/components/SettingsDialog.tsx | 3 + src/lib/api.ts | 12 + src/lib/i18n.ja.ts | 13 + src/lib/i18n.ko.ts | 13 + src/lib/i18n.zh.ts | 13 + src/lib/portal.ts | 52 +++ 20 files changed, 1090 insertions(+), 7 deletions(-) create mode 100644 server/portal.test.ts create mode 100644 server/portal.ts create mode 100644 src/components/PortalPanel.css create mode 100644 src/components/PortalPanel.tsx create mode 100644 src/lib/portal.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe4d58ead..304cd42088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Added +- **Settings → Phone & devices → Portal** gives this PC a public HTTPS address through + [Portal](https://github.com/gosuda/portal-tunnel), with no account and no domain of your own. + On the PC itself, **Install Portal** downloads Portal's official v2.6.1 release, checked against + the digest the app carries, and **Start** runs it in routed mode on a relay you enter, without + passing on a visitor's Tailscale login. It starts only with `HERDR_WEB_TOKEN` set. The address + and its QR code show on every signed-in device, any device that can type stops it, and one left + on comes back when the app restarts. - herdr's pane layout operations from the web: a tab's menu (right-click a tab, or its chevron; a bottom sheet on a phone) now offers **Split right** and **Split down** (herdr's prefix+v and prefix+-), **Zoom pane** (prefix+z), **Swap left / right / up / down** with the pane's diff --git a/DESIGN.md b/DESIGN.md index 2e24274255..ea93b490b3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1082,8 +1082,9 @@ One set for both themes: the card is island black wherever it shows. **Composer**: Enter sends, suggestion chip; then **Quick replies**), **Terminal** (font size and family, wheel speed, input mode, Key bar), **Alerts**, **Voice input**, **Subscription usage**, **Shortcuts** (the platform-resolved global bindings), **Phone & devices** (the phone address, - Keep screen on, Install; then paired devices), **Remote PCs**, **About** (Updates, herdr, - the repository links). A button that points at Updates opens the dialog on About. + Keep screen on, Install; then Portal, where the server offers it; then paired devices), + **Remote PCs**, **About** (Updates, herdr, the repository links). A button that points at + Updates opens the dialog on About. - Every page is built from the same parts (`components/SettingsControls.tsx`). A group is an optional caption (`--fs-sm`, `--fw-semibold`, `--text-dim`, sentence case) over one card: `--bg-elevated`, hairline `--border`, `--radius-lg`, its rows parted by hairlines and never diff --git a/docs/guide.md b/docs/guide.md index 374679d089..88ad866dd0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -391,6 +391,8 @@ Take `` from `portal list`; it has to run v2.6.1 or later. A `portal` pro Portal prints `service ready at https://-
.:443`, where `
` is the identity file's 40-character address; with or without `:443` it is the same address. That address belongs to the identity, so nobody else can take it, even while Portal is stopped; keep the identity file private and keep using it, since a new identity gives a new address. Check it as above: `https://-
./api/session`, opened from another device without signing in, must answer 401. Then open it on the phone and sign in with the token, or pair the phone from **Settings → Devices → Pair a device** on the PC. Use that address, not the shorter `https://.`, which another identity can claim while Portal is stopped. +**Settings → Phone & devices → Portal** does the same from the app. On this PC itself, not through another device or a proxy, **Install Portal** downloads Portal's official v2.6.1 release into the app's state directory and checks it against the digest the app carries, and **Start** runs the command above with the relay you enter. It starts only with `HERDR_WEB_TOKEN` set and `HERDR_WEB_TAILSCALE_SERVE_ONLY` off. The address and its QR code show on every signed-in device, and any device that can type into your terminals can stop it; one left on comes back when the app restarts. The app keeps an identity file of its own, so its address stays the same from one start to the next and differs from the one a `portal` you run yourself has. + **Sign out** in the header or command palette clears this browser's token and device cookies; terminal sessions and agents keep running. It is shown for token or device authentication, not automatic local or Tailscale access. If `devices.json` under `HERDR_WEB_STATE_DIR` (default `~/.config/herdr-web-ui`) is corrupt or unreadable, the server keeps unrecognized external clients out and preserves the file. Local access and the owner's trusted Tailscale login without a configured token, and a valid token, still work. Settings → Devices and the server log explain the error. Restore a valid registry from backup or fix its permissions, then restart; pairing and device changes remain disabled until it is repaired. @@ -424,6 +426,7 @@ Observe connections cannot take a pane, and a displaced bridge never takes it ba | `HERDR_WEB_PUSH_SUBJECT` | this repository's URL | VAPID contact URL or `mailto:` address | | `HERDR_WEB_BUNDLE_MANIFEST` | unset | Remote-PC bundle manifest (path or URL) that overrides local and published bundles | | `HERDR_WEB_HERDR_BIN` | `herdr` | herdr executable used for terminal attach | +| `HERDR_WEB_PORTAL_BIN` | the one Settings installed, else `portal` on `PATH` | The `portal` executable that **Settings → Phone & devices → Portal** runs; v2.6.1 or later | | `HERDR_WEB_APP_NAME` | unset (`herdr`) | The name the installed app has on a phone or computer, for example the PC's name. Each PC installs as an app of its own, and without it they are all called `herdr`. An app already installed takes the new name when the browser next updates it, usually within a day or two, without a reinstall. Keep it short: a home screen cuts a long label | | `HERDR_WEB_PASTE_DIR` | `/.herdr-web-ui` | Where pasted and attached files are saved: an absolute path or `~/…`. Set it to keep them out of your projects; an agent may then ask before reading one. It covers the panes of this server's PC; a remote PC keeps the default | | `CODEX_HOME` | `~/.codex` | Where Codex sessions are read | diff --git a/server/AGENTS.md b/server/AGENTS.md index ae074819a5..e01078ca4b 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -59,6 +59,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the | SSH remote PCs | machines.ts + ssh.ts + machine-api.ts + machine-relay.ts; shared/machines.ts | PC-scoped APIs/SSE/WS; private versioned runtimes, same-socket bridge reuse, explicit setup/update approval; docs/remote-pcs.md | | HTTP routing / static serving | index.ts `createServer().fetch` | route order matters (root AGENTS.md); `/api/machines/local/*` rewrites to `/api/*`; handler modules return a `Response`, or `null` to fall through | | Updating herdr itself | herdr-update.ts (`HerdrUpdater`, `handleHerdrUpdateRequest`) + GET/POST /api/herdr/update in index.ts | runs `herdr update --handoff` with every `HERDR_*` variable removed, stdin closed and `HERDR_SOCKET_PATH` set to this server's herdr, then `herdr server live-handoff` when `herdr status --json` says the binary differs from the server; only the real entrypoint passes `herdrUpdate`, so tests offer nothing unless they pass a stand-in herdr. NEVER run the real `herdr update` in a test: it replaces the herdr on PATH (#373) | +| Public address through Portal | portal.ts (`PortalService`, `handlePortalRequest`) + GET /api/portal, POST /api/portal/{install,start,stop} in index.ts | install and start only from this PC itself (loopback, no forwarding header), never without a token or under HERDR_WEB_TAILSCALE_SERVE_ONLY; stop from any client that can type. The release is checked against the digest pinned in portal.ts, never the checksum file beside it. Portal runs routed with `--strip-request-header Tailscale-User-Login` and none of the server's environment beyond PATH, home, temp and proxy settings, so the token never reaches it. Portal retries a relay it cannot use forever and silently: the relay's `/sdk/domain` is asked first and readiness has its own deadline. A pid file left by a server killed outright lets the next one stop that Portal, after `ps` shows it runs this identity; only the real entrypoint passes `portal`, so tests offer nothing unless they pass a stand-in | | App updates | managed.ts (launcher) + supervisor.ts + updater.ts + update-api.ts + update-state.ts | launcher runs the active release's supervisor and falls back if it dies before ready; supervisor owns Git/build/restart and exits 75 (HANDOVER_EXIT) after an install so the new supervisor takes over; bridge relays status/commands over IPC; /api/updates* gated + POST custom-header/same-origin guard; no update side effects in createServer tests; real Git/build/IPC/rollback tests in updater.test.ts | | Workspace / agent HTTP endpoints | index.ts + herdr/client.ts | /api/agents and /api/workspace/create/rename/move/close; creation validates cwd and may start an agent in the root pane | | Moving a pane | pane-move.ts (`parseMoveRequest`) + POST /api/pane/move in index.ts + `paneMove` in herdr/client.ts | herdr's `pane.move` answers `{ move_result }`; a move inside the workspace keeps the pane id, one into another workspace (or a new one) gives a NEW id (measured on 0.9.3), reported beside `previous_pane_id`, and the emptied tab or workspace closes behind it. The body is checked here because herdr answers a malformed destination with a bare `invalid_request`; a tab destination's `split` defaults to right; `focus` defaults to false so herdr's own focus stays. The old id's transcript cache is forgotten; the collector's `pane.moved` handling (#725) carries the status | diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index baa4076f4c..507f588899 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -4,8 +4,9 @@ import { chmodSync, mkdtempSync, existsSync, readFileSync, realpathSync, rmSync, import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { createServer } from "./index.ts"; -import type { AgentKind, AgentStatus, ApiError, HealthAuth, IntegrationsResponse, PushKey, RemoteAccess, SessionSnapshot, PaneReadResult, UsageReport, WorkspaceCreated, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../shared/protocol.ts"; +import type { AgentKind, AgentStatus, ApiError, HealthAuth, IntegrationsResponse, PortalStatus, PushKey, RemoteAccess, SessionSnapshot, PaneReadResult, UsageReport, WorkspaceCreated, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../shared/protocol.ts"; import { HerdrUpdater } from "./herdr-update.ts"; +import { PortalService } from "./portal.ts"; import { noInstalledNotes, unmanagedUpdateStatus, type HerdrUpdateStatus, type InstalledNotes, type UpdateNotes } from "../shared/update.ts"; import { USAGE_PROVIDERS, UsageService } from "./usage.ts"; import { VoiceService } from "./voice.ts"; @@ -361,6 +362,51 @@ describe("phone access", () => { }); }); +describe("Portal API", () => { + it("opens a public address only for this PC itself, and lets any signed-in client close it", async () => { + const state = mkdtempSync(join(tmpdir(), "herdr-portal-api-")); + const standIn = join(state, "stand-in-portal"); + writeFileSync(standIn, [ + "#!/bin/sh", + 'case "$1" in', + " version) echo v2.6.1 ;;", + " expose) trap 'exit 0' TERM; echo 'INF service ready at https://herdr-test-0123.relay.example:443'; while :; do sleep 0.05; done ;;", + " *) exit 2 ;;", + "esac", + "", + ].join("\n")); + chmodSync(standIn, 0o755); + const relay = (async () => Response.json({ data: { release_version: "v2.6.1" } })) as unknown as typeof fetch; + const server = createServer({ port: 0, stateDir: state, token: "test-portal-token", portal: new PortalService({ stateDir: state, bin: standIn, fetch: relay }) }); + const url = `http://127.0.0.1:${server.port}/api/portal`; + const signedIn = { authorization: "Bearer test-portal-token" }; + // what any proxy on this PC sends, Portal's own address included + const proxied = { ...signedIn, "x-forwarded-for": "203.0.113.7" }; + const post = (action: string, headers: Record) => fetch(`${url}/${action}`, { method: "POST", headers: { ...headers, "x-herdr-update": "1", "content-type": "application/json" }, body: JSON.stringify({ relay: "relay.example" }) }); + const status = async (headers: Record = signedIn) => await (await fetch(url, { headers })).json() as PortalStatus; + try { + expect((await fetch(url)).status).toBe(401); + expect(await status()).toMatchObject({ supported: true, usable: true, here: true, blocked: null, phase: "idle" }); + expect(await status(proxied)).toMatchObject({ here: false }); + const refused = await post("start", proxied); + expect(refused.status).toBe(403); + expect((await refused.json() as ApiError).error.code).toBe("portal_not_here"); + + expect((await post("start", signedIn)).status).toBe(202); + const deadline = Date.now() + 10_000; + let running = await status(); + while (running.phase === "starting") { + if (Date.now() >= deadline) throw new Error("Timed out: Portal was ready"); + await Bun.sleep(20); + running = await status(); + } + expect(running).toMatchObject({ phase: "running", url: "https://herdr-test-0123.relay.example", relay: "https://relay.example" }); + expect((await post("stop", proxied)).status).toBe(202); + expect((await status()).url).toBeNull(); + } finally { server.stop(); rmSync(state, { recursive: true, force: true }); } + }); +}); + describe("mutation body validation", () => { it("rejects non-object JSON without touching herdr or losing the error envelope", async () => { for (const path of [ diff --git a/server/device-access.contract.test.ts b/server/device-access.contract.test.ts index 21a734a9f2..7e823a042a 100644 --- a/server/device-access.contract.test.ts +++ b/server/device-access.contract.test.ts @@ -117,7 +117,7 @@ it("watch credentials cannot mutate HTTP state, read credential files, or elevat }, websocket: { open(ws) { ws.data.relay.bind(ws); }, message(ws, raw) { ws.data.relay.message(raw); }, close(ws) { ws.data.relay.close(); } } }); try { expect((await fetch(`${base}/api/session`, { headers })).status).toBe(200); - for (const path of ["pane/input", "workspace/create", "tab/create", "tab/rename", "tab/close", "machines/setup", "updates/install", "herdr/update"]) { + for (const path of ["pane/input", "workspace/create", "tab/create", "tab/rename", "tab/close", "machines/setup", "updates/install", "herdr/update", "portal/start", "portal/stop"]) { expect((await fetch(`${base}/api/${path}`, { method: "POST", headers, body: "{}" })).status).toBe(403); } expect((await fetch(`${base}/api/fs/file?path=${encodeURIComponent(join(root, "devices.json"))}`, { headers })).status).toBe(403); diff --git a/server/index.ts b/server/index.ts index ab6db8853f..525845a450 100644 --- a/server/index.ts +++ b/server/index.ts @@ -91,6 +91,7 @@ import { OutputWindow, OUTPUT_HIGH_BYTES, OUTPUT_HARD_BYTES, OUTPUT_STALL_MS, Re import { OUTPUT_STALLED_CLOSE_CODE } from "../shared/terminal-flow.ts"; import { connectUpdater, handleUpdateRequest, type UpdateService } from "./update-api.ts"; import { handleHerdrUpdateRequest, HerdrUpdater } from "./herdr-update.ts"; +import { handlePortalRequest, PortalService } from "./portal.ts"; import { handleTelemetryRequest, Telemetry } from "./telemetry.ts"; import { handleUsageRequest, UsageService } from "./usage.ts"; import { handleVoiceRequest, VoiceService } from "./voice.ts"; @@ -386,6 +387,8 @@ export function createServer( updates?: UpdateService; /** updates herdr itself (server/herdr-update.ts); unset, the app offers no herdr update. Tests pass one that runs a stand-in herdr. */ herdrUpdate?: HerdrUpdater; + /** a public address through Portal (server/portal.ts); unset, the app offers none. Tests pass one that runs a stand-in portal. */ + portal?: PortalService; /** anonymous install and update counts (server/telemetry.ts); unset, the server sends none and answers 404. Only the real entrypoint passes one. */ telemetry?: Telemetry; /** plan limits of the AI subscriptions signed in here; tests pass one without real sign-ins */ @@ -1538,6 +1541,9 @@ export function createServer( return handleUpdateRequest(request, pathname, options.updates); } if (pathname === "/api/herdr/update") return handleHerdrUpdateRequest(request, options.herdrUpdate); + if (pathname === "/api/portal" || pathname.startsWith("/api/portal/")) { + return handlePortalRequest(request, pathname, options.portal, { here: loopback && !forwarded, port: bunServer.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); + } if (pathname === "/api/telemetry") return handleTelemetryRequest(request, options.telemetry); if (pathname === "/api/usage") return handleUsageRequest(request, url, usage); @@ -2882,6 +2888,8 @@ export function createServer( }); const registration = options.registerBridge ? registerBridge(server.port ?? 0, bridgeToken) : null; + // an address left on comes back with the server (an app update restarts it) + void options.portal?.resume({ port: server.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); // ACKs can stop arriving entirely (a suspended tab). Bound the pause even then. const outputTimer = setInterval(() => { @@ -2902,6 +2910,7 @@ export function createServer( claudeAgents.stop(); clearInterval(waitTimer); machines?.stop(); + options.portal?.shutdown(); registration?.close(); for (const client of clients) killWatches(client); for (const paneId of [...attachments.keys()]) closeAttachment(paneId); @@ -2914,7 +2923,7 @@ if (import.meta.main) { const updates = connectUpdater(); const version = (JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as { version: string }).version; const telemetry = new Telemetry({ stateDir: defaultStateDir(), version, env: process.env, fetch, previousVersion: () => updates.installed().previous_version }); - const instance = createServer({ updates, herdrUpdate: new HerdrUpdater(), telemetry, registerBridge: true }); + const instance = createServer({ updates, herdrUpdate: new HerdrUpdater(), portal: new PortalService({ stateDir: defaultStateDir() }), telemetry, registerBridge: true }); telemetry.start(); let stopping = false; const shutdown = () => { diff --git a/server/portal.test.ts b/server/portal.test.ts new file mode 100644 index 0000000000..11874185db --- /dev/null +++ b/server/portal.test.ts @@ -0,0 +1,308 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PortalStatus } from "../shared/protocol.ts"; +import { atLeast, handlePortalRequest, PortalService, readyUrl, relayOrigin } from "./portal.ts"; + +/** + * The service runs a stand-in portal here, a shell script, and asks a relay through an injected + * fetch: no test reaches a real relay or downloads a release. + */ +const root = mkdtempSync(join(tmpdir(), "herdr-portal-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const ADDRESS = "https://herdr-test-0123456789abcdef0123456789abcdef01234567.relay.example"; +const open = { tokenSet: true, serveOnly: false }; + +interface StandIn { + path: string; + /** the last `expose`'s arguments, one a line, then what it saw of the environment */ + seen: () => string[]; + /** the pid of every `expose` so far */ + pids: () => number[]; +} + +/** A portal whose `expose` prints `says`, then the ready line unless `ready` is false, and runs until stopped. */ +function standIn(options: { version?: string; ready?: boolean; says?: string; ignoreTerm?: boolean; exitAfter?: boolean } = {}): StandIn { + const dir = mkdtempSync(join(root, "bin-")); + const path = join(dir, "portal"); + const seen = join(dir, "seen"); + const pids = join(dir, "pids"); + writeFileSync(path, [ + "#!/bin/sh", + 'case "$1" in', + ` version) echo '${options.version ?? "v2.6.1"}' ;;`, + " expose)", + ` echo $$ >> '${pids}'`, + ` { printf '%s\\n' "$@"; echo "token=\${HERDR_WEB_TOKEN-unset} identity=\${IDENTITY_PATH-unset}"; } > '${seen}'`, + options.ignoreTerm ? " trap '' TERM" : " trap 'exit 0' TERM", + options.says ? ` echo '${options.says}' >&2` : " :", + options.ready === false ? " :" : ` echo '2026-10-11T00:00:00Z INF service ready at ${ADDRESS}:443'`, + options.exitAfter ? " exit 3 ;;" : " while :; do sleep 0.05; done ;;", + " *) exit 2 ;;", + "esac", + "", + ].join("\n")); + chmodSync(path, 0o755); + return { + path, + seen: () => existsSync(seen) ? readFileSync(seen, "utf8").trim().split("\n") : [], + pids: () => existsSync(pids) ? readFileSync(pids, "utf8").trim().split("\n").map(Number) : [], + }; +} + +/** A relay that answers its version the way Portal's /sdk/domain does. */ +function relay(version = "v2.6.1"): typeof fetch { + return (async (url: string | URL | Request) => { + if (!String(url).endsWith("/sdk/domain")) return new Response(null, { status: 404 }); + return Response.json({ data: { protocol_version: "10", release_version: version } }); + }) as unknown as typeof fetch; +} + +async function until(service: PortalService, done: (status: Omit) => boolean, what: string): Promise> { + const deadline = Date.now() + 10_000; + for (;;) { + const status = await service.status(open); + if (done(status)) return status; + if (Date.now() >= deadline) throw new Error(`Timed out: ${what} (phase ${status.phase}, error ${status.error})`); + await Bun.sleep(20); + } +} + +function alive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +// what a server started by the herdr plugin may carry: its own token, and Portal's own settings +const inherited = { HERDR_WEB_TOKEN: "do-not-pass-this-on", IDENTITY_PATH: "/somewhere/else.json", HERDR_WEB_PORTAL_BIN: undefined }; +const previous: Record = {}; +beforeEach(() => { + for (const [name, value] of Object.entries(inherited)) { + previous[name] = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +}); +afterEach(() => { + for (const name of Object.keys(inherited)) { + if (previous[name] === undefined) delete process.env[name]; + else process.env[name] = previous[name]; + } +}); + +describe("what Portal is given and says", () => { + it("takes a relay as an https origin, a bare host included, and nothing else", () => { + expect(relayOrigin("relay.example")).toBe("https://relay.example"); + expect(relayOrigin(" https://relay.example/ ")).toBe("https://relay.example"); + expect(relayOrigin("https://relay.example:8443")).toBe("https://relay.example:8443"); + for (const refused of ["", "http://relay.example", "https://user:pass@relay.example", "https://relay.example/path", "https://relay.example/?x=1", "relay.example --hide", 7, null]) { + expect(relayOrigin(refused)).toBeNull(); + } + }); + + it("reads the address off the ready line, without the default port", () => { + expect(readyUrl(`2026-10-11T00:00:00Z INF service ready at ${ADDRESS}:443`)).toBe(ADDRESS); + expect(readyUrl("2026-10-11T00:00:00Z WRN relay operation failed")).toBeNull(); + }); + + it("compares versions by number", () => { + expect(atLeast("v2.6.1", "v2.6.1")).toBe(true); + expect(atLeast("v2.10.0", "v2.6.1")).toBe(true); + expect(atLeast("v2.6.0", "v2.6.1")).toBe(false); + expect(atLeast("devel", "v2.6.1")).toBe(false); + }); +}); + +describe("installing Portal", () => { + const binary = "#!/bin/sh\necho v2.6.1\n"; + const digest = createHash("sha256").update(binary).digest("hex"); + const serve = (async () => new Response(binary)) as unknown as typeof fetch; + + it("puts the release on this PC once its digest matches the pinned one", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const service = new PortalService({ stateDir, release: { url: "https://releases.example/portal", sha256: digest }, fetch: serve }); + expect(service.install()).toBe("started"); + expect(service.install()).toBe("busy"); + const status = await until(service, (s) => s.phase !== "installing", "the install ended"); + expect(status).toMatchObject({ phase: "idle", version: "v2.6.1", usable: true, error: null }); + expect(statSync(join(stateDir, "portal", "bin", "portal")).mode & 0o777).toBe(0o755); + }); + + it("installs nothing from a download that does not match", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const service = new PortalService({ stateDir, release: { url: "https://releases.example/portal", sha256: "0".repeat(64) }, fetch: serve }); + expect(service.install()).toBe("started"); + const status = await until(service, (s) => s.phase !== "installing", "the install ended"); + expect(status.phase).toBe("error"); + expect(status.error).toContain("does not match"); + expect(readdirSync(join(stateDir, "portal", "bin"))).toEqual([]); + }); + + it("offers no install where Portal has no release", () => { + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), platform: "plan9-mips" }); + expect(service.install()).toBe("unsupported"); + }); +}); + +describe("running Portal", () => { + it("opens nothing without a token, or while tailscale serve is declared the only way in", async () => { + const portal = standIn(); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); + expect(service.start({ relay: "https://relay.example", port: 7317, tokenSet: false, serveOnly: false })).toBe("token_required"); + expect(service.start({ relay: "https://relay.example", port: 7317, tokenSet: true, serveOnly: true })).toBe("serve_only"); + expect((await service.status({ tokenSet: false, serveOnly: false })).blocked).toBe("token_required"); + expect(portal.pids()).toEqual([]); + }); + + it("exposes this server in routed mode on the one relay given, without a visitor's Tailscale login or this server's secrets", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portal = standIn(); + const service = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + expect(service.start({ relay: "https://relay.example", port: 7317, ...open })).toBe("started"); + const status = await until(service, (s) => s.phase !== "starting", "Portal was ready"); + expect(status).toMatchObject({ phase: "running", url: ADDRESS, relay: "https://relay.example" }); + const seen = portal.seen(); + const flag = (name: string) => seen[seen.indexOf(name) + 1]; + expect(seen[0]).toBe("expose"); + expect(flag("--http-route")).toBe("/=7317"); + expect(flag("--strip-request-header")).toBe("Tailscale-User-Login"); + expect(flag("--relays")).toBe("https://relay.example"); + expect(flag("--identity-path")).toBe(join(stateDir, "portal", "identity.json")); + expect(seen).toContain("--discovery=false"); + expect(seen).toContain("--hide"); + expect(seen.at(-1)).toBe("token=unset identity=unset"); + + service.stop(); + expect((await until(service, (s) => s.phase === "idle", "Portal stopped")).url).toBeNull(); + expect(alive(portal.pids()[0]!)).toBe(false); + // stopped is remembered: the next server leaves it off + const next = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + await next.resume({ port: 7317, ...open }); + expect(await next.status(open)).toMatchObject({ phase: "idle", relay: "https://relay.example" }); + expect(portal.pids()).toHaveLength(1); + }); + + it("comes back with the next server, and takes over from a Portal the last one left running", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portal = standIn(); + const first = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + first.start({ relay: "https://relay.example", port: 7317, ...open }); + await until(first, (s) => s.phase === "running", "the first Portal was ready"); + // a server killed outright stops nothing: its Portal keeps the identity + const next = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + await next.resume({ port: 7317, ...open }); + expect(await until(next, (s) => s.phase !== "starting", "the next Portal was ready")).toMatchObject({ phase: "running", url: ADDRESS }); + const [left, own] = portal.pids(); + expect(alive(left!)).toBe(false); + expect(alive(own!)).toBe(true); + // a server that cannot open the address leaves the saved choice alone and starts nothing + next.shutdown(); + await until(next, (s) => s.phase === "idle", "the Portal ended with its server"); + const unguarded = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + await unguarded.resume({ port: 7317, tokenSet: false, serveOnly: false }); + expect((await unguarded.status(open)).phase).toBe("idle"); + expect(portal.pids()).toHaveLength(2); + }); + + it("refuses a relay older than the tunnel needs before anything runs", async () => { + const portal = standIn(); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay("v2.6.0") }); + service.start({ relay: "https://relay.example", port: 7317, ...open }); + const status = await until(service, (s) => s.phase !== "starting", "the start ended"); + expect(status.phase).toBe("error"); + expect(status.error).toContain("v2.6.1"); + expect(portal.pids()).toEqual([]); + }); + + it("refuses a Portal older than the tunnel needs", async () => { + const portal = standIn({ version: "v2.5.1" }); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); + expect(await service.status(open)).toMatchObject({ version: "v2.5.1", usable: false }); + service.start({ relay: "https://relay.example", port: 7317, ...open }); + expect((await until(service, (s) => s.phase !== "starting", "the start ended")).phase).toBe("error"); + expect(portal.pids()).toEqual([]); + }); + + it("gives up on a relay that never takes the tunnel, and says what Portal said", async () => { + const portal = standIn({ ready: false, says: "2026-10-11T00:00:00Z WRN lease registration failed" }); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay(), readyTimeoutMs: 300 }); + service.start({ relay: "https://relay.example", port: 7317, ...open }); + const status = await until(service, (s) => s.phase !== "starting", "the start gave up"); + expect(status.phase).toBe("error"); + expect(status.error).toContain("lease registration failed"); + const deadline = Date.now() + 5_000; + while (alive(portal.pids()[0]!) && Date.now() < deadline) await Bun.sleep(20); + expect(alive(portal.pids()[0]!)).toBe(false); + }); + + it("stops a Portal a relay refused for good", async () => { + const portal = standIn({ ready: false, says: "2026-10-11T00:00:00Z ERR relay operation failed permanently error=\"name taken\"" }); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); + service.start({ relay: "https://relay.example", port: 7317, ...open }); + const status = await until(service, (s) => s.phase !== "starting", "the start ended"); + expect(status.phase).toBe("error"); + expect(status.error).toContain("name taken"); + }); + + it("tells of a Portal that ended on its own", async () => { + const portal = standIn({ exitAfter: true }); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); + service.start({ relay: "https://relay.example", port: 7317, ...open }); + const status = await until(service, (s) => s.phase === "error", "Portal ended"); + expect(status.error).toContain("exit 3"); + expect(status.url).toBeNull(); + }); + + it("kills a Portal that does not stop when asked", async () => { + const portal = standIn({ ignoreTerm: true }); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay(), stopGraceMs: 200 }); + service.start({ relay: "https://relay.example", port: 7317, ...open }); + await until(service, (s) => s.phase === "running", "Portal was ready"); + service.stop(); + await until(service, (s) => s.phase === "idle", "Portal was killed"); + expect(alive(portal.pids()[0]!)).toBe(false); + }); +}); + +describe("the Portal endpoint", () => { + const url = "http://127.0.0.1:7317/api/portal"; + const own = { "x-herdr-update": "1", origin: "http://127.0.0.1:7317", "sec-fetch-site": "same-origin", "content-type": "application/json" }; + const here = { here: true, port: 7317, ...open }; + const post = (action: string, body: unknown = { relay: "relay.example" }, headers: Record = own) => + new Request(`${url}/${action}`, { method: "POST", headers, body: JSON.stringify(body) }); + const code = async (response: Response) => (await response.json() as { error: { code: string } }).error.code; + + it("answers the status without caching, and offers nothing without the service", async () => { + const response = await handlePortalRequest(new Request(url), "/api/portal", undefined, { ...here, here: false }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toMatchObject({ supported: false, here: false }); + expect(await code(await handlePortalRequest(post("stop"), "/api/portal/stop", undefined, here))).toBe("portal_unsupported"); + }); + + it("starts Portal only for this app's own controls, on this PC itself, with a token", async () => { + const portal = standIn(); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); + for (const headers of [{ ...own, "x-herdr-update": "0" }, { ...own, origin: "https://evil.example" }, { ...own, "sec-fetch-site": "cross-site" }]) { + expect(await code(await handlePortalRequest(post("start", undefined, headers), "/api/portal/start", service, here))).toBe("invalid_portal_request"); + } + for (const action of ["install", "start"]) { + const refused = await handlePortalRequest(post(action), `/api/portal/${action}`, service, { ...here, here: false }); + expect(refused.status).toBe(403); + expect(await code(refused)).toBe("portal_not_here"); + } + expect(await code(await handlePortalRequest(post("start", { relay: "http://relay.example" }), "/api/portal/start", service, here))).toBe("invalid_relay"); + expect(await code(await handlePortalRequest(post("start"), "/api/portal/start", service, { ...here, tokenSet: false }))).toBe("token_required"); + expect(await code(await handlePortalRequest(post("start"), "/api/portal/start", service, { ...here, serveOnly: true }))).toBe("serve_only"); + expect(portal.pids()).toEqual([]); + + expect((await handlePortalRequest(post("start"), "/api/portal/start", service, here)).status).toBe(202); + expect(await until(service, (s) => s.phase !== "starting", "Portal was ready")).toMatchObject({ phase: "running", relay: "https://relay.example" }); + // any signed-in client may take the address down + expect((await handlePortalRequest(post("stop", {}), "/api/portal/stop", service, { ...here, here: false })).status).toBe(202); + await until(service, (s) => s.phase === "idle", "Portal stopped"); + expect((await handlePortalRequest(new Request(url, { method: "DELETE" }), "/api/portal", service, here)).status).toBe(405); + expect((await handlePortalRequest(post("expose"), "/api/portal/expose", service, here)).status).toBe(405); + }); +}); diff --git a/server/portal.ts b/server/portal.ts new file mode 100644 index 0000000000..440d547a31 --- /dev/null +++ b/server/portal.ts @@ -0,0 +1,483 @@ +/** + * Portal (github.com/gosuda/portal-tunnel): an optional public HTTPS address for this server, + * installed, started and stopped from Settings → Phone & devices. Tailscale stays the private + * route and is only ever read (tailscale.ts). This is the one place where the app itself changes + * how it can be reached, so it keeps the guide's rules for a public proxy (docs/guide.md, Behind a + * reverse proxy) whatever the caller asks: + * + * - no address without a token, and none while HERDR_WEB_TAILSCALE_SERVE_ONLY says tailscale serve + * is the only way in; + * - routed mode, which keeps the browser's Host and sends X-Forwarded-For and + * X-Forwarded-Proto: https, and drops a visitor's Tailscale-User-Login; + * - one relay the user picked, discovery off, hidden from the relay's list; the identity file kept + * here holds the name, and with it the address, across restarts; + * - the command is built here, never from the request, and Portal gets none of this server's + * environment beyond what running and reaching the relay need: the token stays here; + * - only this PC itself installs or starts it (index.ts decides who that is); any device signed in + * that can type may stop it. + * + * The binary is the official release checked against the digest pinned below, not against the + * checksum file beside it, which could be replaced together with the binary. Portal keeps retrying + * a relay it cannot use and never exits for it (v2.6.1), so readiness has a deadline of its own and + * the relay's version is asked before the start. + */ +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { chmod, mkdir, readFile, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; +import type { PortalStatus } from "../shared/protocol.ts"; +import { isJsonObject, jsonResponse } from "./http.ts"; +import { chunksOf } from "./remote-bundle.ts"; +import { updateRequestAllowed } from "./update-api.ts"; + +/** the first release with `--strip-request-header` and an address tied to the identity */ +export const PORTAL_VERSION = "v2.6.1"; +const RELEASES = "https://github.com/gosuda/portal-tunnel/releases/download"; +/** The official v2.6.1 binaries and their digests (its checksums.txt), per `${process.platform}-${process.arch}`. */ +const ASSETS: Record = { + "darwin-arm64": ["portal-darwin-arm64", "0b5242aa78f035532a8e2a9b450af82278e9e85e68bf267518c5ad6ad785d910"], + "darwin-x64": ["portal-darwin-amd64", "f2071e830a2f1b07c4fb3b046f66499a7a9772c6a3f95cae3d00b84f9b6b0079"], + "linux-arm64": ["portal-linux-arm64", "bbc366f21e3d50e9047ce2e185c72895e766e069ccd3b4dc985d15cab65ea066"], + "linux-x64": ["portal-linux-amd64", "ca70bdcda37502807e2db82cfd31875eefcf0ccc05146a460b7a86b87d5db789"], + "win32-arm64": ["portal-windows-arm64.exe", "fa6ca9eeb20faeed83201527439e1681e841d963c27c9dc2d0f673a02ab0af71"], + "win32-x64": ["portal-windows-amd64.exe", "5c1a45007a6307f4d70e124c6d61f57a125ae007b77fd8aa25a484913bfd7a28"], +}; +/** a relay that answers but will not take the tunnel is only ever retried, never refused */ +const READY_TIMEOUT_MS = 60_000; +/** Portal unregisters from the relay on SIGTERM; one that does not stop in this long is killed */ +const STOP_GRACE_MS = 15_000; +const VERSION_TIMEOUT_MS = 2_500; +/** the panel polls the status; `portal version` is a process each time */ +const VERSION_CACHE_MS = 5_000; +const RELAY_TIMEOUT_MS = 5_000; +const DOWNLOAD_TIMEOUT_MS = 10 * 60_000; +/** Portal's last words kept for the panel */ +const OUTPUT_LINES = 30; +/** what Portal may keep of this server's environment: enough to run and to reach the relay */ +const PORTAL_ENV = ["PATH", "HOME", "USERPROFILE", "SystemRoot", "TMPDIR", "TEMP", "TMP", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR"]; + +type Phase = PortalStatus["phase"]; +interface Guards { + /** HERDR_WEB_TOKEN is set */ + tokenSet: boolean; + /** HERDR_WEB_TAILSCALE_SERVE_ONLY: tailscale serve is declared the only way in */ + serveOnly: boolean; +} +interface StartRequest extends Guards { + relay: string; + /** this server's port, Portal's upstream */ + port: number; +} +interface SavedState { enabled: boolean; relay: string | null } + +export interface PortalServiceOptions { + /** Portal's binary, identity and on/off choice live in its portal/ */ + stateDir: string; + /** the portal to run; unset, HERDR_WEB_PORTAL_BIN, then the one installed here, then `portal` on PATH */ + bin?: string; + /** `${process.platform}-${process.arch}`, which picks the release */ + platform?: string; + /** the release to install; unset, the pinned official one for the platform. Tests pass their own. */ + release?: { url: string; sha256: string }; + fetch?: typeof fetch; + /** READY_TIMEOUT_MS and STOP_GRACE_MS; tests shorten them */ + readyTimeoutMs?: number; + stopGraceMs?: number; +} + +/** "v2.6.1" → [2, 6, 1]; null for anything else. `portal version` prints the tag alone. */ +function parseVersion(text: string): [number, number, number] | null { + const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text.trim()); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +export function atLeast(version: string, minimum: string): boolean { + const have = parseVersion(version); + const need = parseVersion(minimum); + if (have === null || need === null) return false; + for (let i = 0; i < 3; i++) if (have[i] !== need[i]) return have[i]! > need[i]!; + return true; +} + +/** A relay as the user gave it, as an https origin; null for anything with a path, a query or credentials. */ +export function relayOrigin(input: unknown): string | null { + if (typeof input !== "string" || input.trim() === "") return null; + const text = input.trim(); + let url: URL; + try { url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`); } catch { return null; } + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") return null; + return url.origin; +} + +/** The address on Portal's "service ready at" line, without the default port; null for any other line. */ +export function readyUrl(line: string): string | null { + const match = /service ready at (https:\/\/\S+)/.exec(line); + if (!match) return null; + try { return new URL(match[1]!).origin; } catch { return null; } +} + +function blockedBy({ tokenSet, serveOnly }: Guards): PortalStatus["blocked"] { + if (!tokenSet) return "token_required"; + if (serveOnly) return "serve_only"; + return null; +} + +function portalEnv(): Record { + const env: Record = { NO_COLOR: "1" }; + for (const name of PORTAL_ENV) { + const value = process.env[name]; + if (value !== undefined) env[name] = value; + } + return env; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function readVersion(bin: string): Promise { + let proc: ReturnType; + try { + proc = Bun.spawn([bin, "version"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", env: portalEnv(), windowsHide: true }); + } catch { return null; } + const timer = setTimeout(() => proc.kill(), VERSION_TIMEOUT_MS); + try { + const [text, code] = await Promise.all([new Response(proc.stdout as ReadableStream).text(), proc.exited]); + return code === 0 && parseVersion(text) !== null ? text.trim() : null; + } catch { return null; } finally { clearTimeout(timer); } +} + +export class PortalService { + private phase: Phase = "idle"; + private relay: string | null = null; + private url: string | null = null; + private error: string | null = null; + private lines: string[] = []; + private child: ReturnType | null = null; + /** a start between its request and its process (the relay is asked meanwhile): one at a time */ + private launching = false; + /** the child the user (or this server's own stop) asked to end: its exit is no failure */ + private ending: ReturnType | null = null; + private versionRead: { at: number; bin: string | null; value: Promise } | null = null; + private readonly dir: string; + + constructor(private readonly options: PortalServiceOptions) { + this.dir = join(options.stateDir, "portal"); + } + + private get platform(): string { return this.options.platform ?? `${process.platform}-${process.arch}`; } + private get installedPath(): string { return join(this.dir, "bin", this.platform.startsWith("win32") ? "portal.exe" : "portal"); } + private get identityPath(): string { return join(this.dir, "identity.json"); } + private get statePath(): string { return join(this.dir, "state.json"); } + private get pidPath(): string { return join(this.dir, "portal.pid"); } + + private release(): { url: string; sha256: string } | null { + if (this.options.release) return this.options.release; + const asset = ASSETS[this.platform]; + return asset ? { url: `${RELEASES}/${PORTAL_VERSION}/${asset[0]}`, sha256: asset[1] } : null; + } + + private bin(): string | null { + return this.options.bin ?? (process.env["HERDR_WEB_PORTAL_BIN"] || (existsSync(this.installedPath) ? this.installedPath : Bun.which("portal"))); + } + + private version(fresh = false): Promise { + const bin = this.bin(); + const now = Date.now(); + if (!fresh && this.versionRead && this.versionRead.bin === bin && now - this.versionRead.at < VERSION_CACHE_MS) return this.versionRead.value; + const value = bin === null ? Promise.resolve(null) : readVersion(bin); + this.versionRead = { at: now, bin, value }; + return value; + } + + async status(guards: Guards): Promise> { + const version = await this.version(); + return { + supported: this.release() !== null || this.bin() !== null, + version, + min_version: PORTAL_VERSION, + usable: version !== null && atLeast(version, PORTAL_VERSION), + phase: this.phase, + relay: this.relay, + url: this.url, + blocked: blockedBy(guards), + error: this.error, + output: this.lines.length > 0 ? this.lines.join("\n") : null, + }; + } + + /** At the server's start: brings the address back if it was on, so an app update does not take it down for good. */ + async resume(request: Omit): Promise { + const saved = await this.saved(); + this.relay = saved.relay; + if (saved.enabled && saved.relay !== null && blockedBy(request) === null) this.start({ ...request, relay: saved.relay }); + } + + install(): "started" | "busy" | "unsupported" { + const release = this.release(); + if (release === null) return "unsupported"; + if (this.child !== null || this.launching || this.phase === "installing") return "busy"; + this.phase = "installing"; + this.error = null; + void this.download(release).then(async () => { + if (await this.version(true) === null) throw new Error("The downloaded Portal does not run on this PC."); + this.phase = "idle"; + }).catch((error: unknown) => { + this.phase = "error"; + this.error = messageOf(error); + }); + return "started"; + } + + start(request: StartRequest): "started" | "busy" | "token_required" | "serve_only" { + const blocked = blockedBy(request); + if (blocked !== null) return blocked; + if (this.child !== null || this.launching || this.phase === "installing") return "busy"; + this.phase = "starting"; + this.relay = request.relay; + this.url = null; + this.error = null; + this.lines = []; + this.launching = true; + void this.launch(request) + // a start stopped meanwhile failed for no one + .catch((error: unknown) => { if (this.phase === "starting") this.fail(messageOf(error)); }) + .finally(() => { this.launching = false; }); + return "started"; + } + + /** Takes the address down and remembers that it is off. */ + stop(): void { + this.save({ enabled: false, relay: this.relay }); + this.url = null; + this.error = null; + if (this.child === null) { + // a start still asking its relay ends there + if (this.phase === "starting" || this.phase === "error") this.phase = "idle"; + return; + } + this.phase = "stopping"; + this.end(this.child); + } + + /** The server is going away: Portal goes with it, and the saved choice stays for the next server. */ + shutdown(): void { + if (this.child !== null) this.end(this.child); + else if (this.phase === "starting") this.phase = "idle"; + } + + private end(child: ReturnType): void { + this.ending = child; + child.kill("SIGTERM"); + const killer = setTimeout(() => child.kill("SIGKILL"), this.options.stopGraceMs ?? STOP_GRACE_MS); + void child.exited.finally(() => clearTimeout(killer)); + } + + private fail(text: string): void { + this.phase = "error"; + this.error = text; + this.url = null; + if (this.child !== null) this.end(this.child); + } + + private async launch({ relay, port }: StartRequest): Promise { + const bin = this.bin(); + const version = bin === null ? null : await this.version(true); + if (bin === null || version === null) throw new Error("Portal is not installed on this PC."); + if (!atLeast(version, PORTAL_VERSION)) throw new Error(`This PC has Portal ${version}; the app needs ${PORTAL_VERSION} or later.`); + await this.checkRelay(relay); + await this.stopOrphan(); + // stopped while the relay was asked; from here to the spawn nothing waits, so a stop comes after it + if (this.phase !== "starting") return; + this.save({ enabled: true, relay }); + const child = Bun.spawn([ + bin, "expose", + "--http-route", `/=${port}`, + "--strip-request-header", "Tailscale-User-Login", + // a name counts only when the identity file is created; later runs keep the saved one + "--name", `herdr-${randomBytes(8).toString("hex")}`, + "--identity-path", this.identityPath, + "--relays", relay, "--discovery=false", + "--hide", + ], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: portalEnv(), windowsHide: true }); + this.child = child; + // kept after the exit too: stopOrphan checks what runs under a pid before it stops anything + writeFileSync(this.pidPath, String(child.pid), { mode: 0o600 }); + const deadline = setTimeout(() => { + if (this.child !== child || this.phase !== "starting") return; + const said = this.lines.filter((line) => /\b(WRN|ERR)\b/.test(line)).at(-1); + this.fail(`${relay} did not take the tunnel within ${Math.round((this.options.readyTimeoutMs ?? READY_TIMEOUT_MS) / 1000)} seconds.${said ? ` Portal said: ${said}` : ""}`); + }, this.options.readyTimeoutMs ?? READY_TIMEOUT_MS); + void this.follow(child.stdout as ReadableStream, child); + void this.follow(child.stderr as ReadableStream, child); + void child.exited.then((code) => { + clearTimeout(deadline); + if (this.child !== child) return; + this.child = null; + this.url = null; + if (this.ending === child) { + this.ending = null; + if (this.phase !== "error") this.phase = "idle"; + return; + } + this.phase = "error"; + this.error = `Portal stopped on its own (exit ${code}).${this.lines.length > 0 ? ` It said: ${this.lines.at(-1)}` : ""}`; + }); + } + + /** Portal writes one event a line: the address once a relay takes the tunnel, and why it gave up on one. */ + private async follow(stream: ReadableStream, child: ReturnType): Promise { + const decoder = new TextDecoder(); + let pending = ""; + try { + for await (const chunk of chunksOf(stream)) { + pending += decoder.decode(chunk, { stream: true }); + for (let end = pending.indexOf("\n"); end >= 0; end = pending.indexOf("\n")) { + this.line(pending.slice(0, end), child); + pending = pending.slice(end + 1); + } + } + this.line(pending + decoder.decode(), child); + } catch { /* the pipe closes with the process */ } + } + + private line(raw: string, child: ReturnType): void { + // NO_COLOR is set; the colors are stripped anyway, a terminal's codes are no use here + const line = raw.replace(/\x1b\[[0-9;]*m/g, "").trim(); + if (line === "" || this.child !== child) return; + this.lines = [...this.lines, line].slice(-OUTPUT_LINES); + const url = readyUrl(line); + if (url !== null) { + this.url = url; + if (this.phase === "starting" || this.phase === "running") this.phase = "running"; + return; + } + // the relay refused for good (a name it will not take, an incompatible relay): the process would stay up serving nothing + if (line.includes("relay operation failed permanently")) this.fail(line); + } + + /** A relay Portal cannot use is retried for good and silently: ask the relay first. */ + private async checkRelay(relay: string): Promise { + let body: unknown; + try { + const response = await (this.options.fetch ?? fetch)(`${relay}/sdk/domain`, { signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + body = await response.json(); + } catch (error) { + throw new Error(`${relay} did not answer as a Portal relay (${messageOf(error)}).`); + } + const data = isJsonObject(body) && isJsonObject(body["data"]) ? body["data"] : {}; + const release = typeof data["release_version"] === "string" ? data["release_version"] : "an unknown version"; + if (!atLeast(release, PORTAL_VERSION)) throw new Error(`${relay} runs Portal ${release}; the tunnel needs a relay on ${PORTAL_VERSION} or later.`); + } + + /** + * A Portal an earlier server of this app started and could not stop (it was killed outright): + * two processes with one identity keep taking the address from each other. Only a process + * recorded here and running this identity is stopped; Windows has no `ps`, and is left alone. + */ + private async stopOrphan(): Promise { + if (process.platform === "win32") return; + const pid = Number((await readFile(this.pidPath, "utf8").catch(() => "")).trim()); + if (!Number.isInteger(pid) || pid <= 0) return; + let command = ""; + try { + const ps = Bun.spawn(["ps", "-ww", "-o", "command=", "-p", String(pid)], { stdin: "ignore", stdout: "pipe", stderr: "ignore" }); + command = await new Response(ps.stdout as ReadableStream).text(); + } catch { /* no ps: nothing can be told about the process */ } + if (!command.includes(" expose ") || !command.includes(this.identityPath)) { + await rm(this.pidPath, { force: true }); + return; + } + try { process.kill(pid, "SIGTERM"); } catch { return; } + for (const until = Date.now() + (this.options.stopGraceMs ?? STOP_GRACE_MS); Date.now() < until; await Bun.sleep(100)) { + try { process.kill(pid, 0); } catch { return; } + } + try { process.kill(pid, "SIGKILL"); } catch { /* gone meanwhile */ } + } + + /** The release to portal/bin/, checked on the way; a partial or mismatched file never takes the name. */ + private async download(release: { url: string; sha256: string }): Promise { + const response = await (this.options.fetch ?? fetch)(release.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!response.ok || !response.body) throw new Error(`Portal could not be downloaded (HTTP ${response.status}).`); + await mkdir(join(this.dir, "bin"), { recursive: true, mode: 0o700 }); + const part = `${this.installedPath}.part-${process.pid}-${Date.now()}`; + const sink = Bun.file(part).writer(); + const hash = createHash("sha256"); + try { + for await (const chunk of chunksOf(response.body)) { + hash.update(chunk); sink.write(chunk); await sink.flush(); + } + await sink.end(); + if (hash.digest("hex") !== release.sha256) throw new Error("The download does not match the official Portal release; nothing was installed."); + await chmod(part, 0o755); + await rename(part, this.installedPath); + } catch (error) { + try { await sink.end(); } catch { /* already ended */ } + await rm(part, { force: true }); + throw error; + } + } + + private async saved(): Promise { + try { + const value: unknown = JSON.parse(await readFile(this.statePath, "utf8")); + if (!isJsonObject(value)) return { enabled: false, relay: null }; + return { enabled: value["enabled"] === true, relay: relayOrigin(value["relay"]) }; + } catch { return { enabled: false, relay: null }; } + } + + /** at once, not awaited: a stop and a start's own save never interleave */ + private save(state: SavedState): void { + mkdirSync(this.dir, { recursive: true, mode: 0o700 }); + const temp = `${this.statePath}.tmp-${process.pid}`; + writeFileSync(temp, JSON.stringify(state), { mode: 0o600 }); + renameSync(temp, this.statePath); + } +} + +/** Who asks, and what this server is: index.ts knows, Portal's rules here decide. */ +export interface PortalRequestContext extends Guards { + /** a loopback connection that came through no proxy: this PC itself, the only one that may install or start */ + here: boolean; + port: number; +} + +const NO_PORTAL: Omit = { supported: false, version: null, min_version: PORTAL_VERSION, usable: false, phase: "idle", relay: null, url: null, blocked: null, error: null, output: null }; + +export async function handlePortalRequest(request: Request, pathname: string, portal: PortalService | undefined, context: PortalRequestContext): Promise { + const reply = (body: unknown, code = 200) => jsonResponse(body, code, { "cache-control": "no-store" }); + const fail = (code: string, message: string, http: number) => reply({ error: { code, message } }, http); + if (pathname === "/api/portal" && request.method === "GET") { + // a server started without the service (tests, an embedding) offers nothing + const status = portal ? await portal.status(context) : NO_PORTAL; + return reply({ ...status, here: context.here } satisfies PortalStatus); + } + const action = pathname.slice("/api/portal/".length); + if (request.method !== "POST" || (action !== "install" && action !== "start" && action !== "stop")) { + return fail("method_not_allowed", "Use GET /api/portal, or POST /api/portal/install, /start or /stop", 405); + } + if (!updateRequestAllowed(request)) return fail("invalid_portal_request", "Use the Portal controls from this app.", 403); + if (!portal) return fail("portal_unsupported", "This server offers no Portal.", 409); + if (action === "stop") { + portal.stop(); + return reply({ accepted: true }, 202); + } + // a public address is opened only by someone at this PC, never through a proxy (Portal's own address included) + if (!context.here) return fail("portal_not_here", "Install or start Portal on this PC itself, not through another device or a proxy.", 403); + if (action === "install") { + const started = portal.install(); + if (started === "unsupported") return fail("portal_unsupported", "Portal has no release for this PC.", 409); + if (started === "busy") return fail("portal_busy", "Portal is busy; try again once it is idle.", 409); + return reply({ accepted: true }, 202); + } + let body: unknown = null; + try { body = await request.json(); } catch { /* checked below */ } + const relay = relayOrigin(isJsonObject(body) ? body["relay"] : undefined); + if (relay === null) return fail("invalid_relay", "Give the relay as an https address, such as https://relay.example.com.", 400); + const started = portal.start({ ...context, relay }); + if (started === "token_required") return fail("token_required", "Set HERDR_WEB_TOKEN before you start Portal: anyone on the internet can open its address.", 409); + if (started === "serve_only") return fail("serve_only", "Turn off HERDR_WEB_TAILSCALE_SERVE_ONLY before you start Portal: it says tailscale serve is the only way in.", 409); + if (started === "busy") return fail("portal_busy", "Portal is busy; try again once it is idle.", 409); + return reply({ accepted: true }, 202); +} diff --git a/shared/protocol.ts b/shared/protocol.ts index cd788ff427..6159fbab4a 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -61,6 +61,16 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * GET /api/session -> { snapshot: SessionSnapshot } * GET /api/access -> RemoteAccess (how a phone can reach this server: what * Tailscale on this PC already serves, or the command to run), no-store + * GET /api/portal -> PortalStatus (an optional public HTTPS address through + * Portal, server/portal.ts), no-store + * POST /api/portal/install -> 202 { accepted: true } (the official release, checked + * against its pinned SHA-256); 403 portal_not_here, 409 portal_busy / portal_unsupported + * POST /api/portal/start -> 202 { accepted: true }; body { relay }; 403 portal_not_here, + * 409 token_required / serve_only / portal_busy, 400 invalid_relay + * POST /api/portal/stop -> 202 { accepted: true }, from any client that can type + * (not a watch-only device). + * Portal POSTs require X-Herdr-Update: 1 and same-origin; install and start only from + * this PC itself (a loopback connection that came through no proxy). * GET /api/updates -> UpdateStatus (shared/update.ts), no-store * POST /api/updates/check -> 202 { accepted: true } * POST /api/updates/install -> 202 { accepted: true } @@ -323,6 +333,34 @@ export interface TailscaleAccess { readonly serve_url: string | null; } +/** + * GET /api/portal: a public HTTPS address for this server through Portal (gosuda/portal-tunnel), + * which the app installs, starts and stops itself (Settings → Phone & devices). + */ +export interface PortalStatus { + /** false where Portal has no release for this PC and none is installed: the section stays hidden */ + readonly supported: boolean; + /** what `portal version` says of the binary this server would run; null when none answers */ + readonly version: string | null; + /** the oldest Portal that can run this app's tunnel, and the one Install puts on this PC */ + readonly min_version: string; + /** `version` is `min_version` or later: Start is offered, Install is not */ + readonly usable: boolean; + readonly phase: "idle" | "installing" | "starting" | "running" | "stopping" | "error"; + /** the relay the tunnel uses, or used last; an https origin */ + readonly relay: string | null; + /** the public address while it runs, without the default port */ + readonly url: string | null; + /** why a start would be refused now: no token set, or tailscale serve declared the only way in */ + readonly blocked: "token_required" | "serve_only" | null; + /** the asking client is this PC itself: only it may install or start Portal */ + readonly here: boolean; + /** what went wrong last; null after a success */ + readonly error: string | null; + /** the tail of what Portal printed, for when the error alone does not say enough */ + readonly output: string | null; +} + /** GET /api/push: base64url VAPID public key, the `applicationServerKey` a browser subscribes with. */ export interface PushKey { readonly public_key: string; diff --git a/site/demo/transport.ts b/site/demo/transport.ts index f9259e305f..dcbfdffe80 100644 --- a/site/demo/transport.ts +++ b/site/demo/transport.ts @@ -11,7 +11,7 @@ * type; agent panes show one notice instead of a TUI. A message sent from a chat gets a demo answer. * What does not: files, images, push and remote PCs, which need a real machine. */ -import type { AgentIntegration, AgentStatus, ConversationTurn, IntegrationsResponse, Machine, MachineEvent, PaneDirection, PaneMoved, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PluginActionResult, PluginActions, PluginActionsResponse, ServerMessage, SessionSnapshot, UsageReport, WorkspaceCreated, WorkspaceInfo, WorktreeEntry, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../../shared/protocol.ts"; +import type { AgentIntegration, AgentStatus, ConversationTurn, IntegrationsResponse, Machine, MachineEvent, PaneDirection, PaneMoved, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PluginActionResult, PluginActions, PluginActionsResponse, PortalStatus, ServerMessage, SessionSnapshot, UsageReport, WorkspaceCreated, WorkspaceInfo, WorktreeEntry, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../../shared/protocol.ts"; import { VOICE_DEFAULTS, type VoiceStatus } from "../../shared/voice.ts"; import { isAgentName } from "../../shared/agent-name.ts"; import { neighborPane } from "../../src/lib/layoutMap.ts"; @@ -530,6 +530,8 @@ async function route(url: URL, method: string, init: RequestInit | undefined, in // the demo sends no counts: a server without telemetry answers 404, and the app shows neither its notice nor its switch if (path === "/api/telemetry") return json({ error: { code: "not_found", message: "This server sends no telemetry" } }, 404, { "cache-control": "no-store" }); if (path === "/api/herdr/update") return json({ supported: false, phase: "idle", server_version: null, binary_version: null, stale: false, output: null, finished_at: null }, 200, { "cache-control": "no-store" }); + // the demo opens no public address: the Portal section stays hidden + if (path === "/api/portal") return json({ supported: false, version: null, min_version: "v2.6.1", usable: false, phase: "idle", relay: null, url: null, blocked: null, here: false, error: null, output: null } satisfies PortalStatus, 200, { "cache-control": "no-store" }); if (path === "/api/access") return json({ port: 7317, tailscale: { state: "running", dns_name: "workstation.example.ts.net", serving_url: "https://workstation.example.ts.net", serve_command: null, serve_url: null } }); if (path === "/api/usage") return json(usageReport(), 200, { "cache-control": "no-store" }); if (path === "/api/push" || path.startsWith("/api/push/")) return error("push_unavailable", "the demo sends no alerts", 404); diff --git a/src/components/PhonePanel.tsx b/src/components/PhonePanel.tsx index 6006e27761..496576bdec 100644 --- a/src/components/PhonePanel.tsx +++ b/src/components/PhonePanel.tsx @@ -72,7 +72,7 @@ function Plan({ plan, loading, onRefresh }: PhonePanelProps) { } } -function Address({ url, title }: { url: string; title: string }) { +export function Address({ url, title }: { url: string; title: string }) { return (
diff --git a/src/components/PortalPanel.css b/src/components/PortalPanel.css new file mode 100644 index 0000000000..82bbdf4f52 --- /dev/null +++ b/src/components/PortalPanel.css @@ -0,0 +1,14 @@ +.portal-start { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.portal-start .phone-actions { + margin-top: var(--space-1); +} + +.portal-start .input { + flex: 1 1 auto; + min-width: 0; +} diff --git a/src/components/PortalPanel.tsx b/src/components/PortalPanel.tsx new file mode 100644 index 0000000000..62ed025525 --- /dev/null +++ b/src/components/PortalPanel.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; + +import "./PortalPanel.css"; +import "./UpdateControls.css"; + +import { useT } from "../lib/i18n.ts"; +import { PORTAL_SETTLING, usePortal } from "../lib/portal.ts"; +import { Address } from "./PhonePanel.tsx"; +import { SettingsGroup } from "./SettingsControls.tsx"; + +/** + * Settings → Phone & devices → Portal: a public HTTPS address for this PC through a relay the + * user picks. Only this PC itself installs or starts it (the server decides who that is); any + * signed-in device sees the address, and one that can type takes it down. Hidden where the server + * offers none. + */ +export function PortalPanel() { + const t = useT(); + const { status, error, pending, request } = usePortal(); + // what was typed; until then, the relay this PC used last + const [typed, setTyped] = useState(null); + if (!status?.supported) return null; + const relay = typed ?? status.relay ?? ""; + const busy = pending || PORTAL_SETTLING.has(status.phase); + const on = status.phase === "running" || status.phase === "starting"; + const progress = status.phase === "installing" ? t("Installing Portal…") : status.phase === "starting" ? t("Starting…") : status.phase === "stopping" ? t("Stopping…") : null; + let controls; + if (on) { + controls =
; + } else if (status.blocked !== null) { + controls =

{status.blocked === "token_required" ? t("Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.") : t("Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.")}

; + } else if (!status.here) { + controls =

{t("Only this PC itself can install or start Portal.")}

; + } else if (!status.usable) { + controls = ( + <> +

{status.version === null ? t("Portal is not installed on this PC.") : t("This PC has Portal {version}; the app needs {min} or later.", { version: status.version, min: status.min_version })}

+
+ + ); + } else { + controls = ( +
{ event.preventDefault(); void request("start", relay); }}> + + {t("A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.", { min: status.min_version })} +
+ setTyped(event.currentTarget.value)} /> + +
+
+ ); + } + return ( + +
+ {status.url !== null &&
} + {controls} + {(error ?? progress) !== null &&

{error ?? progress}

} + {/* the server's account of the last failure, then Portal's own words */} + {status.phase === "error" && status.error !== null &&

{status.error}

} + {status.phase === "error" && status.output !== null &&
{status.output}
} +
+
+ ); +} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index fbb5897bdb..95e15345dd 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -27,6 +27,7 @@ import { moveInOrder, orderProviders, PROVIDER_MARK, PROVIDER_NAME, usageName, u import { AgentMark } from "./AgentMark.tsx"; import { DevicesPanel } from "./DevicesPanel.tsx"; import { PhonePanel } from "./PhonePanel.tsx"; +import { PortalPanel } from "./PortalPanel.tsx"; import { copyText } from "../lib/clipboard.ts"; import { PushTestControls } from "./PushTestControls.tsx"; import { previewAlertSound, unlockAlertSound } from "../lib/alertSound.ts"; @@ -628,6 +629,8 @@ function DevicesPage({ auth }: { auth: HealthAuth | null }) { + +
diff --git a/src/lib/api.ts b/src/lib/api.ts index f3bbf1fd87..a170cb6c63 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -29,6 +29,7 @@ import type { PaneFindResponse, PromptAnswer, PushKey, + PortalStatus, RemoteAccess, RemoveWorktreeRequest, ResizePaneRequest, @@ -98,6 +99,17 @@ export async function requestHerdrUpdate(): Promise { if (!response.ok) throw await errorFrom(url, response); } +/** Settings → Phone & devices: Portal's public address for the server's PC. */ +export function fetchPortal(): Promise { + return getJson("/api/portal"); +} + +export async function requestPortal(action: "install" | "start" | "stop", relay?: string): Promise { + const url = `/api/portal/${action}`; + const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-herdr-update": "1" }, body: JSON.stringify(relay === undefined ? {} : { relay }) }); + if (!response.ok) throw await errorFrom(url, response); +} + /** Anonymous install and update counts; null from a server that sends none (404). */ export async function fetchTelemetry(): Promise { try { return await getJson("/api/telemetry"); } diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index e340656d64..6c7bb6152c 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -717,6 +717,19 @@ export const JA: Record = { "Copy": "コピー", "Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "自分の Tailscale デバイスは本人としてアクセスできます。アクセストークンを設定している場合は、デバイスごとに一度トークンを求められます。他のユーザーのデバイスや、LAN・パブリックアドレスからのアクセスにはペアリングが必要です。下の「デバイス」を参照してください。", "Access and safety": "アクセスと安全性", + "A public HTTPS address with no account and no domain of your own. Anyone on the internet can open it, so the access token is required.": "アカウントも独自ドメインも不要な公開 HTTPS アドレスです。インターネット上の誰でも開けるため、アクセストークンが必要です。", + "Portal serves this PC": "Portal がこの PC を公開しています", + "Stop": "停止", + "Stopping…": "停止しています…", + "Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.": "先にこの PC で HERDR_WEB_TOKEN を設定してください。Portal のアドレスはインターネット上の誰でも開けます。", + "Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.": "Portal はもう一つの入口になるため、先に HERDR_WEB_TAILSCALE_SERVE_ONLY をオフにしてください。", + "Only this PC itself can install or start Portal.": "Portal のインストールと開始は、この PC 自体からしか行えません。", + "Portal is not installed on this PC.": "この PC には Portal がインストールされていません。", + "This PC has Portal {version}; the app needs {min} or later.": "この PC の Portal は {version} です。アプリには {min} 以降が必要です。", + "Install Portal {version}": "Portal {version} をインストール", + "Installing Portal…": "Portal をインストールしています…", + "Relay": "リレー", + "A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.": "信頼できる {min} 以降の Portal リレーです(portal list で一覧できます)。リレーはこのアドレスの証明書を持っているため、傍受するよう設定されたリレーなら通信内容を読めます。", "…": "…", "Alerts": "通知", "Needs you": "応答が必要", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index b9e6428a3e..39b925f0d7 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -715,6 +715,19 @@ export const KO: Record = { "Copy": "복사", "Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "내 Tailscale 기기는 나로 인식되어 들어옵니다. 접근 토큰을 설정했다면 기기마다 한 번 토큰을 묻습니다. 다른 사람의 기기나 LAN·공개 주소는 페어링이 필요합니다. 아래 기기 항목을 보세요.", "Access and safety": "접근과 안전", + "A public HTTPS address with no account and no domain of your own. Anyone on the internet can open it, so the access token is required.": "계정도 내 도메인도 없이 쓰는 공개 HTTPS 주소입니다. 인터넷의 누구나 열 수 있으므로 접근 토큰이 필요합니다.", + "Portal serves this PC": "Portal이 이 PC를 서빙하고 있습니다", + "Stop": "중지", + "Stopping…": "중지 중…", + "Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.": "먼저 이 PC에 HERDR_WEB_TOKEN을 설정하세요. Portal 주소는 인터넷의 누구나 열 수 있습니다.", + "Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.": "Portal은 또 하나의 접속 경로이므로 먼저 HERDR_WEB_TAILSCALE_SERVE_ONLY를 끄세요.", + "Only this PC itself can install or start Portal.": "Portal 설치와 시작은 이 PC에서 직접 해야 합니다.", + "Portal is not installed on this PC.": "이 PC에 Portal이 설치되어 있지 않습니다.", + "This PC has Portal {version}; the app needs {min} or later.": "이 PC의 Portal은 {version}입니다. 앱에는 {min} 이상이 필요합니다.", + "Install Portal {version}": "Portal {version} 설치", + "Installing Portal…": "Portal 설치 중…", + "Relay": "릴레이", + "A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.": "신뢰하는 {min} 이상의 Portal 릴레이입니다(portal list로 확인할 수 있습니다). 릴레이는 이 주소의 인증서를 갖고 있으므로, 가로채도록 설정된 릴레이라면 트래픽을 읽을 수 있습니다.", "…": "…", "Alerts": "알림", "Needs you": "내 응답 필요", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 199463ac74..f33453e167 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -719,6 +719,19 @@ export const ZH: Record = { "Copy": "复制", "Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "你自己的 Tailscale 设备会以你的身份进入;如果设置了访问令牌,每台设备会询问一次令牌。其他人的设备,或通过局域网或公网地址访问,则需要配对:请见下方“设备”。", "Access and safety": "访问与安全", + "A public HTTPS address with no account and no domain of your own. Anyone on the internet can open it, so the access token is required.": "无需账号、也无需自己的域名的公开 HTTPS 地址。互联网上任何人都能打开它,因此必须设置访问令牌。", + "Portal serves this PC": "Portal 正在为此 PC 提供服务", + "Stop": "停止", + "Stopping…": "正在停止…", + "Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.": "请先在此 PC 上设置 HERDR_WEB_TOKEN:互联网上任何人都能打开 Portal 地址。", + "Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.": "Portal 是另一条访问途径,请先关闭 HERDR_WEB_TAILSCALE_SERVE_ONLY。", + "Only this PC itself can install or start Portal.": "只能在此 PC 本机上安装或启动 Portal。", + "Portal is not installed on this PC.": "此 PC 未安装 Portal。", + "This PC has Portal {version}; the app needs {min} or later.": "此 PC 上的 Portal 是 {version};应用需要 {min} 或更高版本。", + "Install Portal {version}": "安装 Portal {version}", + "Installing Portal…": "正在安装 Portal…", + "Relay": "中继", + "A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.": "一个你信任的 {min} 或更高版本的 Portal 中继(portal list 可列出)。中继持有该地址的证书,因此被设置为拦截的中继可以读取流量。", "…": "…", "Alerts": "提醒", "Needs you": "需要你回应", diff --git a/src/lib/portal.ts b/src/lib/portal.ts new file mode 100644 index 0000000000..2390550c90 --- /dev/null +++ b/src/lib/portal.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PortalStatus } from "../../shared/protocol.ts"; +import { fetchPortal, requestPortal } from "./api.ts"; +import { usePageVisible } from "./visibility.ts"; + +/** the address appears or goes with these, so they are followed closely */ +export const PORTAL_SETTLING: ReadonlySet = new Set(["installing", "starting", "stopping"]); + +/** + * Portal's public address, which the server opens on its own PC (server/portal.ts). Asked while + * Settings → Phone & devices is open. + */ +export function usePortal() { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [refresh, setRefresh] = useState(0); + // the component's own lifetime, not the poll's: a page hidden while the request is on its way must still get the answer + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); + const visible = usePageVisible(); + useEffect(() => { + if (!visible) return; + let timer: ReturnType; + let stopped = false; + async function poll() { + let delay = 15_000; + try { + const next = await fetchPortal(); + if (!stopped) setStatus((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next); + if (PORTAL_SETTLING.has(next.phase)) delay = 1_500; + } catch { /* an older server has no such route, and a restart must not erase the last answer */ } + if (!stopped) timer = setTimeout(() => void poll(), delay); + } + void poll(); + return () => { stopped = true; clearTimeout(timer); }; + }, [refresh, visible]); + + const request = useCallback(async (action: "install" | "start" | "stop", relay?: string) => { + setPending(true); setError(null); + try { + await requestPortal(action, relay); + if (mounted.current) setRefresh((value) => value + 1); + } catch (err) { + if (mounted.current) setError(err instanceof Error ? err.message : String(err)); + } finally { if (mounted.current) setPending(false); } + }, []); + return { status, error, pending, request }; +} From 470c615f3989a37b026b1adbcc520417a4ee163a Mon Sep 17 00:00:00 2001 From: Hee Sung Son Date: Sun, 11 Oct 2026 10:42:51 +0900 Subject: [PATCH 2/6] refactor(settings): run the Portal already on the PC Drop the in-app install and the this-PC-only rule: any device that can type starts and stops Portal, and the server still refuses without HERDR_WEB_TOKEN. At start the server first stops a Portal an earlier server left running, so a restart without a token keeps no public address; on shutdown the entrypoint waits for Portal to unregister. The panel is built from SettingsRow, errors use role=alert, and the privacy FAQ names the relay. --- CHANGELOG.md | 9 +- DESIGN.md | 5 + docs/guide.md | 10 +- server/AGENTS.md | 2 +- server/api.contract.test.ts | 14 +- server/index.ts | 15 +- server/portal.test.ts | 198 ++++++++++---------- server/portal.ts | 318 ++++++++++++++------------------- shared/protocol.ts | 29 ++- site/demo/transport.ts | 2 +- src/components/PortalPanel.css | 20 ++- src/components/PortalPanel.tsx | 87 +++++---- src/lib/api.ts | 2 +- src/lib/i18n.ja.ts | 16 +- src/lib/i18n.ko.ts | 16 +- src/lib/i18n.zh.ts | 16 +- src/lib/portal.ts | 21 ++- 17 files changed, 381 insertions(+), 399 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 304cd42088..bc7dc55bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ### Added - **Settings → Phone & devices → Portal** gives this PC a public HTTPS address through [Portal](https://github.com/gosuda/portal-tunnel), with no account and no domain of your own. - On the PC itself, **Install Portal** downloads Portal's official v2.6.1 release, checked against - the digest the app carries, and **Start** runs it in routed mode on a relay you enter, without - passing on a visitor's Tailscale login. It starts only with `HERDR_WEB_TOKEN` set. The address - and its QR code show on every signed-in device, any device that can type stops it, and one left - on comes back when the app restarts. + With Portal v2.6.1 or later installed, **Start** runs it on a relay you enter, dropping any + `Tailscale-User-Login` header a visitor sends; it starts only with `HERDR_WEB_TOKEN` set. The + address and its QR code show on every signed-in device, any device that can type into your + terminals can stop it, and an address left on comes back when the app restarts. - herdr's pane layout operations from the web: a tab's menu (right-click a tab, or its chevron; a bottom sheet on a phone) now offers **Split right** and **Split down** (herdr's prefix+v and prefix+-), **Zoom pane** (prefix+z), **Swap left / right / up / down** with the pane's diff --git a/DESIGN.md b/DESIGN.md index ea93b490b3..7bce39decf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1112,6 +1112,11 @@ One set for both themes: the card is island black wherever it shows. workspace-list toggle. Under the row, once the server has answered, the bridge auto-update switch. - Install (a row of the Phone group) reflects installed, promptable or browser-instructions state; About links the repository. +- Portal, a group of its own between Phone and Devices where the server offers it: while it runs, + the address and QR code as the Phone group draws them over a Relay row with Stop; otherwise one + hint for what blocks it (no token, serve-only, no `portal` or an old one, with a link to get it) + or a wide Relay row whose text field and Start stack on a phone. Starting and stopping are one + dim status line; a failure is the server's sentence over Portal's own lines, folded. - The running versions are always written, since the sidebar carries none. Updates opens with **Running vX.Y.Z (commit)**: the server's version and commit, or the client's own build version before the server answers and where it names neither. While the server runs another version than diff --git a/docs/guide.md b/docs/guide.md index 88ad866dd0..e2edc3f230 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -124,7 +124,7 @@ herdr plugin uninstall devswha.herdr-web-ui tailscale serve --https= off # the port the installer printed, if it served the app ``` -The installer's herdr, Bun and Node stay, since other tools may use them: `~/.local/bin/herdr`, `~/.bun`, and `~/.local/share/herdr-web-ui/node` with its link `~/.local/bin/node`. Push keys and paired devices are in `~/.config/herdr-web-ui`. +The installer's herdr, Bun and Node stay, since other tools may use them: `~/.local/bin/herdr`, `~/.bun`, and `~/.local/share/herdr-web-ui/node` with its link `~/.local/bin/node`. Push keys, paired devices and the identity file that owns the app's Portal address are in `~/.config/herdr-web-ui`. ## Supported agents @@ -391,7 +391,7 @@ Take `` from `portal list`; it has to run v2.6.1 or later. A `portal` pro Portal prints `service ready at https://-
.:443`, where `
` is the identity file's 40-character address; with or without `:443` it is the same address. That address belongs to the identity, so nobody else can take it, even while Portal is stopped; keep the identity file private and keep using it, since a new identity gives a new address. Check it as above: `https://-
./api/session`, opened from another device without signing in, must answer 401. Then open it on the phone and sign in with the token, or pair the phone from **Settings → Devices → Pair a device** on the PC. Use that address, not the shorter `https://.`, which another identity can claim while Portal is stopped. -**Settings → Phone & devices → Portal** does the same from the app. On this PC itself, not through another device or a proxy, **Install Portal** downloads Portal's official v2.6.1 release into the app's state directory and checks it against the digest the app carries, and **Start** runs the command above with the relay you enter. It starts only with `HERDR_WEB_TOKEN` set and `HERDR_WEB_TAILSCALE_SERVE_ONLY` off. The address and its QR code show on every signed-in device, and any device that can type into your terminals can stop it; one left on comes back when the app restarts. The app keeps an identity file of its own, so its address stays the same from one start to the next and differs from the one a `portal` you run yourself has. +**Settings → Phone & devices → Portal** runs that command for you once Portal v2.6.1 or later is installed on this PC ([Portal's install script](https://github.com/gosuda/portal-tunnel#quick-start) puts `portal` in `~/.local/bin`): enter a relay and press **Start**. It starts only with `HERDR_WEB_TOKEN` set and `HERDR_WEB_TAILSCALE_SERVE_ONLY` off, and asks the relay for its version first. The address and its QR code show on every signed-in device, any device that can type into your terminals can stop it, and one left on comes back when the app restarts. The app keeps an identity file of its own in `HERDR_WEB_STATE_DIR`, so its address stays the same from one start to the next and differs from the one a `portal` you run yourself has. **Sign out** in the header or command palette clears this browser's token and device cookies; terminal sessions and agents keep running. It is shown for token or device authentication, not automatic local or Tailscale access. @@ -418,7 +418,7 @@ Observe connections cannot take a pane, and a displaced bridge never takes it ba | `HERDR_WEB_CSP` | unset (enforced) | `report-only` sends the policy as `Content-Security-Policy-Report-Only`: the browser reports what it would block and the app keeps working. Used to measure the policy after a change | | `HERDR_WEB_TAILSCALE_OWNER` | this PC's Tailscale login | The Tailscale login that gets in through `tailscale serve` without pairing. Set it on a PC whose Tailscale node is tagged, which has no login of its own | | `HERDR_WEB_TAILSCALE_SERVE_ONLY` | unset (off) | `1` declares `tailscale serve` the only way anything reaches this port. Then the owner's own device gets in through serve without a code, on a tailnet one login owns with no tagged node. Enable it only when no public reverse proxy, tunnel or other forwarding server exposes this port: a visitor through one would otherwise get the owner's access | -| `HERDR_WEB_STATE_DIR` | `~/.config/herdr-web-ui` | Push keys, device subscriptions, PC registrations and update builds | +| `HERDR_WEB_STATE_DIR` | `~/.config/herdr-web-ui` | Push keys, device subscriptions, PC registrations, update builds and the app's Portal identity | | `HERDR_WEB_OPENAI_API_KEY` | unset | OpenAI API key for [voice input](#voice-input). Set here, it cannot be changed from the app | | `HERDR_WEB_OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible API root for voice input | | `HERDR_WEB_AUTO_UPDATE` | `0` | `1` installs new releases without asking | @@ -426,7 +426,6 @@ Observe connections cannot take a pane, and a displaced bridge never takes it ba | `HERDR_WEB_PUSH_SUBJECT` | this repository's URL | VAPID contact URL or `mailto:` address | | `HERDR_WEB_BUNDLE_MANIFEST` | unset | Remote-PC bundle manifest (path or URL) that overrides local and published bundles | | `HERDR_WEB_HERDR_BIN` | `herdr` | herdr executable used for terminal attach | -| `HERDR_WEB_PORTAL_BIN` | the one Settings installed, else `portal` on `PATH` | The `portal` executable that **Settings → Phone & devices → Portal** runs; v2.6.1 or later | | `HERDR_WEB_APP_NAME` | unset (`herdr`) | The name the installed app has on a phone or computer, for example the PC's name. Each PC installs as an app of its own, and without it they are all called `herdr`. An app already installed takes the new name when the browser next updates it, usually within a day or two, without a reinstall. Keep it short: a home screen cuts a long label | | `HERDR_WEB_PASTE_DIR` | `/.herdr-web-ui` | Where pasted and attached files are saved: an absolute path or `~/…`. Set it to keep them out of your projects; an agent may then ask before reading one. It covers the panes of this server's PC; a remote PC keeps the default | | `CODEX_HOME` | `~/.codex` | Where Codex sessions are read | @@ -534,7 +533,7 @@ No, but a phone needs two things Tailscale gives at once: a way to reach the PC - **An SSH tunnel from the phone** (Termux, Blink): `ssh -L 7317:127.0.0.1:7317 `, then open `http://localhost:7317` on the phone. Browsers treat localhost as secure, so installing and alerts should work while the tunnel is up (not verified on iOS yet). The phone still has to reach the PC over SSH. - **A VPN into your home** (WireGuard, ZeroTier, a router VPN): the LAN address works in the browser, but a plain `http://` address can neither install the app nor receive alerts. - **A reverse proxy with a real certificate** on a domain you own, with a token set and the proxy sending `X-Forwarded-For`: [Behind a reverse proxy](#behind-a-reverse-proxy) has Caddy and nginx examples to copy. This exposes the server to the internet, so read [Access and safety](#access-and-safety) first. -- **A public address with Portal**, a tunnel with no account and no domain of your own, with a long random token set: [Behind a reverse proxy](#behind-a-reverse-proxy) has the command. This exposes the server to the internet too. +- **A public address with Portal**, a tunnel with no account and no domain of your own, with a long random token set: [Behind a reverse proxy](#behind-a-reverse-proxy) has the command, and **Settings → Phone & devices → Portal** runs it for you. This exposes the server to the internet too.
@@ -547,6 +546,7 @@ Optional features can send data off the PC: - [Subscription usage](#subscription-usage), when enabled, sends each provider's credentials to that provider's usage endpoint. - Updates and remote-PC setup fetch releases or configured runtime bundles over the network; remote panes are reached over SSH. - Enabled push alerts go through the browser vendor's push service as encrypted notifications. +- [Portal](#behind-a-reverse-proxy), while it runs, carries every connection to the app through the relay you chose: the relay sees connection addresses, timing and volume, and holds the certificate for the address. See [Access and safety](#access-and-safety) for who can connect to the app.
diff --git a/server/AGENTS.md b/server/AGENTS.md index e01078ca4b..f2b6c816d5 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -59,7 +59,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the | SSH remote PCs | machines.ts + ssh.ts + machine-api.ts + machine-relay.ts; shared/machines.ts | PC-scoped APIs/SSE/WS; private versioned runtimes, same-socket bridge reuse, explicit setup/update approval; docs/remote-pcs.md | | HTTP routing / static serving | index.ts `createServer().fetch` | route order matters (root AGENTS.md); `/api/machines/local/*` rewrites to `/api/*`; handler modules return a `Response`, or `null` to fall through | | Updating herdr itself | herdr-update.ts (`HerdrUpdater`, `handleHerdrUpdateRequest`) + GET/POST /api/herdr/update in index.ts | runs `herdr update --handoff` with every `HERDR_*` variable removed, stdin closed and `HERDR_SOCKET_PATH` set to this server's herdr, then `herdr server live-handoff` when `herdr status --json` says the binary differs from the server; only the real entrypoint passes `herdrUpdate`, so tests offer nothing unless they pass a stand-in herdr. NEVER run the real `herdr update` in a test: it replaces the herdr on PATH (#373) | -| Public address through Portal | portal.ts (`PortalService`, `handlePortalRequest`) + GET /api/portal, POST /api/portal/{install,start,stop} in index.ts | install and start only from this PC itself (loopback, no forwarding header), never without a token or under HERDR_WEB_TAILSCALE_SERVE_ONLY; stop from any client that can type. The release is checked against the digest pinned in portal.ts, never the checksum file beside it. Portal runs routed with `--strip-request-header Tailscale-User-Login` and none of the server's environment beyond PATH, home, temp and proxy settings, so the token never reaches it. Portal retries a relay it cannot use forever and silently: the relay's `/sdk/domain` is asked first and readiness has its own deadline. A pid file left by a server killed outright lets the next one stop that Portal, after `ps` shows it runs this identity; only the real entrypoint passes `portal`, so tests offer nothing unless they pass a stand-in | +| Public address through Portal | portal.ts (`PortalService`, `handlePortalRequest`) + GET /api/portal, POST /api/portal/{start,stop} in index.ts | runs the `portal` on PATH and never installs one; refuses to start without a token or under HERDR_WEB_TAILSCALE_SERVE_ONLY, and at boot first stops a Portal the last server left running, so a restart without a token keeps no public address; Portal's environment is PATH, home, temp and proxy variables only. NEVER reach a real relay in a test: tests pass a stand-in `portal` and `fetch` | | App updates | managed.ts (launcher) + supervisor.ts + updater.ts + update-api.ts + update-state.ts | launcher runs the active release's supervisor and falls back if it dies before ready; supervisor owns Git/build/restart and exits 75 (HANDOVER_EXIT) after an install so the new supervisor takes over; bridge relays status/commands over IPC; /api/updates* gated + POST custom-header/same-origin guard; no update side effects in createServer tests; real Git/build/IPC/rollback tests in updater.test.ts | | Workspace / agent HTTP endpoints | index.ts + herdr/client.ts | /api/agents and /api/workspace/create/rename/move/close; creation validates cwd and may start an agent in the root pane | | Moving a pane | pane-move.ts (`parseMoveRequest`) + POST /api/pane/move in index.ts + `paneMove` in herdr/client.ts | herdr's `pane.move` answers `{ move_result }`; a move inside the workspace keeps the pane id, one into another workspace (or a new one) gives a NEW id (measured on 0.9.3), reported beside `previous_pane_id`, and the emptied tab or workspace closes behind it. The body is checked here because herdr answers a malformed destination with a bare `invalid_request`; a tab destination's `split` defaults to right; `focus` defaults to false so herdr's own focus stays. The old id's transcript cache is forgotten; the collector's `pane.moved` handling (#725) carries the status | diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 507f588899..0c9e16ad4b 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -363,7 +363,7 @@ describe("phone access", () => { }); describe("Portal API", () => { - it("opens a public address only for this PC itself, and lets any signed-in client close it", async () => { + it("opens a public address only behind the token, and closes it again", async () => { const state = mkdtempSync(join(tmpdir(), "herdr-portal-api-")); const standIn = join(state, "stand-in-portal"); writeFileSync(standIn, [ @@ -380,18 +380,12 @@ describe("Portal API", () => { const server = createServer({ port: 0, stateDir: state, token: "test-portal-token", portal: new PortalService({ stateDir: state, bin: standIn, fetch: relay }) }); const url = `http://127.0.0.1:${server.port}/api/portal`; const signedIn = { authorization: "Bearer test-portal-token" }; - // what any proxy on this PC sends, Portal's own address included - const proxied = { ...signedIn, "x-forwarded-for": "203.0.113.7" }; const post = (action: string, headers: Record) => fetch(`${url}/${action}`, { method: "POST", headers: { ...headers, "x-herdr-update": "1", "content-type": "application/json" }, body: JSON.stringify({ relay: "relay.example" }) }); const status = async (headers: Record = signedIn) => await (await fetch(url, { headers })).json() as PortalStatus; try { expect((await fetch(url)).status).toBe(401); - expect(await status()).toMatchObject({ supported: true, usable: true, here: true, blocked: null, phase: "idle" }); - expect(await status(proxied)).toMatchObject({ here: false }); - const refused = await post("start", proxied); - expect(refused.status).toBe(403); - expect((await refused.json() as ApiError).error.code).toBe("portal_not_here"); - + expect(await status()).toMatchObject({ supported: true, usable: true, blocked: null, phase: "idle" }); + expect((await post("start", {})).status).toBe(401); expect((await post("start", signedIn)).status).toBe(202); const deadline = Date.now() + 10_000; let running = await status(); @@ -401,7 +395,7 @@ describe("Portal API", () => { running = await status(); } expect(running).toMatchObject({ phase: "running", url: "https://herdr-test-0123.relay.example", relay: "https://relay.example" }); - expect((await post("stop", proxied)).status).toBe(202); + expect((await post("stop", signedIn)).status).toBe(202); expect((await status()).url).toBeNull(); } finally { server.stop(); rmSync(state, { recursive: true, force: true }); } }); diff --git a/server/index.ts b/server/index.ts index 525845a450..bce612f11f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1542,7 +1542,7 @@ export function createServer( } if (pathname === "/api/herdr/update") return handleHerdrUpdateRequest(request, options.herdrUpdate); if (pathname === "/api/portal" || pathname.startsWith("/api/portal/")) { - return handlePortalRequest(request, pathname, options.portal, { here: loopback && !forwarded, port: bunServer.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); + return handlePortalRequest(request, pathname, options.portal, { port: bunServer.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); } if (pathname === "/api/telemetry") return handleTelemetryRequest(request, options.telemetry); @@ -2888,7 +2888,8 @@ export function createServer( }); const registration = options.registerBridge ? registerBridge(server.port ?? 0, bridgeToken) : null; - // an address left on comes back with the server (an app update restarts it) + // a Portal the last server left running stops, and an address left on comes back (an app + // update restarts the server) void options.portal?.resume({ port: server.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); // ACKs can stop arriving entirely (a suspended tab). Bound the pause even then. @@ -2910,7 +2911,7 @@ export function createServer( claudeAgents.stop(); clearInterval(waitTimer); machines?.stop(); - options.portal?.shutdown(); + void options.portal?.shutdown(); registration?.close(); for (const client of clients) killWatches(client); for (const paneId of [...attachments.keys()]) closeAttachment(paneId); @@ -2923,7 +2924,8 @@ if (import.meta.main) { const updates = connectUpdater(); const version = (JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as { version: string }).version; const telemetry = new Telemetry({ stateDir: defaultStateDir(), version, env: process.env, fetch, previousVersion: () => updates.installed().previous_version }); - const instance = createServer({ updates, herdrUpdate: new HerdrUpdater(), portal: new PortalService({ stateDir: defaultStateDir() }), telemetry, registerBridge: true }); + const portal = new PortalService({ stateDir: defaultStateDir() }); + const instance = createServer({ updates, herdrUpdate: new HerdrUpdater(), portal, telemetry, registerBridge: true }); telemetry.start(); let stopping = false; const shutdown = () => { @@ -2931,8 +2933,9 @@ if (import.meta.main) { stopping = true; telemetry.stop(); instance.stop(); - // Attach sidecars need ~1.2s to release herdr's exclusive client slot. - setTimeout(() => process.exit(0), 2000); + // Attach sidecars need ~1.2s to release herdr's exclusive client slot; Portal unregisters from + // its relay, and the pipes it logs to must outlive it + void Promise.all([Bun.sleep(2000), portal.shutdown()]).then(() => process.exit(0)); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); diff --git a/server/portal.test.ts b/server/portal.test.ts index 11874185db..ba0c6876c1 100644 --- a/server/portal.test.ts +++ b/server/portal.test.ts @@ -1,6 +1,5 @@ import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { createHash } from "node:crypto"; -import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PortalStatus } from "../shared/protocol.ts"; @@ -8,12 +7,13 @@ import { atLeast, handlePortalRequest, PortalService, readyUrl, relayOrigin } fr /** * The service runs a stand-in portal here, a shell script, and asks a relay through an injected - * fetch: no test reaches a real relay or downloads a release. + * fetch: no test reaches a real relay. */ const root = mkdtempSync(join(tmpdir(), "herdr-portal-")); afterAll(() => rmSync(root, { recursive: true, force: true })); const ADDRESS = "https://herdr-test-0123456789abcdef0123456789abcdef01234567.relay.example"; +const RELAY = "https://relay.example"; const open = { tokenSet: true, serveOnly: false }; interface StandIn { @@ -61,7 +61,7 @@ function relay(version = "v2.6.1"): typeof fetch { }) as unknown as typeof fetch; } -async function until(service: PortalService, done: (status: Omit) => boolean, what: string): Promise> { +async function until(service: PortalService, done: (status: PortalStatus) => boolean, what: string): Promise { const deadline = Date.now() + 10_000; for (;;) { const status = await service.status(open); @@ -75,15 +75,17 @@ function alive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } +async function gone(pid: number): Promise { + const deadline = Date.now() + 5_000; + while (alive(pid) && Date.now() < deadline) await Bun.sleep(20); + return !alive(pid); +} + // what a server started by the herdr plugin may carry: its own token, and Portal's own settings -const inherited = { HERDR_WEB_TOKEN: "do-not-pass-this-on", IDENTITY_PATH: "/somewhere/else.json", HERDR_WEB_PORTAL_BIN: undefined }; +const inherited = { HERDR_WEB_TOKEN: "do-not-pass-this-on", IDENTITY_PATH: "/somewhere/else.json" }; const previous: Record = {}; beforeEach(() => { - for (const [name, value] of Object.entries(inherited)) { - previous[name] = process.env[name]; - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } + for (const [name, value] of Object.entries(inherited)) { previous[name] = process.env[name]; process.env[name] = value; } }); afterEach(() => { for (const name of Object.keys(inherited)) { @@ -93,11 +95,12 @@ afterEach(() => { }); describe("what Portal is given and says", () => { - it("takes a relay as an https origin, a bare host included, and nothing else", () => { - expect(relayOrigin("relay.example")).toBe("https://relay.example"); - expect(relayOrigin(" https://relay.example/ ")).toBe("https://relay.example"); + it("takes one relay as an https origin, a bare host included, and nothing else", () => { + expect(relayOrigin("relay.example")).toBe(RELAY); + expect(relayOrigin(" https://relay.example/ ")).toBe(RELAY); expect(relayOrigin("https://relay.example:8443")).toBe("https://relay.example:8443"); - for (const refused of ["", "http://relay.example", "https://user:pass@relay.example", "https://relay.example/path", "https://relay.example/?x=1", "relay.example --hide", 7, null]) { + // `--relays` reads a comma as a second relay + for (const refused of ["", "http://relay.example", "https://user:pass@relay.example", "https://relay.example/path", "https://relay.example/?x=1", "relay.example --hide", "relay.example,evil.example", "https://a.example,b.example", 7, null]) { expect(relayOrigin(refused)).toBeNull(); } }); @@ -115,43 +118,12 @@ describe("what Portal is given and says", () => { }); }); -describe("installing Portal", () => { - const binary = "#!/bin/sh\necho v2.6.1\n"; - const digest = createHash("sha256").update(binary).digest("hex"); - const serve = (async () => new Response(binary)) as unknown as typeof fetch; - - it("puts the release on this PC once its digest matches the pinned one", async () => { - const stateDir = mkdtempSync(join(root, "state-")); - const service = new PortalService({ stateDir, release: { url: "https://releases.example/portal", sha256: digest }, fetch: serve }); - expect(service.install()).toBe("started"); - expect(service.install()).toBe("busy"); - const status = await until(service, (s) => s.phase !== "installing", "the install ended"); - expect(status).toMatchObject({ phase: "idle", version: "v2.6.1", usable: true, error: null }); - expect(statSync(join(stateDir, "portal", "bin", "portal")).mode & 0o777).toBe(0o755); - }); - - it("installs nothing from a download that does not match", async () => { - const stateDir = mkdtempSync(join(root, "state-")); - const service = new PortalService({ stateDir, release: { url: "https://releases.example/portal", sha256: "0".repeat(64) }, fetch: serve }); - expect(service.install()).toBe("started"); - const status = await until(service, (s) => s.phase !== "installing", "the install ended"); - expect(status.phase).toBe("error"); - expect(status.error).toContain("does not match"); - expect(readdirSync(join(stateDir, "portal", "bin"))).toEqual([]); - }); - - it("offers no install where Portal has no release", () => { - const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), platform: "plan9-mips" }); - expect(service.install()).toBe("unsupported"); - }); -}); - describe("running Portal", () => { it("opens nothing without a token, or while tailscale serve is declared the only way in", async () => { const portal = standIn(); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); - expect(service.start({ relay: "https://relay.example", port: 7317, tokenSet: false, serveOnly: false })).toBe("token_required"); - expect(service.start({ relay: "https://relay.example", port: 7317, tokenSet: true, serveOnly: true })).toBe("serve_only"); + expect(service.start({ relay: RELAY, port: 7317, tokenSet: false, serveOnly: false })).toBe("token_required"); + expect(service.start({ relay: RELAY, port: 7317, tokenSet: true, serveOnly: true })).toBe("serve_only"); expect((await service.status({ tokenSet: false, serveOnly: false })).blocked).toBe("token_required"); expect(portal.pids()).toEqual([]); }); @@ -160,15 +132,15 @@ describe("running Portal", () => { const stateDir = mkdtempSync(join(root, "state-")); const portal = standIn(); const service = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); - expect(service.start({ relay: "https://relay.example", port: 7317, ...open })).toBe("started"); + expect(service.start({ relay: RELAY, port: 7317, ...open })).toBe("started"); const status = await until(service, (s) => s.phase !== "starting", "Portal was ready"); - expect(status).toMatchObject({ phase: "running", url: ADDRESS, relay: "https://relay.example" }); + expect(status).toMatchObject({ phase: "running", url: ADDRESS, relay: RELAY }); const seen = portal.seen(); const flag = (name: string) => seen[seen.indexOf(name) + 1]; expect(seen[0]).toBe("expose"); expect(flag("--http-route")).toBe("/=7317"); expect(flag("--strip-request-header")).toBe("Tailscale-User-Login"); - expect(flag("--relays")).toBe("https://relay.example"); + expect(flag("--relays")).toBe(RELAY); expect(flag("--identity-path")).toBe(join(stateDir, "portal", "identity.json")); expect(seen).toContain("--discovery=false"); expect(seen).toContain("--hide"); @@ -180,85 +152,121 @@ describe("running Portal", () => { // stopped is remembered: the next server leaves it off const next = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); await next.resume({ port: 7317, ...open }); - expect(await next.status(open)).toMatchObject({ phase: "idle", relay: "https://relay.example" }); + expect(await next.status(open)).toMatchObject({ phase: "idle", relay: RELAY }); expect(portal.pids()).toHaveLength(1); }); - it("comes back with the next server, and takes over from a Portal the last one left running", async () => { + it("ends with its server, and comes back with the next one", async () => { const stateDir = mkdtempSync(join(root, "state-")); const portal = standIn(); const first = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); - first.start({ relay: "https://relay.example", port: 7317, ...open }); + first.start({ relay: RELAY, port: 7317, ...open }); await until(first, (s) => s.phase === "running", "the first Portal was ready"); - // a server killed outright stops nothing: its Portal keeps the identity + await first.shutdown(); + expect(alive(portal.pids()[0]!)).toBe(false); const next = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); await next.resume({ port: 7317, ...open }); expect(await until(next, (s) => s.phase !== "starting", "the next Portal was ready")).toMatchObject({ phase: "running", url: ADDRESS }); + await next.shutdown(); + }); + + it("takes over from a Portal a server killed outright left running", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portal = standIn(); + const first = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + first.start({ relay: RELAY, port: 7317, ...open }); + await until(first, (s) => s.phase === "running", "the first Portal was ready"); + // no shutdown: two processes with one identity would take the address from each other + const next = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + await next.resume({ port: 7317, ...open }); + expect(await until(next, (s) => s.phase !== "starting", "the next Portal was ready")).toMatchObject({ phase: "running" }); const [left, own] = portal.pids(); expect(alive(left!)).toBe(false); expect(alive(own!)).toBe(true); - // a server that cannot open the address leaves the saved choice alone and starts nothing - next.shutdown(); - await until(next, (s) => s.phase === "idle", "the Portal ended with its server"); - const unguarded = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); - await unguarded.resume({ port: 7317, tokenSet: false, serveOnly: false }); - expect((await unguarded.status(open)).phase).toBe("idle"); - expect(portal.pids()).toHaveLength(2); + await next.shutdown(); + }); + + it("stops a Portal the last server left running even when it may not open the address itself", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portal = standIn(); + const first = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + first.start({ relay: RELAY, port: 7317, ...open }); + await until(first, (s) => s.phase === "running", "the first Portal was ready"); + const tokenless = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + await tokenless.resume({ port: 7317, tokenSet: false, serveOnly: false }); + expect(await gone(portal.pids()[0]!)).toBe(true); + expect((await tokenless.status({ tokenSet: false, serveOnly: false })).phase).toBe("idle"); + expect(portal.pids()).toHaveLength(1); + }); + + it("leaves alone the Portal of a server on another port", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portal = standIn(); + const first = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + first.start({ relay: RELAY, port: 7317, ...open }); + await until(first, (s) => s.phase === "running", "the first Portal was ready"); + const other = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + await other.resume({ port: 7318, ...open }); + expect(alive(portal.pids()[0]!)).toBe(true); + expect((await other.status(open)).phase).toBe("idle"); + await first.shutdown(); }); it("refuses a relay older than the tunnel needs before anything runs", async () => { const portal = standIn(); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay("v2.6.0") }); - service.start({ relay: "https://relay.example", port: 7317, ...open }); + service.start({ relay: RELAY, port: 7317, ...open }); const status = await until(service, (s) => s.phase !== "starting", "the start ended"); expect(status.phase).toBe("error"); expect(status.error).toContain("v2.6.1"); expect(portal.pids()).toEqual([]); }); - it("refuses a Portal older than the tunnel needs", async () => { - const portal = standIn({ version: "v2.5.1" }); - const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); - expect(await service.status(open)).toMatchObject({ version: "v2.5.1", usable: false }); - service.start({ relay: "https://relay.example", port: 7317, ...open }); - expect((await until(service, (s) => s.phase !== "starting", "the start ended")).phase).toBe("error"); - expect(portal.pids()).toEqual([]); + it("offers no start without a portal recent enough, and says which", async () => { + const old = standIn({ version: "v2.5.1" }); + const outdated = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: old.path, fetch: relay() }); + expect(await outdated.status(open)).toMatchObject({ version: "v2.5.1", usable: false }); + outdated.start({ relay: RELAY, port: 7317, ...open }); + expect((await until(outdated, (s) => s.phase !== "starting", "the start ended")).error).toContain("v2.5.1"); + expect(old.pids()).toEqual([]); + + const missing = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: join(root, "no-such-portal"), fetch: relay() }); + expect(await missing.status(open)).toMatchObject({ supported: true, version: null, usable: false }); }); it("gives up on a relay that never takes the tunnel, and says what Portal said", async () => { const portal = standIn({ ready: false, says: "2026-10-11T00:00:00Z WRN lease registration failed" }); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay(), readyTimeoutMs: 300 }); - service.start({ relay: "https://relay.example", port: 7317, ...open }); + service.start({ relay: RELAY, port: 7317, ...open }); const status = await until(service, (s) => s.phase !== "starting", "the start gave up"); expect(status.phase).toBe("error"); expect(status.error).toContain("lease registration failed"); - const deadline = Date.now() + 5_000; - while (alive(portal.pids()[0]!) && Date.now() < deadline) await Bun.sleep(20); - expect(alive(portal.pids()[0]!)).toBe(false); + expect(await gone(portal.pids()[0]!)).toBe(true); }); it("stops a Portal a relay refused for good", async () => { const portal = standIn({ ready: false, says: "2026-10-11T00:00:00Z ERR relay operation failed permanently error=\"name taken\"" }); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); - service.start({ relay: "https://relay.example", port: 7317, ...open }); + service.start({ relay: RELAY, port: 7317, ...open }); const status = await until(service, (s) => s.phase !== "starting", "the start ended"); expect(status.phase).toBe("error"); expect(status.error).toContain("name taken"); }); - it("tells of a Portal that ended on its own", async () => { - const portal = standIn({ exitAfter: true }); + it("tells of a Portal that ended on its own, in its last words", async () => { + const portal = standIn({ exitAfter: true, says: "2026-10-11T00:00:00Z ERR listener closed" }); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); - service.start({ relay: "https://relay.example", port: 7317, ...open }); + service.start({ relay: RELAY, port: 7317, ...open }); const status = await until(service, (s) => s.phase === "error", "Portal ended"); expect(status.error).toContain("exit 3"); + expect(status.error).toContain("listener closed"); expect(status.url).toBeNull(); }); it("kills a Portal that does not stop when asked", async () => { const portal = standIn({ ignoreTerm: true }); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay(), stopGraceMs: 200 }); - service.start({ relay: "https://relay.example", port: 7317, ...open }); + service.start({ relay: RELAY, port: 7317, ...open }); await until(service, (s) => s.phase === "running", "Portal was ready"); service.stop(); await until(service, (s) => s.phase === "idle", "Portal was killed"); @@ -269,40 +277,36 @@ describe("running Portal", () => { describe("the Portal endpoint", () => { const url = "http://127.0.0.1:7317/api/portal"; const own = { "x-herdr-update": "1", origin: "http://127.0.0.1:7317", "sec-fetch-site": "same-origin", "content-type": "application/json" }; - const here = { here: true, port: 7317, ...open }; + const server = { port: 7317, ...open }; const post = (action: string, body: unknown = { relay: "relay.example" }, headers: Record = own) => new Request(`${url}/${action}`, { method: "POST", headers, body: JSON.stringify(body) }); const code = async (response: Response) => (await response.json() as { error: { code: string } }).error.code; it("answers the status without caching, and offers nothing without the service", async () => { - const response = await handlePortalRequest(new Request(url), "/api/portal", undefined, { ...here, here: false }); + const response = await handlePortalRequest(new Request(url), "/api/portal", undefined, server); expect(response.headers.get("cache-control")).toBe("no-store"); - expect(await response.json()).toMatchObject({ supported: false, here: false }); - expect(await code(await handlePortalRequest(post("stop"), "/api/portal/stop", undefined, here))).toBe("portal_unsupported"); + expect(await response.json()).toMatchObject({ supported: false }); + expect(await code(await handlePortalRequest(post("stop"), "/api/portal/stop", undefined, server))).toBe("portal_unsupported"); }); - it("starts Portal only for this app's own controls, on this PC itself, with a token", async () => { + it("starts and stops Portal only for this app's own controls, and starts it only with a token", async () => { const portal = standIn(); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); for (const headers of [{ ...own, "x-herdr-update": "0" }, { ...own, origin: "https://evil.example" }, { ...own, "sec-fetch-site": "cross-site" }]) { - expect(await code(await handlePortalRequest(post("start", undefined, headers), "/api/portal/start", service, here))).toBe("invalid_portal_request"); + expect(await code(await handlePortalRequest(post("start", undefined, headers), "/api/portal/start", service, server))).toBe("invalid_portal_request"); } - for (const action of ["install", "start"]) { - const refused = await handlePortalRequest(post(action), `/api/portal/${action}`, service, { ...here, here: false }); - expect(refused.status).toBe(403); - expect(await code(refused)).toBe("portal_not_here"); + for (const relayInput of ["http://relay.example", "relay.example,evil.example"]) { + expect(await code(await handlePortalRequest(post("start", { relay: relayInput }), "/api/portal/start", service, server))).toBe("invalid_relay"); } - expect(await code(await handlePortalRequest(post("start", { relay: "http://relay.example" }), "/api/portal/start", service, here))).toBe("invalid_relay"); - expect(await code(await handlePortalRequest(post("start"), "/api/portal/start", service, { ...here, tokenSet: false }))).toBe("token_required"); - expect(await code(await handlePortalRequest(post("start"), "/api/portal/start", service, { ...here, serveOnly: true }))).toBe("serve_only"); + expect(await code(await handlePortalRequest(post("start"), "/api/portal/start", service, { ...server, tokenSet: false }))).toBe("token_required"); + expect(await code(await handlePortalRequest(post("start"), "/api/portal/start", service, { ...server, serveOnly: true }))).toBe("serve_only"); expect(portal.pids()).toEqual([]); - expect((await handlePortalRequest(post("start"), "/api/portal/start", service, here)).status).toBe(202); - expect(await until(service, (s) => s.phase !== "starting", "Portal was ready")).toMatchObject({ phase: "running", relay: "https://relay.example" }); - // any signed-in client may take the address down - expect((await handlePortalRequest(post("stop", {}), "/api/portal/stop", service, { ...here, here: false })).status).toBe(202); + expect((await handlePortalRequest(post("start"), "/api/portal/start", service, server)).status).toBe(202); + expect(await until(service, (s) => s.phase !== "starting", "Portal was ready")).toMatchObject({ phase: "running", relay: RELAY }); + expect((await handlePortalRequest(post("stop", {}), "/api/portal/stop", service, server)).status).toBe(202); await until(service, (s) => s.phase === "idle", "Portal stopped"); - expect((await handlePortalRequest(new Request(url, { method: "DELETE" }), "/api/portal", service, here)).status).toBe(405); - expect((await handlePortalRequest(post("expose"), "/api/portal/expose", service, here)).status).toBe(405); + expect((await handlePortalRequest(new Request(url, { method: "DELETE" }), "/api/portal", service, server)).status).toBe(405); + expect((await handlePortalRequest(post("install"), "/api/portal/install", service, server)).status).toBe(405); }); }); diff --git a/server/portal.ts b/server/portal.ts index 440d547a31..ad7d5122c0 100644 --- a/server/portal.ts +++ b/server/portal.ts @@ -1,62 +1,50 @@ /** * Portal (github.com/gosuda/portal-tunnel): an optional public HTTPS address for this server, - * installed, started and stopped from Settings → Phone & devices. Tailscale stays the private - * route and is only ever read (tailscale.ts). This is the one place where the app itself changes - * how it can be reached, so it keeps the guide's rules for a public proxy (docs/guide.md, Behind a - * reverse proxy) whatever the caller asks: + * started and stopped from Settings → Phone & devices with the `portal` already on this PC; the + * app never installs it. Tailscale stays the private route and is only ever read (tailscale.ts). + * Whatever a request asks, the guide's rules for a public proxy hold (docs/guide.md, Behind a + * reverse proxy): * * - no address without a token, and none while HERDR_WEB_TAILSCALE_SERVE_ONLY says tailscale serve - * is the only way in; + * is the only way in, not even one an earlier server left running; * - routed mode, which keeps the browser's Host and sends X-Forwarded-For and * X-Forwarded-Proto: https, and drops a visitor's Tailscale-User-Login; * - one relay the user picked, discovery off, hidden from the relay's list; the identity file kept * here holds the name, and with it the address, across restarts; - * - the command is built here, never from the request, and Portal gets none of this server's - * environment beyond what running and reaching the relay need: the token stays here; - * - only this PC itself installs or starts it (index.ts decides who that is); any device signed in - * that can type may stop it. + * - only the relay's origin comes from the request, and Portal gets none of this server's + * environment beyond what running and reaching the relay need: the token stays here. * - * The binary is the official release checked against the digest pinned below, not against the - * checksum file beside it, which could be replaced together with the binary. Portal keeps retrying - * a relay it cannot use and never exits for it (v2.6.1), so readiness has a deadline of its own and - * the relay's version is asked before the start. + * Portal keeps retrying a relay it cannot use and never exits for it (v2.6.1), so the relay's + * version is asked first and readiness has a deadline of its own. */ -import { createHash, randomBytes } from "node:crypto"; -import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; -import { chmod, mkdir, readFile, rename, rm } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { PortalStatus } from "../shared/protocol.ts"; -import { isJsonObject, jsonResponse } from "./http.ts"; +import { errorResponse, isJsonObject, jsonResponse } from "./http.ts"; import { chunksOf } from "./remote-bundle.ts"; import { updateRequestAllowed } from "./update-api.ts"; +import { windowsProcessTable } from "./windows-processes.ts"; /** the first release with `--strip-request-header` and an address tied to the identity */ -export const PORTAL_VERSION = "v2.6.1"; -const RELEASES = "https://github.com/gosuda/portal-tunnel/releases/download"; -/** The official v2.6.1 binaries and their digests (its checksums.txt), per `${process.platform}-${process.arch}`. */ -const ASSETS: Record = { - "darwin-arm64": ["portal-darwin-arm64", "0b5242aa78f035532a8e2a9b450af82278e9e85e68bf267518c5ad6ad785d910"], - "darwin-x64": ["portal-darwin-amd64", "f2071e830a2f1b07c4fb3b046f66499a7a9772c6a3f95cae3d00b84f9b6b0079"], - "linux-arm64": ["portal-linux-arm64", "bbc366f21e3d50e9047ce2e185c72895e766e069ccd3b4dc985d15cab65ea066"], - "linux-x64": ["portal-linux-amd64", "ca70bdcda37502807e2db82cfd31875eefcf0ccc05146a460b7a86b87d5db789"], - "win32-arm64": ["portal-windows-arm64.exe", "fa6ca9eeb20faeed83201527439e1681e841d963c27c9dc2d0f673a02ab0af71"], - "win32-x64": ["portal-windows-amd64.exe", "5c1a45007a6307f4d70e124c6d61f57a125ae007b77fd8aa25a484913bfd7a28"], -}; +const PORTAL_VERSION = "v2.6.1"; /** a relay that answers but will not take the tunnel is only ever retried, never refused */ const READY_TIMEOUT_MS = 60_000; -/** Portal unregisters from the relay on SIGTERM; one that does not stop in this long is killed */ -const STOP_GRACE_MS = 15_000; +/** Portal unregisters from the relay on SIGTERM; one still running after this is killed, within the 6 s the supervisor gives this server to stop */ +const STOP_GRACE_MS = 4_000; const VERSION_TIMEOUT_MS = 2_500; /** the panel polls the status; `portal version` is a process each time */ const VERSION_CACHE_MS = 5_000; const RELAY_TIMEOUT_MS = 5_000; -const DOWNLOAD_TIMEOUT_MS = 10 * 60_000; +/** what is still in Portal's pipes when it exits: its last words */ +const PIPE_GRACE_MS = 250; /** Portal's last words kept for the panel */ const OUTPUT_LINES = 30; /** what Portal may keep of this server's environment: enough to run and to reach the relay */ const PORTAL_ENV = ["PATH", "HOME", "USERPROFILE", "SystemRoot", "TMPDIR", "TEMP", "TMP", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR"]; type Phase = PortalStatus["phase"]; +type Child = ReturnType; interface Guards { /** HERDR_WEB_TOKEN is set */ tokenSet: boolean; @@ -68,24 +56,27 @@ interface StartRequest extends Guards { /** this server's port, Portal's upstream */ port: number; } -interface SavedState { enabled: boolean; relay: string | null } +interface SavedState { + enabled: boolean; + relay: string | null; + /** the server that started it: another one on this state directory leaves its Portal alone */ + port: number | null; + /** the Portal last started, so the next server can stop it if it is still running */ + pid: number | null; +} export interface PortalServiceOptions { - /** Portal's binary, identity and on/off choice live in its portal/ */ + /** Portal's identity and on/off choice live in its portal/ */ stateDir: string; - /** the portal to run; unset, HERDR_WEB_PORTAL_BIN, then the one installed here, then `portal` on PATH */ + /** the portal to run; unset, `portal` from PATH, read at each start */ bin?: string; - /** `${process.platform}-${process.arch}`, which picks the release */ - platform?: string; - /** the release to install; unset, the pinned official one for the platform. Tests pass their own. */ - release?: { url: string; sha256: string }; fetch?: typeof fetch; /** READY_TIMEOUT_MS and STOP_GRACE_MS; tests shorten them */ readyTimeoutMs?: number; stopGraceMs?: number; } -/** "v2.6.1" → [2, 6, 1]; null for anything else. `portal version` prints the tag alone. */ +/** "v2.6.1" → [2, 6, 1]; null for anything else. */ function parseVersion(text: string): [number, number, number] | null { const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text.trim()); return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; @@ -99,14 +90,15 @@ export function atLeast(version: string, minimum: string): boolean { return true; } -/** A relay as the user gave it, as an https origin; null for anything with a path, a query or credentials. */ +/** A relay as the user gave it, as an https origin; null for anything with a path, a query, credentials or a second host. */ export function relayOrigin(input: unknown): string | null { if (typeof input !== "string" || input.trim() === "") return null; const text = input.trim(); let url: URL; try { url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`); } catch { return null; } if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") return null; - return url.origin; + // `--relays` takes a comma-separated list, which a URL's host does not refuse + return /^[a-z0-9.-]+$/i.test(url.hostname) ? url.origin : null; } /** The address on Portal's "service ready at" line, without the default port; null for any other line. */ @@ -135,16 +127,19 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** Portal's last warning or error, else its last line, for a failure that has no words of its own. */ +function lastWords(lines: readonly string[]): string { + const line = [...lines].reverse().find((candidate) => /\b(WRN|ERR|FTL)\b/.test(candidate)) ?? lines.at(-1); + return line === undefined ? "" : ` Portal said: ${line}`; +} + async function readVersion(bin: string): Promise { - let proc: ReturnType; - try { - proc = Bun.spawn([bin, "version"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", env: portalEnv(), windowsHide: true }); - } catch { return null; } - const timer = setTimeout(() => proc.kill(), VERSION_TIMEOUT_MS); try { + const proc = Bun.spawn([bin, "version"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", env: portalEnv(), windowsHide: true, timeout: VERSION_TIMEOUT_MS, killSignal: "SIGKILL" }); const [text, code] = await Promise.all([new Response(proc.stdout as ReadableStream).text(), proc.exited]); - return code === 0 && parseVersion(text) !== null ? text.trim() : null; - } catch { return null; } finally { clearTimeout(timer); } + const tag = /\bv?\d+\.\d+\.\d+\S*/.exec(text)?.[0]; + return code === 0 && tag !== undefined ? tag : null; + } catch { return null; } } export class PortalService { @@ -153,11 +148,11 @@ export class PortalService { private url: string | null = null; private error: string | null = null; private lines: string[] = []; - private child: ReturnType | null = null; - /** a start between its request and its process (the relay is asked meanwhile): one at a time */ + private child: Child | null = null; + /** a start between its request and its process, or the boot's cleanup: one at a time */ private launching = false; - /** the child the user (or this server's own stop) asked to end: its exit is no failure */ - private ending: ReturnType | null = null; + /** the child asked to end, once: its exit is no failure */ + private ending: Child | null = null; private versionRead: { at: number; bin: string | null; value: Promise } | null = null; private readonly dir: string; @@ -165,20 +160,11 @@ export class PortalService { this.dir = join(options.stateDir, "portal"); } - private get platform(): string { return this.options.platform ?? `${process.platform}-${process.arch}`; } - private get installedPath(): string { return join(this.dir, "bin", this.platform.startsWith("win32") ? "portal.exe" : "portal"); } private get identityPath(): string { return join(this.dir, "identity.json"); } private get statePath(): string { return join(this.dir, "state.json"); } - private get pidPath(): string { return join(this.dir, "portal.pid"); } - - private release(): { url: string; sha256: string } | null { - if (this.options.release) return this.options.release; - const asset = ASSETS[this.platform]; - return asset ? { url: `${RELEASES}/${PORTAL_VERSION}/${asset[0]}`, sha256: asset[1] } : null; - } private bin(): string | null { - return this.options.bin ?? (process.env["HERDR_WEB_PORTAL_BIN"] || (existsSync(this.installedPath) ? this.installedPath : Bun.which("portal"))); + return this.options.bin ?? Bun.which("portal"); } private version(fresh = false): Promise { @@ -190,10 +176,10 @@ export class PortalService { return value; } - async status(guards: Guards): Promise> { + async status(guards: Guards): Promise { const version = await this.version(); return { - supported: this.release() !== null || this.bin() !== null, + supported: true, version, min_version: PORTAL_VERSION, usable: version !== null && atLeast(version, PORTAL_VERSION), @@ -206,33 +192,25 @@ export class PortalService { }; } - /** At the server's start: brings the address back if it was on, so an app update does not take it down for good. */ + /** + * At the server's start. A Portal the last server left running is stopped whatever comes next, + * so a server that may not open the address does not keep one open; an address that was on + * comes back, so an app update does not take it down for good. + */ async resume(request: Omit): Promise { - const saved = await this.saved(); + const saved = this.saved(); this.relay = saved.relay; + // a server on another port shares this state directory: what runs is its own + if (saved.port !== null && saved.port !== request.port) return; + this.launching = true; + try { await this.stopLeftover(saved.pid); } finally { this.launching = false; } if (saved.enabled && saved.relay !== null && blockedBy(request) === null) this.start({ ...request, relay: saved.relay }); } - install(): "started" | "busy" | "unsupported" { - const release = this.release(); - if (release === null) return "unsupported"; - if (this.child !== null || this.launching || this.phase === "installing") return "busy"; - this.phase = "installing"; - this.error = null; - void this.download(release).then(async () => { - if (await this.version(true) === null) throw new Error("The downloaded Portal does not run on this PC."); - this.phase = "idle"; - }).catch((error: unknown) => { - this.phase = "error"; - this.error = messageOf(error); - }); - return "started"; - } - start(request: StartRequest): "started" | "busy" | "token_required" | "serve_only" { const blocked = blockedBy(request); if (blocked !== null) return blocked; - if (this.child !== null || this.launching || this.phase === "installing") return "busy"; + if (this.child !== null || this.launching) return "busy"; this.phase = "starting"; this.relay = request.relay; this.url = null; @@ -246,27 +224,33 @@ export class PortalService { return "started"; } - /** Takes the address down and remembers that it is off. */ + /** Takes the address down, then remembers that it is off. */ stop(): void { - this.save({ enabled: false, relay: this.relay }); this.url = null; this.error = null; - if (this.child === null) { + if (this.child !== null) { + this.phase = "stopping"; + this.end(this.child); + } else if (this.phase === "starting" || this.phase === "error") { // a start still asking its relay ends there - if (this.phase === "starting" || this.phase === "error") this.phase = "idle"; - return; + this.phase = "idle"; } - this.phase = "stopping"; - this.end(this.child); + this.save({ enabled: false }); } - /** The server is going away: Portal goes with it, and the saved choice stays for the next server. */ - shutdown(): void { - if (this.child !== null) this.end(this.child); - else if (this.phase === "starting") this.phase = "idle"; + /** The server is going away: Portal goes with it, and the saved choice stays for the next server. Settles once Portal has exited. */ + shutdown(): Promise { + const child = this.child; + if (child === null) { + if (this.phase === "starting") this.phase = "idle"; + return Promise.resolve(); + } + this.end(child); + return child.exited.then(() => undefined); } - private end(child: ReturnType): void { + private end(child: Child): void { + if (this.ending === child) return; this.ending = child; child.kill("SIGTERM"); const killer = setTimeout(() => child.kill("SIGKILL"), this.options.stopGraceMs ?? STOP_GRACE_MS); @@ -286,10 +270,10 @@ export class PortalService { if (bin === null || version === null) throw new Error("Portal is not installed on this PC."); if (!atLeast(version, PORTAL_VERSION)) throw new Error(`This PC has Portal ${version}; the app needs ${PORTAL_VERSION} or later.`); await this.checkRelay(relay); - await this.stopOrphan(); - // stopped while the relay was asked; from here to the spawn nothing waits, so a stop comes after it + // another server of this app on this identity: two Portals keep taking the address from each other + await this.stopLeftover(this.saved().pid); + // stopped meanwhile; from here to the spawn nothing waits, so a stop comes after it if (this.phase !== "starting") return; - this.save({ enabled: true, relay }); const child = Bun.spawn([ bin, "expose", "--http-route", `/=${port}`, @@ -301,17 +285,14 @@ export class PortalService { "--hide", ], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: portalEnv(), windowsHide: true }); this.child = child; - // kept after the exit too: stopOrphan checks what runs under a pid before it stops anything - writeFileSync(this.pidPath, String(child.pid), { mode: 0o600 }); + const readyTimeoutMs = this.options.readyTimeoutMs ?? READY_TIMEOUT_MS; const deadline = setTimeout(() => { - if (this.child !== child || this.phase !== "starting") return; - const said = this.lines.filter((line) => /\b(WRN|ERR)\b/.test(line)).at(-1); - this.fail(`${relay} did not take the tunnel within ${Math.round((this.options.readyTimeoutMs ?? READY_TIMEOUT_MS) / 1000)} seconds.${said ? ` Portal said: ${said}` : ""}`); - }, this.options.readyTimeoutMs ?? READY_TIMEOUT_MS); - void this.follow(child.stdout as ReadableStream, child); - void this.follow(child.stderr as ReadableStream, child); - void child.exited.then((code) => { + if (this.child === child && this.phase === "starting") this.fail(`${relay} did not take the tunnel within ${Math.round(readyTimeoutMs / 1000)} seconds.${lastWords(this.lines)}`); + }, readyTimeoutMs); + const output = Promise.all([this.follow(child.stdout as ReadableStream, child), this.follow(child.stderr as ReadableStream, child)]); + void child.exited.then(async (code) => { clearTimeout(deadline); + await Promise.race([output, Bun.sleep(PIPE_GRACE_MS)]); if (this.child !== child) return; this.child = null; this.url = null; @@ -321,12 +302,14 @@ export class PortalService { return; } this.phase = "error"; - this.error = `Portal stopped on its own (exit ${code}).${this.lines.length > 0 ? ` It said: ${this.lines.at(-1)}` : ""}`; + this.error = `Portal stopped on its own (exit ${code}).${lastWords(this.lines)}`; }); + // a Portal whose pid cannot be kept is one the next server could not stop + this.save({ enabled: true, relay, port, pid: child.pid }); } /** Portal writes one event a line: the address once a relay takes the tunnel, and why it gave up on one. */ - private async follow(stream: ReadableStream, child: ReturnType): Promise { + private async follow(stream: ReadableStream, child: Child): Promise { const decoder = new TextDecoder(); let pending = ""; try { @@ -341,19 +324,21 @@ export class PortalService { } catch { /* the pipe closes with the process */ } } - private line(raw: string, child: ReturnType): void { - // NO_COLOR is set; the colors are stripped anyway, a terminal's codes are no use here + private line(raw: string, child: Child): void { + // NO_COLOR is set; a terminal's codes would be no use here either way const line = raw.replace(/\x1b\[[0-9;]*m/g, "").trim(); if (line === "" || this.child !== child) return; this.lines = [...this.lines, line].slice(-OUTPUT_LINES); const url = readyUrl(line); if (url !== null) { - this.url = url; - if (this.phase === "starting" || this.phase === "running") this.phase = "running"; + if (this.phase === "starting" || this.phase === "running") { + this.url = url; + this.phase = "running"; + } return; } // the relay refused for good (a name it will not take, an incompatible relay): the process would stay up serving nothing - if (line.includes("relay operation failed permanently")) this.fail(line); + if (line.includes("relay operation failed permanently") && (this.phase === "starting" || this.phase === "running")) this.fail(line); } /** A relay Portal cannot use is retried for good and silently: ask the relay first. */ @@ -372,23 +357,22 @@ export class PortalService { } /** - * A Portal an earlier server of this app started and could not stop (it was killed outright): - * two processes with one identity keep taking the address from each other. Only a process - * recorded here and running this identity is stopped; Windows has no `ps`, and is left alone. + * A Portal on this identity that this server did not start: one a server killed outright left + * running, or one another server of this app runs. A pid outlives its process, so only the one + * recorded here, and only while its command line is `portal expose` on this identity, is stopped. */ - private async stopOrphan(): Promise { - if (process.platform === "win32") return; - const pid = Number((await readFile(this.pidPath, "utf8").catch(() => "")).trim()); - if (!Number.isInteger(pid) || pid <= 0) return; - let command = ""; + private async stopLeftover(pid: number | null): Promise { + if (pid === null || pid === this.child?.pid) return; + let command: string | null = null; try { - const ps = Bun.spawn(["ps", "-ww", "-o", "command=", "-p", String(pid)], { stdin: "ignore", stdout: "pipe", stderr: "ignore" }); - command = await new Response(ps.stdout as ReadableStream).text(); - } catch { /* no ps: nothing can be told about the process */ } - if (!command.includes(" expose ") || !command.includes(this.identityPath)) { - await rm(this.pidPath, { force: true }); - return; - } + if (process.platform === "win32") { + command = (await windowsProcessTable()).find((row) => row.pid === pid)?.commandLine ?? null; + } else { + const ps = Bun.spawn(["ps", "-ww", "-o", "command=", "-p", String(pid)], { stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: VERSION_TIMEOUT_MS, killSignal: "SIGKILL" }); + command = await new Response(ps.stdout as ReadableStream).text(); + } + } catch { /* an unreadable process table tells nothing */ } + if (command === null || !command.includes(" expose ") || !command.includes(this.identityPath)) return; try { process.kill(pid, "SIGTERM"); } catch { return; } for (const until = Date.now() + (this.options.stopGraceMs ?? STOP_GRACE_MS); Date.now() < until; await Bun.sleep(100)) { try { process.kill(pid, 0); } catch { return; } @@ -396,79 +380,49 @@ export class PortalService { try { process.kill(pid, "SIGKILL"); } catch { /* gone meanwhile */ } } - /** The release to portal/bin/, checked on the way; a partial or mismatched file never takes the name. */ - private async download(release: { url: string; sha256: string }): Promise { - const response = await (this.options.fetch ?? fetch)(release.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); - if (!response.ok || !response.body) throw new Error(`Portal could not be downloaded (HTTP ${response.status}).`); - await mkdir(join(this.dir, "bin"), { recursive: true, mode: 0o700 }); - const part = `${this.installedPath}.part-${process.pid}-${Date.now()}`; - const sink = Bun.file(part).writer(); - const hash = createHash("sha256"); + private saved(): SavedState { try { - for await (const chunk of chunksOf(response.body)) { - hash.update(chunk); sink.write(chunk); await sink.flush(); + const value: unknown = JSON.parse(readFileSync(this.statePath, "utf8")); + if (isJsonObject(value)) { + const { port, pid } = value; + return { + enabled: value["enabled"] === true, + relay: relayOrigin(value["relay"]), + port: typeof port === "number" && Number.isInteger(port) ? port : null, + pid: typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null, + }; } - await sink.end(); - if (hash.digest("hex") !== release.sha256) throw new Error("The download does not match the official Portal release; nothing was installed."); - await chmod(part, 0o755); - await rename(part, this.installedPath); - } catch (error) { - try { await sink.end(); } catch { /* already ended */ } - await rm(part, { force: true }); - throw error; - } - } - - private async saved(): Promise { - try { - const value: unknown = JSON.parse(await readFile(this.statePath, "utf8")); - if (!isJsonObject(value)) return { enabled: false, relay: null }; - return { enabled: value["enabled"] === true, relay: relayOrigin(value["relay"]) }; - } catch { return { enabled: false, relay: null }; } + } catch { /* none yet, or unreadable: nothing is on */ } + return { enabled: false, relay: null, port: null, pid: null }; } /** at once, not awaited: a stop and a start's own save never interleave */ - private save(state: SavedState): void { + private save(change: Partial): void { mkdirSync(this.dir, { recursive: true, mode: 0o700 }); const temp = `${this.statePath}.tmp-${process.pid}`; - writeFileSync(temp, JSON.stringify(state), { mode: 0o600 }); + writeFileSync(temp, JSON.stringify({ ...this.saved(), ...change }), { mode: 0o600 }); renameSync(temp, this.statePath); } } -/** Who asks, and what this server is: index.ts knows, Portal's rules here decide. */ -export interface PortalRequestContext extends Guards { - /** a loopback connection that came through no proxy: this PC itself, the only one that may install or start */ - here: boolean; - port: number; -} - -const NO_PORTAL: Omit = { supported: false, version: null, min_version: PORTAL_VERSION, usable: false, phase: "idle", relay: null, url: null, blocked: null, error: null, output: null }; +const NO_PORTAL: PortalStatus = { supported: false, version: null, min_version: PORTAL_VERSION, usable: false, phase: "idle", relay: null, url: null, blocked: null, error: null, output: null }; -export async function handlePortalRequest(request: Request, pathname: string, portal: PortalService | undefined, context: PortalRequestContext): Promise { +/** /api/portal and its actions. What this server is (its port, token and serve-only setting) comes from index.ts. */ +export async function handlePortalRequest(request: Request, pathname: string, portal: PortalService | undefined, context: Omit): Promise { const reply = (body: unknown, code = 200) => jsonResponse(body, code, { "cache-control": "no-store" }); const fail = (code: string, message: string, http: number) => reply({ error: { code, message } }, http); if (pathname === "/api/portal" && request.method === "GET") { // a server started without the service (tests, an embedding) offers nothing - const status = portal ? await portal.status(context) : NO_PORTAL; - return reply({ ...status, here: context.here } satisfies PortalStatus); + return reply(portal ? await portal.status(context) : NO_PORTAL); } const action = pathname.slice("/api/portal/".length); - if (request.method !== "POST" || (action !== "install" && action !== "start" && action !== "stop")) { - return fail("method_not_allowed", "Use GET /api/portal, or POST /api/portal/install, /start or /stop", 405); + if (request.method !== "POST" || (action !== "start" && action !== "stop")) { + return fail("method_not_allowed", "Use GET /api/portal, or POST /api/portal/start or /stop", 405); } if (!updateRequestAllowed(request)) return fail("invalid_portal_request", "Use the Portal controls from this app.", 403); if (!portal) return fail("portal_unsupported", "This server offers no Portal.", 409); if (action === "stop") { - portal.stop(); - return reply({ accepted: true }, 202); - } - // a public address is opened only by someone at this PC, never through a proxy (Portal's own address included) - if (!context.here) return fail("portal_not_here", "Install or start Portal on this PC itself, not through another device or a proxy.", 403); - if (action === "install") { - const started = portal.install(); - if (started === "unsupported") return fail("portal_unsupported", "Portal has no release for this PC.", 409); - if (started === "busy") return fail("portal_busy", "Portal is busy; try again once it is idle.", 409); + try { portal.stop(); } catch (error) { return errorResponse(error); } return reply({ accepted: true }, 202); } let body: unknown = null; @@ -476,8 +430,8 @@ export async function handlePortalRequest(request: Request, pathname: string, po const relay = relayOrigin(isJsonObject(body) ? body["relay"] : undefined); if (relay === null) return fail("invalid_relay", "Give the relay as an https address, such as https://relay.example.com.", 400); const started = portal.start({ ...context, relay }); - if (started === "token_required") return fail("token_required", "Set HERDR_WEB_TOKEN before you start Portal: anyone on the internet can open its address.", 409); - if (started === "serve_only") return fail("serve_only", "Turn off HERDR_WEB_TAILSCALE_SERVE_ONLY before you start Portal: it says tailscale serve is the only way in.", 409); + if (started === "token_required") return fail("token_required", "Set HERDR_WEB_TOKEN and restart the app before you start Portal: anyone on the internet can open its address.", 409); + if (started === "serve_only") return fail("serve_only", "Turn off HERDR_WEB_TAILSCALE_SERVE_ONLY and restart the app before you start Portal: it says tailscale serve is the only way in.", 409); if (started === "busy") return fail("portal_busy", "Portal is busy; try again once it is idle.", 409); return reply({ accepted: true }, 202); } diff --git a/shared/protocol.ts b/shared/protocol.ts index 6159fbab4a..06b9b39449 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -63,14 +63,11 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * Tailscale on this PC already serves, or the command to run), no-store * GET /api/portal -> PortalStatus (an optional public HTTPS address through * Portal, server/portal.ts), no-store - * POST /api/portal/install -> 202 { accepted: true } (the official release, checked - * against its pinned SHA-256); 403 portal_not_here, 409 portal_busy / portal_unsupported - * POST /api/portal/start -> 202 { accepted: true }; body { relay }; 403 portal_not_here, - * 409 token_required / serve_only / portal_busy, 400 invalid_relay - * POST /api/portal/stop -> 202 { accepted: true }, from any client that can type - * (not a watch-only device). - * Portal POSTs require X-Herdr-Update: 1 and same-origin; install and start only from - * this PC itself (a loopback connection that came through no proxy). + * POST /api/portal/start -> 202 { accepted: true }; body { relay }; 400 invalid_relay, + * 409 token_required / serve_only / portal_busy / portal_unsupported + * POST /api/portal/stop -> 202 { accepted: true }; 409 portal_unsupported + * Portal POSTs require X-Herdr-Update: 1 and same-origin, like the update POSTs; a + * watch-only device gets read_only. * GET /api/updates -> UpdateStatus (shared/update.ts), no-store * POST /api/updates/check -> 202 { accepted: true } * POST /api/updates/install -> 202 { accepted: true } @@ -334,27 +331,25 @@ export interface TailscaleAccess { } /** - * GET /api/portal: a public HTTPS address for this server through Portal (gosuda/portal-tunnel), - * which the app installs, starts and stops itself (Settings → Phone & devices). + * GET /api/portal: a public HTTPS address for this server through the Portal on its PC + * (gosuda/portal-tunnel), which the app starts and stops (Settings → Phone & devices). */ export interface PortalStatus { - /** false where Portal has no release for this PC and none is installed: the section stays hidden */ + /** false on a server started without Portal (tests, the demo): the section stays hidden */ readonly supported: boolean; - /** what `portal version` says of the binary this server would run; null when none answers */ + /** what `portal version` says of the `portal` on PATH; null when there is none or it does not answer */ readonly version: string | null; - /** the oldest Portal that can run this app's tunnel, and the one Install puts on this PC */ + /** the oldest Portal that can run this app's tunnel */ readonly min_version: string; - /** `version` is `min_version` or later: Start is offered, Install is not */ + /** `version` is `min_version` or later: Start is offered */ readonly usable: boolean; - readonly phase: "idle" | "installing" | "starting" | "running" | "stopping" | "error"; + readonly phase: "idle" | "starting" | "running" | "stopping" | "error"; /** the relay the tunnel uses, or used last; an https origin */ readonly relay: string | null; /** the public address while it runs, without the default port */ readonly url: string | null; /** why a start would be refused now: no token set, or tailscale serve declared the only way in */ readonly blocked: "token_required" | "serve_only" | null; - /** the asking client is this PC itself: only it may install or start Portal */ - readonly here: boolean; /** what went wrong last; null after a success */ readonly error: string | null; /** the tail of what Portal printed, for when the error alone does not say enough */ diff --git a/site/demo/transport.ts b/site/demo/transport.ts index dcbfdffe80..7dd6d972fb 100644 --- a/site/demo/transport.ts +++ b/site/demo/transport.ts @@ -531,7 +531,7 @@ async function route(url: URL, method: string, init: RequestInit | undefined, in if (path === "/api/telemetry") return json({ error: { code: "not_found", message: "This server sends no telemetry" } }, 404, { "cache-control": "no-store" }); if (path === "/api/herdr/update") return json({ supported: false, phase: "idle", server_version: null, binary_version: null, stale: false, output: null, finished_at: null }, 200, { "cache-control": "no-store" }); // the demo opens no public address: the Portal section stays hidden - if (path === "/api/portal") return json({ supported: false, version: null, min_version: "v2.6.1", usable: false, phase: "idle", relay: null, url: null, blocked: null, here: false, error: null, output: null } satisfies PortalStatus, 200, { "cache-control": "no-store" }); + if (path === "/api/portal") return json({ supported: false, version: null, min_version: "v2.6.1", usable: false, phase: "idle", relay: null, url: null, blocked: null, error: null, output: null } satisfies PortalStatus, 200, { "cache-control": "no-store" }); if (path === "/api/access") return json({ port: 7317, tailscale: { state: "running", dns_name: "workstation.example.ts.net", serving_url: "https://workstation.example.ts.net", serve_command: null, serve_url: null } }); if (path === "/api/usage") return json(usageReport(), 200, { "cache-control": "no-store" }); if (path === "/api/push" || path.startsWith("/api/push/")) return error("push_unavailable", "the demo sends no alerts", 404); diff --git a/src/components/PortalPanel.css b/src/components/PortalPanel.css index 82bbdf4f52..45a1618bda 100644 --- a/src/components/PortalPanel.css +++ b/src/components/PortalPanel.css @@ -1,14 +1,22 @@ .portal-start { display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.portal-start .phone-actions { - margin-top: var(--space-1); + flex: 0 1 22rem; + gap: var(--space-2); + min-width: 0; } .portal-start .input { flex: 1 1 auto; min-width: 0; } + +/* a relay's or Portal's sentence can carry a long address or path */ +.portal-error { + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .settings-row.is-wide > .portal-start { + flex: 0 0 auto; + } +} diff --git a/src/components/PortalPanel.tsx b/src/components/PortalPanel.tsx index 62ed025525..b915dbc41e 100644 --- a/src/components/PortalPanel.tsx +++ b/src/components/PortalPanel.tsx @@ -4,62 +4,77 @@ import "./PortalPanel.css"; import "./UpdateControls.css"; import { useT } from "../lib/i18n.ts"; -import { PORTAL_SETTLING, usePortal } from "../lib/portal.ts"; +import { usePortal } from "../lib/portal.ts"; import { Address } from "./PhonePanel.tsx"; -import { SettingsGroup } from "./SettingsControls.tsx"; +import { SettingsGroup, SettingsRow } from "./SettingsControls.tsx"; + +const PORTAL_INSTALL = "https://github.com/gosuda/portal-tunnel#quick-start"; /** * Settings → Phone & devices → Portal: a public HTTPS address for this PC through a relay the - * user picks. Only this PC itself installs or starts it (the server decides who that is); any - * signed-in device sees the address, and one that can type takes it down. Hidden where the server - * offers none. + * user picks, run by the `portal` installed there. Any device that can type starts and stops it; + * the server refuses without a token. Hidden where the server offers none. */ export function PortalPanel() { const t = useT(); - const { status, error, pending, request } = usePortal(); + const { status, error, pending, busy, request } = usePortal(); // what was typed; until then, the relay this PC used last const [typed, setTyped] = useState(null); if (!status?.supported) return null; const relay = typed ?? status.relay ?? ""; - const busy = pending || PORTAL_SETTLING.has(status.phase); - const on = status.phase === "running" || status.phase === "starting"; - const progress = status.phase === "installing" ? t("Installing Portal…") : status.phase === "starting" ? t("Starting…") : status.phase === "stopping" ? t("Stopping…") : null; - let controls; + const on = status.phase === "starting" || status.phase === "running" || status.phase === "stopping"; + const progress = status.phase === "starting" ? t("Starting…") : status.phase === "stopping" ? t("Stopping…") : null; + let control; if (on) { - controls =
; + control = ( + + + + ); } else if (status.blocked !== null) { - controls =

{status.blocked === "token_required" ? t("Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.") : t("Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.")}

; - } else if (!status.here) { - controls =

{t("Only this PC itself can install or start Portal.")}

; + control =

{status.blocked === "token_required" ? t("Set HERDR_WEB_TOKEN on this PC and restart the app to start Portal.") : t("Portal is a second way in: unset HERDR_WEB_TAILSCALE_SERVE_ONLY on this PC and restart the app first.")}

; } else if (!status.usable) { - controls = ( - <> -

{status.version === null ? t("Portal is not installed on this PC.") : t("This PC has Portal {version}; the app needs {min} or later.", { version: status.version, min: status.min_version })}

-
- + control = ( +
+

+ {status.version === null ? t("Portal is not installed on this PC.") : t("This PC has Portal {version}; the app needs {min} or later.", { version: status.version, min: status.min_version })}{" "} + {t("Get Portal")} +

+
); } else { - controls = ( -
{ event.preventDefault(); void request("start", relay); }}> - - {t("A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.", { min: status.min_version })} -
- setTyped(event.currentTarget.value)} /> + control = ( + + { event.preventDefault(); void request("start", relay); }}> + setTyped(event.currentTarget.value)} /> -
-
+ + ); } return ( - -
- {status.url !== null &&
} - {controls} - {(error ?? progress) !== null &&

{error ?? progress}

} - {/* the server's account of the last failure, then Portal's own words */} - {status.phase === "error" && status.error !== null &&

{status.error}

} - {status.phase === "error" && status.output !== null &&
{status.output}
} -
+ + {status.url !== null && ( +
+
+

{t("Open it on your phone and sign in with the access token, or pair the phone: Devices, below.")}

+
+ )} + {control} + {progress !== null &&

{progress}

} + {error !== null &&

{error}

} + {/* the server's account of the last failure, then Portal's own words */} + {status.phase === "error" && status.error !== null && ( +
+

{status.error}

+ {status.output !== null && ( +
+ {t("Portal's output")} +
{status.output}
+
+ )} +
+ )}
); } diff --git a/src/lib/api.ts b/src/lib/api.ts index a170cb6c63..5f40e42b8e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -104,7 +104,7 @@ export function fetchPortal(): Promise { return getJson("/api/portal"); } -export async function requestPortal(action: "install" | "start" | "stop", relay?: string): Promise { +export async function requestPortal(action: "start" | "stop", relay?: string): Promise { const url = `/api/portal/${action}`; const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-herdr-update": "1" }, body: JSON.stringify(relay === undefined ? {} : { relay }) }); if (!response.ok) throw await errorFrom(url, response); diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index 6c7bb6152c..647dec67eb 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -717,19 +717,19 @@ export const JA: Record = { "Copy": "コピー", "Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "自分の Tailscale デバイスは本人としてアクセスできます。アクセストークンを設定している場合は、デバイスごとに一度トークンを求められます。他のユーザーのデバイスや、LAN・パブリックアドレスからのアクセスにはペアリングが必要です。下の「デバイス」を参照してください。", "Access and safety": "アクセスと安全性", - "A public HTTPS address with no account and no domain of your own. Anyone on the internet can open it, so the access token is required.": "アカウントも独自ドメインも不要な公開 HTTPS アドレスです。インターネット上の誰でも開けるため、アクセストークンが必要です。", + "A public HTTPS address for this PC through a Portal relay. Anyone on the internet can open it, so it needs an access token.": "Portal リレーを経由する、この PC の公開 HTTPS アドレスです。インターネット上の誰でも開けるため、アクセストークンが必要です。", "Portal serves this PC": "Portal がこの PC を公開しています", + "Open it on your phone and sign in with the access token, or pair the phone: Devices, below.": "スマートフォンで開いてアクセストークンでサインインするか、スマートフォンをペアリングしてください。下の「デバイス」を参照してください。", + "Relay": "リレー", "Stop": "停止", "Stopping…": "停止しています…", - "Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.": "先にこの PC で HERDR_WEB_TOKEN を設定してください。Portal のアドレスはインターネット上の誰でも開けます。", - "Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.": "Portal はもう一つの入口になるため、先に HERDR_WEB_TAILSCALE_SERVE_ONLY をオフにしてください。", - "Only this PC itself can install or start Portal.": "Portal のインストールと開始は、この PC 自体からしか行えません。", + "Set HERDR_WEB_TOKEN on this PC and restart the app to start Portal.": "Portal を開始するには、この PC で HERDR_WEB_TOKEN を設定してアプリを再起動してください。", + "Portal is a second way in: unset HERDR_WEB_TAILSCALE_SERVE_ONLY on this PC and restart the app first.": "Portal はもう一つの入口です。先にこの PC で HERDR_WEB_TAILSCALE_SERVE_ONLY を解除し、アプリを再起動してください。", "Portal is not installed on this PC.": "この PC には Portal がインストールされていません。", "This PC has Portal {version}; the app needs {min} or later.": "この PC の Portal は {version} です。アプリには {min} 以降が必要です。", - "Install Portal {version}": "Portal {version} をインストール", - "Installing Portal…": "Portal をインストールしています…", - "Relay": "リレー", - "A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.": "信頼できる {min} 以降の Portal リレーです(portal list で一覧できます)。リレーはこのアドレスの証明書を持っているため、傍受するよう設定されたリレーなら通信内容を読めます。", + "Get Portal": "Portal を入手", + "A relay you trust, from portal list: it holds the address's certificate, so one set up to intercept could read the traffic.": "portal list に出てくるリレーのうち、信頼できるものを使ってください。リレーはこのアドレスの証明書を持つため、傍受するよう設定されたリレーは通信内容を読めます。", + "Portal's output": "Portal の出力", "…": "…", "Alerts": "通知", "Needs you": "応答が必要", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index 39b925f0d7..ecf7cbe0f5 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -715,19 +715,19 @@ export const KO: Record = { "Copy": "복사", "Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "내 Tailscale 기기는 나로 인식되어 들어옵니다. 접근 토큰을 설정했다면 기기마다 한 번 토큰을 묻습니다. 다른 사람의 기기나 LAN·공개 주소는 페어링이 필요합니다. 아래 기기 항목을 보세요.", "Access and safety": "접근과 안전", - "A public HTTPS address with no account and no domain of your own. Anyone on the internet can open it, so the access token is required.": "계정도 내 도메인도 없이 쓰는 공개 HTTPS 주소입니다. 인터넷의 누구나 열 수 있으므로 접근 토큰이 필요합니다.", + "A public HTTPS address for this PC through a Portal relay. Anyone on the internet can open it, so it needs an access token.": "Portal 릴레이를 거치는 이 PC의 공개 HTTPS 주소입니다. 인터넷의 누구나 열 수 있으므로 접근 토큰이 필요합니다.", "Portal serves this PC": "Portal이 이 PC를 서빙하고 있습니다", + "Open it on your phone and sign in with the access token, or pair the phone: Devices, below.": "폰에서 열고 접근 토큰으로 로그인하거나 폰을 페어링하세요. 아래 기기 항목을 보세요.", + "Relay": "릴레이", "Stop": "중지", "Stopping…": "중지 중…", - "Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.": "먼저 이 PC에 HERDR_WEB_TOKEN을 설정하세요. Portal 주소는 인터넷의 누구나 열 수 있습니다.", - "Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.": "Portal은 또 하나의 접속 경로이므로 먼저 HERDR_WEB_TAILSCALE_SERVE_ONLY를 끄세요.", - "Only this PC itself can install or start Portal.": "Portal 설치와 시작은 이 PC에서 직접 해야 합니다.", + "Set HERDR_WEB_TOKEN on this PC and restart the app to start Portal.": "Portal을 시작하려면 이 PC에 HERDR_WEB_TOKEN을 설정하고 앱을 다시 시작하세요.", + "Portal is a second way in: unset HERDR_WEB_TAILSCALE_SERVE_ONLY on this PC and restart the app first.": "Portal은 또 하나의 접속 경로입니다. 먼저 이 PC에서 HERDR_WEB_TAILSCALE_SERVE_ONLY를 해제하고 앱을 다시 시작하세요.", "Portal is not installed on this PC.": "이 PC에 Portal이 설치되어 있지 않습니다.", "This PC has Portal {version}; the app needs {min} or later.": "이 PC의 Portal은 {version}입니다. 앱에는 {min} 이상이 필요합니다.", - "Install Portal {version}": "Portal {version} 설치", - "Installing Portal…": "Portal 설치 중…", - "Relay": "릴레이", - "A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.": "신뢰하는 {min} 이상의 Portal 릴레이입니다(portal list로 확인할 수 있습니다). 릴레이는 이 주소의 인증서를 갖고 있으므로, 가로채도록 설정된 릴레이라면 트래픽을 읽을 수 있습니다.", + "Get Portal": "Portal 받기", + "A relay you trust, from portal list: it holds the address's certificate, so one set up to intercept could read the traffic.": "portal list에 나오는 릴레이 중 신뢰하는 것을 쓰세요. 릴레이는 이 주소의 인증서를 가지므로, 가로채도록 설정된 릴레이는 트래픽을 읽을 수 있습니다.", + "Portal's output": "Portal 출력", "…": "…", "Alerts": "알림", "Needs you": "내 응답 필요", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index f33453e167..9728892a7c 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -719,19 +719,19 @@ export const ZH: Record = { "Copy": "复制", "Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "你自己的 Tailscale 设备会以你的身份进入;如果设置了访问令牌,每台设备会询问一次令牌。其他人的设备,或通过局域网或公网地址访问,则需要配对:请见下方“设备”。", "Access and safety": "访问与安全", - "A public HTTPS address with no account and no domain of your own. Anyone on the internet can open it, so the access token is required.": "无需账号、也无需自己的域名的公开 HTTPS 地址。互联网上任何人都能打开它,因此必须设置访问令牌。", + "A public HTTPS address for this PC through a Portal relay. Anyone on the internet can open it, so it needs an access token.": "经由 Portal 中继为此 PC 提供的公开 HTTPS 地址。互联网上任何人都能打开它,因此需要访问令牌。", "Portal serves this PC": "Portal 正在为此 PC 提供服务", + "Open it on your phone and sign in with the access token, or pair the phone: Devices, below.": "在手机上打开它并用访问令牌登录,或配对手机:请见下方“设备”。", + "Relay": "中继", "Stop": "停止", "Stopping…": "正在停止…", - "Set HERDR_WEB_TOKEN on this PC first: anyone on the internet can open a Portal address.": "请先在此 PC 上设置 HERDR_WEB_TOKEN:互联网上任何人都能打开 Portal 地址。", - "Portal is a second way in, so turn HERDR_WEB_TAILSCALE_SERVE_ONLY off first.": "Portal 是另一条访问途径,请先关闭 HERDR_WEB_TAILSCALE_SERVE_ONLY。", - "Only this PC itself can install or start Portal.": "只能在此 PC 本机上安装或启动 Portal。", + "Set HERDR_WEB_TOKEN on this PC and restart the app to start Portal.": "要启动 Portal,请在此 PC 上设置 HERDR_WEB_TOKEN 并重启应用。", + "Portal is a second way in: unset HERDR_WEB_TAILSCALE_SERVE_ONLY on this PC and restart the app first.": "Portal 是另一条访问途径:请先在此 PC 上取消 HERDR_WEB_TAILSCALE_SERVE_ONLY 并重启应用。", "Portal is not installed on this PC.": "此 PC 未安装 Portal。", "This PC has Portal {version}; the app needs {min} or later.": "此 PC 上的 Portal 是 {version};应用需要 {min} 或更高版本。", - "Install Portal {version}": "安装 Portal {version}", - "Installing Portal…": "正在安装 Portal…", - "Relay": "中继", - "A Portal relay you trust, on {min} or later (portal list shows them). It holds the certificate for the address, so a relay set up to intercept could read the traffic.": "一个你信任的 {min} 或更高版本的 Portal 中继(portal list 可列出)。中继持有该地址的证书,因此被设置为拦截的中继可以读取流量。", + "Get Portal": "获取 Portal", + "A relay you trust, from portal list: it holds the address's certificate, so one set up to intercept could read the traffic.": "使用 portal list 中你信任的中继:中继持有该地址的证书,因此被设置为拦截的中继可以读取流量。", + "Portal's output": "Portal 输出", "…": "…", "Alerts": "提醒", "Needs you": "需要你回应", diff --git a/src/lib/portal.ts b/src/lib/portal.ts index 2390550c90..35cb28b8c3 100644 --- a/src/lib/portal.ts +++ b/src/lib/portal.ts @@ -3,12 +3,12 @@ import type { PortalStatus } from "../../shared/protocol.ts"; import { fetchPortal, requestPortal } from "./api.ts"; import { usePageVisible } from "./visibility.ts"; -/** the address appears or goes with these, so they are followed closely */ -export const PORTAL_SETTLING: ReadonlySet = new Set(["installing", "starting", "stopping"]); +/** the address comes or goes with these, so they are followed closely */ +const SETTLING: ReadonlySet = new Set(["starting", "stopping"]); /** - * Portal's public address, which the server opens on its own PC (server/portal.ts). Asked while - * Settings → Phone & devices is open. + * Portal's public address, which the server opens with the `portal` on its PC (server/portal.ts). + * Asked while Settings → Phone & devices is open. */ export function usePortal() { const [status, setStatus] = useState(null); @@ -31,7 +31,7 @@ export function usePortal() { try { const next = await fetchPortal(); if (!stopped) setStatus((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next); - if (PORTAL_SETTLING.has(next.phase)) delay = 1_500; + if (SETTLING.has(next.phase)) delay = 1_500; } catch { /* an older server has no such route, and a restart must not erase the last answer */ } if (!stopped) timer = setTimeout(() => void poll(), delay); } @@ -39,14 +39,19 @@ export function usePortal() { return () => { stopped = true; clearTimeout(timer); }; }, [refresh, visible]); - const request = useCallback(async (action: "install" | "start" | "stop", relay?: string) => { + const request = useCallback(async (action: "start" | "stop", relay?: string) => { setPending(true); setError(null); try { await requestPortal(action, relay); - if (mounted.current) setRefresh((value) => value + 1); + if (mounted.current) { + // the server is already on it: the controls follow at once, not at the next answer + setStatus((previous) => previous ? { ...previous, phase: action === "start" ? "starting" : "stopping", error: null, ...(action === "stop" ? { url: null } : {}) } : previous); + setRefresh((value) => value + 1); + } } catch (err) { if (mounted.current) setError(err instanceof Error ? err.message : String(err)); } finally { if (mounted.current) setPending(false); } }, []); - return { status, error, pending, request }; + const busy = pending || (status !== null && SETTLING.has(status.phase)); + return { status, error, pending, busy, request }; } From 89fd4df99c000561fce9f554eb110cdcfc0f7411 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 16:43:48 +0900 Subject: [PATCH 3/6] fix(portal): close startup exposure and lifecycle races --- scripts/ci-browser.sh | 1 + scripts/portal-regression.ts | 218 +++++++++++++++++++++ server/api.contract.test.ts | 44 +++++ server/index.ts | 32 +++- server/portal.test.ts | 336 ++++++++++++++++++++++++++++++++- server/portal.ts | 237 ++++++++++++++++++++--- src/components/PortalPanel.tsx | 2 +- 7 files changed, 835 insertions(+), 35 deletions(-) create mode 100644 scripts/portal-regression.ts diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index ddf7a2ea09..5af9e044ee 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -67,6 +67,7 @@ run_script scripts/terminal-viewport-regression.ts run_script scripts/key-bar-customization-demo-regression.ts run_script scripts/key-bar-hold-repeat-regression.ts run_script scripts/settings-pages-demo-regression.ts +run_script scripts/portal-regression.ts run_script scripts/deep-link-demo-regression.ts run_script scripts/chat-history-browser-qa.ts run_script scripts/chat-fallback-browser-qa.ts diff --git a/scripts/portal-regression.ts b/scripts/portal-regression.ts new file mode 100644 index 0000000000..026bec98ab --- /dev/null +++ b/scripts/portal-regression.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type Browser, type BrowserContext, type Page } from "playwright-core"; + +import "./test-herdr.ts"; // check run gives the server its own herdr session, not the user's +import { DEVICE_COOKIE } from "../server/auth.ts"; +import { createServer, type ServerInstance } from "../server/index.ts"; +import { PortalService } from "../server/portal.ts"; +import { UsageService } from "../server/usage.ts"; +import type { RemoteAccess } from "../shared/protocol.ts"; +import { openSettingsPage } from "./settings-page.ts"; + +const dist = join(import.meta.dir, "..", "dist", "index.html"); +assert.ok(existsSync(dist), "dist/index.html is missing: run `bun run build` first"); + +const root = mkdtempSync(join(tmpdir(), "herdr-portal-browser-")); +const portalBin = join(root, "portal"); +const portalAddress = "https://herdr-portal-test-0123.relay.example"; +const token = "portal-browser-test-token"; +const watchToken = randomBytes(32).toString("hex"); +const relayRequests: string[] = []; +const relayFetch: typeof fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + relayRequests.push(url); + if (url === "https://unavailable.relay.example/sdk/domain") return new Response(null, { status: 503 }); + if (url !== "https://relay.example/sdk/domain") throw new Error(`Unexpected Portal fixture request: ${url}`); + return Response.json({ data: { release_version: "v2.6.1" } }); +}; +const remoteAccess = { + port: 443, + tailscale: { state: "missing", dns_name: null, serving_url: null, serve_command: null, serve_url: null }, +} satisfies RemoteAccess; +const portalFetchPage = async (context: BrowserContext): Promise => { + await context.route("**/api/access", (route) => route.fulfill({ json: remoteAccess })); +}; +const openPhoneSettings = async (page: Page): Promise => { + await page.keyboard.press("ControlOrMeta+Shift+Comma"); + await page.locator(".settings-dialog").waitFor({ timeout: 10_000 }); + await openSettingsPage(page, "Phone & devices"); +}; +const settingsOf = (page: Page) => page.locator(".settings-dialog"); + +const servers: ServerInstance[] = []; +const portals: PortalService[] = []; +const contexts: BrowserContext[] = []; +let browser: Browser | undefined; + +try { + writeFileSync(portalBin, [ + "#!/bin/sh", + 'case "$1" in', + " version) printf '%s\\n' 'v2.6.1' ;;", + " expose)", + " trap 'exit 0' TERM", + ` printf '%s\\n' 'INF service ready at ${portalAddress}:443'`, + " while :; do sleep 0.05; done ;;", + " *) exit 2 ;;", + "esac", + "", + ].join("\n"), { mode: 0o700 }); + chmodSync(portalBin, 0o755); + + const stateDir = join(root, "token-server"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, "devices.json"), JSON.stringify({ + gate_closed_at: new Date().toISOString(), + devices: [{ + id: "0123456789abcdef", + label: "Portal watch fixture", + role: "watch", + token_hash: createHash("sha256").update(watchToken).digest("hex"), + created_at: new Date().toISOString(), + last_seen_at: null, + }], + }), { mode: 0o600 }); + const portal = new PortalService({ stateDir, bin: portalBin, fetch: relayFetch, readyTimeoutMs: 5_000, stopGraceMs: 1_000 }); + portals.push(portal); + const server = createServer({ + port: 0, + hostname: "127.0.0.1", + stateDir, + token, + tailscaleOwner: null, + tailscaleServeOnly: false, + portal, + usage: new UsageService(undefined, []), + registerBridge: false, + }); + servers.push(server); + const origin = `http://127.0.0.1:${server.port}`; + + browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? chromium.executablePath(), headless: true, args: ["--no-sandbox"] }); + const ownerContext = await browser.newContext({ viewport: { width: 1100, height: 800 }, locale: "en-US" }); + contexts.push(ownerContext); + await ownerContext.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" }))); + await portalFetchPage(ownerContext); + const ownerPage = await ownerContext.newPage(); + ownerPage.setDefaultTimeout(10_000); + const pageErrors: string[] = []; + ownerPage.on("pageerror", (error) => pageErrors.push(error.message)); + await ownerPage.goto(origin, { waitUntil: "domcontentloaded" }); + await ownerPage.getByTestId("token-gate").waitFor({ timeout: 10_000 }); + assert.equal(await ownerPage.evaluate(async () => (await fetch("/api/portal")).status), 401, "Portal status is gated until token login"); + await ownerPage.getByText("Have an access token instead?", { exact: true }).click(); + await ownerPage.getByLabel("Access token", { exact: true }).fill(token); + await ownerPage.getByRole("button", { name: "Unlock", exact: true }).click(); + await ownerPage.locator(".conn-live").waitFor({ state: "attached", timeout: 15_000 }); + const ownerAuth = await ownerPage.evaluate(async () => (await (await fetch("/api/health?scope=bridge")).json()).auth); + assert.deepEqual(ownerAuth, { required: false, authenticated: true, via: "token", role: "drive" }, "the UI token login receives the owner role"); + + await openPhoneSettings(ownerPage); + const ownerSettings = settingsOf(ownerPage); + const ownerRelay = ownerSettings.locator("#portal-relay"); + const start = ownerSettings.getByRole("button", { name: "Start", exact: true }); + await ownerRelay.waitFor({ timeout: 10_000 }); + assert.equal(await start.isDisabled(), true, "Start is disabled until a relay is entered"); + await ownerRelay.fill("relay.example"); + assert.equal(await start.isEnabled(), true, "a supported, usable Portal offers Start"); + await start.click(); + const address = ownerSettings.getByRole("link", { name: portalAddress, exact: true }); + await address.waitFor({ timeout: 15_000 }); + assert.equal(await address.getAttribute("href"), portalAddress, "the ready address is shown as the public link"); + assert.deepEqual(relayRequests, ["https://relay.example/sdk/domain"], "only the synthetic relay was checked"); + + const watchContext = await browser.newContext({ viewport: { width: 1100, height: 800 }, locale: "en-US" }); + contexts.push(watchContext); + await watchContext.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" }))); + await watchContext.addCookies([{ name: DEVICE_COOKIE, value: watchToken, url: origin, httpOnly: true, sameSite: "Strict" }]); + await portalFetchPage(watchContext); + const watchPage = await watchContext.newPage(); + watchPage.setDefaultTimeout(10_000); + watchPage.on("pageerror", (error) => pageErrors.push(error.message)); + await watchPage.goto(origin, { waitUntil: "domcontentloaded" }); + await watchPage.locator(".conn-live").waitFor({ state: "attached", timeout: 15_000 }); + const watchAuth = await watchPage.evaluate(async () => (await (await fetch("/api/health?scope=bridge")).json()).auth); + assert.deepEqual(watchAuth, { required: false, authenticated: true, via: "device", role: "watch" }, "the fixture cookie is authenticated as watch-only"); + await openPhoneSettings(watchPage); + const watchSettings = settingsOf(watchPage); + const watchStop = watchSettings.getByRole("button", { name: "Stop", exact: true }); + await watchStop.waitFor({ timeout: 10_000 }); + assert.equal(await watchStop.isDisabled(), true, "a watch-only device cannot stop a running Portal"); + + await ownerSettings.getByRole("button", { name: "Stop", exact: true }).click(); + await start.waitFor({ timeout: 10_000 }); + await address.waitFor({ state: "detached", timeout: 10_000 }); + await watchSettings.getByRole("button", { name: "Close settings", exact: true }).click(); + await watchPage.locator(".settings-dialog").waitFor({ state: "detached", timeout: 10_000 }); + await openPhoneSettings(watchPage); + const idleWatchSettings = settingsOf(watchPage); + const watchRelay = idleWatchSettings.locator("#portal-relay"); + const watchStart = idleWatchSettings.getByRole("button", { name: "Start", exact: true }); + await watchRelay.waitFor({ timeout: 10_000 }); + assert.equal(await watchRelay.isDisabled(), true, "a watch-only device cannot edit the relay"); + assert.equal(await watchStart.isDisabled(), true, "a watch-only device cannot start Portal"); + + const failingRelay = "unavailable.relay.example"; + await ownerRelay.fill(failingRelay); + await start.click(); + const failure = ownerSettings.getByRole("alert").filter({ hasText: failingRelay }); + await failure.waitFor({ timeout: 10_000 }); + const stopAfterError = ownerSettings.getByRole("button", { name: "Stop", exact: true }); + await stopAfterError.waitFor({ timeout: 10_000 }); + assert.match(await failure.innerText(), /did not answer as a Portal relay \(HTTP 503\)/); + await stopAfterError.click(); + await start.waitFor({ timeout: 10_000 }); + await failure.waitFor({ state: "detached", timeout: 10_000 }); + assert.equal(await start.isEnabled(), true, "Start is available again after stopping the failed Portal"); + const finalPhase = await ownerPage.evaluate(async () => (await (await fetch("/api/portal")).json()).phase); + assert.equal(finalPhase, "idle", "Stop clears the failed Portal state"); + assert.equal(await ownerRelay.inputValue(), failingRelay, "the failed relay remains editable after stopping"); + assert.deepEqual(relayRequests, [ + "https://relay.example/sdk/domain", + "https://unavailable.relay.example/sdk/domain", + ], "all relay checks use the injected fixture fetch"); + assert.deepEqual(pageErrors, [], "the Portal settings pages have no browser errors"); + console.log("PASS Portal browser flow: token login, ready/start/address/stop, error recovery, and watch-only controls"); + + const blockedState = join(root, "no-token-server"); + mkdirSync(blockedState, { recursive: true }); + const blockedPortal = new PortalService({ stateDir: blockedState, bin: portalBin, fetch: relayFetch }); + portals.push(blockedPortal); + const blockedServer = createServer({ + port: 0, + hostname: "127.0.0.1", + stateDir: blockedState, + token: "", + tailscaleOwner: null, + tailscaleServeOnly: false, + portal: blockedPortal, + usage: new UsageService(undefined, []), + registerBridge: false, + }); + servers.push(blockedServer); + const blockedContext = await browser.newContext({ viewport: { width: 1100, height: 800 }, locale: "en-US" }); + contexts.push(blockedContext); + await blockedContext.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" }))); + await portalFetchPage(blockedContext); + const blockedPage = await blockedContext.newPage(); + blockedPage.setDefaultTimeout(10_000); + blockedPage.on("pageerror", (error) => pageErrors.push(error.message)); + await blockedPage.goto(`http://127.0.0.1:${blockedServer.port}`, { waitUntil: "domcontentloaded" }); + await blockedPage.locator(".conn-live").waitFor({ state: "attached", timeout: 15_000 }); + await openPhoneSettings(blockedPage); + const blockedSettings = settingsOf(blockedPage); + await blockedSettings.getByText("Set HERDR_WEB_TOKEN on this PC and restart the app to start Portal.", { exact: true }).waitFor({ timeout: 10_000 }); + assert.equal(await blockedSettings.getByRole("button", { name: "Start", exact: true }).count(), 0, "Portal Start is unavailable without a configured token"); + assert.deepEqual(pageErrors, [], "the token-required page has no browser errors"); + console.log("PASS Portal token-required UI: the relay cannot be started without a configured token"); +} finally { + await Promise.all(contexts.map((context) => context.close().catch(() => undefined))); + for (const server of servers) server.stop(); + await Promise.all(portals.map((portal) => portal.shutdown().catch(() => undefined))); + await browser?.close().catch(() => undefined); + rmSync(root, { recursive: true, force: true }); +} diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 1f1f0a485b..bcd7633b41 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -535,6 +535,50 @@ describe("phone access", () => { }); describe("Portal API", () => { + it("does not serve requests until startup cleanup completes", async () => { + const state = mkdtempSync(join(tmpdir(), "herdr-portal-startup-gate-")); + let releaseCleanup!: () => void; + const cleanup = new Promise((resolve) => { releaseCleanup = resolve; }); + class DeferredPortal extends PortalService { + override resume(): Promise { return cleanup; } + } + const server = createServer({ port: 0, stateDir: state, token: "", portal: new DeferredPortal({ stateDir: state }) }); + try { + let resolved = false; + const request = fetch(`http://127.0.0.1:${server.port}/api/health?scope=bridge`).then((response) => { + resolved = true; + return response; + }); + const deadline = Date.now() + 50; + while (!resolved && Date.now() < deadline) await Bun.sleep(5); + expect(resolved).toBe(false); + releaseCleanup(); + const response = await request; + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, bridge_protocol: expect.any(Number) }); + } finally { + releaseCleanup(); + server.stop(); + rmSync(state, { recursive: true, force: true }); + } + }); + + it("fails closed when startup cleanup rejects", async () => { + const state = mkdtempSync(join(tmpdir(), "herdr-portal-startup-failure-")); + class FailedPortal extends PortalService { + override resume(): Promise { return Promise.reject(new Error("Portal cleanup failed")); } + } + const server = createServer({ port: 0, stateDir: state, token: "", portal: new FailedPortal({ stateDir: state }) }); + try { + const response = await fetch(`http://127.0.0.1:${server.port}/api/health?scope=bridge`); + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ error: { code: "internal_error" } }); + } finally { + server.stop(); + rmSync(state, { recursive: true, force: true }); + } + }); + it("opens a public address only behind the token, and closes it again", async () => { const state = mkdtempSync(join(tmpdir(), "herdr-portal-api-")); const standIn = join(state, "stand-in-portal"); diff --git a/server/index.ts b/server/index.ts index 06dd41bf45..0b8feb16df 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1632,12 +1632,25 @@ export function createServer( waitTimer.unref?.(); plans.start(); + type PortalStartup = { ok: true } | { ok: false; error: unknown }; + let settlePortalStartup!: (result: PortalStartup) => void; + let portalStartupResult: PortalStartup | undefined; + const portalStartup = new Promise((resolve) => { + settlePortalStartup = (result) => { + if (portalStartupResult !== undefined) return; + portalStartupResult = result; + resolve(result); + }; + }); + const envPort = process.env["PORT"]; const server = Bun.serve({ port: options.port ?? (envPort ? Number(envPort) : DEFAULT_PORT), hostname, async fetch(request, bunServer) { + const startup = portalStartupResult ?? await portalStartup; + if (!startup.ok) return compressResponse(request, errorResponse(startup.error)); // the route's answer, gzipped for a browser that takes it (server/compress.ts) return compressResponse(request, await (async (): Promise => { const url = new URL(request.url); @@ -3233,10 +3246,22 @@ export function createServer( }, }); + if (options.portal) { + let resume: Promise; + try { + resume = options.portal.resume({ port: server.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); + } catch (error) { + resume = Promise.reject(error); + } + void resume.then( + () => settlePortalStartup({ ok: true }), + (error: unknown) => settlePortalStartup({ ok: false, error }), + ); + } else { + settlePortalStartup({ ok: true }); + } + const registration = options.registerBridge ? registerBridge(server.port ?? 0, bridgeToken) : null; - // a Portal the last server left running stops, and an address left on comes back (an app - // update restarts the server) - void options.portal?.resume({ port: server.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); // ACKs can stop arriving entirely (a suspended tab). Bound the pause even then. const outputTimer = setInterval(() => { @@ -3252,6 +3277,7 @@ export function createServer( alertsSettled: () => push.settled(), stop: () => { stopped = true; + settlePortalStartup({ ok: false, error: new Error("Server stopped before Portal startup cleanup completed") }); conversations.stop(); clearInterval(outputTimer); collector.stop(); diff --git a/server/portal.test.ts b/server/portal.test.ts index ba0c6876c1..c3976b0ad8 100644 --- a/server/portal.test.ts +++ b/server/portal.test.ts @@ -1,5 +1,5 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { afterAll, afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PortalStatus } from "../shared/protocol.ts"; @@ -81,6 +81,38 @@ async function gone(pid: number): Promise { return !alive(pid); } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +async function bounded(promise: Promise, what: string, timeoutMs = 2_000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`Timed out: ${what}`)), timeoutMs); }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function waitFor(done: () => boolean, what: string): Promise { + const deadline = Date.now() + 2_000; + while (!done()) { + if (Date.now() >= deadline) throw new Error(`Timed out: ${what}`); + await Bun.sleep(10); + } +} + +function seedSavedPortal(stateDir: string, pid: number | null = process.pid): void { + const directory = join(stateDir, "portal"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync(join(directory, "state.json"), JSON.stringify({ enabled: true, relay: RELAY, port: 7317, pid })); +} + // what a server started by the herdr plugin may carry: its own token, and Portal's own settings const inherited = { HERDR_WEB_TOKEN: "do-not-pass-this-on", IDENTITY_PATH: "/somewhere/else.json" }; const previous: Record = {}; @@ -128,6 +160,25 @@ describe("running Portal", () => { expect(portal.pids()).toEqual([]); }); + it("does not follow redirects when checking the relay version", async () => { + const requests: RequestInit[] = []; + const fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + requests.push(init ?? {}); + return Response.json({ data: { release_version: "v2.6.1" } }); + }) as unknown as typeof globalThis.fetch; + const portal = standIn(); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch }); + try { + service.start({ relay: RELAY, port: 7317, ...open }); + await until(service, (status) => status.phase === "running", "Portal was ready"); + expect(requests).toHaveLength(1); + expect(requests[0]?.redirect).toBe("error"); + } finally { + service.stop(); + await bounded(service.shutdown(), "Portal shutdown settled"); + } + }); + it("exposes this server in routed mode on the one relay given, without a visitor's Tailscale login or this server's secrets", async () => { const stateDir = mkdtempSync(join(root, "state-")); const portal = standIn(); @@ -156,6 +207,31 @@ describe("running Portal", () => { expect(portal.pids()).toHaveLength(1); }); + it("creates the private identity directory before spawning Portal", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portalDir = join(stateDir, "portal"); + const portal = standIn(); + const service = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + const spawn = Bun.spawn; + const observed = spyOn(Bun, "spawn").mockImplementation(((command: string[], options?: Parameters[1]) => { + if (Array.isArray(command) && command[1] === "expose") { + expect(existsSync(portalDir)).toBe(true); + expect(statSync(portalDir).mode & 0o777).toBe(0o700); + } + return spawn(command, options); + }) as typeof Bun.spawn); + try { + service.start({ relay: RELAY, port: 7317, ...open }); + await until(service, (s) => s.phase === "running", "Portal was ready"); + expect(observed).toHaveBeenCalled(); + service.stop(); + await until(service, (s) => s.phase === "idle", "Portal stopped"); + } finally { + observed.mockRestore(); + await bounded(service.shutdown(), "Portal shutdown settled"); + } + }); + it("ends with its server, and comes back with the next one", async () => { const stateDir = mkdtempSync(join(root, "state-")); const portal = standIn(); @@ -170,6 +246,116 @@ describe("running Portal", () => { await next.shutdown(); }); + it("does not restart a resumed Portal after stop while leftover cleanup is pending", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + seedSavedPortal(stateDir); + const portal = standIn(); + const service = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + const entered = deferred(); + const release = deferred(); + const cleanup = spyOn(service as unknown as { stopLeftover(pid: number | null): Promise }, "stopLeftover") + .mockImplementation(async () => { entered.resolve(); await release.promise; }); + const resumed = service.resume({ port: 7317, ...open }); + try { + await bounded(entered.promise, "resume entered leftover cleanup"); + service.stop(); + release.resolve(); + await bounded(resumed, "resume completed after stop"); + expect((await service.status(open)).phase).toBe("idle"); + expect(portal.pids()).toEqual([]); + expect(JSON.parse(readFileSync(join(stateDir, "portal", "state.json"), "utf8"))).toMatchObject({ enabled: false }); + } finally { + release.resolve(); + cleanup.mockRestore(); + await bounded(resumed, "resume cleanup settled").catch(() => undefined); + } + }); + + it("waits for leftover cleanup during shutdown and permanently prevents a later start", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + seedSavedPortal(stateDir); + const portal = standIn(); + const service = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + const entered = deferred(); + const release = deferred(); + const cleanup = spyOn(service as unknown as { stopLeftover(pid: number | null): Promise }, "stopLeftover") + .mockImplementation(async () => { entered.resolve(); await release.promise; }); + const resumed = service.resume({ port: 7317, ...open }); + try { + await bounded(entered.promise, "resume entered leftover cleanup"); + let shutdownSettled = false; + const stopped = service.shutdown().then(() => { shutdownSettled = true; }); + await Promise.resolve(); + expect(shutdownSettled).toBe(false); + release.resolve(); + await bounded(stopped, "shutdown waited for leftover cleanup"); + await bounded(resumed, "resume completed after shutdown"); + expect(service.start({ relay: RELAY, port: 7317, ...open })).toBe("busy"); + expect((await service.status(open)).phase).toBe("idle"); + expect(portal.pids()).toEqual([]); + } finally { + release.resolve(); + cleanup.mockRestore(); + await bounded(resumed, "resume cleanup settled").catch(() => undefined); + } + }); + + it("rechecks cancellation after awaiting the version before examining leftovers", async () => { + const portal = standIn(); + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); + const internals = service as unknown as { + version(fresh?: boolean): Promise; + stopLeftover(pid: number | null): Promise; + launching: boolean; + }; + const versionStarted = deferred(); + const versionGate = deferred(); + const version = spyOn(internals, "version").mockImplementation(async () => { + versionStarted.resolve(); + return versionGate.promise; + }); + const leftover = spyOn(internals, "stopLeftover").mockResolvedValue(); + try { + service.start({ relay: RELAY, port: 7317, ...open }); + await bounded(versionStarted.promise, "Portal version check started"); + service.stop(); + versionGate.resolve("v2.6.1"); + await waitFor(() => !internals.launching, "cancelled start settled"); + expect(leftover).not.toHaveBeenCalled(); + expect(portal.pids()).toEqual([]); + } finally { + versionGate.resolve("v2.6.1"); + version.mockRestore(); + leftover.mockRestore(); + } + }); + + it("rechecks cancellation after awaiting the relay before examining leftovers", async () => { + const relayStarted = deferred(); + const relayGate = deferred(); + const portal = standIn(); + const fetch = (async () => { + relayStarted.resolve(); + await relayGate.promise; + return Response.json({ data: { protocol_version: "10", release_version: "v2.6.1" } }); + }) as unknown as typeof globalThis.fetch; + const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch }); + const internals = service as unknown as { stopLeftover(pid: number | null): Promise; launching: boolean }; + const leftover = spyOn(internals, "stopLeftover").mockResolvedValue(); + try { + service.start({ relay: RELAY, port: 7317, ...open }); + await bounded(relayStarted.promise, "relay check started"); + service.stop(); + relayGate.resolve(); + await waitFor(() => !internals.launching, "cancelled start settled"); + expect(leftover).not.toHaveBeenCalled(); + expect(portal.pids()).toEqual([]); + } finally { + relayGate.resolve(); + leftover.mockRestore(); + } + }); + it("takes over from a Portal a server killed outright left running", async () => { const stateDir = mkdtempSync(join(root, "state-")); const portal = standIn(); @@ -209,9 +395,155 @@ describe("running Portal", () => { await other.resume({ port: 7318, ...open }); expect(alive(portal.pids()[0]!)).toBe(true); expect((await other.status(open)).phase).toBe("idle"); + const statePath = join(stateDir, "portal", "state.json"); + const ownerState = JSON.parse(readFileSync(statePath, "utf8")); + expect(ownerState).toMatchObject({ enabled: true, port: 7317 }); + other.stop(); + expect(JSON.parse(readFileSync(statePath, "utf8"))).toEqual(ownerState); + expect(alive(portal.pids()[0]!)).toBe(true); await first.shutdown(); }); + it("refuses an explicit start from another port without changing the owning Portal state", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portal = standIn(); + const first = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + first.start({ relay: RELAY, port: 7317, ...open }); + await until(first, (s) => s.phase === "running", "the first Portal was ready"); + const pid = portal.pids()[0]!; + const other = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + const response = await handlePortalRequest( + new Request("http://127.0.0.1:7318/api/portal/start", { + method: "POST", + headers: { "x-herdr-update": "1", origin: "http://127.0.0.1:7318", "sec-fetch-site": "same-origin", "content-type": "application/json" }, + body: JSON.stringify({ relay: "relay.example" }), + }), "/api/portal/start", other, { port: 7318, ...open }, + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + error: { code: "portal_state_in_use", message: expect.stringContaining("HERDR_WEB_STATE_DIR") }, + }); + expect(alive(pid)).toBe(true); + expect(readFileSync(join(stateDir, "portal", "state.json"), "utf8")).toContain('"port":7317'); + expect(portal.pids()).toHaveLength(1); + await bounded(first.shutdown(), "the owning Portal stopped"); + }); + + it("does not stop an unrelated process whose argv merely mentions expose and the identity path", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portalDir = join(stateDir, "portal"); + mkdirSync(portalDir, { recursive: true, mode: 0o700 }); + const identityPath = join(portalDir, "identity.json"); + const unrelated = Bun.spawn(["sh", "-c", "while :; do sleep 0.1; done", "sh", "expose", "--identity-path", identityPath], { + stdin: "ignore", stdout: "ignore", stderr: "ignore", + }); + writeFileSync(join(portalDir, "state.json"), JSON.stringify({ enabled: true, relay: RELAY, port: 7317, pid: unrelated.pid })); + const service = new PortalService({ stateDir, fetch: relay() }); + try { + await service.resume({ port: 7317, tokenSet: false, serveOnly: false }); + expect(alive(unrelated.pid)).toBe(true); + } finally { + unrelated.kill("SIGTERM"); + await bounded(unrelated.exited, "unrelated process stopped"); + } + }); + + it("does not treat an arbitrary interpreter as a Portal script", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const portalDir = join(stateDir, "portal"); + mkdirSync(portalDir, { recursive: true, mode: 0o700 }); + const identityPath = join(portalDir, "identity.json"); + const scriptPath = join(portalDir, "portal"); + writeFileSync(scriptPath, "await new Promise(() => {});\n"); + const unrelated = Bun.spawn([process.execPath, scriptPath, "expose", "--identity-path", identityPath], { + stdin: "ignore", stdout: "ignore", stderr: "ignore", + }); + writeFileSync(join(portalDir, "state.json"), JSON.stringify({ enabled: true, relay: RELAY, port: 7317, pid: unrelated.pid })); + const service = new PortalService({ stateDir, fetch: relay() }); + const internals = service as unknown as { processArgv(pid: number): Promise }; + try { + await waitFor(() => alive(unrelated.pid), "unrelated interpreter started"); + expect((await internals.processArgv(unrelated.pid))?.slice(1, 3)).toEqual([scriptPath, "expose"]); + await service.resume({ port: 7317, tokenSet: false, serveOnly: false }); + expect(alive(unrelated.pid)).toBe(true); + } finally { + if (alive(unrelated.pid)) unrelated.kill("SIGTERM"); + await bounded(unrelated.exited, "unrelated interpreter stopped"); + } + }); + + it("rejects resume when a live saved PID has unreadable identity", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + seedSavedPortal(stateDir, process.pid); + const service = new PortalService({ stateDir, fetch: relay() }); + const internals = service as unknown as { processArgv(pid: number): Promise }; + const argv = spyOn(internals, "processArgv").mockResolvedValue(null); + try { + await expect(service.resume({ port: 7317, ...open })).rejects.toThrow("Could not verify the identity"); + expect(argv).toHaveBeenCalledWith(process.pid, expect.any(Number)); + } finally { + argv.mockRestore(); + } + }); + + it("rejects resume when termination of the recorded Portal fails", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + seedSavedPortal(stateDir, process.pid); + const service = new PortalService({ stateDir, fetch: relay() }); + const internals = service as unknown as { processArgv(pid: number): Promise }; + const identityPath = join(stateDir, "portal", "identity.json"); + const argv = spyOn(internals, "processArgv").mockResolvedValue([ + "/usr/bin/portal", "expose", "--identity-path", identityPath, + ]); + const killProcess = process.kill.bind(process); + const kill = spyOn(process, "kill").mockImplementation(((pid, signal) => { + if (pid === process.pid && signal === 0) return true; + if (pid === process.pid && signal === "SIGTERM") throw Object.assign(new Error("permission denied"), { code: "EPERM" }); + return killProcess(pid, signal); + }) as typeof process.kill); + try { + await expect(service.resume({ port: 7317, ...open })).rejects.toThrow("permission denied"); + expect(kill).toHaveBeenCalledWith(process.pid, "SIGTERM"); + } finally { + kill.mockRestore(); + argv.mockRestore(); + } + }); + + it("waits for the recorded PID to disappear after SIGKILL", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + const pid = 987654321; + seedSavedPortal(stateDir, pid); + const service = new PortalService({ stateDir, fetch: relay(), stopGraceMs: 0 }); + const internals = service as unknown as { processArgv(pid: number): Promise }; + const identityPath = join(stateDir, "portal", "identity.json"); + const argv = spyOn(internals, "processArgv").mockResolvedValue([ + "/usr/bin/portal", "expose", "--identity-path", identityPath, + ]); + const killProcess = process.kill.bind(process); + let sentSigkill = false; + let probesAfterSigkill = 0; + const kill = spyOn(process, "kill").mockImplementation(((target, signal) => { + if (target !== pid) return killProcess(target, signal); + if (signal === "SIGKILL") { sentSigkill = true; return true; } + if (signal === "SIGTERM") return true; + if (signal === 0) { + if (sentSigkill && ++probesAfterSigkill === 3) throw Object.assign(new Error("gone"), { code: "ESRCH" }); + return true; + } + return killProcess(target, signal); + }) as typeof process.kill); + try { + await service.resume({ port: 7317, tokenSet: false, serveOnly: false }); + expect(sentSigkill).toBe(true); + expect(probesAfterSigkill).toBe(3); + expect(argv).toHaveBeenCalled(); + } finally { + kill.mockRestore(); + argv.mockRestore(); + } + }); + it("refuses a relay older than the tunnel needs before anything runs", async () => { const portal = standIn(); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay("v2.6.0") }); diff --git a/server/portal.ts b/server/portal.ts index ad7d5122c0..6a40a428a8 100644 --- a/server/portal.ts +++ b/server/portal.ts @@ -24,7 +24,7 @@ import type { PortalStatus } from "../shared/protocol.ts"; import { errorResponse, isJsonObject, jsonResponse } from "./http.ts"; import { chunksOf } from "./remote-bundle.ts"; import { updateRequestAllowed } from "./update-api.ts"; -import { windowsProcessTable } from "./windows-processes.ts"; +import { windowsArgv, windowsProcessTable } from "./windows-processes.ts"; /** the first release with `--strip-request-header` and an address tied to the identity */ const PORTAL_VERSION = "v2.6.1"; @@ -32,6 +32,8 @@ const PORTAL_VERSION = "v2.6.1"; const READY_TIMEOUT_MS = 60_000; /** Portal unregisters from the relay on SIGTERM; one still running after this is killed, within the 6 s the supervisor gives this server to stop */ const STOP_GRACE_MS = 4_000; +const PROCESS_POLL_MS = 100; +const KILL_CONFIRM_MS = 1_000; const VERSION_TIMEOUT_MS = 2_500; /** the panel polls the status; `portal version` is a process each time */ const VERSION_CACHE_MS = 5_000; @@ -127,6 +129,112 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function processErrorCode(error: unknown): string | null { + if (typeof error !== "object" || error === null || !("code" in error)) return null; + return typeof error.code === "string" ? error.code : null; +} + +function processPresence(pid: number): "alive" | "gone" | "unknown" { + if (process.platform === "linux") { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const end = stat.lastIndexOf(")"); + // A zombie still has a PID but cannot be running or signaled. + if (end >= 0 && stat.slice(end + 1).trimStart().startsWith("Z")) return "gone"; + } catch { /* process.kill below distinguishes a gone pid from an unreadable one */ } + } + try { + process.kill(pid, 0); + return "alive"; + } catch (error) { + const code = processErrorCode(error); + if (code === "ESRCH") return "gone"; + if (code === "EPERM") return "alive"; + return "unknown"; + } +} + +function portalExecutable(value: string | undefined): boolean { + return /^(?:portal)(?:\.exe)?$/i.test(value?.split(/[\\/]/).at(-1) ?? ""); +} + +function scriptInterpreter(value: string | undefined): boolean { + return /^(?:sh|bash|dash|zsh|ksh|ash)(?:\.exe)?$/i.test(value?.split(/[\\/]/).at(-1) ?? ""); +} + +/** `ps` has no argv mode on every supported Unix, so parse its quoted command conservatively. */ +function splitCommandLine(command: string): string[] | null { + const args: string[] = []; + let arg = ""; + let quote: "'" | '"' | null = null; + let escaped = false; + let started = false; + for (const char of command.trim()) { + if (escaped) { + arg += char; + escaped = false; + started = true; + } else if (char === "\\") { + escaped = true; + started = true; + } else if (quote !== null) { + if (char === quote) quote = null; + else arg += char; + started = true; + } else if (char === "'" || char === '"') { + quote = char; + started = true; + } else if (/\s/.test(char)) { + if (started) { + args.push(arg); + arg = ""; + started = false; + } + } else { + arg += char; + started = true; + } + } + if (quote !== null || escaped) return null; + if (started) args.push(arg); + return args.length > 0 ? args : null; +} + +async function processArgv(pid: number, timeoutMs: number): Promise { + try { + if (process.platform === "win32") { + const row = (await windowsProcessTable(timeoutMs)).find((entry) => entry.pid === pid); + if (row?.commandLine === null || row?.commandLine === undefined) return null; + const argv = windowsArgv(row.commandLine); + if (argv.length === 0) return null; + if (row.path !== null) argv[0] = row.path; + return argv; + } + if (process.platform === "linux") { + const text = readFileSync(`/proc/${pid}/cmdline`, "utf8"); + const argv = text.split("\0"); + if (argv.at(-1) === "") argv.pop(); + return argv.length > 0 && argv[0] !== "" ? argv : null; + } + const ps = Bun.spawn(["ps", "-ww", "-o", "command=", "-p", String(pid)], { + stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: timeoutMs, killSignal: "SIGKILL", + }); + const [code, text] = await Promise.all([ps.exited, new Response(ps.stdout as ReadableStream).text()]); + return code === 0 ? splitCommandLine(text) : null; + } catch { return null; } +} + +function isPortalExpose(argv: readonly string[], identityPath: string): boolean { + const direct = portalExecutable(argv[0]) && argv[1] === "expose"; + // A shebang script (including the shell stand-in used by tests) appears as interpreter, script, argv. + const script = scriptInterpreter(argv[0]) && portalExecutable(argv[1]) && argv[2] === "expose"; + if (!direct && !script) return false; + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === "--identity-path" && argv[i + 1] === identityPath) return true; + } + return false; +} + /** Portal's last warning or error, else its last line, for a failure that has no words of its own. */ function lastWords(lines: readonly string[]): string { const line = [...lines].reverse().find((candidate) => /\b(WRN|ERR|FTL)\b/.test(candidate)) ?? lines.at(-1); @@ -151,9 +259,16 @@ export class PortalService { private child: Child | null = null; /** a start between its request and its process, or the boot's cleanup: one at a time */ private launching = false; - /** the child asked to end, once: its exit is no failure */ + /** invalidates starts/resumes that are still waiting on a version, relay or old process */ + private operation = 0; + /** shutdown is final for this server instance, even if a start has not spawned yet */ + private shutdownRequested = false; + private cleanup: Promise | null = null; + /** a child asked to end, once: its exit is no failure */ private ending: Child | null = null; private versionRead: { at: number; bin: string | null; value: Promise } | null = null; + /** the port this service represents; a service for another port must not change the owner's saved choice */ + private serverPort: number | null = null; private readonly dir: string; constructor(private readonly options: PortalServiceOptions) { @@ -198,34 +313,43 @@ export class PortalService { * comes back, so an app update does not take it down for good. */ async resume(request: Omit): Promise { + if (this.shutdownRequested || this.child !== null || this.launching) return; const saved = this.saved(); this.relay = saved.relay; + this.serverPort = request.port; // a server on another port shares this state directory: what runs is its own if (saved.port !== null && saved.port !== request.port) return; + const operation = ++this.operation; this.launching = true; - try { await this.stopLeftover(saved.pid); } finally { this.launching = false; } + try { await this.cleanLeftover(saved.pid); } finally { this.launching = false; } + if (this.shutdownRequested || this.operation !== operation) return; if (saved.enabled && saved.relay !== null && blockedBy(request) === null) this.start({ ...request, relay: saved.relay }); } - start(request: StartRequest): "started" | "busy" | "token_required" | "serve_only" { + start(request: StartRequest): "started" | "busy" | "token_required" | "serve_only" | "state_in_use" { const blocked = blockedBy(request); if (blocked !== null) return blocked; - if (this.child !== null || this.launching) return "busy"; + if (this.shutdownRequested || this.child !== null || this.launching) return "busy"; + const saved = this.saved(); + this.serverPort = request.port; + if (saved.port !== null && saved.port !== request.port) return "state_in_use"; this.phase = "starting"; this.relay = request.relay; this.url = null; this.error = null; this.lines = []; this.launching = true; - void this.launch(request) + const operation = ++this.operation; + void this.launch(request, operation) // a start stopped meanwhile failed for no one - .catch((error: unknown) => { if (this.phase === "starting") this.fail(messageOf(error)); }) + .catch((error: unknown) => { if (this.isCurrentStart(operation)) this.fail(messageOf(error)); }) .finally(() => { this.launching = false; }); return "started"; } - /** Takes the address down, then remembers that it is off. */ + /** Takes this server's address down and remembers that it is off when this server owns the saved state. */ stop(): void { + this.operation++; this.url = null; this.error = null; if (this.child !== null) { @@ -235,18 +359,21 @@ export class PortalService { // a start still asking its relay ends there this.phase = "idle"; } - this.save({ enabled: false }); + const saved = this.saved(); + if (saved.port === null || saved.port === this.serverPort) this.save({ enabled: false }); } /** The server is going away: Portal goes with it, and the saved choice stays for the next server. Settles once Portal has exited. */ shutdown(): Promise { + this.shutdownRequested = true; + this.operation++; const child = this.child; if (child === null) { if (this.phase === "starting") this.phase = "idle"; - return Promise.resolve(); + return this.cleanup ?? Promise.resolve(); } this.end(child); - return child.exited.then(() => undefined); + return Promise.all([child.exited, this.cleanup]).then(() => undefined); } private end(child: Child): void { @@ -264,16 +391,23 @@ export class PortalService { if (this.child !== null) this.end(this.child); } - private async launch({ relay, port }: StartRequest): Promise { + private isCurrentStart(operation: number): boolean { + return !this.shutdownRequested && this.operation === operation && this.phase === "starting"; + } + + private async launch({ relay, port }: StartRequest, operation: number): Promise { const bin = this.bin(); const version = bin === null ? null : await this.version(true); + if (!this.isCurrentStart(operation)) return; if (bin === null || version === null) throw new Error("Portal is not installed on this PC."); if (!atLeast(version, PORTAL_VERSION)) throw new Error(`This PC has Portal ${version}; the app needs ${PORTAL_VERSION} or later.`); await this.checkRelay(relay); + if (!this.isCurrentStart(operation)) return; // another server of this app on this identity: two Portals keep taking the address from each other - await this.stopLeftover(this.saved().pid); - // stopped meanwhile; from here to the spawn nothing waits, so a stop comes after it - if (this.phase !== "starting") return; + await this.cleanLeftover(this.saved().pid); + if (!this.isCurrentStart(operation)) return; + // Portal needs its identity parent before it starts; it contains the saved address. + mkdirSync(this.dir, { recursive: true, mode: 0o700 }); const child = Bun.spawn([ bin, "expose", "--http-route", `/=${port}`, @@ -345,7 +479,7 @@ export class PortalService { private async checkRelay(relay: string): Promise { let body: unknown; try { - const response = await (this.options.fetch ?? fetch)(`${relay}/sdk/domain`, { signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }); + const response = await (this.options.fetch ?? fetch)(`${relay}/sdk/domain`, { signal: AbortSignal.timeout(RELAY_TIMEOUT_MS), redirect: "error" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); body = await response.json(); } catch (error) { @@ -361,23 +495,67 @@ export class PortalService { * running, or one another server of this app runs. A pid outlives its process, so only the one * recorded here, and only while its command line is `portal expose` on this identity, is stopped. */ + private async cleanLeftover(pid: number | null): Promise { + const cleanup = this.stopLeftover(pid); + this.cleanup = cleanup; + try { await cleanup; } finally { + if (this.cleanup === cleanup) this.cleanup = null; + } + } + private async stopLeftover(pid: number | null): Promise { if (pid === null || pid === this.child?.pid) return; - let command: string | null = null; + // All inspection and termination fits inside the supervisor's six-second shutdown budget. + const deadline = Date.now() + STOP_GRACE_MS + KILL_CONFIRM_MS; + const argv = await this.identifyProcess(pid, deadline); + if (argv === null || !isPortalExpose(argv, this.identityPath)) return; + if (!this.signalLeftover(pid, "SIGTERM")) return; + const queryBudget = process.platform === "linux" ? 0 : VERSION_TIMEOUT_MS; + const graceDeadline = Math.min(Date.now() + (this.options.stopGraceMs ?? STOP_GRACE_MS), deadline - queryBudget - KILL_CONFIRM_MS); + while (Date.now() < graceDeadline) { + await Bun.sleep(Math.min(PROCESS_POLL_MS, graceDeadline - Date.now())); + if (this.processIsGone(pid)) return; + } + // The pid may have been recycled while Portal was given its grace period. + const currentArgv = await this.identifyProcess(pid, deadline); + if (currentArgv === null || !isPortalExpose(currentArgv, this.identityPath)) return; + if (!this.signalLeftover(pid, "SIGKILL")) return; + const killDeadline = Math.min(Date.now() + KILL_CONFIRM_MS, deadline); + for (;;) { + if (this.processIsGone(pid)) return; + if (Date.now() >= killDeadline) throw new Error(`Portal process ${pid} did not exit after SIGKILL.`); + await Bun.sleep(Math.min(PROCESS_POLL_MS, killDeadline - Date.now())); + } + } + + private processArgv(pid: number, timeoutMs: number): Promise { + return processArgv(pid, timeoutMs); + } + + private async identifyProcess(pid: number, deadline: number): Promise { + if (this.processIsGone(pid)) return null; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`Timed out inspecting recorded Portal process ${pid}.`); + const argv = await this.processArgv(pid, Math.min(VERSION_TIMEOUT_MS, remaining)); + if (argv !== null) return argv; + if (this.processIsGone(pid)) return null; + throw new Error(`Could not verify the identity of recorded Portal process ${pid}.`); + } + + private processIsGone(pid: number): boolean { + const presence = processPresence(pid); + if (presence === "unknown") throw new Error(`Could not determine whether recorded Portal process ${pid} is running.`); + return presence === "gone"; + } + + private signalLeftover(pid: number, signal: NodeJS.Signals): boolean { try { - if (process.platform === "win32") { - command = (await windowsProcessTable()).find((row) => row.pid === pid)?.commandLine ?? null; - } else { - const ps = Bun.spawn(["ps", "-ww", "-o", "command=", "-p", String(pid)], { stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: VERSION_TIMEOUT_MS, killSignal: "SIGKILL" }); - command = await new Response(ps.stdout as ReadableStream).text(); - } - } catch { /* an unreadable process table tells nothing */ } - if (command === null || !command.includes(" expose ") || !command.includes(this.identityPath)) return; - try { process.kill(pid, "SIGTERM"); } catch { return; } - for (const until = Date.now() + (this.options.stopGraceMs ?? STOP_GRACE_MS); Date.now() < until; await Bun.sleep(100)) { - try { process.kill(pid, 0); } catch { return; } + process.kill(pid, signal); + return true; + } catch (error) { + if (processErrorCode(error) === "ESRCH") return false; + throw new Error(`Could not send ${signal} to recorded Portal process ${pid} (${messageOf(error)}).`); } - try { process.kill(pid, "SIGKILL"); } catch { /* gone meanwhile */ } } private saved(): SavedState { @@ -433,5 +611,6 @@ export async function handlePortalRequest(request: Request, pathname: string, po if (started === "token_required") return fail("token_required", "Set HERDR_WEB_TOKEN and restart the app before you start Portal: anyone on the internet can open its address.", 409); if (started === "serve_only") return fail("serve_only", "Turn off HERDR_WEB_TAILSCALE_SERVE_ONLY and restart the app before you start Portal: it says tailscale serve is the only way in.", 409); if (started === "busy") return fail("portal_busy", "Portal is busy; try again once it is idle.", 409); + if (started === "state_in_use") return fail("portal_state_in_use", "Another server owns this Portal state directory. Give this server its own HERDR_WEB_STATE_DIR, or stop Portal from the server that owns this state before retrying.", 409); return reply({ accepted: true }, 202); } diff --git a/src/components/PortalPanel.tsx b/src/components/PortalPanel.tsx index 5941b52c34..4b9d652dd8 100644 --- a/src/components/PortalPanel.tsx +++ b/src/components/PortalPanel.tsx @@ -22,7 +22,7 @@ export function PortalPanel({ readOnly = false }: { readOnly?: boolean }) { const [typed, setTyped] = useState(null); if (!status?.supported) return null; const relay = typed ?? status.relay ?? ""; - const on = status.phase === "starting" || status.phase === "running" || status.phase === "stopping"; + const on = status.phase === "starting" || status.phase === "running" || status.phase === "stopping" || status.phase === "error"; const progress = status.phase === "starting" ? t("Starting…") : status.phase === "stopping" ? t("Stopping…") : null; let control; if (on) { From 853f911963d0bf1f587c4d195d224769729947d5 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 16:46:08 +0900 Subject: [PATCH 4/6] test(portal): require browser regression in CI registry --- scripts/ci-browser.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index 6b3e6d4e23..18ae73a31d 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -60,7 +60,8 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(41); + expect(rows(text)).toHaveLength(42); + expect(rows(text).map((row) => row.script)).toContain("scripts/portal-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/agy-chat-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/lan-exposure-notice-regression.ts"); From 2f85073623c29c27170413f582f3b48f2c47fe1b Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 17:03:42 +0900 Subject: [PATCH 5/6] fix(portal): fail closed on damaged state and settle shutdown --- server/portal.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ server/portal.ts | 42 +++++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/server/portal.test.ts b/server/portal.test.ts index c3976b0ad8..9f381d8fd9 100644 --- a/server/portal.test.ts +++ b/server/portal.test.ts @@ -300,6 +300,55 @@ describe("running Portal", () => { } }); + it("still finishes shutdown when pending leftover cleanup fails", async () => { + const stateDir = mkdtempSync(join(root, "state-")); + seedSavedPortal(stateDir); + const service = new PortalService({ stateDir, fetch: relay() }); + const entered = deferred(); + const release = deferred(); + const cleanup = spyOn(service as unknown as { stopLeftover(pid: number | null): Promise }, "stopLeftover") + .mockImplementation(async () => { + entered.resolve(); + await release.promise; + throw new Error("fixture cleanup failure"); + }); + const resumed = service.resume({ port: 7317, ...open }); + const failed = resumed.catch((error: unknown) => error); + try { + await bounded(entered.promise, "cleanup entered"); + const stopped = service.shutdown(); + release.resolve(); + await bounded(stopped, "shutdown settled despite cleanup failure"); + expect(await failed).toMatchObject({ message: "fixture cleanup failure" }); + expect(service.start({ relay: RELAY, port: 7317, ...open })).toBe("busy"); + } finally { + release.resolve(); + await failed; + cleanup.mockRestore(); + } + }); + + it("rejects damaged saved state without overwriting it or assuming no tunnel exists", async () => { + for (const contents of [ + "{", "null", "{}", + JSON.stringify({ enabled: true, relay: RELAY, port: 7317, pid: "invalid" }), + JSON.stringify({ enabled: true, relay: null, port: null, pid: null }), + ]) { + const stateDir = mkdtempSync(join(root, "state-")); + seedSavedPortal(stateDir); + const path = join(stateDir, "portal", "state.json"); + writeFileSync(path, contents); + const service = new PortalService({ stateDir, fetch: relay() }); + await expect(service.resume({ port: 7317, tokenSet: false, serveOnly: false })).rejects.toThrow(); + expect(() => service.start({ port: 7317, relay: RELAY, ...open })).toThrow(); + expect(readFileSync(path, "utf8")).toBe(contents); + } + const stateDir = mkdtempSync(join(root, "state-")); + mkdirSync(join(stateDir, "portal", "state.json"), { recursive: true }); + const service = new PortalService({ stateDir, fetch: relay() }); + await expect(service.resume({ port: 7317, ...open })).rejects.toThrow("Could not read saved Portal state"); + }); + it("rechecks cancellation after awaiting the version before examining leftovers", async () => { const portal = standIn(); const service = new PortalService({ stateDir: mkdtempSync(join(root, "state-")), bin: portal.path, fetch: relay() }); diff --git a/server/portal.ts b/server/portal.ts index 6a40a428a8..062620b4f4 100644 --- a/server/portal.ts +++ b/server/portal.ts @@ -367,13 +367,17 @@ export class PortalService { shutdown(): Promise { this.shutdownRequested = true; this.operation++; + // resume already fails the startup gate closed; a cleanup error must not strand shutdown. + const cleanup = this.cleanup?.catch((error: unknown) => { + console.error(`Portal cleanup failed during shutdown: ${messageOf(error)}`); + }); const child = this.child; if (child === null) { if (this.phase === "starting") this.phase = "idle"; - return this.cleanup ?? Promise.resolve(); + return cleanup ?? Promise.resolve(); } this.end(child); - return Promise.all([child.exited, this.cleanup]).then(() => undefined); + return Promise.all([child.exited, cleanup]).then(() => undefined); } private end(child: Child): void { @@ -559,19 +563,24 @@ export class PortalService { } private saved(): SavedState { + let value: unknown; try { - const value: unknown = JSON.parse(readFileSync(this.statePath, "utf8")); - if (isJsonObject(value)) { - const { port, pid } = value; - return { - enabled: value["enabled"] === true, - relay: relayOrigin(value["relay"]), - port: typeof port === "number" && Number.isInteger(port) ? port : null, - pid: typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null, - }; - } - } catch { /* none yet, or unreadable: nothing is on */ } - return { enabled: false, relay: null, port: null, pid: null }; + value = JSON.parse(readFileSync(this.statePath, "utf8")); + } catch (error) { + if (processErrorCode(error) === "ENOENT") return { enabled: false, relay: null, port: null, pid: null }; + throw new Error("Could not read saved Portal state; refusing to assume its tunnel is stopped.", { cause: error }); + } + if (!isJsonObject(value)) throw new Error("Saved Portal state is invalid."); + const { enabled, relay, port, pid } = value; + const origin = relayOrigin(relay); + if (typeof enabled !== "boolean" + || (relay !== null && origin === null) + || (port !== null && !(typeof port === "number" && Number.isSafeInteger(port) && port > 0 && port <= 65535)) + || (pid !== null && !(typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0)) + || (enabled && (origin === null || port === null || pid === null))) { + throw new Error("Saved Portal state is invalid."); + } + return { enabled, relay: origin, port: port as number | null, pid: pid as number | null }; } /** at once, not awaited: a stop and a start's own save never interleave */ @@ -607,10 +616,11 @@ export async function handlePortalRequest(request: Request, pathname: string, po try { body = await request.json(); } catch { /* checked below */ } const relay = relayOrigin(isJsonObject(body) ? body["relay"] : undefined); if (relay === null) return fail("invalid_relay", "Give the relay as an https address, such as https://relay.example.com.", 400); - const started = portal.start({ ...context, relay }); + let started: ReturnType; + try { started = portal.start({ ...context, relay }); } catch (error) { return errorResponse(error); } if (started === "token_required") return fail("token_required", "Set HERDR_WEB_TOKEN and restart the app before you start Portal: anyone on the internet can open its address.", 409); if (started === "serve_only") return fail("serve_only", "Turn off HERDR_WEB_TAILSCALE_SERVE_ONLY and restart the app before you start Portal: it says tailscale serve is the only way in.", 409); if (started === "busy") return fail("portal_busy", "Portal is busy; try again once it is idle.", 409); - if (started === "state_in_use") return fail("portal_state_in_use", "Another server owns this Portal state directory. Give this server its own HERDR_WEB_STATE_DIR, or stop Portal from the server that owns this state before retrying.", 409); + if (started === "state_in_use") return fail("portal_state_in_use", "Another server owns this Portal state directory. Give this server its own HERDR_WEB_STATE_DIR.", 409); return reply({ accepted: true }, 202); } From be1933a5b22d9d4b9f55d11cc96785b6117f0cff Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 17:07:15 +0900 Subject: [PATCH 6/6] docs(portal): document error responses and safe recovery --- docs/guide.md | 2 ++ shared/protocol.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 5705885bc3..d9f1fbf172 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -415,6 +415,8 @@ Portal prints `service ready at https://-
.:443`, where ` PortalStatus (an optional public HTTPS address through * Portal, server/portal.ts), no-store * POST /api/portal/start -> 202 { accepted: true }; body { relay }; 400 invalid_relay, - * 409 token_required / serve_only / portal_busy / portal_unsupported + * 409 token_required / serve_only / portal_busy / portal_state_in_use / portal_unsupported * POST /api/portal/stop -> 202 { accepted: true }; 409 portal_unsupported - * Portal POSTs require X-Herdr-Update: 1 and same-origin, like the update POSTs; a - * watch-only device gets read_only. + * Portal POSTs require X-Herdr-Update: 1 and same-origin, like the update POSTs: + * 403 invalid_portal_request (or invalid_origin from the outer origin gate). + * Unsupported methods/actions get 405 method_not_allowed; a watch-only device gets + * 403 read_only. Startup holds HTTP/WS requests until leftover Portal cleanup completes; + * unreadable saved state or unverifiable cleanup fails closed with 500 internal_error. * GET /api/updates -> UpdateStatus (shared/update.ts), no-store * POST /api/updates/check -> 202 { accepted: true } * POST /api/updates/install -> 202 { accepted: true }