From af7f3eeada77ec2aca88ebde407c90afe91a9395 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 09:31:12 +0300 Subject: [PATCH 01/15] fix(access): the owner's own phone opens the Tailscale address without a code Opening https://..ts.net:7317 on the owner's own phone showed the pairing screen instead of the app. `decideAccess` recognizes the owner only from the `Tailscale-User-Login` header, and reads a serve-proxied request that carries no login as a tagged node, which a tailnet can hold many of. Measured on the reporter's PC: the same serve listener does state the login for this PC's own requests (`auth.via` is `tailscale`, over HTTP/1.1 and HTTP/2 alike), Funnel is off, no token is set, nothing is paired, and this node and all six peers are untagged and belong to one login. Under those conditions the only branch that can answer `pairing_required` is the loopback-and-forwarded one, which is reached only when the login header is absent, so serve stated no login for the phone. Where `tailscale status --json` proves that one login owns every node of the tailnet and that none of them is tagged, a tagged node is not something the request can be, and no second login exists for it to belong to: it is the owner's own device, and it gets in. The proof has to be positive, so a second login, a single tag anywhere, a node whose owner the status does not name, or a status the CLI could not read all withhold it. The floor is unchanged. A login that is stated and does not match is still refused as `other_user`; a tagged PC still pairs every device, its owner's included; Funnel is excluded, so the public internet gains nothing; a configured token is still required of everything but a paired device; and the rule needs the loopback and forwarding evidence, so a LAN client or an outside proxy that merely claims the headers is no better off than before. --- CHANGELOG.md | 7 +++++++ docs/guide.md | 6 +++--- server/AGENTS.md | 2 +- server/access.test.ts | 30 +++++++++++++++++++++++++++++- server/access.ts | 14 +++++++++++++- server/api.contract.test.ts | 18 ++++++++++++++++++ server/index.ts | 6 +++++- server/tailscale.test.ts | 34 +++++++++++++++++++++++++++++++++- server/tailscale.ts | 36 ++++++++++++++++++++++++++++++------ 9 files changed, 139 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48fc86a89a..d0a0ae450a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#493](https://github.com/devswha/herdr-web-ui/pull/493) by @kilhyeonjun) ### Fixed +- 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. 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. - A Claude Code pane started with its own `CLAUDE_CONFIG_DIR` (a launcher such as cac keeps one store per environment) shows its chat. Before, only `~/.claude` was searched, so the chat was unavailable and only the terminal worked. The store is read from the pane's Claude process, as diff --git a/docs/guide.md b/docs/guide.md index 96345298df..e7b18f8559 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -256,7 +256,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. Where `tailscale status` shows a tailnet that one login owns with no tagged node anywhere on it, a serve request that states no login is the owner as well: no one else is on that tailnet to be mistaken for, 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. @@ -264,12 +264,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, or that one login owns the tailnet. 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 that tailnet has one login and no tagged node, where such a request is the owner's own device. 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. diff --git a/server/AGENTS.md b/server/AGENTS.md index eb2c9a8176..85e18d973b 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 by a tailnet one login provably owns → 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..3db5da8e0a 100644 --- a/server/access.test.ts +++ b/server/access.test.ts @@ -2,7 +2,7 @@ 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, tokenMatched: false, device: null, owner: null, tagged: false, soleUser: false, tokenConfigured: false, gated: false }; const via = (input: Partial) => { const a = decideAccess({ ...base, ...input }); return a.level === "full" ? a.via : `refused:${a.reason}`; }; describe("decideAccess", () => { @@ -36,6 +36,34 @@ 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, on a tailnet one login owns", () => { + // the phone's shape: tailscale serve proxies from loopback, states no person, nothing is paired + expect(via({ forwarded: true, owner: "me@example.com", soleUser: true })).toBe("tailscale"); + expect(via({ forwarded: true, owner: "me@example.com", soleUser: true, gated: true })).toBe("tailscale"); + const access = decideAccess({ ...base, forwarded: true, owner: "me@example.com", soleUser: true }); + expect(access).toEqual({ level: "full", via: "tailscale", role: "drive", login: "me@example.com" }); + // the same request where Tailscale proves nothing is the stranger it always was + expect(via({ forwarded: true, owner: "me@example.com" })).toBe("refused:pairing_required"); + }); + + it("holds the floor the sole-login rule sits on: another user, Funnel, a token, a tag, a LAN client", () => { + const sole = { forwarded: true, owner: "me@example.com", soleUser: true } 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, soleUser: 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"); + }); + 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..5b7c8e01ae 100644 --- a/server/access.ts +++ b/server/access.ts @@ -10,7 +10,10 @@ * 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 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. * 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. * @@ -39,6 +42,8 @@ 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; + /** `tailscale status` proves one login owns every node of this tailnet and none is tagged (`parseSoleTailnetUser`) */ + soleUser: boolean; tokenConfigured: boolean; /** a device has been paired at some point: the gate is closed to strangers (server/devices.ts) */ gated: boolean; @@ -90,6 +95,13 @@ 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 + // Tailscale says this one holds none and that a single login owns every node on it, there is + // nobody for that request to be but the owner, so the owner's own phone gets in by opening + // the address. Funnel is the public internet, which that proof says nothing about. + if (input.loopback && input.forwarded && !input.funnel && input.tailscaleLogin === null && input.owner !== null && input.soleUser) { + return { level: "full", via: "tailscale", role: "drive", login: input.owner }; + } 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" }; // the public internet is never "open", whatever is paired diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index e9dbb7a8fe..13a0585ee7 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -1794,6 +1794,24 @@ 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, on a tailnet one login owns", async () => { + const state = mkdtempSync(join(tmpdir(), "herdr-sole-user-")); + const sole = createServer({ port: 0, stateDir: state, tailscaleOwner: OWNER, tailscaleSoleUser: true }); + try { + const at = async (headers: Record) => ((await (await fetch(`http://127.0.0.1:${sole.port}/api/health?scope=bridge`, { headers })).json()) as { auth: HealthAuth }).auth; + // the phone's request: tailscale serve proxied it and named no person, with no tagged node on the tailnet to be + expect(await at(proxied())).toMatchObject({ authenticated: true, via: "tailscale" }); + expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: proxied() })).status).not.toBe(401); + // and the floor stays where it was: another login, Funnel and a LAN client gain nothing + expect(await at(proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" }); + expect(await at(proxied(undefined, { "tailscale-funnel-request": "?1" }))).toMatchObject({ authenticated: false, reason: "pairing_required" }); + expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" }); + } finally { + sole.stop(); + 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 7e75e03f17..62d14c0549 100644 --- a/server/index.ts +++ b/server/index.ts @@ -313,6 +313,8 @@ 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; + /** one login owns every node of the tailnet and none is tagged; only a test names it, otherwise `tailscale status` proves it */ + tailscaleSoleUser?: boolean; /** Native Codex store; defaults to CODEX_HOME. Tests use an isolated store. */ codexHome?: string; updates?: UpdateService; @@ -377,7 +379,9 @@ 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; + const identityOf = namedOwner !== undefined + ? () => ({ owner: namedOwner, tagged: false, soleUser: options.tailscaleSoleUser ?? false }) + : tailscaleIdentity; identityOf(); /** diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index a61a3c1deb..bde29e5810 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -1,11 +1,43 @@ import { describe, expect, it } from "bun:test"; -import { parseTailscale, parseTailscaleIp } from "./tailscale.ts"; +import { parseSoleTailnetUser, parseTailscale, parseTailscaleIp } 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 */ +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 } : {}) }])), +}); + +describe("parseSoleTailnetUser", () => { + it("proves 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(parseSoleTailnetUser(tailnet([7511875822626835, undefined], Array.from({ length: 6 }, () => [7511875822626835, undefined] as [number, undefined])))).toBe(true); + expect(parseSoleTailnetUser(tailnet([42, undefined]))).toBe(true); + expect(parseSoleTailnetUser(JSON.stringify({ BackendState: "Running", Self: { UserID: 42 } }))).toBe(true); + }); + + it("refuses to prove it where a second login or any tag exists", () => { + expect(parseSoleTailnetUser(tailnet([42, undefined], [[42, undefined], [43, undefined]]))).toBe(false); + expect(parseSoleTailnetUser(tailnet([42, undefined], [[42, undefined], [42, ["tag:ci"]]]))).toBe(false); + expect(parseSoleTailnetUser(tailnet([42, ["tag:server"]], [[42, undefined]]))).toBe(false); + // a peer the status names no owner for is not proof of anything + expect(parseSoleTailnetUser(JSON.stringify({ BackendState: "Running", Self: { UserID: 42 }, Peer: { k: { DNSName: "p.example.ts.net." } } }))).toBe(false); + }); + + it("proves nothing from a status it could not read, or a daemon that is not running", () => { + expect(parseSoleTailnetUser(tailnet([42, undefined], [], "Stopped"))).toBe(false); + expect(parseSoleTailnetUser(tailnet([42, undefined], [], "NeedsLogin"))).toBe(false); + expect(parseSoleTailnetUser(JSON.stringify({ BackendState: "Running" }))).toBe(false); + expect(parseSoleTailnetUser("not json")).toBe(false); + expect(parseSoleTailnetUser(null)).toBe(false); + }); +}); + 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..ab3ed35c07 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -20,7 +20,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 */ @@ -115,22 +116,45 @@ export function parseTailscaleOwner(status: string | null): string | null { return parsed?.User?.[String(id)]?.LoginName || null; } +/** + * Does `tailscale status --json` prove that one login owns every node of this tailnet, and that + * none of them is tagged? 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 + * false: the proof has to be positive, never the benefit of the doubt. + */ +export function parseSoleTailnetUser(status: string | null): boolean { + const parsed = parseJson(status); + if (parsed === null || parsed.BackendState !== "Running") return false; + const self = parsed.Self; + if (self === undefined) return false; + const owner = self.UserID; + if (owner === undefined || owner === null) return false; + // offline peers count: they are nodes of this tailnet and can come back at any moment + const nodes: NodeJson[] = [self, ...Object.values(parsed.Peer ?? {})]; + return nodes.every((node) => (node.Tags?.length ?? 0) === 0 + && node.UserID !== undefined && node.UserID !== null && String(node.UserID) === String(owner)); +} + /** 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; } const OWNER_TTL_MS = 5 * 60_000; -let ownerCache: { owner: string | null; tagged: boolean; at: number } | null = null; +let ownerCache: { owner: string | null; tagged: boolean; soleUser: boolean; at: number } | null = null; let ownerRefresh: Promise | null = null; /** - * The PC's own Tailscale login, or that its node is tagged and has none, cached five minutes. + * The PC's own Tailscale login, or that its node is tagged and has none, plus whether one login + * owns this whole tailnet (`parseSoleTailnetUser`), 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. */ -export function tailscaleIdentity(): { owner: string | null; tagged: boolean } { +export function tailscaleIdentity(): { owner: string | null; tagged: boolean; soleUser: boolean } { const now = Date.now(); if ((ownerCache === null || now - ownerCache.at >= OWNER_TTL_MS) && ownerRefresh === null) { ownerRefresh = (async () => { @@ -139,8 +163,8 @@ export function tailscaleIdentity(): { owner: string | null; tagged: boolean } { // 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() }; + : { owner: parseTailscaleOwner(status), tagged: isTaggedNode(status), soleUser: parseSoleTailnetUser(status), at: Date.now() }; })().finally(() => { ownerRefresh = null; }); } - return { owner: ownerCache?.owner ?? null, tagged: ownerCache?.tagged ?? false }; + return { owner: ownerCache?.owner ?? null, tagged: ownerCache?.tagged ?? false, soleUser: ownerCache?.soleUser ?? false }; } From 1bb515daa512053ebb1b7b01d076a48c29817a0a Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 09:49:17 +0300 Subject: [PATCH 02/15] no-mistakes(review): limit sole-login grant to this PC's Tailscale name --- CHANGELOG.md | 9 ++--- docs/guide.md | 6 ++-- server/AGENTS.md | 2 +- server/access.test.ts | 37 ++++++++++++------- server/access.ts | 43 ++++++++++++++-------- server/api.contract.test.ts | 40 ++++++++++++++++----- server/index.ts | 5 ++- server/tailscale.test.ts | 37 +++++++++---------- server/tailscale.ts | 71 +++++++++++++++++++++++++++---------- 9 files changed, 167 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a0ae450a..b58430b784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,10 +63,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ### Fixed - 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. Another Tailscale login is still - refused, a tagged node still pairs, Funnel stays closed, a LAN client that claims the same + pairing code, on a tailnet that one Tailscale login owns and that has no tagged node on it, + when the address is this PC's own Tailscale name. 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. Another Tailscale login is still refused, a tagged node still pairs, a public domain + forwarded to this PC 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. - A Claude Code pane started with its own `CLAUDE_CONFIG_DIR` (a launcher such as cac keeps one diff --git a/docs/guide.md b/docs/guide.md index e7b18f8559..83c9bd7b32 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -256,7 +256,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. Where `tailscale status` shows a tailnet that one login owns with no tagged node anywhere on it, a serve request that states no login is the owner as well: no one else is on that tailnet to be mistaken for, 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 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. Where `tailscale status` shows a tailnet that one login owns with no tagged node anywhere on it, a serve request that states no login is the owner as well when it is addressed to this computer's own Tailscale name: no one else is on that tailnet to be mistaken for, so your own phone opens that 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. @@ -264,12 +264,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, or that one login owns the tailnet. With a token set, the token, once per device | +| `tailscale serve`, your own devices | Nothing needed: your login, or the tailnet's one login at this computer's Tailscale name. 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, unless that tailnet has one login and no tagged node, where such a request is the owner's own device. 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 that tailnet has one login and no tagged node and the request is addressed to this computer's Tailscale name, where it is the owner's own device. A public domain forwarded here still 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. diff --git a/server/AGENTS.md b/server/AGENTS.md index 85e18d973b..bdd0068d17 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, by login header or by a tailnet one login provably owns → 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 addressed to this PC's MagicDNS name → 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 3db5da8e0a..0170468f9d 100644 --- a/server/access.test.ts +++ b/server/access.test.ts @@ -2,7 +2,8 @@ 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, soleUser: false, tokenConfigured: false, gated: false }; +const base: AccessInput = { loopback: true, forwarded: false, funnel: false, tailscaleLogin: null, tokenMatched: false, device: null, owner: null, tagged: false, soleLogin: null, dnsName: null, host: null, tokenConfigured: false, gated: false }; +const node = "pc.tail5cc90b.ts.net"; const via = (input: Partial) => { const a = decideAccess({ ...base, ...input }); return a.level === "full" ? a.via : `refused:${a.reason}`; }; describe("decideAccess", () => { @@ -36,18 +37,28 @@ 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, on a tailnet one login owns", () => { - // the phone's shape: tailscale serve proxies from loopback, states no person, nothing is paired - expect(via({ forwarded: true, owner: "me@example.com", soleUser: true })).toBe("tailscale"); - expect(via({ forwarded: true, owner: "me@example.com", soleUser: true, gated: true })).toBe("tailscale"); - const access = decideAccess({ ...base, forwarded: true, owner: "me@example.com", soleUser: true }); - expect(access).toEqual({ level: "full", via: "tailscale", role: "drive", login: "me@example.com" }); - // the same request where Tailscale proves nothing is the stranger it always was - expect(via({ forwarded: true, owner: "me@example.com" })).toBe("refused:pairing_required"); + it("lets the owner's own device in through serve with no login, on a tailnet one login owns, addressed to this PC's name", () => { + // the phone's shape: tailscale serve proxies from loopback, states no person, is addressed to this PC's name, nothing is paired + const phone = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", dnsName: node, host: `${node}:7317` } as const; + expect(via(phone)).toBe("tailscale"); + expect(via({ ...phone, gated: true })).toBe("tailscale"); + expect(via({ ...phone, host: node.toUpperCase() })).toBe("tailscale"); + expect(decideAccess({ ...base, ...phone })).toEqual({ level: "full", via: "tailscale", role: "drive", login: "me@example.com" }); + // the same request where Tailscale proves nothing, or where it names no address, is the stranger it always was + expect(via({ ...phone, soleLogin: null })).toBe("refused:pairing_required"); + expect(via({ ...phone, host: null })).toBe("refused:pairing_required"); + }); + + it("does not take a public domain forwarded to this PC for the owner, even on a tailnet one login owns", () => { + const sole = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", dnsName: node } as const; + // nginx or Caddy forwards the visitor's public Host, and no login + expect(via({ ...sole, host: "herdr.example.com" })).toBe("refused:pairing_required"); + expect(via({ ...sole, host: `${node}.evil.example` })).toBe("refused:pairing_required"); + expect(via({ ...sole, host: "herdr.example.com", tokenConfigured: true })).toBe("refused:token_required"); }); - it("holds the floor the sole-login rule sits on: another user, Funnel, a token, a tag, a LAN client", () => { - const sole = { forwarded: true, owner: "me@example.com", soleUser: true } as const; + 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", dnsName: node, host: 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"); @@ -58,10 +69,12 @@ describe("decideAccess", () => { 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, soleUser: true })).toBe("refused:pairing_required"); + expect(via({ forwarded: true, tagged: true, soleLogin: "me@example.com", dnsName: node, host: node })).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", () => { diff --git a/server/access.ts b/server/access.ts index 5b7c8e01ae..d11a3f23b1 100644 --- a/server/access.ts +++ b/server/access.ts @@ -12,8 +12,9 @@ * 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. The * exception is 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. + * exists there to be mistaken for, so a serve-proxied request with no login that is addressed to + * this PC's own Tailscale name is the owner, and the owner's own phone opens the address without + * a code. A public domain forwarded here is still a stranger: its Host is not that name. * 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. * @@ -42,8 +43,12 @@ 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; - /** `tailscale status` proves one login owns every node of this tailnet and none is tagged (`parseSoleTailnetUser`) */ - soleUser: 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, the one `tailscale serve` answers to, without its trailing dot */ + dnsName: string | null; + /** the request's Host header, as sent */ + host: string | null; tokenConfigured: boolean; /** a device has been paired at some point: the gate is closed to strangers (server/devices.ts) */ gated: boolean; @@ -61,15 +66,20 @@ 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 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); + if (name === null) return false; return name === "localhost" || name.endsWith(".localhost") || name === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(name) || /^\[::ffff:127(?:\.\d{1,3}){3}\]$/.test(name); } @@ -96,10 +106,13 @@ export function decideAccess(input: AccessInput): Access { return { level: "none", reason: "other_user" }; } // A proxied request with no login is a tagged node, which a tailnet can hold many of; where - // Tailscale says this one holds none and that a single login owns every node on it, there is - // nobody for that request to be but the owner, so the owner's own phone gets in by opening - // the address. Funnel is the public internet, which that proof says nothing about. - if (input.loopback && input.forwarded && !input.funnel && input.tailscaleLogin === null && input.owner !== null && input.soleUser) { + // Tailscale says one login owns every node on this tailnet and none is tagged, and the request + // is addressed to this PC's own name, there is nobody for it to be but the owner, so the owner's + // own phone gets in by opening the address. Funnel is the public internet, which that proof says + // nothing about, and a proxy forwarding a public domain does not address this PC's name. + const soleOwner = input.soleLogin !== null && input.owner !== null && input.soleLogin.toLowerCase() === input.owner.toLowerCase(); + const addressedHere = input.host !== null && input.dnsName !== null && hostName(input.host) === input.dnsName.toLowerCase(); + if (input.loopback && input.forwarded && !input.funnel && input.tailscaleLogin === null && input.owner !== null && soleOwner && addressedHere) { return { level: "full", via: "tailscale", role: "drive", login: input.owner }; } if (input.loopback && !input.forwarded) return { level: "full", via: "local", role: "drive" }; diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 13a0585ee7..2f85a05054 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 { forgetTailscaleIdentity, rememberTailnetStatus } from "./tailscale.ts"; /** * Contract test for herdr-web-ui's HTTP + WS surface. @@ -1794,20 +1795,41 @@ 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, on a tailnet one login owns", async () => { + it("lets the owner's own device in with no login header, on a tailnet one login owns, at this PC's Tailscale name", async () => { + const NODE = "denisss-macbook-pro-m1.tail5cc90b.ts.net"; const state = mkdtempSync(join(tmpdir(), "herdr-sole-user-")); - const sole = createServer({ port: 0, stateDir: state, tailscaleOwner: OWNER, tailscaleSoleUser: true }); + const savedOwner = process.env["HERDR_WEB_TAILSCALE_OWNER"]; + delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; + // what `tailscale status --json` says for the reported tailnet: one login, untagged, and this Mac's name + const status = JSON.stringify({ BackendState: "Running", Self: { DNSName: `${NODE}.`, UserID: 7 }, Peer: { phone: { DNSName: "phone.tail5cc90b.ts.net.", UserID: 7 } }, User: { "7": { LoginName: OWNER } } }); + await forgetTailscaleIdentity(); + rememberTailnetStatus(status); + const sole = createServer({ port: 0, stateDir: state }); + const named = createServer({ port: 0, stateDir: state, tailscaleOwner: OWNER }); + const other = createServer({ port: 0, stateDir: state, tailscaleOwner: "named@example.com" }); + 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; + // 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}:7317` }; try { - const at = async (headers: Record) => ((await (await fetch(`http://127.0.0.1:${sole.port}/api/health?scope=bridge`, { headers })).json()) as { auth: HealthAuth }).auth; - // the phone's request: tailscale serve proxied it and named no person, with no tagged node on the tailnet to be - expect(await at(proxied())).toMatchObject({ authenticated: true, via: "tailscale" }); - expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: proxied() })).status).not.toBe(401); + expect(await at(sole.port, phone)).toMatchObject({ authenticated: true, via: "tailscale" }); + expect(await at(sole.port, { ...phone, host: NODE.toUpperCase() })).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 tailnet's no-login request under a public domain is a visitor through a proxy: it pairs + expect(await at(sole.port, { ...proxied(), host: "herdr.example.com" })).toMatchObject({ authenticated: false, reason: "pairing_required" }); + expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: { ...proxied(), host: "herdr.example.com" } })).status).toBe(401); // and the floor stays where it was: another login, Funnel and a LAN client gain nothing - expect(await at(proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" }); - expect(await at(proxied(undefined, { "tailscale-funnel-request": "?1" }))).toMatchObject({ authenticated: false, reason: "pairing_required" }); - expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" }); + 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" }); + // 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(); + named.stop(); + other.stop(); + await forgetTailscaleIdentity(); + if (savedOwner === undefined) delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; else process.env["HERDR_WEB_TAILSCALE_OWNER"] = savedOwner; rmSync(state, { recursive: true, force: true }); } }); diff --git a/server/index.ts b/server/index.ts index 62d14c0549..a2324d6dca 100644 --- a/server/index.ts +++ b/server/index.ts @@ -313,8 +313,6 @@ 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; - /** one login owns every node of the tailnet and none is tagged; only a test names it, otherwise `tailscale status` proves it */ - tailscaleSoleUser?: boolean; /** Native Codex store; defaults to CODEX_HOME. Tests use an isolated store. */ codexHome?: string; updates?: UpdateService; @@ -380,7 +378,7 @@ export function createServer( /** 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, soleUser: options.tailscaleSoleUser ?? false }) + ? () => ({ ...tailscaleIdentity(), owner: namedOwner, tagged: false }) : tailscaleIdentity; identityOf(); @@ -1018,6 +1016,7 @@ export function createServer( forwarded: cameThroughProxy(request.headers), funnel: request.headers.has("tailscale-funnel-request"), tailscaleLogin: request.headers.get("tailscale-user-login"), + host: request.headers.get("host"), tokenMatched: token !== "" && isAuthenticated(request, token), device: devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)), ...identityOf(), diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index bde29e5810..9e359f7d33 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -1,40 +1,41 @@ import { describe, expect, it } from "bun:test"; -import { parseSoleTailnetUser, parseTailscale, parseTailscaleIp } from "./tailscale.ts"; +import { parseSoleTailnetLogin, parseTailscale, parseTailscaleIp } 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 */ +/** 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("parseSoleTailnetUser", () => { - it("proves a tailnet one login owns, phones and offline peers included", () => { +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(parseSoleTailnetUser(tailnet([7511875822626835, undefined], Array.from({ length: 6 }, () => [7511875822626835, undefined] as [number, undefined])))).toBe(true); - expect(parseSoleTailnetUser(tailnet([42, undefined]))).toBe(true); - expect(parseSoleTailnetUser(JSON.stringify({ BackendState: "Running", Self: { UserID: 42 } }))).toBe(true); + 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("refuses to prove it where a second login or any tag exists", () => { - expect(parseSoleTailnetUser(tailnet([42, undefined], [[42, undefined], [43, undefined]]))).toBe(false); - expect(parseSoleTailnetUser(tailnet([42, undefined], [[42, undefined], [42, ["tag:ci"]]]))).toBe(false); - expect(parseSoleTailnetUser(tailnet([42, ["tag:server"]], [[42, undefined]]))).toBe(false); + 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(parseSoleTailnetUser(JSON.stringify({ BackendState: "Running", Self: { UserID: 42 }, Peer: { k: { DNSName: "p.example.ts.net." } } }))).toBe(false); + expect(parseSoleTailnetLogin(JSON.stringify({ BackendState: "Running", Self: { UserID: 42 }, Peer: { k: { DNSName: "p.example.ts.net." } } }))).toBeNull(); }); - it("proves nothing from a status it could not read, or a daemon that is not running", () => { - expect(parseSoleTailnetUser(tailnet([42, undefined], [], "Stopped"))).toBe(false); - expect(parseSoleTailnetUser(tailnet([42, undefined], [], "NeedsLogin"))).toBe(false); - expect(parseSoleTailnetUser(JSON.stringify({ BackendState: "Running" }))).toBe(false); - expect(parseSoleTailnetUser("not json")).toBe(false); - expect(parseSoleTailnetUser(null)).toBe(false); + 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(); }); }); diff --git a/server/tailscale.ts b/server/tailscale.ts index ab3ed35c07..7c0e12d99b 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -54,7 +54,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; @@ -116,26 +116,32 @@ export function parseTailscaleOwner(status: string | null): string | null { return parsed?.User?.[String(id)]?.LoginName || 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; +} + /** - * Does `tailscale status --json` prove that one login owns every node of this tailnet, and that - * none of them is tagged? 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 + * 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 - * false: the proof has to be positive, never the benefit of the doubt. + * null: the proof has to be positive, never the benefit of the doubt. */ -export function parseSoleTailnetUser(status: string | null): boolean { +export function parseSoleTailnetLogin(status: string | null): string | null { const parsed = parseJson(status); - if (parsed === null || parsed.BackendState !== "Running") return false; + if (parsed === null || parsed.BackendState !== "Running") return null; const self = parsed.Self; - if (self === undefined) return false; + if (self === undefined) return null; const owner = self.UserID; - if (owner === undefined || owner === null) return false; + if (owner === undefined || owner === null) return null; // offline peers count: they are nodes of this tailnet and can come back at any moment const nodes: NodeJson[] = [self, ...Object.values(parsed.Peer ?? {})]; - return nodes.every((node) => (node.Tags?.length ?? 0) === 0 + const sole = nodes.every((node) => (node.Tags?.length ?? 0) === 0 && node.UserID !== undefined && node.UserID !== null && String(node.UserID) === String(owner)); + return sole ? parsed.User?.[String(owner)]?.LoginName || null : null; } /** This PC's IPv4 address on the tailnet (100.x.y.z), from `tailscale status --json`; null when it has none. */ @@ -143,18 +149,42 @@ export function parseTailscaleIp(status: string | null): string | null { return parseJson(status)?.Self?.TailscaleIPs?.find((ip) => /^\d+\.\d+\.\d+\.\d+$/.test(ip)) ?? null; } +export interface TailnetIdentity { + owner: string | null; + tagged: boolean; + soleLogin: string | null; + dnsName: string | null; +} + const OWNER_TTL_MS = 5 * 60_000; -let ownerCache: { owner: string | null; tagged: boolean; soleUser: boolean; at: number } | null = null; +let ownerCache: (TailnetIdentity & { at: number }) | null = null; let ownerRefresh: Promise | null = null; +/** Keeps what `tailscale status --json` says about this PC and its tailnet, as `tailscaleIdentity` reads it. */ +export function rememberTailnetStatus(status: string | null): void { + ownerCache = { + owner: parseTailscaleOwner(status), + tagged: isTaggedNode(status), + soleLogin: parseSoleTailnetLogin(status), + dnsName: parseNodeName(status), + at: Date.now(), + }; +} + +/** Tests: drops what was remembered, once any lookup in flight has landed. */ +export async function forgetTailscaleIdentity(): Promise { + await ownerRefresh; + ownerCache = null; +} + /** - * The PC's own Tailscale login, or that its node is tagged and has none, plus whether one login - * owns this whole tailnet (`parseSoleTailnetUser`), cached five minutes. + * The PC's own Tailscale login, or that its node is tagged and has none, the one login that owns + * this whole tailnet (`parseSoleTailnetLogin`), and this PC's MagicDNS name, 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. */ -export function tailscaleIdentity(): { owner: string | null; tagged: boolean; soleUser: boolean } { +export function tailscaleIdentity(): TailnetIdentity { const now = Date.now(); if ((ownerCache === null || now - ownerCache.at >= OWNER_TTL_MS) && ownerRefresh === null) { ownerRefresh = (async () => { @@ -162,9 +192,14 @@ export function tailscaleIdentity(): { owner: string | null; tagged: boolean; so 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), soleUser: parseSoleTailnetUser(status), at: Date.now() }; + if (binary !== null && status === null && ownerCache !== null) ownerCache = { ...ownerCache, at: Date.now() }; + else rememberTailnetStatus(status); })().finally(() => { ownerRefresh = null; }); } - return { owner: ownerCache?.owner ?? null, tagged: ownerCache?.tagged ?? false, soleUser: ownerCache?.soleUser ?? false }; + return { + owner: ownerCache?.owner ?? null, + tagged: ownerCache?.tagged ?? false, + soleLogin: ownerCache?.soleLogin ?? null, + dnsName: ownerCache?.dnsName ?? null, + }; } From 2c33b859844838adb20349a50b8c6dc7b1a2d2d4 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 10:11:54 +0300 Subject: [PATCH 03/15] no-mistakes(review): make owner no-login grant an explicit serve-only opt-in --- CHANGELOG.md | 16 ++++++------- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/guide.md | 9 ++++---- server/AGENTS.md | 2 +- server/access.test.ts | 27 ++++++++-------------- server/access.ts | 46 +++++++++++++++---------------------- server/api.contract.test.ts | 41 ++++++++++++++++++--------------- server/index.ts | 5 +++- server/tailscale.ts | 14 +++-------- 12 files changed, 75 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b58430b784..b0f1d4e364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,14 +62,14 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#493](https://github.com/devswha/herdr-web-ui/pull/493) by @kilhyeonjun) ### Fixed -- 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, - when the address is this PC's own Tailscale name. 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. Another Tailscale login is still refused, a tagged node still pairs, a public domain - forwarded to this PC 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. +- 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. - A Claude Code pane started with its own `CLAUDE_CONFIG_DIR` (a launcher such as cac keeps one store per environment) shows its chat. Before, only `~/.claude` was searched, so the chat was unavailable and only the terminal worked. The store is read from the pane's Claude process, as diff --git a/README.ja.md b/README.ja.md index d7d79fa7eb..c0ecbfdf11 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 コードが表示されます。スマートフォンからコードなしで接続するには `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` を設定します。ただし、公開プロキシやトンネルなど別の経路でこのポートに届く場合は設定しないでください([アクセスと安全性](docs/guide.md#access-and-safety))。

