diff --git a/CHANGELOG.md b/CHANGELOG.md index f9fde72f6f..fdbb48951f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,15 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa read as an option of its own, so the question fell back to a card of arrow keys. Option descriptions that wrap over two lines are shown whole, not cut off at the first line. ([#517](https://github.com/devswha/herdr-web-ui/pull/517) by @kilhyeonjun) +- With `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`, which declares `tailscale serve` the only way in, your + own phone lands in the app as soon as it opens the `tailscale serve` address, with no pairing + code, on a tailnet that one Tailscale login owns and that has no tagged node on it. Before, + `tailscale serve` states no login for some of those requests, and every request without one was + taken for a tagged node's and asked to pair. Off by default, such a request still pairs. Another + Tailscale login is still refused, a tagged node still pairs, Funnel stays closed, a LAN client + that claims the same headers still gains nothing, and a configured token is still required of + everything but a paired device. + ([#525](https://github.com/devswha/herdr-web-ui/pull/525) by @jetmobsol) ## [0.3.52] - 2026-10-06 diff --git a/INSTALL.md b/INSTALL.md index c0db7d0752..5788290597 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -154,7 +154,9 @@ code, or the exact command still to run. Who gets in: - The user's own Tailscale devices get in as the user: `tailscale serve` states the login, and the server compares it with this PC's. Nothing to configure. Other people's logins are refused, and - tagged devices (no person's login) need pairing. + tagged devices (no person's login) need pairing. If the user's own phone is asked to pair anyway, + `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` lets it in without a code on a tailnet one login owns + ([Access and safety](docs/guide.md#access-and-safety)). - Any other device (someone else's, or a LAN or public address) is paired: **Settings → Devices** on the PC shows a six-digit code and a QR code; the device enters it once. On a headless PC with no browser, `bun "$(ls -d ~/.config/herdr/plugins/github/devswha.herdr-web-ui-* | head -1)/scripts/plugin.ts" pair` diff --git a/README.ja.md b/README.ja.md index d7d79fa7eb..d2bf8af5a9 100644 --- a/README.ja.md +++ b/README.ja.md @@ -93,7 +93,7 @@ https://github.com/user-attachments/assets/db788c07-cd68-486d-8ce9-e676a2889c2d curl -fsSL https://devswha.github.io/herdr-web-ui/install.sh | sh ``` -Linux(x64、arm64)または macOS に対応しています。必要な herdr 0.9.0+、Bun 1.4+、Node 18+ がなければ現在のユーザー向けにインストールし、その後アプリを herdr プラグインとしてインストールします。既存の herdr が 0.9.0 より古い場合は、自分で herdr を更新・再起動してからインストーラーを再実行してください。デフォルトの待ち受けアドレスを使用し、Tailscale が起動している場合、HTTPS の設定に成功すると tailnet 内のアクセス用アドレスと QR コードが表示されます。 +Linux(x64、arm64)または macOS に対応しています。必要な herdr 0.9.0+、Bun 1.4+、Node 18+ がなければ現在のユーザー向けにインストールし、その後アプリを herdr プラグインとしてインストールします。既存の herdr が 0.9.0 より古い場合は、自分で herdr を更新・再起動してからインストーラーを再実行してください。デフォルトの待ち受けアドレスを使用し、Tailscale が起動している場合、HTTPS の設定に成功すると tailnet 内のアクセス用アドレスと QR コードが表示されます。自分のデバイスはもともとコードなしで入れます。`tailscale serve` がログイン名を伝えるためです。それでも自分のスマートフォンがペアリングを求められるときは、`HERDR_WEB_TAILSCALE_SERVE_ONLY=1` を設定すると、ログインが1つだけの tailnet ならコードなしで入れるようになります。ただし、公開プロキシやトンネルなど別の経路でこのポートに届く場合は設定しないでください([アクセスと安全性](docs/guide.md#access-and-safety))。

インストーラーの出力:Bun、Node、herdr プラグインのインストール後、tailscale serve でアプリへのアクセスを有効にし、スマートフォン用の QR コードを表示します。 diff --git a/README.ko.md b/README.ko.md index f060b24156..b4f07f11b1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -92,7 +92,7 @@ https://github.com/user-attachments/assets/db788c07-cd68-486d-8ce9-e676a2889c2d curl -fsSL https://devswha.github.io/herdr-web-ui/install.sh | sh ``` -Linux(x64, arm64)와 macOS를 지원합니다. herdr 0.9.0 이상, Bun 1.4 이상, Node 18 이상 중 없는 것을 현재 사용자 계정에 설치한 뒤, 앱을 herdr 플러그인으로 설치합니다. 이미 설치된 herdr가 0.9.0보다 오래됐다면 herdr를 직접 업데이트하고 다시 시작한 다음 설치 스크립트를 다시 실행하세요. 기본 수신 주소를 쓰고 Tailscale이 켜져 있으면, HTTPS 설정이 끝났을 때 tailnet 주소와 QR 코드가 나옵니다. +Linux(x64, arm64)와 macOS를 지원합니다. herdr 0.9.0 이상, Bun 1.4 이상, Node 18 이상 중 없는 것을 현재 사용자 계정에 설치한 뒤, 앱을 herdr 플러그인으로 설치합니다. 이미 설치된 herdr가 0.9.0보다 오래됐다면 herdr를 직접 업데이트하고 다시 시작한 다음 설치 스크립트를 다시 실행하세요. 기본 수신 주소를 쓰고 Tailscale이 켜져 있으면, HTTPS 설정이 끝났을 때 tailnet 주소와 QR 코드가 나옵니다. 자신의 기기는 원래 코드 없이 들어갑니다. `tailscale serve`가 로그인을 알려 주기 때문입니다. 그래도 자신의 폰이 페어링을 요구받는다면 `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`을 설정하면 로그인이 하나뿐인 tailnet에서는 코드 없이 들어갈 수 있습니다. 단, 공용 프록시나 터널 등 다른 경로로 이 포트에 접근할 수 있다면 설정하지 마세요 ([접근과 안전](docs/guide.md#access-and-safety)).

설치 스크립트 출력: Bun, Node, herdr 플러그인이 설치되고 tailscale serve가 앱을 공개한 뒤 폰용 QR 코드가 나옵니다. diff --git a/README.md b/README.md index c1fafb79c8..0f01aa18e7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ A browser and phone client for [herdr](https://github.com/herdrdev/herdr). Read curl -fsSL https://devswha.github.io/herdr-web-ui/install.sh | sh ``` -Linux (x64, arm64) or macOS. Installs missing herdr 0.9.0+, Bun 1.4+ and Node 18+ prerequisites for your user, then installs the app as a herdr plugin. If an existing herdr installation is older than 0.9.0, update and restart herdr yourself before rerunning the installer. With the default listen address and Tailscale running, successful HTTPS setup provides a tailnet address and QR code. +Linux (x64, arm64) or macOS. Installs missing herdr 0.9.0+, Bun 1.4+ and Node 18+ prerequisites for your user, then installs the app as a herdr plugin. If an existing herdr installation is older than 0.9.0, update and restart herdr yourself before rerunning the installer. With the default listen address and Tailscale running, successful HTTPS setup provides a tailnet address and QR code. Your own devices get in without a code as it is: `tailscale serve` states your login. If your own phone is asked to pair anyway, `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` lets it in without a code on a tailnet one login owns, but only when nothing else, such as a public proxy or tunnel, reaches this port ([Access and safety](docs/guide.md#access-and-safety)). Windows x64, in PowerShell: diff --git a/README.zh-CN.md b/README.zh-CN.md index c7be562c65..d60e411d48 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -93,7 +93,7 @@ https://github.com/user-attachments/assets/db788c07-cd68-486d-8ce9-e676a2889c2d curl -fsSL https://devswha.github.io/herdr-web-ui/install.sh | sh ``` -支持 Linux(x64、arm64)或 macOS。安装程序会为当前用户补齐 herdr 0.9.0+、Bun 1.4+ 和 Node 18+ 依赖,然后将应用安装为 herdr 插件。如果已安装的 herdr 低于 0.9.0,请先自行更新并重启 herdr,再重新运行安装程序。使用默认监听地址且 Tailscale 正在运行时,HTTPS 配置成功后会提供 tailnet 内的访问地址和二维码。 +支持 Linux(x64、arm64)或 macOS。安装程序会为当前用户补齐 herdr 0.9.0+、Bun 1.4+ 和 Node 18+ 依赖,然后将应用安装为 herdr 插件。如果已安装的 herdr 低于 0.9.0,请先自行更新并重启 herdr,再重新运行安装程序。使用默认监听地址且 Tailscale 正在运行时,HTTPS 配置成功后会提供 tailnet 内的访问地址和二维码。你自己的设备本来就能免验证码进入:`tailscale serve` 会告知你的登录名。如果你的手机仍被要求配对,设置 `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` 后,在只有一个登录名的 tailnet 上也能免验证码进入;但仅限于没有公共代理或隧道等其他途径能访问此端口的情况([访问与安全](docs/guide.md#access-and-safety))。

安装程序输出:安装 Bun、Node 和 herdr 插件,然后通过 tailscale serve 提供应用访问地址,并显示供手机扫描的二维码。 diff --git a/docs/guide.md b/docs/guide.md index f84293a03d..2fa5ed5b6f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -228,7 +228,7 @@ tailscale serve --bg --https=443 http://127.0.0.1:7317 The one-line installer runs this for you when Tailscale runs on the PC and does not serve the app yet, on the first free port of 443, 8443, 7317 and 17317, and prints the command that undoes it. On Linux, `tailscale serve` needs root or `sudo tailscale set --operator=$USER` once; the installer says so when Tailscale refuses. -Only devices in your tailnet can open that address, and only yours get in without a code: see [Access and safety](#access-and-safety). +Only devices in your tailnet can open that address. Your own devices get in without a code as it is: `tailscale serve` states your login. If your own phone is asked to pair anyway, `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` lets it in without a code on a tailnet one login owns, and only when nothing else, such as a public reverse proxy or tunnel, reaches this port (see [Access and safety](#access-and-safety)). Devices that belong to someone else pair with a six-digit code. **Settings → Phone** in the app does this step for you as far as it can: it shows the address Tailscale already serves for this PC as a QR code, or the exact command still to run, and the address it will give. @@ -279,7 +279,7 @@ More in [remote PCs](remote-pcs.md). Anyone who can reach the server can type into your terminals, so what matters is who gets in. It listens on `127.0.0.1` by default, which means only this computer. From anywhere else, a request gets in in one of three ways: -- **It is you, says Tailscale.** `tailscale serve` states the requesting device's Tailscale login in a header it strips from anything incoming. A login that matches this PC's own gets in; another login is refused, and a tagged device (one with no person's login) needs pairing. Nothing to set up, unless this PC's own Tailscale node is tagged: it then has no login of its own, so every device pairs, yours included, or you name your login in `HERDR_WEB_TAILSCALE_OWNER`. +- **It is you, says Tailscale.** `tailscale serve` states the requesting device's Tailscale login in a header it strips from anything incoming. A login that matches this PC's own gets in; another login is refused, and a tagged device (one with no person's login) needs pairing. If you set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` to say `tailscale serve` is the only way in, and `tailscale status` on this PC lists one login on every node it sees and no tagged node, a serve request that states no login is the owner as well: no one else is among those nodes to be mistaken for (a node an ACL hides from this PC is not seen, and cannot reach serve either), so your own phone opens the address and lands in the app with no code. Nothing to set up, unless this PC's own Tailscale node is tagged: it then has no login of its own, so every device pairs, yours included, or you name your login in `HERDR_WEB_TAILSCALE_OWNER`. - **It is a paired device.** **Settings → Devices**, on the PC (or on a device already paired), shows a six-digit code that lives ten minutes and a QR code that carries it. On a headless PC, the `pair` command prints the same in its terminal (see [In a terminal](#in-a-terminal)); Devices also shows the pairing link as text, to send to the other device. The other device enters it once and keeps its own credential in an HttpOnly cookie; the list shows it, and **Revoke** immediately closes its terminal connections and roster stream and refuses subsequent requests. - **It holds the token.** `HERDR_WEB_TOKEN`, for scripts and proxies, as a cookie after sign-in or as `Authorization: Bearer `. When a token is set, everything else needs it, this computer and your own Tailscale login included: enter it once on each device, and that browser stays signed in for a year. A paired device still gets in without it. @@ -287,12 +287,12 @@ Anyone who can reach the server can type into your terminals, so what matters is | --- | --- | | This computer only (default) | Nothing needed. With a token set, the token | | SSH tunnel (`ssh -L 7317:127.0.0.1:7317 host`) | Nothing needed. With a token set, the token | -| `tailscale serve`, your own devices | Nothing needed: your login. With a token set, the token, once per device | +| `tailscale serve`, your own devices | Nothing needed: your login. With `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` on a one-login tailnet, the owner's phone needs nothing either. With a token set, the token, once per device | | `tailscale serve` on a tailnet you share with others | Your devices: your login (with a token set, the token once). Theirs: refused unless you pair them | | Your LAN (`HOST=0.0.0.0` or a LAN address) | Pair each device, or set a token | | A public domain or reverse proxy | Set a token, with HTTPS, and set the proxy up as in [Behind a reverse proxy](#behind-a-reverse-proxy). Never `tailscale funnel` it | -Until the first device is paired, and with no token set, a LAN or proxied address is open to anyone who reaches it, as it always was: the server warns on startup. The exception is a proxy on this PC while its Tailscale login is known, as with `tailscale serve`: a request that carries no login there needs pairing from the start. Pairing the first device closes it for good; revoking every device does not reopen it. Without a token, this computer itself stays in whatever happens, so you can never lock yourself out: revoke everything and pair again from `http://localhost:7317`. With a token set, this computer signs in with the token. +Until the first device is paired, and with no token set, a LAN or proxied address is open to anyone who reaches it, as it always was: the server warns on startup. The exception is a proxy on this PC while its Tailscale login is known, as with `tailscale serve`: a request that carries no login there needs pairing from the start, unless `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` is set and that tailnet has one login and no tagged node, where such a request is the owner's own device. With that setting off, which is the default, every such request pairs. Pairing the first device closes it for good; revoking every device does not reopen it. Without a token, this computer itself stays in whatever happens, so you can never lock yourself out: revoke everything and pair again from `http://localhost:7317`. With a token set, this computer signs in with the token. The pairing code is a one-time secret: five wrong tries spend it. @@ -375,6 +375,7 @@ Observe connections cannot take a pane, and a displaced bridge never takes it ba | `HERDR_SOCKET` | `~/.config/herdr/herdr.sock` | herdr socket for API calls and terminal attach. For a named session, use `~/.config/herdr/sessions//herdr.sock`. | | `HERDR_WEB_TOKEN` | unset | Shared token for scripts and proxies. Once set, every client that is not a paired device needs it, this computer and your own Tailscale login included | | `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_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 | diff --git a/server/AGENTS.md b/server/AGENTS.md index 4a2b8569a3..e7291c2b27 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -17,7 +17,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the - Output control adds `{"t":"p","paused":true|false}`. Parent pause and stdout backpressure are independent; both must clear to resume. Kill resumes the reader to observe EOF and has a bounded forced cleanup. PtySession awaits stdout EOF before reporting process exit. - ACK-capable attaches carry `flow_control:"ack"`; `pty-data.flow` identifies the subscription and cumulative UTF-8 byte offset. `pty-ack` releases only already-sent credit in that subscription. Pause at 256 KiB, resume at <=64 KiB, close consumers blocked for 2s with code 4008; 1 MiB hard application/transport budgets. `-1` from Bun send is already queued, never retransmit it. Legacy clients have the transport cap only. See docs/terminal-flow-control.md and server/output.contract.test.ts. - `HerdrError.code` → HTTP status (http.ts `errorResponse`): `connect_failed`/`timeout` → 502; other HerdrError codes → 404; anything else → 500 `internal_error`. -- Auth: auth.ts `isAuthenticated` accepts the `herdr_web_token` cookie or an Authorization Bearer (length-safe compare); access.ts `decideAccess` (token → device cookie → Tailscale owner → loopback local → pairing) runs for /ws and every /api/* route except /api/health, /api/auth and /api/devices/pair; static files stay open. +- Auth: auth.ts `isAuthenticated` accepts the `herdr_web_token` cookie or an Authorization Bearer (length-safe compare); access.ts `decideAccess` (token → device cookie → Tailscale owner, by login header, or with no login header on a tailnet one login provably owns when HERDR_WEB_TAILSCALE_SERVE_ONLY declares serve the only ingress → loopback local → pairing) runs for /ws and every /api/* route except /api/health, /api/auth and /api/devices/pair; static files stay open. - An unauthenticated /ws upgrade is refused with a plain-text 401 `unauthorized` (the WS client never parses a body); every other refusal is the JSON envelope. - `terminalInfoFor` resolves pane_id → `{ terminalId, rect }` from one fresh `session.snapshot` (rect = the pane's `layouts[].panes[].rect`, null when absent); missing pane → `pane_not_found`, pane without terminal → `no_terminal`. - The layout `rect` is NOT the terminal's current size (measured on herdr 0.9.3): it stayed 120x40 while a `terminal attach` sized the pane 100x30, then 140x40, and the pane kept 140x40 after that attach left. Only the attached client's size is real; nothing in the API tells another bridge's size. An attachment started again (held retry, live handoff) therefore reuses its own cached `cols`/`rows` (#363 review). diff --git a/server/access.test.ts b/server/access.test.ts index 16ba82037a..a9ef5e7504 100644 --- a/server/access.test.ts +++ b/server/access.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "bun:test"; import { cameThroughProxy, decideAccess, isLoopbackAddress, isLoopbackHost, type AccessInput } from "./access.ts"; const device = { id: "d1", label: "Phone", role: "drive" as const }; -const base: AccessInput = { loopback: true, forwarded: false, funnel: false, tailscaleLogin: null, tokenMatched: false, device: null, owner: null, tagged: false, tokenConfigured: false, gated: false }; +const base: AccessInput = { loopback: true, forwarded: false, funnel: false, tailscaleLogin: null, host: null, tokenMatched: false, device: null, owner: null, tagged: false, soleLogin: null, dnsName: null, tailnetIp: null, serveOnly: false, tokenConfigured: false, gated: false }; +const NODE = "pc.tail5cc90b.ts.net"; +const IP = "100.64.0.7"; +const node = { host: NODE, dnsName: NODE, tailnetIp: IP } as const; const via = (input: Partial) => { const a = decideAccess({ ...base, ...input }); return a.level === "full" ? a.via : `refused:${a.reason}`; }; describe("decideAccess", () => { @@ -36,6 +39,49 @@ describe("decideAccess", () => { expect(via({ forwarded: true, tailscaleLogin: "me@example.com", owner: null })).toBe("open"); }); + it("lets the owner's own device in through serve with no login, when serve is declared the only ingress and one login owns the tailnet", () => { + // the phone's shape: tailscale serve proxies from loopback, states no person, and the Host is this PC's name + const phone = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", serveOnly: true, ...node } as const; + expect(via(phone)).toBe("tailscale"); + expect(via({ ...phone, gated: true })).toBe("tailscale"); + expect(decideAccess({ ...base, ...phone })).toEqual({ level: "full", via: "tailscale", role: "drive", login: "me@example.com" }); + // the same request where the operator did not declare serve the only ingress pairs, as it always did + expect(via({ ...phone, serveOnly: false })).toBe("refused:pairing_required"); + // and where Tailscale proves nothing, it is the stranger it always was + expect(via({ ...phone, soleLogin: null })).toBe("refused:pairing_required"); + // with the name unknown, a Host that could be this PC's cannot be taken for it + expect(via({ ...phone, dnsName: null, tailnetIp: null })).toBe("refused:pairing_required"); + }); + + it("grants only on this PC's tailnet name or address, in any case, with a trailing dot or a port", () => { + const phone = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", serveOnly: true, dnsName: NODE, tailnetIp: IP } as const; + for (const host of [`${NODE}:7317`, NODE.toUpperCase(), `${NODE}.`, `${NODE}.:443`, IP, `${IP}:7317`]) expect(via({ ...phone, host })).toBe("tailscale"); + // a rebinding page or a public domain forwarded here presents its own name, never this PC's + for (const host of [null, "evil.example", "evil.example:7317", `${NODE}.evil.example`, "herdr.example.com:7317", "100.64.0.8", `[${IP}]`, "[fd7a::1]:7317"]) { + expect(via({ ...phone, host })).toBe("refused:pairing_required"); + } + }); + + it("holds the floor the sole-login rule sits on: another user, Funnel, a token, a tag, a LAN client, a named owner that is not the sole login", () => { + const sole = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", serveOnly: true, ...node } as const; + // a login Tailscale did stage is still read, and still refused when it is not the owner's + expect(via({ ...sole, tailscaleLogin: "them@example.com" })).toBe("refused:other_user"); + expect(via({ ...sole, tailscaleLogin: "me@example.com" })).toBe("tailscale"); + // the public internet proves nothing about who is asking, whoever owns the tailnet + expect(via({ ...sole, funnel: true })).toBe("refused:pairing_required"); + expect(via({ ...sole, funnel: true, gated: true })).toBe("refused:pairing_required"); + // a token still gates everything but a paired device + expect(via({ ...sole, tokenConfigured: true })).toBe("refused:token_required"); + expect(via({ ...sole, tokenConfigured: true, tokenMatched: true })).toBe("token"); + // a tagged PC names no owner to be: its visitors pair, as before + expect(via({ forwarded: true, tagged: true, soleLogin: "me@example.com", serveOnly: true })).toBe("refused:pairing_required"); + // off this machine the rule buys nothing: any LAN client could claim the same proxy + expect(via({ ...sole, loopback: false, gated: true })).toBe("refused:pairing_required"); + expect(via({ ...sole, loopback: false, tailscaleLogin: "me@example.com", gated: true })).toBe("refused:pairing_required"); + // a named owner the tailnet's sole login does not match keeps the refusal it had + expect(via({ ...sole, owner: "named@example.com" })).toBe("refused:pairing_required"); + }); + it("asks a tagged PC's visitors to pair, its owner included, and never calls them another user", () => { // the node names no person: there is no login to match, and none to be a stranger to expect(via({ forwarded: true, tagged: true, tailscaleLogin: "me@example.com" })).toBe("refused:pairing_required"); diff --git a/server/access.ts b/server/access.ts index dbf9b81ee1..acce2477d9 100644 --- a/server/access.ts +++ b/server/access.ts @@ -10,7 +10,14 @@ * Without a token, and until the first device is paired, anything that reaches the server is * let in as it always was, except through a proxy * on a PC whose Tailscale login is known: there, a request with no login header is a tagged - * node (tailscale serve states no person for it), and a tailnet can hold many of those. + * node (tailscale serve states no person for it), and a tailnet can hold many of those. The + * exception is an install whose operator declared `tailscale serve` the only way in + * (HERDR_WEB_TAILSCALE_SERVE_ONLY) on a tailnet Tailscale says holds no tagged node and no second + * login: no stranger exists there to be mistaken for, so a serve-proxied request with no login is + * the owner, and the owner's own phone opens the address without a code. On that path, and only + * there, the request's Host must name this PC's own Tailscale name or address: `tailscale serve` + * passes the client's Host through, and under the declaration no other ingress exists to forge it + * through, while a rebinding page can only ever present its own origin. * A PC whose own node is tagged has no login to compare with: its proxied requests pair, the * owner's included, unless HERDR_WEB_TAILSCALE_OWNER names the login to let in. * @@ -31,6 +38,8 @@ export interface AccessInput { funnel: boolean; /** Tailscale-User-Login: set by tailscale serve for a person's device, absent for tagged nodes */ tailscaleLogin: string | null; + /** the request's Host header, as sent */ + host: string | null; /** the shared token matched (cookie or bearer) */ tokenMatched: boolean; /** the device the cookie belongs to */ @@ -39,6 +48,14 @@ export interface AccessInput { owner: string | null; /** this PC's Tailscale node is tagged: Tailscale runs here, and names no person as its owner */ tagged: boolean; + /** the one login `tailscale status` proves owns every node of this tailnet, none tagged (`parseSoleTailnetLogin`), or null */ + soleLogin: string | null; + /** this PC's MagicDNS name from `tailscale status`, without its trailing dot */ + dnsName: string | null; + /** this PC's IPv4 address on the tailnet from `tailscale status` */ + tailnetIp: string | null; + /** the operator declared `tailscale serve` as this install's only ingress (HERDR_WEB_TAILSCALE_SERVE_ONLY) */ + serveOnly: boolean; tokenConfigured: boolean; /** a device has been paired at some point: the gate is closed to strangers (server/devices.ts) */ gated: boolean; @@ -56,17 +73,22 @@ export function isLoopbackAddress(address: string): boolean { const PROXY_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-real-ip", "forwarded", "via", "tailscale-user-login"]; /** - * Is this Host header a name for this machine itself? It is read as a bare authority, a name - * or a bracketed address with an optional port, and nothing else: handed to a URL parser, - * `public.example@localhost` and `localhost/x@public.example` both came out as localhost. + * The name a Host header addresses, lower-cased, without its port or its DNS root dot. It is read + * as a bare authority, a name or a bracketed address with an optional port, and nothing else: + * handed to a URL parser, `public.example@localhost` and `localhost/x@public.example` both came + * out as localhost. */ -export function isLoopbackHost(host: string): boolean { +export function hostName(host: string): string | null { const authority = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::\d{1,5})?$/i.exec(host); - if (!authority) return false; // one trailing dot is the DNS root, the same name - const name = authority[1]!.toLowerCase().replace(/\.$/, ""); - return name === "localhost" || name.endsWith(".localhost") || name === "[::1]" - || /^127(?:\.\d{1,3}){3}$/.test(name) || /^\[::ffff:127(?:\.\d{1,3}){3}\]$/.test(name); + return authority ? authority[1]!.toLowerCase().replace(/\.$/, "") : null; +} + +/** Is this Host header a name for this machine itself? */ +export function isLoopbackHost(host: string): boolean { + const name = hostName(host); + return name !== null && (name === "localhost" || name.endsWith(".localhost") || name === "[::1]" + || /^127(?:\.\d{1,3}){3}$/.test(name) || /^\[::ffff:127(?:\.\d{1,3}){3}\]$/.test(name)); } /** @@ -80,6 +102,18 @@ export function cameThroughProxy(headers: Headers): boolean { return host === null || !isLoopbackHost(host); } +/** Does the request's Host name this PC on the tailnet, by its MagicDNS name or its IPv4 address? */ +export function addressesNode(input: Pick): boolean { + const name = input.host === null ? null : hostName(input.host); + if (name === null) return false; + return (input.dnsName !== null && name === input.dnsName.toLowerCase()) || (input.tailnetIp !== null && name === input.tailnetIp); +} + +/** The request shape of the owner's no-login grant: a serve-only install, a forwarded request from loopback with no login, not Funnel. */ +export function isServeOwnerRequest(input: Pick): boolean { + return input.serveOnly && input.loopback && input.forwarded && !input.funnel && input.tailscaleLogin === null; +} + export function decideAccess(input: AccessInput): Access { if (input.tokenMatched) return { level: "full", via: "token", role: "drive" }; if (input.device !== null) return { level: "full", via: "device", role: input.device.role, device: input.device }; @@ -90,8 +124,16 @@ export function decideAccess(input: AccessInput): Access { if (input.tailscaleLogin.toLowerCase() === input.owner.toLowerCase()) return { level: "full", via: "tailscale", role: "drive", login: input.tailscaleLogin }; return { level: "none", reason: "other_user" }; } + // A proxied request with no login is a tagged node, which a tailnet can hold many of. Where the + // operator declared tailscale serve the only ingress, Tailscale says one login owns every node on + // this tailnet, none tagged, and the request is addressed to this PC, there is nobody for it to be + // but the owner. Funnel is the public internet, which that proof says nothing about. + const sole = input.soleLogin; + if (sole !== null && input.owner?.toLowerCase() === sole.toLowerCase() && isServeOwnerRequest(input) && addressesNode(input)) { + return { level: "full", via: "tailscale", role: "drive", login: sole }; + } if (input.loopback && !input.forwarded) return { level: "full", via: "local", role: "drive" }; - if (input.loopback && input.forwarded && (input.owner !== null || input.tagged)) return { level: "none", reason: "pairing_required" }; + if (input.loopback && input.forwarded && (input.owner !== null || input.tagged || input.serveOnly)) return { level: "none", reason: "pairing_required" }; // the public internet is never "open", whatever is paired if (!input.gated && !input.funnel) return { level: "full", via: "open", role: "drive" }; return { level: "none", reason: "pairing_required" }; diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index e9dbb7a8fe..d99628eae4 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -13,6 +13,7 @@ import { startFakePushService, type FakePushService } from "./push.fake.ts"; import { descriptorPath, type BridgeDescriptor } from "./bridge.ts"; import { handleMachineRequest } from "./machine-api.ts"; import type { MachineManager } from "./machines.ts"; +import { TailnetIdentitySource } from "./tailscale.ts"; /** * Contract test for herdr-web-ui's HTTP + WS surface. @@ -1794,6 +1795,76 @@ describe("pairing and identity", () => { expect(((await refused.json()) as ApiError).error.code).toBe("other_user"); }); + it("lets the owner's own device in with no login header, where serve is the only ingress and one login owns the tailnet", async () => { + const state = mkdtempSync(join(tmpdir(), "herdr-sole-user-")); + const savedOwner = process.env["HERDR_WEB_TAILSCALE_OWNER"]; + const savedServeOnly = process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"]; + delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; + // what `tailscale status --json` says for the reported tailnet: one login, every node untagged + const NODE = "denisss-macbook-pro-m1.tail5cc90b.ts.net"; + const status = JSON.stringify({ BackendState: "Running", Self: { DNSName: `${NODE}.`, UserID: 7, TailscaleIPs: ["100.101.102.103"] }, Peer: { phone: { DNSName: "phone.tail5cc90b.ts.net.", UserID: 7 } }, User: { "7": { LoginName: OWNER } } }); + const tailnet = new TailnetIdentitySource(async () => status); + process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] = "1"; + const sole = createServer({ port: 0, stateDir: state, tailnet }); + if (savedServeOnly === undefined) delete process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"]; else process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] = savedServeOnly; + const off = createServer({ port: 0, stateDir: state, tailscaleServeOnly: false, tailnet }); + const named = createServer({ port: 0, stateDir: state, tailscaleOwner: OWNER, tailscaleServeOnly: true, tailnet }); + const other = createServer({ port: 0, stateDir: state, tailscaleOwner: "named@example.com", tailscaleServeOnly: true, tailnet }); + const at = async (port: number, headers: Record) => ((await (await fetch(`http://127.0.0.1:${port}/api/health?scope=bridge`, { headers })).json()) as { auth: HealthAuth }).auth; + try { + // the captain's phone: tailscale serve proxied it, names no person, and the address is this PC's Tailscale name + const phone = { ...proxied(), host: NODE }; + expect(await at(sole.port, phone)).toMatchObject({ authenticated: true, via: "tailscale" }); + expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: phone })).status).not.toBe(401); + // the same name in another case, or this PC's tailnet address, is the same PC + expect(await at(sole.port, { ...proxied(), host: NODE.toUpperCase() })).toMatchObject({ authenticated: true, via: "tailscale" }); + expect(await at(sole.port, { ...proxied(), host: "100.101.102.103:7317" })).toMatchObject({ authenticated: true, via: "tailscale" }); + // a rebinding page or a public domain forwarded here presents its own Host, and pairs + expect(await at(sole.port, { ...proxied(), host: "evil.example:7317" })).toMatchObject({ authenticated: false, reason: "pairing_required" }); + expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: { ...proxied(), host: "evil.example:7317" } })).status).toBe(401); + // and the floor stays where it was: another login, Funnel and a LAN client gain nothing + expect(await at(sole.port, { ...phone, "tailscale-user-login": "someone@example.com" })).toMatchObject({ authenticated: false, reason: "other_user" }); + expect(await at(sole.port, { ...phone, "tailscale-funnel-request": "?1" })).toMatchObject({ authenticated: false, reason: "pairing_required" }); + expect(await at(sole.port, { ...phone, "tailscale-user-login": OWNER })).toMatchObject({ authenticated: true, via: "tailscale" }); + // where the operator did not declare serve the only ingress, the same request pairs, with or without the tailnet's name as Host + expect(await at(off.port, proxied())).toMatchObject({ authenticated: false, reason: "pairing_required" }); + expect(await at(off.port, phone)).toMatchObject({ authenticated: false, reason: "pairing_required" }); + expect((await fetch(`http://127.0.0.1:${off.port}/api/session`, { headers: proxied() })).status).toBe(401); + // a named owner is read the same way: the sole login it matches is let in, another name is not + expect(await at(named.port, phone)).toMatchObject({ authenticated: true, via: "tailscale" }); + expect(await at(other.port, phone)).toMatchObject({ authenticated: false, reason: "pairing_required" }); + } finally { + sole.stop(); + off.stop(); + named.stop(); + other.stop(); + if (savedOwner === undefined) delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; else process.env["HERDR_WEB_TAILSCALE_OWNER"] = savedOwner; + rmSync(state, { recursive: true, force: true }); + } + }); + + it("refuses a serve request inside the cache's TTL once a node is tagged since the cache was read", async () => { + const state = mkdtempSync(join(tmpdir(), "herdr-stale-identity-")); + const savedOwner = process.env["HERDR_WEB_TAILSCALE_OWNER"]; + delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; + const NODE = "stale-pc.example.ts.net"; + const self = { DNSName: `${NODE}.`, TailscaleIPs: ["100.101.102.103"], UserID: 7 }; + const sole = JSON.stringify({ BackendState: "Running", Self: self, User: { "7": { LoginName: OWNER } } }); + const tagged = JSON.stringify({ BackendState: "Running", Self: self, Peer: { node: { UserID: 7, Tags: ["tag:server"] } }, User: { "7": { LoginName: OWNER } } }); + let read = 0; + const tailnet = new TailnetIdentitySource(() => Promise.resolve(read++ === 0 ? sole : tagged)); + await tailnet.freshIdentity(NODE); + const serveOnly = createServer({ port: 0, stateDir: state, tailscaleServeOnly: true, tailnet }); + try { + const auth = ((await (await fetch(`http://127.0.0.1:${serveOnly.port}/api/health?scope=bridge`, { headers: { ...proxied(), host: NODE } })).json()) as { auth: HealthAuth }).auth; + expect(auth).toMatchObject({ authenticated: false, reason: "pairing_required" }); + } finally { + serveOnly.stop(); + if (savedOwner === undefined) delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; else process.env["HERDR_WEB_TAILSCALE_OWNER"] = savedOwner; + rmSync(state, { recursive: true, force: true }); + } + }); + it("takes the login named in HERDR_WEB_TAILSCALE_OWNER for the PC's own, as a tagged node needs", async () => { const state = mkdtempSync(join(tmpdir(), "herdr-named-owner-")); const before = process.env["HERDR_WEB_TAILSCALE_OWNER"]; diff --git a/server/index.ts b/server/index.ts index 272b919ec0..af414ccaa7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,9 +8,9 @@ import type { AgentKind, AgentStatus, ClientMessage, ClientRole, HealthAuth, Her import { paneTitle } from "../shared/notify-policy.ts"; import { DEFAULT_PORT } from "../shared/protocol.ts"; import { DEVICE_COOKIE, handleAuthRequest, isAuthenticated, parseCookies, requiresAuth, unauthorizedJson } from "./auth.ts"; -import { cameThroughProxy, decideAccess, isLoopbackAddress } from "./access.ts"; +import { cameThroughProxy, decideAccess, isLoopbackAddress, isServeOwnerRequest } from "./access.ts"; import { DeviceStore, handleDeviceRequest } from "./devices.ts"; -import { remoteAccess, tailscaleIdentity } from "./tailscale.ts"; +import { remoteAccess, TailnetIdentitySource } from "./tailscale.ts"; import { paneCommands } from "./commands.ts"; import { paneFiles } from "./files.ts"; import { badRequest, errorResponse, isCount, isJsonObject, jsonResponse } from "./http.ts"; @@ -316,6 +316,10 @@ export function createServer( stateDir?: string; /** the PC's own Tailscale login, for the identity check; tests set it, otherwise `tailscale status` says */ tailscaleOwner?: string | null; + /** the operator declares `tailscale serve` as this install's only ingress; HERDR_WEB_TAILSCALE_SERVE_ONLY=1 says the same */ + tailscaleServeOnly?: boolean; + /** the tailnet identity behind the access check; unset, the tailscale CLI says. Tests pass one with their own status reader. */ + tailnet?: TailnetIdentitySource; /** Native Codex store; defaults to CODEX_HOME. Tests use an isolated store. */ codexHome?: string; updates?: UpdateService; @@ -391,8 +395,13 @@ export function createServer( const voice = options.voice ?? new VoiceService({ stateDir: options.stateDir ?? defaultStateDir(), env: process.env, fetch }); /** a login named here is taken as it is: a tagged node has none of its own to read (HERDR_WEB_TAILSCALE_OWNER) */ const namedOwner = options.tailscaleOwner !== undefined ? options.tailscaleOwner : process.env["HERDR_WEB_TAILSCALE_OWNER"]?.trim() || undefined; - const identityOf = namedOwner !== undefined ? () => ({ owner: namedOwner, tagged: false }) : tailscaleIdentity; - identityOf(); + const tailnet = options.tailnet ?? new TailnetIdentitySource(); + const identityOf = async (grantPath: boolean, host: string | null) => { + if (namedOwner !== undefined) return { ...(grantPath ? await tailnet.freshIdentity(host) : { soleLogin: null, dnsName: null, tailnetIp: null }), owner: namedOwner, tagged: false }; + return grantPath ? tailnet.freshIdentity(host) : tailnet.identity(); + }; + identityOf(false, null); + const serveOnly = options.tailscaleServeOnly ?? process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] === "1"; /** * Runs `task` after everything queued for the pane. While a composer message is in @@ -1175,14 +1184,21 @@ export function createServer( const bridgeAuthorized = isAuthenticated(request, bridgeToken); const bridgePath = pathname === "/api/bridge" || pathname === "/api/session" || pathname === "/api/agents" || pathname.startsWith("/api/pane/") || pathname.startsWith("/api/workspace/") || pathname.startsWith("/api/worktree/") || pathname.startsWith("/api/tab/") || pathname.startsWith("/api/fs/") || pathname === "/ws"; const ip = bunServer.requestIP(request); + const loopback = ip !== null && isLoopbackAddress(ip.address); + const forwarded = cameThroughProxy(request.headers); + const funnel = request.headers.has("tailscale-funnel-request"); + const tailscaleLogin = request.headers.get("tailscale-user-login"); + const requestHost = request.headers.get("host"); + const tokenMatched = token !== "" && isAuthenticated(request, token); + const pairedDevice = devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)); + const requestShape = { loopback, forwarded, funnel, tailscaleLogin, serveOnly }; + const identity = await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && (pathname === "/ws" || pathname.startsWith("/api/")), requestHost); const access = decideAccess({ - loopback: ip !== null && isLoopbackAddress(ip.address), - forwarded: cameThroughProxy(request.headers), - funnel: request.headers.has("tailscale-funnel-request"), - tailscaleLogin: request.headers.get("tailscale-user-login"), - tokenMatched: token !== "" && isAuthenticated(request, token), - device: devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)), - ...identityOf(), + ...requestShape, + host: requestHost, + tokenMatched, + device: pairedDevice, + ...identity, tokenConfigured: token !== "", gated: devices.gated, }); diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index a61a3c1deb..ff2c5ed473 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -1,11 +1,150 @@ -import { describe, expect, it } from "bun:test"; -import { parseTailscale, parseTailscaleIp } from "./tailscale.ts"; +import { describe, expect, it, spyOn } from "bun:test"; +import { decideAccess, type AccessInput } from "./access.ts"; +import { parseSoleTailnetLogin, parseTailscale, parseTailscaleIp, TailnetIdentitySource, type TailnetIdentity } from "./tailscale.ts"; const status = (state = "Running", dns = "pc.example.ts.net.") => JSON.stringify({ BackendState: state, Self: { DNSName: dns } }); /** a `tailscale serve status --json` document: listeners by port, and one "/" proxy per host:port */ const serve = (tcp: Record, web: Record) => JSON.stringify({ TCP: tcp, Web: Object.fromEntries(Object.entries(web).map(([key, proxy]) => [key, { Handlers: { "/": { Proxy: proxy } } }])) }); +/** a `tailscale status --json` document: this PC, then one peer per `[userId, tags]`, as the real one is shaped; `User` names each login by id */ +const tailnet = (self: [number, string[] | undefined], peers: [number, string[] | undefined][] = [], state = "Running") => JSON.stringify({ + BackendState: state, + Self: { DNSName: "pc.example.ts.net.", UserID: self[0], ...(self[1] ? { Tags: self[1] } : {}) }, + Peer: Object.fromEntries(peers.map(([userId, tags], index) => [`key${index}`, { DNSName: `peer${index}.example.ts.net.`, UserID: userId, ...(tags ? { Tags: tags } : {}) }])), + User: Object.fromEntries([self[0], ...peers.map(([userId]) => userId)].map((userId) => [String(userId), { LoginName: `user${userId}@example.com` }])), +}); + +describe("parseSoleTailnetLogin", () => { + it("names the login a tailnet one login owns, phones and offline peers included", () => { + // the shape of the reported tailnet: this Mac plus six of the owner's own untagged devices + expect(parseSoleTailnetLogin(tailnet([7511875822626835, undefined], Array.from({ length: 6 }, () => [7511875822626835, undefined] as [number, undefined])))).toBe("user7511875822626835@example.com"); + expect(parseSoleTailnetLogin(tailnet([42, undefined]))).toBe("user42@example.com"); + }); + + it("names no login where a second login or any tag exists", () => { + expect(parseSoleTailnetLogin(tailnet([42, undefined], [[42, undefined], [43, undefined]]))).toBeNull(); + expect(parseSoleTailnetLogin(tailnet([42, undefined], [[42, undefined], [42, ["tag:ci"]]]))).toBeNull(); + expect(parseSoleTailnetLogin(tailnet([42, ["tag:server"]], [[42, undefined]]))).toBeNull(); + // a peer the status names no owner for is not proof of anything + expect(parseSoleTailnetLogin(JSON.stringify({ BackendState: "Running", Self: { UserID: 42 }, Peer: { k: { DNSName: "p.example.ts.net." } } }))).toBeNull(); + }); + + it("names no login the status does not state, nor from a status it could not read, or a daemon that is not running", () => { + expect(parseSoleTailnetLogin(JSON.stringify({ BackendState: "Running", Self: { UserID: 42 } }))).toBeNull(); + expect(parseSoleTailnetLogin(tailnet([42, undefined], [], "Stopped"))).toBeNull(); + expect(parseSoleTailnetLogin(tailnet([42, undefined], [], "NeedsLogin"))).toBeNull(); + expect(parseSoleTailnetLogin(JSON.stringify({ BackendState: "Running" }))).toBeNull(); + expect(parseSoleTailnetLogin("not json")).toBeNull(); + expect(parseSoleTailnetLogin(null)).toBeNull(); + }); +}); + +/** the access decision for a no-login request that `tailscale serve` forwarded, with the operator's serve-only switch on */ +const HOST = "pc.example.ts.net"; +const serveRequest = (identity: TailnetIdentity, host: string | null = HOST) => { + const input: AccessInput = { loopback: true, forwarded: true, funnel: false, tailscaleLogin: null, host, tokenMatched: false, device: null, ...identity, serveOnly: true, tokenConfigured: false, gated: false }; + const access = decideAccess(input); + return access.level === "full" ? access.via : `refused:${access.reason}`; +}; + +/** a source whose cache holds `status` (none, when undefined) as read `minutesAgo` minutes ago, and whose reads from then on answer `read` */ +async function withCachedStatus(status: string | null | undefined, minutesAgo: number, read: () => Promise, body: (source: TailnetIdentitySource) => Promise): Promise { + let warm = status !== undefined ? status : null; + const source = new TailnetIdentitySource(async () => { + if (warm !== null) { const first = warm; warm = null; return first; } + return read(); + }); + if (status !== undefined) await source.freshIdentity(null); + const start = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => start + minutesAgo * 60_000); + try { await body(source); } finally { clock.mockRestore(); } +} + +describe("freshIdentity", () => { + it("refuses the owner's grant once a node is tagged, even inside the cache's TTL", async () => { + for (const minutesAgo of [2, 6]) { + await withCachedStatus(tailnet([42, undefined]), minutesAgo, async () => tailnet([42, undefined], [[42, ["tag:server"]]]), async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + }); + } + }); + + it("grants the owner when the fresh read proves one login owns the tailnet, from a warm sole cache or an empty one", async () => { + for (const status of [tailnet([42, undefined]), undefined]) { + await withCachedStatus(status, 2, async () => tailnet([42, undefined]), async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("tailscale"); + }); + } + }); + + it("withdraws the sole-login proof when the fresh read fails, and keeps pairing the stranger", async () => { + await withCachedStatus(tailnet([42, undefined]), 2, async () => null, async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + }); + }); + + it("shares one status read among concurrent owner grants", async () => { + let reads = 0; + await withCachedStatus(tailnet([42, undefined]), 0, async () => { reads += 1; await new Promise((done) => setTimeout(done, 5)); return tailnet([42, undefined]); }, async (source) => { + const identities = await Promise.all([1, 2, 3, 4, 5].map(() => source.freshIdentity(HOST))); + expect(identities.map((identity) => serveRequest(identity))).toEqual(["tailscale", "tailscale", "tailscale", "tailscale", "tailscale"]); + expect(reads).toBe(1); + }); + }); + + it("reads once for a tagged node's first request, and its later polls pair without reading", async () => { + let reads = 0; + await withCachedStatus(tailnet([42, undefined]), 0, async () => { reads += 1; return tailnet([42, undefined], [[42, ["tag:server"]]]); }, async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + expect(reads).toBe(1); + }); + }); + + it("reads again after a status the daemon had not finished writing, so a Mac that wakes grants on its next request", async () => { + await withCachedStatus(tailnet([42, undefined], [], "Starting"), 2, async () => tailnet([42, undefined]), async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("tailscale"); + }); + }); + + it("keeps a negative a settled status itself states, once the daemon finished coming up", async () => { + let reads = 0; + await withCachedStatus(tailnet([42, undefined], [], "Starting"), 2, async () => { reads += 1; return tailnet([42, undefined], [[42, ["tag:server"]]]); }, async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + expect(reads).toBe(1); + }); + }); +}); + +describe("the owner's grant read, by Host and by outcome", () => { + it("answers a request addressed to another name from the cache, without reading the status", async () => { + let reads = 0; + await withCachedStatus(tailnet([42, undefined]), 0, async () => { reads += 1; return tailnet([42, undefined]); }, async (source) => { + expect(serveRequest(await source.freshIdentity("evil.example"), "evil.example")).toBe("refused:pairing_required"); + expect(reads).toBe(0); + }); + }); + + it("grants on the read after a failed one, and pairs while the read before it failed", async () => { + let call = 0; + await withCachedStatus(tailnet([42, undefined]), 0, async () => (call += 1) === 1 ? null : tailnet([42, undefined]), async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + expect(serveRequest(await source.freshIdentity(HOST))).toBe("tailscale"); + }); + }); + + it("pairs on the retry when the read after a failed one finds a node tagged", async () => { + let call = 0; + await withCachedStatus(tailnet([42, undefined]), 0, async () => (call += 1) === 1 ? null : tailnet([42, undefined], [[42, ["tag:server"]]]), async (source) => { + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + expect(serveRequest(await source.freshIdentity(HOST))).toBe("refused:pairing_required"); + expect(call).toBe(2); + }); + }); +}); + describe("parseTailscaleIp", () => { it("picks this PC's IPv4 tailnet address, and nothing when there is none", () => { expect(parseTailscaleIp(JSON.stringify({ Self: { TailscaleIPs: ["fd7a:115c:a1e0::1", "100.64.0.7"] } }))).toBe("100.64.0.7"); diff --git a/server/tailscale.ts b/server/tailscale.ts index 6fe9d5e9ef..2120b51c70 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -6,6 +6,7 @@ */ import { existsSync } from "node:fs"; import type { RemoteAccess, TailscaleAccess } from "../shared/protocol.ts"; +import { addressesNode } from "./access.ts"; const TIMEOUT_MS = 2500; /** the macOS App Store build puts no `tailscale` on the PATH; its CLI lives in the app bundle */ @@ -20,7 +21,8 @@ export interface TailscaleOutput { serve: string | null; } -interface StatusJson { BackendState?: string; Self?: { DNSName?: string; UserID?: number | string; TailscaleIPs?: string[]; Tags?: string[] }; User?: Record } +interface NodeJson { DNSName?: string; UserID?: number | string; TailscaleIPs?: string[]; Tags?: string[] } +interface StatusJson { BackendState?: string; Self?: NodeJson; Peer?: Record; User?: Record } interface ServeJson { TCP?: Record; /** "host:port" -> handlers by path */ @@ -53,7 +55,7 @@ export function parseTailscale(output: TailscaleOutput | null, port: number): Ta if (output === null) return NONE; const status = parseJson(output.status); if (status === null || status.BackendState !== "Running") return { ...NONE, state: "stopped" }; - const dns = status.Self?.DNSName?.replace(/\.$/, "") || null; + const dns = parseNodeName(output.status); const serve = parseJson(output.serve); const taken = new Set(Object.keys(serve?.TCP ?? {}).map(Number).filter(Number.isFinite)); let servingUrl: string | null = null; @@ -115,32 +117,146 @@ export function parseTailscaleOwner(status: string | null): string | null { return parsed?.User?.[String(id)]?.LoginName || null; } +/** + * The one login `tailscale status --json` proves owns every node of this tailnet, with none of them + * tagged, or null. A tagged node is the one case `tailscale serve` deliberately states no person + * for, and a tailnet can hold many of those, which is why a proxied request with no login is + * otherwise a stranger. Where Tailscale says there is no tagged node and no second login, the + * owner's own devices are all a tailnet request can come from. A second login, a single tag + * anywhere, a node whose owner the status does not name, or a status it could not read answer + * null: the proof has to be positive, never the benefit of the doubt. + */ +export function parseSoleTailnetLogin(status: string | null): string | null { + return readSoleLogin(status).login; +} + +/** + * `parseSoleTailnetLogin`'s proof together with what the status settles for good: a Running status + * that names this PC's user states whether one login owns the tailnet - the login above, or a tag + * or a second login against it. A daemon still coming up, a status that names no user for this + * PC, or one that names no login for a tailnet it would prove, leaves the question open instead. + */ +function readSoleLogin(status: string | null): { login: string | null; settled: boolean } { + // "every node" is every node this PC's Tailscale lists: a node an ACL hides is not in its network map + const parsed = parseJson(status); + if (parsed === null || parsed.BackendState !== "Running") return { login: null, settled: false }; + const self = parsed.Self; + if (self === undefined || self.UserID === undefined || self.UserID === null) return { login: null, settled: false }; + const owner = self.UserID; + // offline peers count: they are nodes of this tailnet and can come back at any moment + const nodes: NodeJson[] = [self, ...Object.values(parsed.Peer ?? {})]; + const sole = nodes.every((node) => (node.Tags?.length ?? 0) === 0 + && node.UserID !== undefined && node.UserID !== null && String(node.UserID) === String(owner)); + if (!sole) return { login: null, settled: true }; + const login = parsed.User?.[String(owner)]?.LoginName || null; + return { login, settled: login !== null }; +} + /** This PC's IPv4 address on the tailnet (100.x.y.z), from `tailscale status --json`; null when it has none. */ export function parseTailscaleIp(status: string | null): string | null { return parseJson(status)?.Self?.TailscaleIPs?.find((ip) => /^\d+\.\d+\.\d+\.\d+$/.test(ip)) ?? null; } +/** This PC's MagicDNS name from `tailscale status --json`, without its trailing dot; null when it has none. */ +export function parseNodeName(status: string | null): string | null { + return parseJson(status)?.Self?.DNSName?.replace(/\.$/, "") || null; +} + +export interface TailnetIdentity { + owner: string | null; + tagged: boolean; + soleLogin: string | null; + dnsName: string | null; + tailnetIp: string | null; +} + const OWNER_TTL_MS = 5 * 60_000; -let ownerCache: { owner: string | null; tagged: boolean; at: number } | null = null; -let ownerRefresh: Promise | null = null; + +const cliStatus = async (): Promise => { + const binary = tailscaleBinary(); + return binary === null ? null : run(binary, ["status", "--json"]); +}; /** - * The PC's own Tailscale login, or that its node is tagged and has none, cached five minutes. - * A stale value is answered at once and refreshed in the background, so a request never waits - * on the tailscale CLI; the first lookup is what `createServer` starts, so the answer is - * usually there before any request. + * What `tailscale status --json` says about this PC and its tailnet, read through `readStatus` and + * kept five minutes. `identity` answers every reader that does not grant from the cache, refreshing + * a stale value in the background, so those readers never wait on the tailscale CLI; the first + * lookup is what `createServer` starts. `freshIdentity` is the owner's grant path, the one reader + * that waits for a read, shared with any read already in flight. */ -export function tailscaleIdentity(): { owner: string | null; tagged: boolean } { - const now = Date.now(); - if ((ownerCache === null || now - ownerCache.at >= OWNER_TTL_MS) && ownerRefresh === null) { - ownerRefresh = (async () => { - const binary = tailscaleBinary(); - const status = binary === null ? null : await run(binary, ["status", "--json"]); - // a CLI that failed this once says nothing new: what it last said stands, so the owner is not - // taken for a stranger, nor a stranger let in, for the five minutes until it is asked again - ownerCache = binary !== null && status === null && ownerCache !== null ? { ...ownerCache, at: Date.now() } - : { owner: parseTailscaleOwner(status), tagged: isTaggedNode(status), at: Date.now() }; - })().finally(() => { ownerRefresh = null; }); +export class TailnetIdentitySource { + /** the last read's answer and when it landed; null until the first read lands */ + private cache: (TailnetIdentity & { at: number; settled: boolean }) | null = null; + /** the read in flight, so concurrent grants share one bounded read */ + private reading: Promise | null = null; + + constructor(private readonly readStatus: () => Promise = cliStatus) {} + + /** + * A read that failed, or one whose status leaves the sole-login question open, keeps what the + * last read said about this PC, withdraws the sole-login proof, and marks the cache unsettled, + * so the next grant-path request reads again at once instead of waiting out the TTL. A status + * that states the answer for good - one login's tailnet, or a tag or a second login against it + * - stands for the TTL. Readers that do not grant keep the TTL as before. + */ + private async read(): Promise { + const status = await this.readStatus(); + if (status === null) { + this.cache = { + owner: this.cache?.owner ?? null, + tagged: this.cache?.tagged ?? false, + soleLogin: null, + dnsName: this.cache?.dnsName ?? null, + tailnetIp: this.cache?.tailnetIp ?? null, + settled: false, + at: Date.now(), + }; + return; + } + const sole = readSoleLogin(status); + this.cache = { + owner: parseTailscaleOwner(status), + tagged: isTaggedNode(status), + soleLogin: sole.login, + dnsName: parseNodeName(status), + tailnetIp: parseTailscaleIp(status), + settled: sole.settled, + at: Date.now(), + }; + } + + private refresh(): Promise { + this.reading ??= this.read().finally(() => { this.reading = null; }); + return this.reading; + } + + /** + * The PC's own Tailscale login, or that its node is tagged and has none, and the one login that + * owns this whole tailnet (`parseSoleTailnetLogin`), cached five minutes. A stale value is + * answered at once and refreshed in the background. + */ + identity(): TailnetIdentity { + if (this.cache === null || Date.now() - this.cache.at >= OWNER_TTL_MS) void this.refresh(); + return { + owner: this.cache?.owner ?? null, + tagged: this.cache?.tagged ?? false, + soleLogin: this.cache?.soleLogin ?? null, + dnsName: this.cache?.dnsName ?? null, + tailnetIp: this.cache?.tailnetIp ?? null, + }; + } + + /** + * `identity` for a request the owner's sole-login proof would admit. A cache that already says + * a node is tagged, that the tailnet is not one login's, or that this Host is not this PC's name + * answers at once and grants nothing. Otherwise this waits for one status read, shared with any + * read in flight and bounded by the read's own timeout, so the grant never rests on a warm cache. + * A read that fails, or leaves the question open, grants nothing and is read again next request. + */ + async freshIdentity(host: string | null): Promise { + const cached = this.identity(); + if (this.cache !== null && this.cache.settled && (this.cache.soleLogin === null || !addressesNode({ host, dnsName: this.cache.dnsName, tailnetIp: this.cache.tailnetIp }))) return cached; + await this.refresh(); + return this.identity(); } - return { owner: ownerCache?.owner ?? null, tagged: ownerCache?.tagged ?? false }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index 22b49a3f22..4d7bcfb8cd 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -130,7 +130,9 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * * Access: every route above except /api/health, /api/auth and /api/devices/pair, plus the * /ws upgrade, needs the request to be one of: from this PC itself (no proxy in front); - * the PC's own Tailscale login, as `tailscale serve` states it; a paired device's cookie; + * the PC's own Tailscale login, as `tailscale serve` states it — or, where + * HERDR_WEB_TAILSCALE_SERVE_ONLY=1 declares serve the only ingress on a tailnet one login + * owns with no tagged node, a serve request that states no login; a paired device's cookie; * the shared token (HERDR_WEB_TOKEN) as cookie or `Authorization: Bearer `. With a * token configured, only the last two count, this PC and its Tailscale login included. Without one, and while no * device is paired, anything that reaches the server is let in as before (the startup