diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dfc61f3c..a0b6252b82 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. + 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. + ([#746](https://github.com/devswha/herdr-web-ui/pull/746) by @rabbitson87) - **Two tabs side by side** on a desktop, from different workspaces or PCs: drag a tab down out of its strip, or a sidebar row over the pane area, and drop it on the left or right half. A guide shows the two halves as soon as the drag starts and keeps its side steady at the centre; Escape diff --git a/DESIGN.md b/DESIGN.md index c9e49878d5..b76dc204bf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1225,8 +1225,9 @@ One set for both themes: the card is island black wherever it shows. lines, Markdown width), **Terminal** (font size, 80 columns in a split, font family, wheel speed, input mode, Key bar), **Alerts**, **Voice input**, **Subscription usage**, **Shortcuts** (the platform-resolved global bindings, then **Paging** with a 0–100 line overlap stepper; one line by default, zero for a full viewport), **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 @@ -1254,6 +1255,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 8c445b67b2..d9f1fbf172 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -135,7 +135,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 @@ -413,6 +413,10 @@ 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 → Phone & 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** 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. + +At startup, the app waits for any recorded Portal process to stop before answering requests. If `portal/state.json` under `HERDR_WEB_STATE_DIR` is damaged, or the recorded process cannot be identified or stopped, requests fail closed rather than risk exposing a server restarted without a token. Check the server log. After confirming that this app's Portal process has stopped, repair or remove only `portal/state.json` and restart the app. Keep `portal/identity.json` to preserve the address; never discard the state record while its tunnel may still be running. Servers on different ports need separate state directories to control their own Portal addresses. + **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 → Phone & 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. @@ -438,7 +442,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 | @@ -581,7 +585,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.
@@ -594,6 +598,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/scripts/ci-browser.sh b/scripts/ci-browser.sh index d15db3a24d..2321b2f974 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -69,6 +69,7 @@ run_script scripts/pane-paging-demo-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/ci-browser.test.ts b/scripts/ci-browser.test.ts index 3a0273308a..6160628818 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(43); + expect(rows(text)).toHaveLength(44); + 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"); 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/AGENTS.md b/server/AGENTS.md index 47a51c0516..c03dd2532a 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -61,6 +61,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/{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. `/api/tab/move` forwards an insertion boundary counted before removing the source tab; native `tab.move` returns `tab_list` (the event is `tab_moved`), keeps the active pane and tab, and leaves each tab's number unchanged. | | 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 5c638cab75..c03d728767 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -5,8 +5,9 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { ConversationMonitor } from "./conversation-monitor.ts"; import { createServer } from "./index.ts"; +import type { AgentKind, AgentStatus, ApiError, HealthAuth, IntegrationsResponse, LanExposure, PortalStatus, PushKey, RemoteAccess, SessionSnapshot, TabMoved, PaneFindResponse, PaneReadResult, UsageReport, WorkspaceCreated, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../shared/protocol.ts"; import { HerdrUpdater } from "./herdr-update.ts"; -import type { AgentKind, AgentStatus, ApiError, HealthAuth, IntegrationsResponse, LanExposure, PushKey, RemoteAccess, SessionSnapshot, TabMoved, PaneFindResponse, PaneReadResult, UsageReport, WorkspaceCreated, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../shared/protocol.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"; @@ -563,6 +564,89 @@ 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"); + 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" }; + 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, 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(); + 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", signedIn)).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 aa7a3f9fe3..2f8f81d041 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); } // the shared settings: a watching device reads them, and cannot change them for every browser diff --git a/server/index.ts b/server/index.ts index e8b5971c6b..0b8feb16df 100644 --- a/server/index.ts +++ b/server/index.ts @@ -101,6 +101,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"; @@ -427,6 +428,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 */ @@ -1629,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); @@ -1758,6 +1774,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, { port: bunServer.port ?? DEFAULT_PORT, tokenSet: token !== "", serveOnly }); + } if (pathname === "/api/telemetry") return handleTelemetryRequest(request, options.telemetry); // what every browser of this server shares; a watching device reads it and cannot change it (above) if (pathname === "/api/preferences") return handlePreferencesRequest(request, preferences); @@ -3227,6 +3246,21 @@ 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; // ACKs can stop arriving entirely (a suspended tab). Bound the pause even then. @@ -3243,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(); @@ -3251,6 +3286,7 @@ export function createServer( clearInterval(waitTimer); plans.stop(); machines?.stop(); + void options.portal?.shutdown(); registration?.close(); for (const client of clients) killWatches(client); for (const paneId of [...attachments.keys()]) closeAttachment(paneId); @@ -3263,7 +3299,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(), telemetry, registerBridge: true, herdrProfiles: readHerdrProfiles }); + const portal = new PortalService({ stateDir: defaultStateDir() }); + const instance = createServer({ updates, herdrUpdate: new HerdrUpdater(), portal, telemetry, registerBridge: true, herdrProfiles: readHerdrProfiles }); telemetry.start(); let stopping = false; const shutdown = () => { @@ -3271,8 +3308,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 new file mode 100644 index 0000000000..9f381d8fd9 --- /dev/null +++ b/server/portal.test.ts @@ -0,0 +1,693 @@ +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"; +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. + */ +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 { + 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: PortalStatus) => 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; } +} + +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); +} + +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 = {}; +beforeEach(() => { + 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)) { + if (previous[name] === undefined) delete process.env[name]; + else process.env[name] = previous[name]; + } +}); + +describe("what Portal is given and says", () => { + 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"); + // `--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(); + } + }); + + 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("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: 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([]); + }); + + 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(); + const service = new PortalService({ stateDir, bin: portal.path, fetch: relay() }); + 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: 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(RELAY); + 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: RELAY }); + 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(); + 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"); + 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("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("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() }); + 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(); + 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); + 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"); + 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") }); + 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("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: 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"); + 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: 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, 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: 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: 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"); + 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 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, server); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toMatchObject({ supported: false }); + expect(await code(await handlePortalRequest(post("stop"), "/api/portal/stop", undefined, server))).toBe("portal_unsupported"); + }); + + 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, server))).toBe("invalid_portal_request"); + } + 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"), "/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, 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, 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 new file mode 100644 index 0000000000..062620b4f4 --- /dev/null +++ b/server/portal.ts @@ -0,0 +1,626 @@ +/** + * Portal (github.com/gosuda/portal-tunnel): an optional public HTTPS address for this server, + * 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, 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; + * - 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. + * + * 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 { 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 { errorResponse, isJsonObject, jsonResponse } from "./http.ts"; +import { chunksOf } from "./remote-bundle.ts"; +import { updateRequestAllowed } from "./update-api.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"; +/** 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 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; +const RELAY_TIMEOUT_MS = 5_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; + /** 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; + /** 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 identity and on/off choice live in its portal/ */ + stateDir: string; + /** the portal to run; unset, `portal` from PATH, read at each start */ + bin?: 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. */ +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, 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; + // `--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. */ +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); +} + +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); + return line === undefined ? "" : ` Portal said: ${line}`; +} + +async function readVersion(bin: string): Promise { + 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]); + const tag = /\bv?\d+\.\d+\.\d+\S*/.exec(text)?.[0]; + return code === 0 && tag !== undefined ? tag : null; + } catch { return null; } +} + +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: Child | null = null; + /** a start between its request and its process, or the boot's cleanup: one at a time */ + private launching = false; + /** 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) { + this.dir = join(options.stateDir, "portal"); + } + + private get identityPath(): string { return join(this.dir, "identity.json"); } + private get statePath(): string { return join(this.dir, "state.json"); } + + private bin(): string | null { + return this.options.bin ?? 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: true, + 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. 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 { + 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.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" | "state_in_use" { + const blocked = blockedBy(request); + if (blocked !== null) return blocked; + 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; + const operation = ++this.operation; + void this.launch(request, operation) + // a start stopped meanwhile failed for no one + .catch((error: unknown) => { if (this.isCurrentStart(operation)) this.fail(messageOf(error)); }) + .finally(() => { this.launching = false; }); + return "started"; + } + + /** 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) { + this.phase = "stopping"; + this.end(this.child); + } else if (this.phase === "starting" || this.phase === "error") { + // a start still asking its relay ends there + this.phase = "idle"; + } + 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++; + // 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 cleanup ?? Promise.resolve(); + } + this.end(child); + return Promise.all([child.exited, cleanup]).then(() => undefined); + } + + 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); + 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 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.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}`, + "--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; + const readyTimeoutMs = this.options.readyTimeoutMs ?? READY_TIMEOUT_MS; + const deadline = setTimeout(() => { + 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; + 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}).${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: Child): 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: 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) { + 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.phase === "starting" || this.phase === "running")) 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), redirect: "error" }); + 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 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 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; + // 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 { + 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)}).`); + } + } + + private saved(): SavedState { + let value: unknown; + try { + 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 */ + private save(change: Partial): void { + mkdirSync(this.dir, { recursive: true, mode: 0o700 }); + const temp = `${this.statePath}.tmp-${process.pid}`; + writeFileSync(temp, JSON.stringify({ ...this.saved(), ...change }), { mode: 0o600 }); + renameSync(temp, this.statePath); + } +} + +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 }; + +/** /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 + return reply(portal ? await portal.status(context) : NO_PORTAL); + } + const action = pathname.slice("/api/portal/".length); + 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") { + try { portal.stop(); } catch (error) { return errorResponse(error); } + 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); + 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.", 409); + return reply({ accepted: true }, 202); +} diff --git a/shared/protocol.ts b/shared/protocol.ts index 8b4f95e1dd..9a4550208c 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -64,6 +64,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/start -> 202 { accepted: true }; body { relay }; 400 invalid_relay, + * 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: + * 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 } @@ -368,6 +378,32 @@ export interface TailscaleAccess { readonly serve_url: string | null; } +/** + * 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 on a server started without Portal (tests, the demo): the section stays hidden */ + readonly supported: boolean; + /** 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 */ + readonly min_version: string; + /** `version` is `min_version` or later: Start is offered */ + readonly usable: boolean; + 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; + /** 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 26c5367e45..437e191cac 100644 --- a/site/demo/transport.ts +++ b/site/demo/transport.ts @@ -12,7 +12,7 @@ * 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, PaneFindMatch, PaneFindRequest, PaneMoved, PanePlan, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PlanStep, PluginActionResult, PluginActions, PluginActionsResponse, ServerMessage, SessionSnapshot, TabMoved, UsageReport, WorkspaceCreated, WorkspaceInfo, WorktreeEntry, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../../shared/protocol.ts"; +import type { AgentIntegration, AgentStatus, ConversationTurn, IntegrationsResponse, Machine, MachineEvent, PaneDirection, PaneFindMatch, PaneFindRequest, PaneMoved, PanePlan, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PlanStep, PluginActionResult, PluginActions, PluginActionsResponse, PortalStatus, ServerMessage, SessionSnapshot, TabMoved, 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"; @@ -589,6 +589,9 @@ 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, error: null, output: null } satisfies PortalStatus, 200, { "cache-control": "no-store" }); + if (path === "/api/portal/start" || path === "/api/portal/stop") return error("portal_unsupported", "The demo opens no public address.", 409); 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..45a1618bda --- /dev/null +++ b/src/components/PortalPanel.css @@ -0,0 +1,22 @@ +.portal-start { + display: flex; + 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 new file mode 100644 index 0000000000..4b9d652dd8 --- /dev/null +++ b/src/components/PortalPanel.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; + +import "./PortalPanel.css"; +import "./UpdateControls.css"; + +import { useT } from "../lib/i18n.ts"; +import { usePortal } from "../lib/portal.ts"; +import { Address } from "./PhonePanel.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, 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({ readOnly = false }: { readOnly?: boolean }) { + const t = useT(); + 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 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) { + control = ( + + + + ); + } else if (status.blocked !== null) { + 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) { + 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 { + control = ( + +
{ event.preventDefault(); void request("start", relay); }}> + setTyped(event.currentTarget.value)} /> + +
+
+ ); + } + return ( + + {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/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index a9474e160b..abb222ac49 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"; @@ -679,6 +680,8 @@ function DevicesPage({ auth }: { auth: HealthAuth | null }) { + +
diff --git a/src/lib/api.ts b/src/lib/api.ts index 646a5fb208..51d647f680 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -36,6 +36,7 @@ import type { PlanStep, PromptAnswer, PushKey, + PortalStatus, RemoteAccess, RemoveWorktreeRequest, ResizePaneRequest, @@ -107,6 +108,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: "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). */ /** What every browser of this server shares (shared/preferences.ts); null where the server predates it. */ export async function fetchPreferences(): Promise { diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index 499d5d3b83..cfb32f07f9 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -780,6 +780,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 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 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} 以降が必要です。", + "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 ce5b77dac0..490b904b2d 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -778,6 +778,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 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 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} 이상이 필요합니다.", + "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.tr.ts b/src/lib/i18n.tr.ts index aceb6d0aaf..2b377b61f4 100644 --- a/src/lib/i18n.tr.ts +++ b/src/lib/i18n.tr.ts @@ -735,6 +735,19 @@ export const TR: Record = { "Copy": "Kopyala", "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.": "Kendi Tailscale cihazlarınız sizin hesabınızla erişir; erişim anahtarı ayarlanmışsa her cihaz bu anahtarı bir kez ister. Başka birinin cihazı veya LAN/genel adres üzerinden erişim için aşağıdaki Cihazlar bölümünden eşleştirme gerekir.", "Access and safety": "Erişim ve güvenlik", + "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 aktarıcısı üzerinden bu bilgisayara herkese açık bir HTTPS adresi sağlar. İnternetteki herkes bu adresi açabilir; bu yüzden erişim anahtarı gerekir.", + "Portal serves this PC": "Portal bu bilgisayarı erişime açıyor", + "Open it on your phone and sign in with the access token, or pair the phone: Devices, below.": "Telefonunuzda açıp erişim anahtarıyla oturum açın veya telefonu eşleştirin: aşağıdaki Cihazlar bölümü.", + "Relay": "Aktarıcı", + "Stop": "Durdur", + "Stopping…": "Durduruluyor…", + "Set HERDR_WEB_TOKEN on this PC and restart the app to start Portal.": "Portal'ı başlatmak için bu bilgisayarda HERDR_WEB_TOKEN ayarlayın ve uygulamayı yeniden başlatın.", + "Portal is a second way in: unset HERDR_WEB_TAILSCALE_SERVE_ONLY on this PC and restart the app first.": "Portal ikinci bir erişim yolu sunar: önce bu bilgisayarda HERDR_WEB_TAILSCALE_SERVE_ONLY değişkenini kaldırıp uygulamayı yeniden başlatın.", + "Portal is not installed on this PC.": "Bu bilgisayarda Portal kurulu değil.", + "This PC has Portal {version}; the app needs {min} or later.": "Bu bilgisayardaki Portal {version}; uygulama için {min} veya daha yenisi gerekiyor.", + "Get Portal": "Portal'ı edinin", + "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 çıktısında güvendiğiniz bir aktarıcıyı seçin. Adresin sertifikası aktarıcıda bulunduğundan, trafiği gözetleyecek şekilde kurulmuş bir aktarıcı iletişimi okuyabilir.", + "Portal's output": "Portal çıktısı", "…": "…", "Alerts": "Uyarılar", "Needs you": "Sizi bekliyor", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index d9c9a1e4eb..7b05a74341 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -782,6 +782,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 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 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} 或更高版本。", + "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 new file mode 100644 index 0000000000..35cb28b8c3 --- /dev/null +++ b/src/lib/portal.ts @@ -0,0 +1,57 @@ +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 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 with the `portal` on its 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 (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: "start" | "stop", relay?: string) => { + setPending(true); setError(null); + try { + await requestPortal(action, relay); + 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); } + }, []); + const busy = pending || (status !== null && SETTLING.has(status.phase)); + return { status, error, pending, busy, request }; +}