インストーラーの出力:Bun、Node、herdr プラグインのインストール後、tailscale serve でアプリへのアクセスを有効にし、スマートフォン用の QR コードを表示します。 diff --git a/README.ko.md b/README.ko.md index f060b24156..77ac98925f 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 코드가 나옵니다. 폰에서 코드 없이 들어가려면 `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`을 설정하세요. 단, 공용 프록시나 터널 등 다른 경로로 이 포트에 접근할 수 있다면 설정하지 마세요 ([접근과 안전](docs/guide.md#access-and-safety)).

설치 스크립트 출력: Bun, Node, herdr 플러그인이 설치되고 tailscale serve가 앱을 공개한 뒤 폰용 QR 코드가 나옵니다. diff --git a/README.md b/README.md index c1fafb79c8..7721e95ef6 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. To let your own phone in without a code, set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`, 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..6f0b6eb4b4 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 内的访问地址和二维码。若要让自己的手机无需验证码即可进入,请设置 `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`,但仅限于没有公共代理或隧道等其他途径能访问此端口的情况([访问与安全](docs/guide.md#access-and-safety))。

安装程序输出:安装 Bun、Node 和 herdr 插件,然后通过 tailscale serve 提供应用访问地址,并显示供手机扫描的二维码。 diff --git a/docs/guide.md b/docs/guide.md index 83c9bd7b32..aa6efcb853 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -210,7 +210,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, and only yours get in without a code: see [Access and safety](#access-and-safety). To let your own phone in without a code, set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` on this PC, and only when nothing else, such as a public reverse proxy or tunnel, reaches this port. Without it, the phone pairs 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. @@ -256,7 +256,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. Where `tailscale status` shows a tailnet that one login owns with no tagged node anywhere on it, a serve request that states no login is the owner as well when it is addressed to this computer's own Tailscale name: no one else is on that tailnet to be mistaken for, so your own phone opens that 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 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` shows a tailnet that one login owns with no tagged node anywhere on it, a serve request that states no login is the owner as well: no one else is on that tailnet to be mistaken for, 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. @@ -264,12 +264,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, or the tailnet's one login at this computer's Tailscale name. 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, unless that tailnet has one login and no tagged node and the request is addressed to this computer's Tailscale name, where it is the owner's own device. A public domain forwarded here still 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. +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. @@ -352,6 +352,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 bdd0068d17..42cf834893 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, by login header, or with no login header on a tailnet one login provably owns when addressed to this PC's MagicDNS name → 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 0170468f9d..dca9544933 100644 --- a/server/access.test.ts +++ b/server/access.test.ts @@ -2,8 +2,7 @@ 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, soleLogin: null, dnsName: null, host: null, tokenConfigured: false, gated: false }; -const node = "pc.tail5cc90b.ts.net"; +const base: AccessInput = { loopback: true, forwarded: false, funnel: false, tailscaleLogin: null, tokenMatched: false, device: null, owner: null, tagged: false, soleLogin: null, serveOnly: false, tokenConfigured: false, gated: false }; const via = (input: Partial) => { const a = decideAccess({ ...base, ...input }); return a.level === "full" ? a.via : `refused:${a.reason}`; }; describe("decideAccess", () => { @@ -37,28 +36,20 @@ 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, on a tailnet one login owns, addressed to this PC's name", () => { - // the phone's shape: tailscale serve proxies from loopback, states no person, is addressed to this PC's name, nothing is paired - const phone = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", dnsName: node, host: `${node}:7317` } as const; + 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 and states no person; nothing is paired + const phone = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", serveOnly: true } as const; expect(via(phone)).toBe("tailscale"); expect(via({ ...phone, gated: true })).toBe("tailscale"); - expect(via({ ...phone, host: node.toUpperCase() })).toBe("tailscale"); expect(decideAccess({ ...base, ...phone })).toEqual({ level: "full", via: "tailscale", role: "drive", login: "me@example.com" }); - // the same request where Tailscale proves nothing, or where it names no address, is the stranger it always was + // 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"); - expect(via({ ...phone, host: null })).toBe("refused:pairing_required"); - }); - - it("does not take a public domain forwarded to this PC for the owner, even on a tailnet one login owns", () => { - const sole = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", dnsName: node } as const; - // nginx or Caddy forwards the visitor's public Host, and no login - expect(via({ ...sole, host: "herdr.example.com" })).toBe("refused:pairing_required"); - expect(via({ ...sole, host: `${node}.evil.example` })).toBe("refused:pairing_required"); - expect(via({ ...sole, host: "herdr.example.com", tokenConfigured: true })).toBe("refused:token_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", dnsName: node, host: node } as const; + const sole = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", serveOnly: true } 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"); @@ -69,7 +60,7 @@ describe("decideAccess", () => { 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", dnsName: node, host: node })).toBe("refused:pairing_required"); + 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"); diff --git a/server/access.ts b/server/access.ts index d11a3f23b1..93bf82f3c2 100644 --- a/server/access.ts +++ b/server/access.ts @@ -11,10 +11,11 @@ * 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. The - * exception is 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 that is addressed to - * this PC's own Tailscale name is the owner, and the owner's own phone opens the address without - * a code. A public domain forwarded here is still a stranger: its Host is not that name. + * 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. Nothing else is read + * from the request to decide it, since a visitor sends any Host a proxy passes on. * 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. * @@ -45,10 +46,8 @@ export interface AccessInput { 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, the one `tailscale serve` answers to, without its trailing dot */ - dnsName: string | null; - /** the request's Host header, as sent */ - host: 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; @@ -66,20 +65,15 @@ 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"]; /** - * 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. + * 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. */ -export function hostName(host: string): string | null { - const authority = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::\d{1,5})?$/i.exec(host); - 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); - if (name === null) return false; + 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); } @@ -105,14 +99,12 @@ 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 - // Tailscale says one login owns every node on this tailnet and none is tagged, and the request - // is addressed to this PC's own name, there is nobody for it to be but the owner, so the owner's - // own phone gets in by opening the address. Funnel is the public internet, which that proof says - // nothing about, and a proxy forwarding a public domain does not address this PC's name. + // 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 and Tailscale says one login owns every node + // on this tailnet, none tagged, there is nobody for it to be but the owner. Funnel is the public + // internet, which that proof says nothing about. const soleOwner = input.soleLogin !== null && input.owner !== null && input.soleLogin.toLowerCase() === input.owner.toLowerCase(); - const addressedHere = input.host !== null && input.dnsName !== null && hostName(input.host) === input.dnsName.toLowerCase(); - if (input.loopback && input.forwarded && !input.funnel && input.tailscaleLogin === null && input.owner !== null && soleOwner && addressedHere) { + if (input.serveOnly && input.loopback && input.forwarded && !input.funnel && input.tailscaleLogin === null && input.owner !== null && soleOwner) { return { level: "full", via: "tailscale", role: "drive", login: input.owner }; } if (input.loopback && !input.forwarded) return { level: "full", via: "local", role: "drive" }; diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 2f85a05054..25c19eaba9 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -1795,37 +1795,40 @@ 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, on a tailnet one login owns, at this PC's Tailscale name", async () => { - const NODE = "denisss-macbook-pro-m1.tail5cc90b.ts.net"; + 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, untagged, and this Mac's name - const status = JSON.stringify({ BackendState: "Running", Self: { DNSName: `${NODE}.`, UserID: 7 }, Peer: { phone: { DNSName: "phone.tail5cc90b.ts.net.", UserID: 7 } }, User: { "7": { LoginName: OWNER } } }); + // what `tailscale status --json` says for the reported tailnet: one login, every node untagged + const status = JSON.stringify({ BackendState: "Running", Self: { DNSName: "denisss-macbook-pro-m1.tail5cc90b.ts.net.", UserID: 7 }, Peer: { phone: { DNSName: "phone.tail5cc90b.ts.net.", UserID: 7 } }, User: { "7": { LoginName: OWNER } } }); await forgetTailscaleIdentity(); rememberTailnetStatus(status); + process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] = "1"; const sole = createServer({ port: 0, stateDir: state }); - const named = createServer({ port: 0, stateDir: state, tailscaleOwner: OWNER }); - const other = createServer({ port: 0, stateDir: state, tailscaleOwner: "named@example.com" }); + 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 }); + const named = createServer({ port: 0, stateDir: state, tailscaleOwner: OWNER, tailscaleServeOnly: true }); + const other = createServer({ port: 0, stateDir: state, tailscaleOwner: "named@example.com", tailscaleServeOnly: true }); 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; - // 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}:7317` }; try { - expect(await at(sole.port, phone)).toMatchObject({ authenticated: true, via: "tailscale" }); - expect(await at(sole.port, { ...phone, host: NODE.toUpperCase() })).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 tailnet's no-login request under a public domain is a visitor through a proxy: it pairs - expect(await at(sole.port, { ...proxied(), host: "herdr.example.com" })).toMatchObject({ authenticated: false, reason: "pairing_required" }); - expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: { ...proxied(), host: "herdr.example.com" } })).status).toBe(401); + // the captain's phone: tailscale serve proxied it and names no person + expect(await at(sole.port, proxied())).toMatchObject({ authenticated: true, via: "tailscale" }); + expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: proxied() })).status).not.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" }); + expect(await at(sole.port, proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" }); + expect(await at(sole.port, proxied(undefined, { "tailscale-funnel-request": "?1" }))).toMatchObject({ authenticated: false, reason: "pairing_required" }); + expect(await at(sole.port, proxied(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, { ...proxied(), host: "denisss-macbook-pro-m1.tail5cc90b.ts.net" })).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" }); + expect(await at(named.port, proxied())).toMatchObject({ authenticated: true, via: "tailscale" }); + expect(await at(other.port, proxied())).toMatchObject({ authenticated: false, reason: "pairing_required" }); } finally { sole.stop(); + off.stop(); named.stop(); other.stop(); await forgetTailscaleIdentity(); diff --git a/server/index.ts b/server/index.ts index a2324d6dca..56fcb56e0c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -313,6 +313,8 @@ 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; /** Native Codex store; defaults to CODEX_HOME. Tests use an isolated store. */ codexHome?: string; updates?: UpdateService; @@ -381,6 +383,7 @@ export function createServer( ? () => ({ ...tailscaleIdentity(), owner: namedOwner, tagged: false }) : tailscaleIdentity; identityOf(); + 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 @@ -1016,10 +1019,10 @@ export function createServer( forwarded: cameThroughProxy(request.headers), funnel: request.headers.has("tailscale-funnel-request"), tailscaleLogin: request.headers.get("tailscale-user-login"), - host: request.headers.get("host"), tokenMatched: token !== "" && isAuthenticated(request, token), device: devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)), ...identityOf(), + serveOnly, tokenConfigured: token !== "", gated: devices.gated, }); diff --git a/server/tailscale.ts b/server/tailscale.ts index 7c0e12d99b..807b90c283 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -54,7 +54,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 = parseNodeName(output.status); + const dns = status.Self?.DNSName?.replace(/\.$/, "") || null; const serve = parseJson(output.serve); const taken = new Set(Object.keys(serve?.TCP ?? {}).map(Number).filter(Number.isFinite)); let servingUrl: string | null = null; @@ -116,11 +116,6 @@ export function parseTailscaleOwner(status: string | null): string | null { return parsed?.User?.[String(id)]?.LoginName || 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; -} - /** * 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 @@ -153,7 +148,6 @@ export interface TailnetIdentity { owner: string | null; tagged: boolean; soleLogin: string | null; - dnsName: string | null; } const OWNER_TTL_MS = 5 * 60_000; @@ -166,7 +160,6 @@ export function rememberTailnetStatus(status: string | null): void { owner: parseTailscaleOwner(status), tagged: isTaggedNode(status), soleLogin: parseSoleTailnetLogin(status), - dnsName: parseNodeName(status), at: Date.now(), }; } @@ -178,8 +171,8 @@ export async function forgetTailscaleIdentity(): Promise { } /** - * The PC's own Tailscale login, or that its node is tagged and has none, the one login that owns - * this whole tailnet (`parseSoleTailnetLogin`), and this PC's MagicDNS name, cached five minutes. + * 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, 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. @@ -200,6 +193,5 @@ export function tailscaleIdentity(): TailnetIdentity { owner: ownerCache?.owner ?? null, tagged: ownerCache?.tagged ?? false, soleLogin: ownerCache?.soleLogin ?? null, - dnsName: ownerCache?.dnsName ?? null, }; } From 34a60d5e0ab6c849f20e27c28e5a601604d79977 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 10:27:01 +0300 Subject: [PATCH 04/15] no-mistakes(review): re-read stale tailnet status before owner grant --- docs/guide.md | 2 +- server/api.contract.test.ts | 29 +++++++++++++++++++++-- server/index.ts | 26 +++++++++++++-------- server/tailscale.test.ts | 42 +++++++++++++++++++++++++++++++-- server/tailscale.ts | 46 ++++++++++++++++++++++++++++--------- 5 files changed, 119 insertions(+), 26 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index aa6efcb853..081865dc16 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -210,7 +210,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). To let your own phone in without a code, set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` on this PC, and only when nothing else, such as a public reverse proxy or tunnel, reaches this port. Without it, the phone pairs with a six-digit code. +Only devices in your tailnet can open that address. Your own devices get in without a code only when you set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` on this PC, and only when nothing else, such as a public reverse proxy or tunnel, reaches this port (see [Access and safety](#access-and-safety)). Without it, the phone pairs 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. diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 25c19eaba9..0d9fc236c5 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { describe, expect, it, beforeAll, afterAll, spyOn } from "bun:test"; import { chmodSync, mkdtempSync, existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -13,7 +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 { forgetTailscaleIdentity, rememberTailnetStatus } from "./tailscale.ts"; +import { forgetTailscaleIdentity, rememberTailnetStatus, setTailnetStatusReader } from "./tailscale.ts"; /** * Contract test for herdr-web-ui's HTTP + WS surface. @@ -1837,6 +1837,31 @@ describe("pairing and identity", () => { } }); + it("re-reads a stale tailnet status before it grants the owner's serve request", 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 sole = JSON.stringify({ BackendState: "Running", Self: { UserID: 7 }, User: { "7": { LoginName: OWNER } } }); + const tagged = JSON.stringify({ BackendState: "Running", Self: { UserID: 7, Tags: ["tag:server"] }, User: { "7": { LoginName: OWNER } } }); + await forgetTailscaleIdentity(); + rememberTailnetStatus(sole); + const start = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => start + 6 * 60_000); + setTailnetStatusReader(async () => tagged); + const serveOnly = createServer({ port: 0, stateDir: state, tailscaleServeOnly: true }); + try { + const auth = ((await (await fetch(`http://127.0.0.1:${serveOnly.port}/api/health?scope=bridge`, { headers: proxied() })).json()) as { auth: HealthAuth }).auth; + expect(auth).toMatchObject({ authenticated: false, reason: "pairing_required" }); + } finally { + serveOnly.stop(); + clock.mockRestore(); + setTailnetStatusReader(null); + await forgetTailscaleIdentity(); + 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 56fcb56e0c..7f89a488bf 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,7 +10,7 @@ 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 { DeviceStore, handleDeviceRequest } from "./devices.ts"; -import { remoteAccess, tailscaleIdentity } from "./tailscale.ts"; +import { freshTailscaleIdentity, remoteAccess, tailscaleIdentity } from "./tailscale.ts"; import { paneCommands } from "./commands.ts"; import { paneFiles } from "./files.ts"; import { badRequest, errorResponse, isCount, isJsonObject, jsonResponse } from "./http.ts"; @@ -379,10 +379,11 @@ 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 - ? () => ({ ...tailscaleIdentity(), owner: namedOwner, tagged: false }) - : tailscaleIdentity; - identityOf(); + const identityOf = async (fresh: boolean) => { + const identity = fresh ? await freshTailscaleIdentity() : tailscaleIdentity(); + return namedOwner !== undefined ? { ...identity, owner: namedOwner, tagged: false } : identity; + }; + identityOf(false); const serveOnly = options.tailscaleServeOnly ?? process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] === "1"; /** @@ -1014,14 +1015,19 @@ 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 ownerGrantPath = serveOnly && loopback && forwarded && !funnel && tailscaleLogin === null; 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"), + loopback, + forwarded, + funnel, + tailscaleLogin, tokenMatched: token !== "" && isAuthenticated(request, token), device: devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)), - ...identityOf(), + ...(await identityOf(ownerGrantPath)), serveOnly, tokenConfigured: token !== "", gated: devices.gated, diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index 9e359f7d33..f10a965a0f 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "bun:test"; -import { parseSoleTailnetLogin, parseTailscale, parseTailscaleIp } from "./tailscale.ts"; +import { describe, expect, it, spyOn } from "bun:test"; +import { decideAccess, type AccessInput } from "./access.ts"; +import { forgetTailscaleIdentity, freshTailscaleIdentity, parseSoleTailnetLogin, parseTailscale, parseTailscaleIp, rememberTailnetStatus, setTailnetStatusReader } 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 */ @@ -39,6 +40,43 @@ describe("parseSoleTailnetLogin", () => { }); }); +/** the access decision for a no-login request that `tailscale serve` forwarded, with the operator's serve-only switch on */ +const serveRequest = (identity: { owner: string | null; tagged: boolean; soleLogin: string | null }) => { + const input: AccessInput = { loopback: true, forwarded: true, funnel: false, tailscaleLogin: null, tokenMatched: false, device: null, ...identity, serveOnly: true, tokenConfigured: false, gated: false }; + const access = decideAccess(input); + return access.level === "full" ? access.via : `refused:${access.reason}`; +}; + +/** warms the identity cache with `status`, then moves the clock past its five-minute TTL and answers later reads with `read` */ +async function afterTtl(status: string | null, read: () => Promise, body: () => Promise): Promise { + await forgetTailscaleIdentity(); + rememberTailnetStatus(status); + const start = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => start + 6 * 60_000); + setTailnetStatusReader(read); + try { await body(); } finally { clock.mockRestore(); setTailnetStatusReader(null); await forgetTailscaleIdentity(); } +} + +describe("freshTailscaleIdentity", () => { + it("does not grant the owner on a sole-login read that a tagged node has since made stale", async () => { + await afterTtl(tailnet([42, undefined]), async () => tailnet([42, undefined], [[42, ["tag:server"]]]), async () => { + expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + }); + }); + + it("grants the owner's first request after idle when the fresh read proves one login owns the tailnet", async () => { + await afterTtl(null, async () => tailnet([42, undefined]), async () => { + expect(serveRequest(await freshTailscaleIdentity())).toBe("tailscale"); + }); + }); + + it("withdraws the sole-login proof when the fresh read fails, and keeps pairing the stranger", async () => { + await afterTtl(tailnet([42, undefined]), async () => null, async () => { + expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + }); + }); +}); + 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 807b90c283..024335fb2b 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -170,6 +170,29 @@ export async function forgetTailscaleIdentity(): Promise { ownerCache = null; } +const cliStatus = async (): Promise => { + const binary = tailscaleBinary(); + return binary === null ? null : run(binary, ["status", "--json"]); +}; +let readStatus = cliStatus; + +/** Tests: supplies the status text in place of the tailscale CLI; null restores the CLI. */ +export function setTailnetStatusReader(read: (() => Promise) | null): void { + readStatus = read ?? cliStatus; +} + +function refreshIdentity(): Promise { + ownerRefresh ??= (async () => { + const status = await readStatus(); + // a read that failed keeps what it last said about the owner, so the owner is not taken for a + // stranger, nor a stranger let in, until it is asked again; it withdraws the sole-login proof, + // since a grant never rests on a read that failed + if (status === null && ownerCache !== null) ownerCache = { ...ownerCache, soleLogin: null, at: Date.now() }; + else rememberTailnetStatus(status); + })().finally(() => { ownerRefresh = null; }); + return ownerRefresh; +} + /** * 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. @@ -178,20 +201,21 @@ export async function forgetTailscaleIdentity(): Promise { * usually there before any request. */ export function tailscaleIdentity(): TailnetIdentity { - 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 - if (binary !== null && status === null && ownerCache !== null) ownerCache = { ...ownerCache, at: Date.now() }; - else rememberTailnetStatus(status); - })().finally(() => { ownerRefresh = null; }); - } + if (ownerCache === null || Date.now() - ownerCache.at >= OWNER_TTL_MS) void refreshIdentity(); return { owner: ownerCache?.owner ?? null, tagged: ownerCache?.tagged ?? false, soleLogin: ownerCache?.soleLogin ?? null, }; } + +/** + * `tailscaleIdentity` for a request the owner's sole-login proof would admit: when the cached read is + * older than the TTL it waits for a fresh read first, bounded by the status read's own timeout, so a + * tagged node that joined since the last read is seen before the decision. A failed read leaves no + * sole-login proof. Every other request uses `tailscaleIdentity`. + */ +export async function freshTailscaleIdentity(): Promise { + if (ownerCache === null || Date.now() - ownerCache.at >= OWNER_TTL_MS) await refreshIdentity(); + return tailscaleIdentity(); +} From c3fc262b37fa218746dc8e5db40c15189ca58aae Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 10:38:05 +0300 Subject: [PATCH 05/15] no-mistakes(review): read tailnet status on every serve-path owner grant --- server/access.ts | 2 +- server/api.contract.test.ts | 12 ++++-------- server/tailscale.test.ts | 22 ++++++++++++---------- server/tailscale.ts | 27 ++++++++++++++------------- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/server/access.ts b/server/access.ts index 93bf82f3c2..b94106d752 100644 --- a/server/access.ts +++ b/server/access.ts @@ -108,7 +108,7 @@ export function decideAccess(input: AccessInput): Access { return { level: "full", via: "tailscale", role: "drive", login: input.owner }; } 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 0d9fc236c5..d9bd60c620 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeAll, afterAll, spyOn } from "bun:test"; +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; import { chmodSync, mkdtempSync, existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -13,7 +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 { forgetTailscaleIdentity, rememberTailnetStatus, setTailnetStatusReader } from "./tailscale.ts"; +import { forgetTailscaleIdentity, setTailnetStatusReader } from "./tailscale.ts"; /** * Contract test for herdr-web-ui's HTTP + WS surface. @@ -1803,7 +1803,7 @@ describe("pairing and identity", () => { // what `tailscale status --json` says for the reported tailnet: one login, every node untagged const status = JSON.stringify({ BackendState: "Running", Self: { DNSName: "denisss-macbook-pro-m1.tail5cc90b.ts.net.", UserID: 7 }, Peer: { phone: { DNSName: "phone.tail5cc90b.ts.net.", UserID: 7 } }, User: { "7": { LoginName: OWNER } } }); await forgetTailscaleIdentity(); - rememberTailnetStatus(status); + setTailnetStatusReader(async () => status); process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] = "1"; const sole = createServer({ port: 0, stateDir: state }); if (savedServeOnly === undefined) delete process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"]; else process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] = savedServeOnly; @@ -1831,6 +1831,7 @@ describe("pairing and identity", () => { off.stop(); named.stop(); other.stop(); + setTailnetStatusReader(null); await forgetTailscaleIdentity(); if (savedOwner === undefined) delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; else process.env["HERDR_WEB_TAILSCALE_OWNER"] = savedOwner; rmSync(state, { recursive: true, force: true }); @@ -1841,12 +1842,8 @@ describe("pairing and identity", () => { const state = mkdtempSync(join(tmpdir(), "herdr-stale-identity-")); const savedOwner = process.env["HERDR_WEB_TAILSCALE_OWNER"]; delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; - const sole = JSON.stringify({ BackendState: "Running", Self: { UserID: 7 }, User: { "7": { LoginName: OWNER } } }); const tagged = JSON.stringify({ BackendState: "Running", Self: { UserID: 7, Tags: ["tag:server"] }, User: { "7": { LoginName: OWNER } } }); await forgetTailscaleIdentity(); - rememberTailnetStatus(sole); - const start = Date.now(); - const clock = spyOn(Date, "now").mockImplementation(() => start + 6 * 60_000); setTailnetStatusReader(async () => tagged); const serveOnly = createServer({ port: 0, stateDir: state, tailscaleServeOnly: true }); try { @@ -1854,7 +1851,6 @@ describe("pairing and identity", () => { expect(auth).toMatchObject({ authenticated: false, reason: "pairing_required" }); } finally { serveOnly.stop(); - clock.mockRestore(); setTailnetStatusReader(null); await forgetTailscaleIdentity(); if (savedOwner === undefined) delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; else process.env["HERDR_WEB_TAILSCALE_OWNER"] = savedOwner; diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index f10a965a0f..efa082f3a8 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -47,31 +47,33 @@ const serveRequest = (identity: { owner: string | null; tagged: boolean; soleLog return access.level === "full" ? access.via : `refused:${access.reason}`; }; -/** warms the identity cache with `status`, then moves the clock past its five-minute TTL and answers later reads with `read` */ -async function afterTtl(status: string | null, read: () => Promise, body: () => Promise): Promise { +/** warms the identity cache with `status`, then moves the clock `minutesAgo` past that read and answers later reads with `read` */ +async function withCachedStatus(status: string | null, minutesAgo: number, read: () => Promise, body: () => Promise): Promise { await forgetTailscaleIdentity(); rememberTailnetStatus(status); const start = Date.now(); - const clock = spyOn(Date, "now").mockImplementation(() => start + 6 * 60_000); + const clock = spyOn(Date, "now").mockImplementation(() => start + minutesAgo * 60_000); setTailnetStatusReader(read); try { await body(); } finally { clock.mockRestore(); setTailnetStatusReader(null); await forgetTailscaleIdentity(); } } describe("freshTailscaleIdentity", () => { - it("does not grant the owner on a sole-login read that a tagged node has since made stale", async () => { - await afterTtl(tailnet([42, undefined]), async () => tailnet([42, undefined], [[42, ["tag:server"]]]), async () => { - expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); - }); + it("does not grant the owner on a sole-login read that a tagged node has since made stale, within the cache's TTL or after it", async () => { + for (const minutesAgo of [2, 6]) { + await withCachedStatus(tailnet([42, undefined]), minutesAgo, async () => tailnet([42, undefined], [[42, ["tag:server"]]]), async () => { + expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + }); + } }); - it("grants the owner's first request after idle when the fresh read proves one login owns the tailnet", async () => { - await afterTtl(null, async () => tailnet([42, undefined]), async () => { + it("grants the owner's request when the fresh read proves one login owns the tailnet, after idle", async () => { + await withCachedStatus(null, 2, async () => tailnet([42, undefined]), async () => { expect(serveRequest(await freshTailscaleIdentity())).toBe("tailscale"); }); }); it("withdraws the sole-login proof when the fresh read fails, and keeps pairing the stranger", async () => { - await afterTtl(tailnet([42, undefined]), async () => null, async () => { + await withCachedStatus(tailnet([42, undefined]), 2, async () => null, async () => { expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); }); }); diff --git a/server/tailscale.ts b/server/tailscale.ts index 024335fb2b..528815a327 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -181,15 +181,17 @@ export function setTailnetStatusReader(read: (() => Promise) | nu readStatus = read ?? cliStatus; } +async function readIdentity(): Promise { + const status = await readStatus(); + // a read that failed keeps what it last said about the owner, so the owner is not taken for a + // stranger, nor a stranger let in, until it is asked again; it withdraws the sole-login proof, + // since a grant never rests on a read that failed + if (status === null && ownerCache !== null) ownerCache = { ...ownerCache, soleLogin: null, at: Date.now() }; + else rememberTailnetStatus(status); +} + function refreshIdentity(): Promise { - ownerRefresh ??= (async () => { - const status = await readStatus(); - // a read that failed keeps what it last said about the owner, so the owner is not taken for a - // stranger, nor a stranger let in, until it is asked again; it withdraws the sole-login proof, - // since a grant never rests on a read that failed - if (status === null && ownerCache !== null) ownerCache = { ...ownerCache, soleLogin: null, at: Date.now() }; - else rememberTailnetStatus(status); - })().finally(() => { ownerRefresh = null; }); + ownerRefresh ??= readIdentity().finally(() => { ownerRefresh = null; }); return ownerRefresh; } @@ -210,12 +212,11 @@ export function tailscaleIdentity(): TailnetIdentity { } /** - * `tailscaleIdentity` for a request the owner's sole-login proof would admit: when the cached read is - * older than the TTL it waits for a fresh read first, bounded by the status read's own timeout, so a - * tagged node that joined since the last read is seen before the decision. A failed read leaves no - * sole-login proof. Every other request uses `tailscaleIdentity`. + * `tailscaleIdentity` for a request the owner's sole-login proof would admit: it always reads the + * status now, bounded by the read's own timeout, so a node tagged since the last read is seen before + * the decision. A failed read leaves no sole-login proof. Every other request uses `tailscaleIdentity`. */ export async function freshTailscaleIdentity(): Promise { - if (ownerCache === null || Date.now() - ownerCache.at >= OWNER_TTL_MS) await refreshIdentity(); + await readIdentity(); return tailscaleIdentity(); } From dff3029938e9bc8c08cc0e8c28dc2d7b6f71525e Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 10:45:51 +0300 Subject: [PATCH 06/15] no-mistakes(review): read tailnet status only before owner grants, single-flight --- server/access.ts | 8 ++++++-- server/api.contract.test.ts | 8 +++++--- server/index.ts | 17 +++++++---------- server/tailscale.test.ts | 36 ++++++++++++++++++++++++++++-------- server/tailscale.ts | 19 +++++++++++-------- 5 files changed, 57 insertions(+), 31 deletions(-) diff --git a/server/access.ts b/server/access.ts index b94106d752..33e3237b5d 100644 --- a/server/access.ts +++ b/server/access.ts @@ -89,6 +89,10 @@ export function cameThroughProxy(headers: Headers): boolean { return host === null || !isLoopbackHost(host); } +export function isOwnerGrantPath(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 }; @@ -103,8 +107,8 @@ export function decideAccess(input: AccessInput): Access { // operator declared tailscale serve the only ingress and Tailscale says one login owns every node // on this tailnet, none tagged, there is nobody for it to be but the owner. Funnel is the public // internet, which that proof says nothing about. - const soleOwner = input.soleLogin !== null && input.owner !== null && input.soleLogin.toLowerCase() === input.owner.toLowerCase(); - if (input.serveOnly && input.loopback && input.forwarded && !input.funnel && input.tailscaleLogin === null && input.owner !== null && soleOwner) { + const soleOwner = input.soleLogin !== null && input.soleLogin.toLowerCase() === input.owner?.toLowerCase(); + if (isOwnerGrantPath(input) && input.owner !== null && soleOwner) { return { level: "full", via: "tailscale", role: "drive", login: input.owner }; } if (input.loopback && !input.forwarded) return { level: "full", via: "local", role: "drive" }; diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index d9bd60c620..a592dceead 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -13,7 +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 { forgetTailscaleIdentity, setTailnetStatusReader } from "./tailscale.ts"; +import { forgetTailscaleIdentity, rememberTailnetStatus, setTailnetStatusReader } from "./tailscale.ts"; /** * Contract test for herdr-web-ui's HTTP + WS surface. @@ -1838,12 +1838,14 @@ describe("pairing and identity", () => { } }); - it("re-reads a stale tailnet status before it grants the owner's serve request", async () => { + 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 tagged = JSON.stringify({ BackendState: "Running", Self: { UserID: 7, Tags: ["tag:server"] }, User: { "7": { LoginName: OWNER } } }); + const sole = JSON.stringify({ BackendState: "Running", Self: { UserID: 7 }, User: { "7": { LoginName: OWNER } } }); + const tagged = JSON.stringify({ BackendState: "Running", Self: { UserID: 7 }, Peer: { node: { UserID: 7, Tags: ["tag:server"] } }, User: { "7": { LoginName: OWNER } } }); await forgetTailscaleIdentity(); + rememberTailnetStatus(sole); setTailnetStatusReader(async () => tagged); const serveOnly = createServer({ port: 0, stateDir: state, tailscaleServeOnly: true }); try { diff --git a/server/index.ts b/server/index.ts index 7f89a488bf..dfdbd785dc 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,7 @@ 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, isOwnerGrantPath } from "./access.ts"; import { DeviceStore, handleDeviceRequest } from "./devices.ts"; import { freshTailscaleIdentity, remoteAccess, tailscaleIdentity } from "./tailscale.ts"; import { paneCommands } from "./commands.ts"; @@ -379,9 +379,9 @@ 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 = async (fresh: boolean) => { - const identity = fresh ? await freshTailscaleIdentity() : tailscaleIdentity(); - return namedOwner !== undefined ? { ...identity, owner: namedOwner, tagged: false } : identity; + const identityOf = async (grantPath: boolean) => { + if (namedOwner !== undefined) return { ...(grantPath ? await freshTailscaleIdentity() : { soleLogin: null }), owner: namedOwner, tagged: false }; + return grantPath ? freshTailscaleIdentity() : tailscaleIdentity(); }; identityOf(false); const serveOnly = options.tailscaleServeOnly ?? process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] === "1"; @@ -1019,15 +1019,12 @@ export function createServer( const forwarded = cameThroughProxy(request.headers); const funnel = request.headers.has("tailscale-funnel-request"); const tailscaleLogin = request.headers.get("tailscale-user-login"); - const ownerGrantPath = serveOnly && loopback && forwarded && !funnel && tailscaleLogin === null; + const requestShape = { loopback, forwarded, funnel, tailscaleLogin, serveOnly }; const access = decideAccess({ - loopback, - forwarded, - funnel, - tailscaleLogin, + ...requestShape, tokenMatched: token !== "" && isAuthenticated(request, token), device: devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)), - ...(await identityOf(ownerGrantPath)), + ...(await identityOf(isOwnerGrantPath(requestShape))), serveOnly, tokenConfigured: token !== "", gated: devices.gated, diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index efa082f3a8..bca633c785 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -47,10 +47,10 @@ const serveRequest = (identity: { owner: string | null; tagged: boolean; soleLog return access.level === "full" ? access.via : `refused:${access.reason}`; }; -/** warms the identity cache with `status`, then moves the clock `minutesAgo` past that read and answers later reads with `read` */ -async function withCachedStatus(status: string | null, minutesAgo: number, read: () => Promise, body: () => Promise): Promise { +/** warms the identity cache with `status` (none, when undefined), moves the clock `minutesAgo` past that read, and answers reads with `read` */ +async function withCachedStatus(status: string | null | undefined, minutesAgo: number, read: () => Promise, body: () => Promise): Promise { await forgetTailscaleIdentity(); - rememberTailnetStatus(status); + if (status !== undefined) rememberTailnetStatus(status); const start = Date.now(); const clock = spyOn(Date, "now").mockImplementation(() => start + minutesAgo * 60_000); setTailnetStatusReader(read); @@ -58,7 +58,7 @@ async function withCachedStatus(status: string | null, minutesAgo: number, read: } describe("freshTailscaleIdentity", () => { - it("does not grant the owner on a sole-login read that a tagged node has since made stale, within the cache's TTL or after it", async () => { + 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 () => { expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); @@ -66,10 +66,12 @@ describe("freshTailscaleIdentity", () => { } }); - it("grants the owner's request when the fresh read proves one login owns the tailnet, after idle", async () => { - await withCachedStatus(null, 2, async () => tailnet([42, undefined]), async () => { - expect(serveRequest(await freshTailscaleIdentity())).toBe("tailscale"); - }); + 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 () => { + expect(serveRequest(await freshTailscaleIdentity())).toBe("tailscale"); + }); + } }); it("withdraws the sole-login proof when the fresh read fails, and keeps pairing the stranger", async () => { @@ -77,6 +79,24 @@ describe("freshTailscaleIdentity", () => { expect(serveRequest(await freshTailscaleIdentity())).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 () => { + const identities = await Promise.all([1, 2, 3, 4, 5].map(() => freshTailscaleIdentity())); + expect(identities.map(serveRequest)).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 () => { + expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + expect(reads).toBe(1); + }); + }); }); describe("parseTailscaleIp", () => { diff --git a/server/tailscale.ts b/server/tailscale.ts index 528815a327..989fb93274 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -197,10 +197,10 @@ function refreshIdentity(): Promise { /** * 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, 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. + * owns this whole tailnet (`parseSoleTailnetLogin`), cached five minutes. A stale value is answered + * at once and refreshed in the background, so these readers never wait on the tailscale CLI; the + * first lookup is what `createServer` starts. Only the owner's grant path (`freshTailscaleIdentity`) + * waits, for one bounded read shared with any read in flight. */ export function tailscaleIdentity(): TailnetIdentity { if (ownerCache === null || Date.now() - ownerCache.at >= OWNER_TTL_MS) void refreshIdentity(); @@ -212,11 +212,14 @@ export function tailscaleIdentity(): TailnetIdentity { } /** - * `tailscaleIdentity` for a request the owner's sole-login proof would admit: it always reads the - * status now, bounded by the read's own timeout, so a node tagged since the last read is seen before - * the decision. A failed read leaves no sole-login proof. Every other request uses `tailscaleIdentity`. + * `tailscaleIdentity` for a request the owner's sole-login proof would admit. A cache that already + * says the tailnet is not one login's, or that a node is tagged, 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 failed read leaves no sole-login proof. */ export async function freshTailscaleIdentity(): Promise { - await readIdentity(); + const cached = tailscaleIdentity(); + if (ownerCache !== null && ownerCache.soleLogin === null) return cached; + await refreshIdentity(); return tailscaleIdentity(); } From 3fbeeb961baf0372d3087a3d23c6d322300ed096 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 10:57:29 +0300 Subject: [PATCH 07/15] no-mistakes(review): gate owner grant on tailnet Host; retry failed reads --- server/access.test.ts | 22 ++++++++++++--- server/access.ts | 55 +++++++++++++++++++++++++------------ server/api.contract.test.ts | 28 ++++++++++++------- server/index.ts | 22 +++++++++------ server/tailscale.test.ts | 46 +++++++++++++++++++++++++------ server/tailscale.ts | 49 +++++++++++++++++++++++++-------- 6 files changed, 161 insertions(+), 61 deletions(-) diff --git a/server/access.test.ts b/server/access.test.ts index dca9544933..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, soleLogin: null, serveOnly: 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", () => { @@ -37,8 +40,8 @@ describe("decideAccess", () => { }); 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 and states no person; nothing is paired - const phone = { forwarded: true, owner: "me@example.com", soleLogin: "me@example.com", serveOnly: true } as const; + // 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" }); @@ -46,10 +49,21 @@ describe("decideAccess", () => { 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 } as const; + 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"); diff --git a/server/access.ts b/server/access.ts index 33e3237b5d..acce2477d9 100644 --- a/server/access.ts +++ b/server/access.ts @@ -14,8 +14,10 @@ * 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. Nothing else is read - * from the request to decide it, since a visitor sends any Host a proxy passes on. + * 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. * @@ -36,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 */ @@ -46,6 +50,10 @@ export interface AccessInput { 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; @@ -65,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)); } /** @@ -89,7 +102,15 @@ export function cameThroughProxy(headers: Headers): boolean { return host === null || !isLoopbackHost(host); } -export function isOwnerGrantPath(input: Pick): boolean { +/** 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; } @@ -104,12 +125,12 @@ export function decideAccess(input: AccessInput): Access { 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 and Tailscale says one login owns every node - // on this tailnet, none tagged, there is nobody for it to be but the owner. Funnel is the public - // internet, which that proof says nothing about. - const soleOwner = input.soleLogin !== null && input.soleLogin.toLowerCase() === input.owner?.toLowerCase(); - if (isOwnerGrantPath(input) && input.owner !== null && soleOwner) { - return { level: "full", via: "tailscale", role: "drive", login: input.owner }; + // 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 || input.serveOnly)) return { level: "none", reason: "pairing_required" }; diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index a592dceead..b168af1f99 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -1801,7 +1801,8 @@ describe("pairing and identity", () => { 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 status = JSON.stringify({ BackendState: "Running", Self: { DNSName: "denisss-macbook-pro-m1.tail5cc90b.ts.net.", UserID: 7 }, Peer: { phone: { DNSName: "phone.tail5cc90b.ts.net.", UserID: 7 } }, User: { "7": { LoginName: OWNER } } }); + 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 } } }); await forgetTailscaleIdentity(); setTailnetStatusReader(async () => status); process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] = "1"; @@ -1812,20 +1813,27 @@ describe("pairing and identity", () => { const other = createServer({ port: 0, stateDir: state, tailscaleOwner: "named@example.com", tailscaleServeOnly: true }); 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 and names no person - expect(await at(sole.port, proxied())).toMatchObject({ authenticated: true, via: "tailscale" }); - expect((await fetch(`http://127.0.0.1:${sole.port}/api/session`, { headers: proxied() })).status).not.toBe(401); + // 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, proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" }); - expect(await at(sole.port, proxied(undefined, { "tailscale-funnel-request": "?1" }))).toMatchObject({ authenticated: false, reason: "pairing_required" }); - expect(await at(sole.port, proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" }); + 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, { ...proxied(), host: "denisss-macbook-pro-m1.tail5cc90b.ts.net" })).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, proxied())).toMatchObject({ authenticated: true, via: "tailscale" }); - expect(await at(other.port, proxied())).toMatchObject({ authenticated: false, reason: "pairing_required" }); + 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(); diff --git a/server/index.ts b/server/index.ts index dfdbd785dc..93161f7cbb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,7 @@ 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, isOwnerGrantPath } from "./access.ts"; +import { cameThroughProxy, decideAccess, isLoopbackAddress, isServeOwnerRequest } from "./access.ts"; import { DeviceStore, handleDeviceRequest } from "./devices.ts"; import { freshTailscaleIdentity, remoteAccess, tailscaleIdentity } from "./tailscale.ts"; import { paneCommands } from "./commands.ts"; @@ -379,11 +379,11 @@ 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 = async (grantPath: boolean) => { - if (namedOwner !== undefined) return { ...(grantPath ? await freshTailscaleIdentity() : { soleLogin: null }), owner: namedOwner, tagged: false }; - return grantPath ? freshTailscaleIdentity() : tailscaleIdentity(); + const identityOf = async (grantPath: boolean, host: string | null) => { + if (namedOwner !== undefined) return { ...(grantPath ? await freshTailscaleIdentity(host) : { soleLogin: null, dnsName: null, tailnetIp: null }), owner: namedOwner, tagged: false }; + return grantPath ? freshTailscaleIdentity(host) : tailscaleIdentity(); }; - identityOf(false); + identityOf(false, null); const serveOnly = options.tailscaleServeOnly ?? process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] === "1"; /** @@ -1019,13 +1019,17 @@ export function createServer( 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(!tokenMatched && pairedDevice === null && isServeOwnerRequest(requestShape), requestHost); const access = decideAccess({ ...requestShape, - tokenMatched: token !== "" && isAuthenticated(request, token), - device: devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)), - ...(await identityOf(isOwnerGrantPath(requestShape))), - serveOnly, + host: requestHost, + tokenMatched, + device: pairedDevice, + ...identity, tokenConfigured: token !== "", gated: devices.gated, }); diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index bca633c785..f2ccaad423 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -41,8 +41,9 @@ describe("parseSoleTailnetLogin", () => { }); /** the access decision for a no-login request that `tailscale serve` forwarded, with the operator's serve-only switch on */ -const serveRequest = (identity: { owner: string | null; tagged: boolean; soleLogin: string | null }) => { - const input: AccessInput = { loopback: true, forwarded: true, funnel: false, tailscaleLogin: null, tokenMatched: false, device: null, ...identity, serveOnly: true, tokenConfigured: false, gated: false }; +const HOST = "pc.example.ts.net"; +const serveRequest = (identity: Awaited>, 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}`; }; @@ -61,7 +62,7 @@ describe("freshTailscaleIdentity", () => { 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 () => { - expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); }); } }); @@ -69,22 +70,22 @@ describe("freshTailscaleIdentity", () => { 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 () => { - expect(serveRequest(await freshTailscaleIdentity())).toBe("tailscale"); + expect(serveRequest(await freshTailscaleIdentity(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 () => { - expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + expect(serveRequest(await freshTailscaleIdentity(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 () => { - const identities = await Promise.all([1, 2, 3, 4, 5].map(() => freshTailscaleIdentity())); - expect(identities.map(serveRequest)).toEqual(["tailscale", "tailscale", "tailscale", "tailscale", "tailscale"]); + const identities = await Promise.all([1, 2, 3, 4, 5].map(() => freshTailscaleIdentity(HOST))); + expect(identities.map((identity) => serveRequest(identity))).toEqual(["tailscale", "tailscale", "tailscale", "tailscale", "tailscale"]); expect(reads).toBe(1); }); }); @@ -92,13 +93,40 @@ describe("freshTailscaleIdentity", () => { 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 () => { - expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); - expect(serveRequest(await freshTailscaleIdentity())).toBe("refused:pairing_required"); + expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); + expect(serveRequest(await freshTailscaleIdentity(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 () => { + expect(serveRequest(await freshTailscaleIdentity("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 () => { + expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); + expect(serveRequest(await freshTailscaleIdentity(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 () => { + expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); + expect(serveRequest(await freshTailscaleIdentity(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 989fb93274..39f439f2c2 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 */ @@ -54,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; @@ -144,14 +145,21 @@ 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: (TailnetIdentity & { at: number }) | null = null; +let ownerCache: (TailnetIdentity & { at: number; unreadable: boolean }) | null = null; let ownerRefresh: Promise | null = null; /** Keeps what `tailscale status --json` says about this PC and its tailnet, as `tailscaleIdentity` reads it. */ @@ -160,6 +168,9 @@ export function rememberTailnetStatus(status: string | null): void { owner: parseTailscaleOwner(status), tagged: isTaggedNode(status), soleLogin: parseSoleTailnetLogin(status), + dnsName: parseNodeName(status), + tailnetIp: parseTailscaleIp(status), + unreadable: false, at: Date.now(), }; } @@ -181,13 +192,24 @@ export function setTailnetStatusReader(read: (() => Promise) | nu readStatus = read ?? cliStatus; } +/** + * A read that failed is an unknown, not a finding: the owner it last named stands, the sole-login + * proof is withdrawn, and the identity is marked unreadable, so the next grant-path request reads + * again at once instead of waiting out the TTL. Readers that do not grant keep the TTL as before. + */ async function readIdentity(): Promise { const status = await readStatus(); - // a read that failed keeps what it last said about the owner, so the owner is not taken for a - // stranger, nor a stranger let in, until it is asked again; it withdraws the sole-login proof, - // since a grant never rests on a read that failed - if (status === null && ownerCache !== null) ownerCache = { ...ownerCache, soleLogin: null, at: Date.now() }; - else rememberTailnetStatus(status); + if (status === null) { + ownerCache = { + owner: ownerCache?.owner ?? null, + tagged: ownerCache?.tagged ?? false, + soleLogin: null, + dnsName: ownerCache?.dnsName ?? null, + tailnetIp: ownerCache?.tailnetIp ?? null, + unreadable: true, + at: Date.now(), + }; + } else rememberTailnetStatus(status); } function refreshIdentity(): Promise { @@ -208,18 +230,21 @@ export function tailscaleIdentity(): TailnetIdentity { owner: ownerCache?.owner ?? null, tagged: ownerCache?.tagged ?? false, soleLogin: ownerCache?.soleLogin ?? null, + dnsName: ownerCache?.dnsName ?? null, + tailnetIp: ownerCache?.tailnetIp ?? null, }; } /** * `tailscaleIdentity` for a request the owner's sole-login proof would admit. A cache that already - * says the tailnet is not one login's, or that a node is tagged, 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 failed read leaves no sole-login proof. + * 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 failed + * read leaves no sole-login proof, and the next request reads again. */ -export async function freshTailscaleIdentity(): Promise { +export async function freshTailscaleIdentity(host: string | null): Promise { const cached = tailscaleIdentity(); - if (ownerCache !== null && ownerCache.soleLogin === null) return cached; + if (ownerCache !== null && !ownerCache.unreadable && (ownerCache.soleLogin === null || !addressesNode({ host, dnsName: ownerCache.dnsName, tailnetIp: ownerCache.tailnetIp }))) return cached; await refreshIdentity(); return tailscaleIdentity(); } From dbf68147b1d2300a2f2786ddbf6891349323adf7 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 12:50:41 +0300 Subject: [PATCH 08/15] no-mistakes(review): injectable tailnet identity source; unsettled statuses re-read before grants --- server/api.contract.test.ts | 33 ++++--- server/index.ts | 9 +- server/tailscale.test.ts | 74 ++++++++++------ server/tailscale.ts | 169 +++++++++++++++++++----------------- 4 files changed, 157 insertions(+), 128 deletions(-) diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index b168af1f99..d99628eae4 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -13,7 +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 { forgetTailscaleIdentity, rememberTailnetStatus, setTailnetStatusReader } from "./tailscale.ts"; +import { TailnetIdentitySource } from "./tailscale.ts"; /** * Contract test for herdr-web-ui's HTTP + WS surface. @@ -1803,14 +1803,13 @@ describe("pairing and identity", () => { // 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 } } }); - await forgetTailscaleIdentity(); - setTailnetStatusReader(async () => status); + const tailnet = new TailnetIdentitySource(async () => status); process.env["HERDR_WEB_TAILSCALE_SERVE_ONLY"] = "1"; - const sole = createServer({ port: 0, stateDir: state }); + 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 }); - const named = createServer({ port: 0, stateDir: state, tailscaleOwner: OWNER, tailscaleServeOnly: true }); - const other = createServer({ port: 0, stateDir: state, tailscaleOwner: "named@example.com", tailscaleServeOnly: true }); + 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 @@ -1839,8 +1838,6 @@ describe("pairing and identity", () => { off.stop(); named.stop(); other.stop(); - setTailnetStatusReader(null); - await forgetTailscaleIdentity(); if (savedOwner === undefined) delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; else process.env["HERDR_WEB_TAILSCALE_OWNER"] = savedOwner; rmSync(state, { recursive: true, force: true }); } @@ -1850,19 +1847,19 @@ describe("pairing and identity", () => { const state = mkdtempSync(join(tmpdir(), "herdr-stale-identity-")); const savedOwner = process.env["HERDR_WEB_TAILSCALE_OWNER"]; delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; - const sole = JSON.stringify({ BackendState: "Running", Self: { UserID: 7 }, User: { "7": { LoginName: OWNER } } }); - const tagged = JSON.stringify({ BackendState: "Running", Self: { UserID: 7 }, Peer: { node: { UserID: 7, Tags: ["tag:server"] } }, User: { "7": { LoginName: OWNER } } }); - await forgetTailscaleIdentity(); - rememberTailnetStatus(sole); - setTailnetStatusReader(async () => tagged); - const serveOnly = createServer({ port: 0, stateDir: state, tailscaleServeOnly: true }); + 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() })).json()) as { auth: HealthAuth }).auth; + 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(); - setTailnetStatusReader(null); - await forgetTailscaleIdentity(); if (savedOwner === undefined) delete process.env["HERDR_WEB_TAILSCALE_OWNER"]; else process.env["HERDR_WEB_TAILSCALE_OWNER"] = savedOwner; rmSync(state, { recursive: true, force: true }); } diff --git a/server/index.ts b/server/index.ts index 93161f7cbb..6cda18971a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,7 +10,7 @@ import { DEFAULT_PORT } from "../shared/protocol.ts"; import { DEVICE_COOKIE, handleAuthRequest, isAuthenticated, parseCookies, requiresAuth, unauthorizedJson } from "./auth.ts"; import { cameThroughProxy, decideAccess, isLoopbackAddress, isServeOwnerRequest } from "./access.ts"; import { DeviceStore, handleDeviceRequest } from "./devices.ts"; -import { freshTailscaleIdentity, 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"; @@ -315,6 +315,8 @@ export function createServer( 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; @@ -379,9 +381,10 @@ 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 tailnet = options.tailnet ?? new TailnetIdentitySource(); const identityOf = async (grantPath: boolean, host: string | null) => { - if (namedOwner !== undefined) return { ...(grantPath ? await freshTailscaleIdentity(host) : { soleLogin: null, dnsName: null, tailnetIp: null }), owner: namedOwner, tagged: false }; - return grantPath ? freshTailscaleIdentity(host) : tailscaleIdentity(); + 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"; diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index f2ccaad423..ff2c5ed473 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, spyOn } from "bun:test"; import { decideAccess, type AccessInput } from "./access.ts"; -import { forgetTailscaleIdentity, freshTailscaleIdentity, parseSoleTailnetLogin, parseTailscale, parseTailscaleIp, rememberTailnetStatus, setTailnetStatusReader } from "./tailscale.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 */ @@ -42,49 +42,52 @@ describe("parseSoleTailnetLogin", () => { /** 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: Awaited>, host: string | null = HOST) => { +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}`; }; -/** warms the identity cache with `status` (none, when undefined), moves the clock `minutesAgo` past that read, and answers reads with `read` */ -async function withCachedStatus(status: string | null | undefined, minutesAgo: number, read: () => Promise, body: () => Promise): Promise { - await forgetTailscaleIdentity(); - if (status !== undefined) rememberTailnetStatus(status); +/** 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); - setTailnetStatusReader(read); - try { await body(); } finally { clock.mockRestore(); setTailnetStatusReader(null); await forgetTailscaleIdentity(); } + try { await body(source); } finally { clock.mockRestore(); } } -describe("freshTailscaleIdentity", () => { +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 () => { - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); + 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 () => { - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("tailscale"); + 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 () => { - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); + 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 () => { - const identities = await Promise.all([1, 2, 3, 4, 5].map(() => freshTailscaleIdentity(HOST))); + 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); }); @@ -92,9 +95,24 @@ describe("freshTailscaleIdentity", () => { 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 () => { - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); + 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); }); }); @@ -103,25 +121,25 @@ describe("freshTailscaleIdentity", () => { 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 () => { - expect(serveRequest(await freshTailscaleIdentity("evil.example"), "evil.example")).toBe("refused:pairing_required"); + 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 () => { - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("tailscale"); + 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 () => { - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); - expect(serveRequest(await freshTailscaleIdentity(HOST))).toBe("refused:pairing_required"); + 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); }); }); diff --git a/server/tailscale.ts b/server/tailscale.ts index 39f439f2c2..96854b6c67 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -127,17 +127,28 @@ export function parseTailscaleOwner(status: string | null): string | null { * 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 } { const parsed = parseJson(status); - if (parsed === null || parsed.BackendState !== "Running") return null; + if (parsed === null || parsed.BackendState !== "Running") return { login: null, settled: false }; const self = parsed.Self; - if (self === undefined) return null; + if (self === undefined || self.UserID === undefined || self.UserID === null) return { login: null, settled: false }; const owner = self.UserID; - if (owner === undefined || owner === null) return null; // 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)); - return sole ? parsed.User?.[String(owner)]?.LoginName || null : null; + 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. */ @@ -159,92 +170,92 @@ export interface TailnetIdentity { } const OWNER_TTL_MS = 5 * 60_000; -let ownerCache: (TailnetIdentity & { at: number; unreadable: boolean }) | null = null; -let ownerRefresh: Promise | null = null; - -/** Keeps what `tailscale status --json` says about this PC and its tailnet, as `tailscaleIdentity` reads it. */ -export function rememberTailnetStatus(status: string | null): void { - ownerCache = { - owner: parseTailscaleOwner(status), - tagged: isTaggedNode(status), - soleLogin: parseSoleTailnetLogin(status), - dnsName: parseNodeName(status), - tailnetIp: parseTailscaleIp(status), - unreadable: false, - at: Date.now(), - }; -} - -/** Tests: drops what was remembered, once any lookup in flight has landed. */ -export async function forgetTailscaleIdentity(): Promise { - await ownerRefresh; - ownerCache = null; -} const cliStatus = async (): Promise => { const binary = tailscaleBinary(); return binary === null ? null : run(binary, ["status", "--json"]); }; -let readStatus = cliStatus; - -/** Tests: supplies the status text in place of the tailscale CLI; null restores the CLI. */ -export function setTailnetStatusReader(read: (() => Promise) | null): void { - readStatus = read ?? cliStatus; -} /** - * A read that failed is an unknown, not a finding: the owner it last named stands, the sole-login - * proof is withdrawn, and the identity is marked unreadable, so the next grant-path request reads - * again at once instead of waiting out the TTL. Readers that do not grant keep the TTL as before. + * 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. */ -async function readIdentity(): Promise { - const status = await readStatus(); - if (status === null) { - ownerCache = { - owner: ownerCache?.owner ?? null, - tagged: ownerCache?.tagged ?? false, - soleLogin: null, - dnsName: ownerCache?.dnsName ?? null, - tailnetIp: ownerCache?.tailnetIp ?? null, - unreadable: true, +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(), }; - } else rememberTailnetStatus(status); -} + } -function refreshIdentity(): Promise { - ownerRefresh ??= readIdentity().finally(() => { ownerRefresh = null; }); - return ownerRefresh; -} + 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, so these readers never wait on the tailscale CLI; the - * first lookup is what `createServer` starts. Only the owner's grant path (`freshTailscaleIdentity`) - * waits, for one bounded read shared with any read in flight. - */ -export function tailscaleIdentity(): TailnetIdentity { - if (ownerCache === null || Date.now() - ownerCache.at >= OWNER_TTL_MS) void refreshIdentity(); - return { - owner: ownerCache?.owner ?? null, - tagged: ownerCache?.tagged ?? false, - soleLogin: ownerCache?.soleLogin ?? null, - dnsName: ownerCache?.dnsName ?? null, - tailnetIp: ownerCache?.tailnetIp ?? null, - }; -} + /** + * 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, + }; + } -/** - * `tailscaleIdentity` 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 failed - * read leaves no sole-login proof, and the next request reads again. - */ -export async function freshTailscaleIdentity(host: string | null): Promise { - const cached = tailscaleIdentity(); - if (ownerCache !== null && !ownerCache.unreadable && (ownerCache.soleLogin === null || !addressesNode({ host, dnsName: ownerCache.dnsName, tailnetIp: ownerCache.tailnetIp }))) return cached; - await refreshIdentity(); - return tailscaleIdentity(); + /** + * `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(); + } } From 9042d2e85e98e7f43ba3665556c7c5a7b5053d60 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 13:21:06 +0300 Subject: [PATCH 09/15] no-mistakes(review): skip owner-grant status read when a token is configured --- server/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index 6cda18971a..2af101f379 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1026,7 +1026,7 @@ export function createServer( 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(!tokenMatched && pairedDevice === null && isServeOwnerRequest(requestShape), requestHost); + const identity = await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape), requestHost); const access = decideAccess({ ...requestShape, host: requestHost, From 66266b803493c910ee4c54ad2f5da39f47e2c260 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 14:05:56 +0300 Subject: [PATCH 10/15] no-mistakes(document): Point INSTALL.md at the serve-only pairing option --- INSTALL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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` From 5fd423b8502790fcceec747787c751ab7dae5db5 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 17:27:58 +0300 Subject: [PATCH 11/15] no-mistakes(review): move changelog entry to Unreleased; gate grant read on api/ws paths --- CHANGELOG.md | 16 ++++++++-------- server/index.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f1d4e364..e8d956b351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - Codex chat hides internal memory citation blocks and pairs the answer with its display record, so a reply that uses memory appears once without raw citation markup. Quoted code examples keep their text. ([#514](https://github.com/devswha/herdr-web-ui/pull/514) by @JJLiebig) +- 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. ## [0.3.52] - 2026-10-06 @@ -62,14 +70,6 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#493](https://github.com/devswha/herdr-web-ui/pull/493) by @kilhyeonjun) ### Fixed -- 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. - A Claude Code pane started with its own `CLAUDE_CONFIG_DIR` (a launcher such as cac keeps one store per environment) shows its chat. Before, only `~/.claude` was searched, so the chat was unavailable and only the terminal worked. The store is read from the pane's Claude process, as diff --git a/server/index.ts b/server/index.ts index 2af101f379..7cdec73391 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1026,7 +1026,7 @@ export function createServer( 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), requestHost); + const identity = await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && (pathname === "/ws" || pathname.startsWith("/api/")), requestHost); const access = decideAccess({ ...requestShape, host: requestHost, From 5ec94f91c0dd76a34820b7a7e1dde4aff60bd8cc Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 17:37:07 +0300 Subject: [PATCH 12/15] no-mistakes(review): docs: serve-only flag as fallback, not sole codeless path --- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/guide.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.ja.md b/README.ja.md index c0ecbfdf11..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 コードが表示されます。スマートフォンからコードなしで接続するには `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` を設定します。ただし、公開プロキシやトンネルなど別の経路でこのポートに届く場合は設定しないでください([アクセスと安全性](docs/guide.md#access-and-safety))。 +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 77ac98925f..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 코드가 나옵니다. 폰에서 코드 없이 들어가려면 `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`을 설정하세요. 단, 공용 프록시나 터널 등 다른 경로로 이 포트에 접근할 수 있다면 설정하지 마세요 ([접근과 안전](docs/guide.md#access-and-safety)). +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 7721e95ef6..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. To let your own phone in without a code, set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`, but only when nothing else, such as a public proxy or tunnel, reaches this port ([Access and safety](docs/guide.md#access-and-safety)). +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 6f0b6eb4b4..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 内的访问地址和二维码。若要让自己的手机无需验证码即可进入,请设置 `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`,但仅限于没有公共代理或隧道等其他途径能访问此端口的情况([访问与安全](docs/guide.md#access-and-safety))。 +支持 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 081865dc16..fb4a53205a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -210,7 +210,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. Your own devices get in without a code only when you set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` on this PC, and only when nothing else, such as a public reverse proxy or tunnel, reaches this port (see [Access and safety](#access-and-safety)). Without it, the phone pairs with a six-digit code. +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. From 15c2bd4db0c4eed4f624e0e59da4cdf82b222df5 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 6 Oct 2026 18:00:07 +0300 Subject: [PATCH 13/15] no-mistakes(document): docs: add serve-only grant to protocol access summary --- shared/protocol.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/protocol.ts b/shared/protocol.ts index 9bec13cc28..3742bc4b8b 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 From f3e24aa0a8c21ba30f8294fe455852a7cab13dce Mon Sep 17 00:00:00 2001 From: Hako Date: Wed, 7 Oct 2026 19:26:48 +0900 Subject: [PATCH 14/15] docs(changelog): link #525 and credit its author Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a2b7fa9b..58ab4373c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa 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 From badd3cc640d465a8a5bffbd7abb36af053769398 Mon Sep 17 00:00:00 2001 From: Hako Date: Wed, 7 Oct 2026 19:26:48 +0900 Subject: [PATCH 15/15] docs(access): the sole-login proof covers the nodes this PC's Tailscale lists `tailscale status` is built from this PC's own network map, so a node an ACL hides from it is not counted. The guide and the reader's comment now say so instead of "anywhere on the tailnet"; such a node cannot reach this PC's serve either, so the grant is unchanged. Co-Authored-By: Claude Fable 5.1 --- docs/guide.md | 2 +- server/tailscale.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/guide.md b/docs/guide.md index 1027f99200..2fa5ed5b6f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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. If you set `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` to say `tailscale serve` is the only way in, and `tailscale status` shows a tailnet that one login owns with no tagged node anywhere on it, a serve request that states no login is the owner as well: no one else is on that tailnet to be mistaken for, 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 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. diff --git a/server/tailscale.ts b/server/tailscale.ts index 96854b6c67..2120b51c70 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -137,6 +137,7 @@ export function parseSoleTailnetLogin(status: string | null): string | null { * 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;